Espial is an open-source, web-based bookmarking server.
It supports multiple user accounts and is primarily intended for self-hosted deployments.
Bookmarks are stored in a SQLite database to keep setup and maintenance straightforward.
Espial also includes internationalization support.
The easist way for logged-in users to add bookmarks, is with the "bookmarklet", found on the Settings page.
Espial also supports file import options (see CLI, or the settings -> Import / Export page in the web UI).
- Log in and go to the Settings page.
- Drag the "add url bookmarklet" link into your browser's bookmarks bar (or otherwise save the link as a bookmark in the browser)
- While viewing any page you want to save, click the bookmarklet in your bookmarks bar.
- A small popup opens with the URL, title, and any selected text (as the description) pre-filled — add tags or edit the fields, then save.
- Visit or re-fresh the Espial page to see the added bookmark
Log in with:
- username:
demo - password:
demo
https://espdemo.ae8.org/u:demo
Also, see the android app for adding bookmarks via an Android Share intent:
https://github.com/jonschoning/espial-share-android
Docker installation is the recommended approach for most deployments.
See:
https://github.com/jonschoning/espial-docker
For a quick trial, or a minimal setup without cloning espial-docker, run Espial directly with a single docker run command backed by a Docker-managed named volume for storage of the sqlite database:
- Create the container:
MSYS_NO_PATHCONV=1 docker run --name espial \
-p 9090:3000 \
-v espial-data:/app/data \
-e SQLITE_DATABASE=/app/data/espial.sqlite3 \
-d jonschoning/espial:espial- Maps host port
9090to Espial's internal port3000— change9090to whatever port you prefer. - Creates a named volume called
espial-dataat/app/data; the sqlite database will be stored inside a docker Named Volume. SQLITE_DATABASEsets the database filename inside the named volume at/app/data- The database is created and migrated automatically on startup — no separate
createdbstep required.
- Create a user:
docker exec espial ./migration createuser --userName myusername --userPassword myuserpassword- Log in and create bookmarks from the bookmarklet (see Using the Bookmarklet), or import bookmarks from the settings -> Import / Export page (try importing sample-bookmarks.json )
- Install Haskell tooling (choose one):
- Stack: https://docs.haskellstack.org/en/stable/
- GHCup (installs GHC, Stack, and more): https://www.haskell.org/ghcup/install/
- Build executables:
stack build- Create the database:
stack exec migration -- createdb- Create a user:
stack exec migration -- createuser --userName myusername --userPassword myuserpassword- Import a pinboard bookmark file for a user (optional):
stack exec migration -- importbookmarks --userName myusername --bookmarkFile sample-bookmarks.json- Import a firefox bookmark file for a user (optional):
stack exec migration -- importfirefoxbookmarks --userName myusername --bookmarkFile firefox-bookmarks.json- Start a production server:
stack exec espialBookmarks can be added programmatically by POSTing JSON to /api/add, authenticated with an API key.
-
Generate an API key for a user, either:
-
From the Account Settings (
settings) page in the web UI, under API Key — click Create API Key (or Reset API Key to replace an existing one). The key is shown only once, so copy it immediately. -
Or via the CLI:
stack exec migration -- createapikey --userName myusername
-
-
Call the endpoint, passing the key in the
Authorization: ApiKey <key>header:
Example Request:
curl -X POST https://your-espial-host/api/add \
-H "Authorization: ApiKey <key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"url": "https://example.com",
"title": "Example Site",
"description": "",
"tags": "example some-tag",
"private": false,
"toread": false
}'Only url is required; all other fields are optional. On success the response is 201 Created with the new bookmark id, or 204 No Content if an existing bookmark was updated instead — matched by bid if given, otherwise by the (userid, url) pair.
| Field | Type | Description |
|---|---|---|
url |
string |
The bookmark's URL. Required. |
title |
string |
The bookmark's title/description line. Defaults to empty. |
description |
string |
Extended/longer notes for the bookmark. Defaults to empty. |
tags |
string |
Space-separated list of tags, e.g. "example some-tag". |
private |
boolean |
true keeps the bookmark private to the owner; false (or omitted) makes it shared/public. |
toread |
boolean |
Marks the bookmark on the "to read" list. Defaults to false. |
bid |
number |
Existing bookmark id to update. Omit to create a new bookmark (or update by matching url — see above). |
slug |
string |
URL-friendly identifier used in bookmark links (u:<user>/<slug>). Auto-generated when omitted. |
selected |
boolean |
Whether the bookmark is "starred". Defaults to false. |
time |
string |
Creation timestamp, ISO 8601 (e.g. "2018-02-26T22:57:20Z"). Defaults to the current time; useful when importing bookmarks with a historical date. |
archiveUrl |
string |
Link to an already-archived copy of the page. Normally set by the archiver, not supplied by clients. |
archiveRequested |
boolean |
Whether to kick off archiving of url after saving. Only takes effect when an archive backend is configured (see Archive Backends). Not stored on the bookmark itself. |
Multiple bookmarks can be added in a single request by POSTing a JSON array to /api/addBulk, using the same fields and auth as /api/add.
Example Request:
curl -X POST https://your-espial-host/api/addBulk \
-H "Authorization: ApiKey <key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '
[
{
"url": "https://example.com/11111111",
"title": "Example Site 1",
"description": "Random test bookmark 11111111",
"tags": "example test run1",
"private": false,
"toread": false
},
{
"url": "https://example.com/22222222",
"title": "Example Site 2",
"description": "Random test bookmark 22222222",
"tags": "example test run2",
"private": false,
"toread": false
},
{
"url": "33333333",
"title": "Example Site 3",
"description": "Random test bookmark 33333333",
"tags": "example test run3",
"private": false,
"toread": false
}
]'The response is 200 OK with a JSON array of per-bookmark results, in the same order as the request:
[
{ "status": "created", "id": 105831 },
{ "status": "updated", "id": 105832 },
{
"status": "failed",
"error": "Invalid URL: InvalidUrlException \"33333333\" \"Invalid URL\""
}
]If the request array has more items than the add-bulk-max-items setting allows (default 200), the response is 413 Payload Too Large and no bookmarks are saved.
See config/settings.yml for changing default run-time parameters & environment variables.
config/settings.ymlis embedded into the app executable when compiled and also read once when the app starts. Current settings inconfig/settings.ymlwill override the embedded compile-time settings.config/settings.ymlvalues formatted like_env:ENV_VAR_NAME:default_valuecan be overridden by the specified environment variable.- Example:
_env:PORT:3000- environment variable
PORT - default app http port:
3000
Espial's frontend supports a selectable UI language per account, on the Account Settings (settings) page.
The Server Language default is controlled by language-default in config/settings.yml, which can also be set with environment variable LANGUAGE_DEFAULT to the language code; the default value is en.
| Code | English name | Native name |
|---|---|---|
en |
English | English |
de |
German | Deutsch |
es |
Spanish | Español |
fr |
French | Français |
it |
Italian | Italiano |
ja |
Japanese | 日本語 |
ko |
Korean | 한국어 |
pl |
Polish | Polski |
pt-BR |
Portuguese (Brazil) | Português (Brasil) |
ru |
Russian | Русский |
tr |
Turkish | Türkçe |
uk |
Ukrainian | Українська |
zh-Hans |
Chinese (Simplified) | 简体中文 |
zh-Hant |
Chinese (Traditional) | 繁體中文 |
Espial supports the IP_FROM_HEADER environment variable for request logging.
IP_FROM_HEADER=true: log the client IP from theX-Real-IPorX-Forwarded-Forheader when present, and fall back to the peer address if neither header is available.IP_FROM_HEADER=false: log the peer address from the HTTP connection.
Only set IP_FROM_HEADER=true if your application is safely positioned behind a trusted reverse proxy.
A reverse proxy is the recommended approach for production and most self-hosted deployments. For simple local or LAN setups where that is impractical, Espial can also terminate TLS directly — see Optional: In-Process TLS below.
Set SSL_ONLY=true whenever Espial is served over HTTPS (via reverse proxy or in-process TLS) to enable the Secure cookie flag and HTTP→HTTPS redirects.
Running Espial behind a reverse proxy is the recommended approach for production and most self-hosted deployments. The proxy terminates TLS and forwards plain HTTP to Espial. This gives you automatic certificate management, HTTP/2 and HTTP/3 at the edge, and cleaner separation of concerns.
For container-based deployment examples, including production-oriented layouts, see the espial-docker repository:
Minimal Caddy example:
Localhost without a real domain:
https://localhost:3050 {
reverse_proxy localhost:3000
}or with a domain:
espial.example.com {
reverse_proxy 127.0.0.1:3000
}With the domain setup:
- Caddy terminates TLS for
espial.example.com. - Espial continues listening on HTTP, locally on
127.0.0.1:3000- If using Docker Compose, it would look like
espial:3000
- If using Docker Compose, it would look like
- Set
IP_FROM_HEADER=trueonly when Espial is reachable solely through that trusted proxy.
If you are using Cloudflare:
- Prefer Cloudflare SSL mode
Full (strict). - use
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} - If traffic can reach Espial directly without passing through your trusted proxy, do not enable
IP_FROM_HEADER=true, because client IP headers can be spoofed.
Espial can also be served under a path prefix on a shared domain, e.g. https://www.domain.com/espial alongside other apps on the same host. This needs two pieces working together:
-
APPROOT— tell Espial the full external URL (including the subpath) it's being served at, so generated links, redirects, and static asset URLs come out correct:approot: "_env:APPROOT:https://www.domain.com/espial"
or via environment variable:
APPROOT=https://www.domain.com/espial stack exec espial -
Reverse proxy — strip the
/espialprefix before forwarding to Espial, since Espial itself always routes as if mounted at/. Also redirect the bare/espial(no trailing slash) to/espial/so relative URLs in the page resolve against the right base.Minimal Caddy example (see
caddy/Caddyfile-Standalone-Subpath, which runs Caddy in Docker alongside an Espial instance on the host)::80 { redir /espial /espial/ handle_path /espial/* { # Caddy pools and reuses idle connections to the backend # by default, but Espial's Warp server closes idle connections after 30s # set keepalive to off or 15s reverse_proxy host.docker.internal:3000 { transport http { keepalive off } } } }
handle_pathstrips the/espialprefix before proxying, matching whatAPPROOTtold Espial to expect. If Caddy and Espial run on the same host (not in Docker), replacehost.docker.internal:3000withlocalhost:3000.
For simple local or LAN deployments where adding a reverse proxy is impractical, Espial can terminate TLS directly using your own certificate and key files (PEM format, unencrypted key).
Set the TLS_CERT_FILE and TLS_KEY_FILE environment variables (or the corresponding tls-cert-file / tls-key-file keys in config/settings.yml) to the paths of your certificate and private key:
TLS_CERT_FILE=/path/to/cert.pem TLS_KEY_FILE=/path/to/key.pem stack exec espialOr in config/settings.yml:
tls-cert-file: "/path/to/cert.pem"
tls-key-file: "/path/to/key.pem"When both values are set Espial listens on HTTPS with HTTP/2 enabled. When either is absent Espial falls back to plain HTTP.
Certificate rotation — Espial reloads the certificate from disk automatically every 12 hours without restarting or dropping existing connections. To trigger an immediate reload send SIGHUP to the process:
kill -HUP <espial-pid>Notes:
- Let's Encrypt certificates (
fullchain.pem+privkey.pem) work directly. - A self-signed certificate for local testing can be generated with:
openssl req -x509 -newkey rsa:2048 -nodes \ -keyout key.pem -out cert.pem -days 3650 \ -subj "/CN=localhost" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
- A reverse proxy is still preferred for production: it provides HTTP/3, edge caching, and hides the Espial process from the public internet.
Espial supports configurable archive backends for saving bookmark snapshots.
Set the backend with archive-backend in config/settings.yml:
-
disabled: archiving is turned off (default). -
wayback-machine: enables submission to the Internet Archive Wayback Machine.Wayback Machine support requires the following settings:
-
wayback-machine-access-key -
wayback-machine-secret-keyCreate these by signing in to your Internet Archive account and generating S3-style API credentials at
https://archive.org/account/s3.php.
Ifwayback-machineis selected but the access key or secret key is missing, archiving is disabled at runtime.
-
-
archivebox07: queues the URL in a local ArchiveBox 0.7 instance and stores an ArchiveBox link on the bookmark.IMPORTANT - ArchiveBox stores all archive data in a single global index space, so this arcive-backend is best suited to single-user Espial instances.
Recommended setup is to use Docker Compose to run the ArchiveBox instance
- Simple example, running on localhost: docker-compose.archivebox07.yml
- See https://github.com/jonschoning/espial-docker for more examples intended for deployment
- In all examples, you must change the
ARCHIVEBOX_PASSWORDfrom it's default value.
ArchiveBox support requires the following settings:
-
archivebox-urlarchivebox-urlis the URL espial uses to sign in to ArchiveBox and submit URLs through the web UI. In Docker Compose this is typicallyhttp://archivebox:8000. -
archivebox-public-url(optional)Public ArchiveBox URL stored on bookmarks.
-
archivebox-usernameplusarchivebox-passwordEspial signs in to the ArchiveBox web UI with these credentials before submitting URLs.
-
archivebox-tag(optional)A tag Espial adds to submissions (example:
espial). -
archivebox-plugins(optional)Comma-separated list of ArchiveBox methods (plugins) to request when submitting URLs, e.g.
title,favicon,singlefile,screenshot.
Set the ArchiveBox admin credentials in the override path by supplying:
ARCHIVEBOX_USERNAME=...ARCHIVEBOX_PASSWORD=...
The
Makefileincludes the following helpers:docker-compose-up-archivebox07docker-compose-up-d-archivebox07docker-compose-exec-archivebox07
Or start the instance manually via docker compose, example:
docker compose -f docker-compose.archivebox07.yml up
Configure the enrivonment variable
ARCHIVE_METHODSto control which archive methods ArchiveBox uses:environment: - ARCHIVE_METHODS=title,favicon,singlefile,screenshot
Available ARCHIVE_METHODS plugins:
archive_org,dom,favicon,git,headers,htmltotext,media,mercury,pdf,readability,screenshot,singlefile,title,wget
If
ARCHIVE_METHODSis unset/not-present, ArchiveBox will uses all plugins.For additional information and configuration, refer to the ArchiveBox repository
Optional proxy settings for archive requests:
archive-socks-proxy-hostarchive-socks-proxy-port
Migration commands are run via:
stack exec migration -- <command> [options]All commands take an optional --conn parameter for the database location; if omitted, the database location is loaded from config/settings.yml or environment variable SQLITE_DATABASE
| Command | Example |
|---|---|
createdb |
stack exec migration -- createdb |
createuser |
stack exec migration -- createuser --userName myusername --userPassword myuserpassword |
createuser (password file) |
stack exec migration -- createuser --userName myusername --userPasswordFile mypassword.txt |
deleteuser |
stack exec migration -- deleteuser --userName myusername |
createapikey |
stack exec migration -- createapikey --userName myusername |
deleteapikey |
stack exec migration -- deleteapikey --userName myusername |
importbookmarks |
stack exec migration -- importbookmarks --userName myusername --bookmarkFile sample-bookmarks.json |
importfirefoxbookmarks |
stack exec migration -- importfirefoxbookmarks --userName myusername --bookmarkFile firefox-bookmarks.json |
importnetscapebookmarks |
stack exec migration -- importnetscapebookmarks --userName myusername --bookmarkFile bookmarks.html |
importnotes |
stack exec migration -- importnotes --userName myusername --noteDirectory ./notes |
importnotesjson |
stack exec migration -- importnotesjson --userName myusername --noteFile exported-notes.json |
exportbookmarks |
stack exec migration -- exportbookmarks --userName myusername --bookmarkFile exported-bookmarks.json |
exportnetscapebookmarks |
stack exec migration -- exportnetscapebookmarks --userName myusername --bookmarkFile exported-bookmarks.html |
exportnotesjson |
stack exec migration -- exportnotesjson --userName myusername --noteFile exported-notes.json |
printmigratedb |
stack exec migration -- printmigratedb |
runmigratedb |
stack exec migration -- runmigratedb |
showuser |
stack exec migration -- showuser --userName myusername |
See sample-bookmarks.json, which contains a JSON array, each line containing a FileBookmark object.
Example:
[
{
"href": "http://raganwald.com/2018/02/23/forde.html",
"description": "Forde's Tenth Rule, or, \"How I Learned to Stop Worrying and \u2764\ufe0f the State Machine\"",
"extended": "",
"time": "2018-02-26T22:57:20Z",
"shared": "yes",
"toread": "yes",
"tags": "raganwald"
},
,
{
"href": "http://downloads.haskell.org/~ghc/latest/docs/html/users_guide/flags.html",
"description": "7.6. Flag reference \u2014 Glasgow Haskell Compiler 8.2.2 User's Guide",
"extended": "-fprint-expanded-synonyms",
"time": "2018-02-26T21:52:02Z",
"shared": "yes",
"toread": "no",
"tags": "ghc haskell"
}
]