Running docket serve on an always-on machine with no display — a Raspberry Pi, an old
mini PC, a NAS, a VPS, a home server — so your todo list keeps working (and stays reachable
from your phone) even when every laptop is off. See self-hosting.md
for the product picture and setup walkthrough; this document is the operational how-to.
Everything here works entirely from a terminal — no GUI step is required.
-
Install docket and create a dedicated system user:
sudo useradd --system --home /var/lib/docket --create-home docket sudo npm install -g @pasichdev/docket which docket # confirm the path — used in the unit file's ExecStart -
Install the unit file (adjust
ExecStart's path first ifwhich docketprinted something other than/usr/local/bin/docket):sudo cp docs/docket.service /etc/systemd/system/docket.service sudo systemctl daemon-reload sudo systemctl enable --now docket sudo journalctl -u docket -f -
Pair your first device (run on the server, as the
docketuser):sudo -u docket DOCKET_DATA_DIR=/var/lib/docket docket devices pair
It prints a short-lived pairing code. On the client machine, run
docket pair http://<server-host>:8788(orhttps://...once you've put a reverse proxy in front — see below), enter the code, and approve the request the server side prints underdocket devices pending/docket devices approve <requestId>. -
Check it's healthy any time with:
sudo -u docket DOCKET_DATA_DIR=/var/lib/docket docket status
Binding beyond localhost. By default docket serve only listens on 127.0.0.1 (RFC
§9 — binding to all interfaces must be a deliberate choice, never the default). To accept
LAN/remote connections, uncomment Environment=DOCKET_SERVER_HOST=0.0.0.0 in the unit file
and sudo systemctl restart docket.
HTTPS. Put a reverse proxy (Caddy, nginx, Traefik, or a Cloudflare Tunnel) in front of docket for anything reachable outside a trusted LAN — docket itself doesn't terminate TLS. A minimal Caddy example:
todo.home.example {
reverse_proxy 127.0.0.1:8788
}
Note what a proxy does to source addresses: every request it forwards reaches docket from
127.0.0.1. Device management is therefore not gated on the request looking local — it
requires a secret that docket serve writes to admin-token in the data directory, mode
0600, which docket devices … reads because it runs as the same user on the same machine.
A proxied request cannot obtain it. Do not forward /api/v1/admin/ through the proxy, and
do not add the token to a proxy configuration.
For trusted-LAN-only development, plain HTTP is allowed but requires an explicit opt-in on
every client — see security.md.
docker compose up -dThat is the whole quickstart — there is no directory to create first. The compose file uses
a named volume (docket-data) rather than a bind mount, and the reason matters if you
change it: the image runs as an unprivileged docket user and owns /data at build time. A
bind mount covers that with a host directory, which mkdir -p data creates owned by you
at 0755, so the container user cannot write to it and docket exits at startup saying it has
no writable data directory. A named volume inherits the image's ownership.
To keep the data somewhere you can browse it, bind-mount and hand it to the container's user first:
mkdir -p data && sudo chown -R 100:101 data # the image's docket:docket
# then in docker-compose.yml: volumes: ["./data:/data"]Already running something on 8788 (a docket serve outside Docker, say)? The published host
port is overridable:
DOCKET_HOST_PORT=18788 docker compose up -dThis builds the image locally from this checkout (build: . in
docker-compose.yml) — no image is published yet, so there's
nothing to pull. The compose file also includes a container healthcheck against
/api/v1/health. The Dockerfile (repo root) is a multi-stage build targeting
linux/amd64 and linux/arm64.
If you'd rather publish an image once and pull it on multiple machines instead of building locally on each one:
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/pasichdev/docket:latest --push .then change docker-compose.yml's build: . to image: ghcr.io/pasichdev/docket:latest.
Pairing a device against a Docker-hosted server works the same way as systemd — run
docket devices pair inside the container:
docker compose exec docket docket devices pairWorks in both local and remote mode, and is the fastest way to sanity-check a headless install without a browser:
$ docket status
Mode: remote
Server: https://todo.home.example
Status: connected
Latency: 18 ms
Server version: 2.3.0
Device: jane-desktop
Device authorization: active
Exit code is non-zero whenever something needs attention (unreachable server, revoked device) — safe to wire into a monitoring script or a cron job.
docket serve handles SIGTERM/SIGINT directly: it stops accepting new connections,
closes every open SSE stream, and only then exits (see startServeServer's close() in
src/server/server.ts). systemd's default stop sequence (SIGTERM, then SIGKILL after
TimeoutStopSec) and Docker's default docker stop both work correctly with no extra
init/tini layer needed to forward the signal.
- Read the release notes for anything that touches the on-disk store format
(
todos.json.enc'sformatVersion— seeCURRENT_FORMAT_VERSIONinsrc/storage.ts). A newer server refuses to read a NEWER format than it understands (fails loud, not silently), so upgrading the server before any client that depends on a new feature is always safe; the reverse (an old server, new client) is guarded by the protocol compatibility check inGET /api/v1/info(RFC §23). - Back up first. On the server machine:
sudo -u docket DOCKET_DATA_DIR=/var/lib/docket docket backup /var/backups/docket-$(date +%F).backup - Upgrade and restart:
- systemd:
sudo npm install -g @pasichdev/docket@latest && sudo systemctl restart docket - Docker (local build):
git pull && docker compose up -d --build(the named volume survives a rebuild —docker compose downalone never touches it; onlydown -vremoves it) - Docker (published image):
docker compose pull && docker compose up -d
- systemd:
- Verify with
docket status(exit code 0,Status: connected/local health all green) before considering the upgrade done. If something looks wrong,docket restore <backup file>on the server puts the previous state back (see the README's Data & encryption section for what a restore actually does — it renames the current files aside rather than deleting them, so a bad restore is itself recoverable).
Client-side (docket pair-ed) machines never need a coordinated upgrade — a mismatched
protocol version fails loudly and specifically at connection time (RFC §23), rather than
silently misbehaving.