Espial's default is to publish and its image fetches BusyBox
Espial is an open-source, web-based bookmarking server.
At a glance
- What is it?
- A self-hosted bookmarking server written in Haskell that stores to SQLite, with a bookmarklet, a REST endpoint, and imports for Pinboard and Firefox bookmark files. Two details are worth reading before you deploy it: the add endpoint publishes a bookmark when the private flag is omitted, and the container image downloads and compiles BusyBox during the build. The version is still 0.0.42.
- Who is it for?
- Espial is worth reading if you want a bookmarking server you run yourself and can inspect in full, because it is a Haskell application with its own migrations, its own tests, a load test directory and a reverse proxy configuration, and a SQLite file you can open. Three things to check before it holds anything you care about.
- Can I use it commercially?
- Yes, with strict conditions. AGPL-3.0 is a network copyleft licence: if people use a modified version over a network, for example as a hosted service, you must offer them its source code under the same licence.
- Is it still maintained?
- Yes. The repository last received commits 62 days ago.
- What is it written in?
- Mainly Haskell, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 2, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Omitting the private flag publishes the bookmark
The add endpoint takes a JSON body in which exactly one field is required. Everything else is optional, and the optional fields carry defaults that are worth reading before you script against it.
`url` is the only required field. `title` and `description` default to empty, `toread` defaults to false, and `slug` is auto-generated when omitted and is used to form the bookmark's link as a user and slug pair. `bid` takes an existing bookmark id, so the same endpoint creates or updates.
The field to notice is `private`. Its row says that true keeps the bookmark private to the owner, and that false, or omitted, makes it shared and public. The default state of a bookmark created by a minimal request is therefore the visible one, which is the opposite of the safe default for a bookmarking tool and is not called out anywhere except in that table row.
The response also has two shapes worth coding against. A new bookmark returns 201 Created with the new id. An update returns 204 No Content, and the match is made by `bid` when one is given, otherwise by the user and url pair.
The API key is displayed once and lives in an HTTP header
Authentication is a single static key per user, sent on every call.
There are two ways to get one. The web interface path goes to the account settings page, finds the API key section, and clicks Create API Key, or Reset API Key to replace an existing one. The page states that the key is shown only once, so it has to be copied at that moment, with no second retrieval path offered. The command line path is one command:
stack exec migration -- createapikey --userName myusernameThe key then goes into the request as a scheme inside the Authorization header, with the literal word ApiKey followed by the value. The documented example also sets a JSON content type and a JSON accept header, and the body is the same field set as above.
So the whole authentication surface is one bearer-like string in a custom scheme, created through the same administrative page that manages the account, with rotation expressed as resetting rather than as having more than one active key. Nothing on the page describes a second key or a revocation list.
The compose file mounts the working directory and needs a session key
The compose file is short and its defaults are the interesting part. The data volume is written as a variable with the current directory as its fallback, mounted at the data path inside the container, and the database file name is set explicitly beneath it. So a compose start with no configuration writes the SQLite file into whatever directory the command was run from, which is the opposite of the named volume the quick start uses.
Two environment variables have no default at all. The client session key is passed through from the environment, so an unset value leaves it empty, and the image and registry names are both parameterised. One security-relevant default is switched off: reading the client address from a request header is set to false, so the server does not trust a forwarded address unless asked to.
Several features are present but commented out. In-process TLS is available with a certificate and key path, and the comment recommends a reverse proxy instead. Verbose logging, allowing non-HTTP URL schemes, a source code link and a SOCKS proxy for archiving are all disabled. The archiving backend defaults to disabled, with a wayback machine option and a key pair sitting commented beneath it.
The ports do not match the quick start. Compose maps 3000 to 3000, while the one-command container maps host 9090 to the same internal 3000.
The container image downloads and compiles BusyBox
The Dockerfile is a multi-stage build that reaches out to the network during the image build, twice over.
The first stage starts from a stack build image and runs a toolchain setup against the three project descriptor files copied in ahead of the source. The second stage injects build metadata: it takes a git revision argument that defaults to the string UNKNOWN, and writes a small generated Haskell module line by line into the buildinfo directory, containing a single string field holding that revision. The build then runs with a flag that tells the project to use it, and copies the binaries to a fixed path inside the image.
The third stage is the runtime dependency layer on a slim Debian base. It installs the C runtime and compression libraries from the distribution, then pins a BusyBox version as a build argument, downloads that tarball directly from the BusyBox site, unpacks it, runs a default-free configuration, and then edits the resulting configuration file with a series of substitutions that each flip one option on: static linking, preferring applets, a standalone shell, and a list of individual utilities including cat, cp, ls, mkdir, rm and a shell choice.
So the image is not reproducible from the source tree, and a reader has to notice that a shell inside the runtime image came from a download rather than from the base distribution.
The Makefile reads its own metadata out of the cabal file
Six variables at the top of the Makefile are extracted from the Haskell package description with a line-matching command rather than being written down, covering the version, the source URL, the package name, the synopsis and the licence. The git revision comes from git, and the build date from a UTC timestamp on the host.
The build targets then differ in how strict they are. The default build passes a warnings-as-errors flag, so any compiler warning fails the build, while a fast build drops that. A watch target rebuilds on file changes, and a second watch target adds a no-code flag that compiles without generating output, which is the cheap way to typecheck a large Haskell tree while editing.
The developer targets reveal the framework and the mode. The repl starts an interactive session with the test and benchmark suites loaded, the development target runs the web framework's own development command, and the serve target runs the built binary with a runtime flag that turns on the runtime's own statistics rather than serving quietly. The test target is a single command. A docker compose build passes the extracted metadata as build arguments, and a second compose variable points at a separate compose file for an ArchiveBox integration at a specific version.
A .env file is committed, and there is a Caddy directory
The top level of the repository is a Haskell project with a web front end, and a few entries say more than the documentation does.
There is a `.env` file at the root. That is a file of local values, not an example file, sitting in version control next to the compose file that consumes environment variables. Nothing in the visible material says what it contains.
There is also a `caddy/` directory, which pairs with the compose file's comment recommending a reverse proxy over its own TLS option. So the intended production shape is a proxy in front of the application, and the configuration for it is versioned.
The rest of the tree is a Yesod-shaped layout: an application directory, migrations, config, templates, static assets, a separate frontend directory, a test directory and a load test directory. On the tooling side there is a Visual Studio Code workspace file and a Haskell IDE engine file, both for editor integration, plus two Dockerfiles for the two build paths and a small script that migrates the sample data.
Version 0.0.42, three patch releases in July
The release history is three tags in July 2026, v0.0.40, v0.0.41 and v0.0.42, and the default branch last moved on 2026-08-02. The version number is still in the zero series, so nothing here has declared a stable interface.
The project also publishes a demo server, and the credentials for it are printed on the page: a username of demo and a password of demo, with a URL that carries the user in the path. That is the normal shape for a public trial instance, and it is the same credential pair anyone would create for themselves, since the source setup instructions use a placeholder username and password in the same command shape.
The demo is the fastest way to see the bookmarklet and the import behaviour before installing anything. What it cannot show is the part that matters for a self-hosted deployment, which is the migration and the compose configuration.
The related project is a small Android application that shares a URL into Espial through a share intent, which makes Espial the receiving end of a mobile save flow rather than a browser-only tool.
Editorial conclusion
Espial is worth reading if you want a bookmarking server you run yourself and can inspect in full, because it is a Haskell application with its own migrations, its own tests, a load test directory and a reverse proxy configuration, and a SQLite file you can open. Three things to check before it holds anything you care about. The add endpoint treats an omitted private flag as shared, so a script that posts a url and nothing else publishes it. The container image fetches a BusyBox tarball over the network and compiles it as part of the build, which means the build is not reproducible from the source tree alone. And the default compose configuration mounts the directory you run it from, so a casual start writes the database into your working copy unless you set the data path.
Frequently asked questions
How do I add a bookmark to Espial from the command line?
POST JSON to the add endpoint with an Authorization header of ApiKey followed by the key, plus JSON content and accept headers. Only url is required. A new bookmark returns 201 Created with its id, and an update returns 204 No Content, matched by bid when supplied or by the user and url pair otherwise.
Where does Espial store its bookmarks?
In a SQLite database, described as chosen to keep setup and maintenance straightforward. The compose file names the database file inside the data volume, and the single-command container maps a Docker-managed named volume at the same location so the file outlives the container.
How do I install Espial myself?
Docker is the recommended route, either through the separate espial-docker setup or one docker run with a named volume. Building from source needs Haskell tooling, Stack or GHCup, then stack build, a createdb step through the migration executable, a createuser step, optional Pinboard and Firefox import steps, and stack exec espial to start the server.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/jonschoning-espial)