Files
verdande/docs/self-hosting.md
T
Kristian WindandClaude Opus 5 1eb6fd930d docs: the registry is private, and the handoff is current again
Installing the Rune failed with

    create container: Error response from daemon:
    No such image: ghcr.io/kristianwind/verdande:latest

which was two things wearing one message. No semver tag had ever been
pushed, so release.yml had never run and the image did not exist. And now
that v0.1.0 is published, the package is private because the repository
is — and an anonymous pull reports that as "no such image" rather than as
a permission error, which sends you looking in the wrong place. The
Docker daemon on the host has to be logged in once, as whichever user
runs it. Said in rune.md, self-hosting.md, the README and the handoff,
because whichever one you are holding is the one that has to say it.

The handoff's seven gaps are down to the ones that need real credentials
and real services: Gmail against an account, Web Push against a push
service, PNG icons for iOS, the AI adapters against a key. Added in their
place are the traps this round cost the most time on — that the embedded
frontend is a copy that npm run build does not touch, that Playwright
re-runs its config in every worker, and that svelte-check is not a gate
in a codebase with no type annotations.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 10:56:33 +02:00

3.4 KiB

Self-hosting

verdande is one static binary and one directory. There is no database server, no cache, no queue and no second process.

Docker

!!! warning "Log in to the registry first" The package is private, and an anonymous pull fails with No such image rather than with a permission error. Once per host:

```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
```

`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
docker run -d --name verdande \
  --restart unless-stopped \
  -p 8080:8080 \
  -v verdande-data:/data \
  -e VERDANDE_BASE_URL=https://todo.example.dk \
  ghcr.io/kristianwind/verdande:latest

VERDANDE_BASE_URL has to be the address you actually reach it on. Invite links, password resets and calendar feeds are all built from it, so getting it wrong means the links in your email point somewhere that does not answer.

Docker Compose

services:
  verdande:
    image: ghcr.io/kristianwind/verdande:latest
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - ./data:/data
    environment:
      VERDANDE_BASE_URL: https://todo.example.dk
      VERDANDE_SMTP_HOST: mail.example.dk
      VERDANDE_SMTP_USER: [email protected]
      VERDANDE_SMTP_PASS: ${SMTP_PASSWORD}
      VERDANDE_SMTP_FROM: [email protected]

Behind a reverse proxy

verdande expects to be terminated by something else. It reads X-Forwarded-For, so rate limiting and the activity log see the real caller rather than the proxy.

=== "Caddy"

```
todo.example.dk {
    reverse_proxy localhost:8080
}
```

=== "Cloudflare Tunnel"

```yaml
ingress:
  - hostname: todo.example.dk
    service: http://localhost:8080
  - service: http_status:404
```

The WebSocket needs no special handling; Cloudflare proxies it as-is. A silent
socket is closed after 100 seconds, which is why verdande pings every 30.

!!! warning "Use HTTPS" Over plain HTTP the session cookie cannot be Secure, and verdande will not use the __Host- prefix that stops a subdomain overwriting it. Fine on a laptop; not fine on anything reachable from outside.

First run

Open the address and create an account. The first one is the administrator, and the endpoint that creates it refuses once any account exists — so there is no window in which somebody else can claim your instance.

Everybody after that joins by invite.

What lives in /data

/data
├── verdande.db      SQLite, WAL mode
├── files/           attachments, content-addressed
└── backups/         nightly snapshots, 14 kept

Back up the whole directory.

!!! danger "Do not copy verdande.db on its own" It runs in WAL mode, so recent writes live in verdande.db-wal until they are checkpointed. A copy of the .db file alone can be missing them. Copy the whole directory, or take one of the nightly snapshots from backups/ — those are written with VACUUM INTO and are complete on their own.

Backups

A snapshot is written once a day and the most recent fourteen are kept. They are counted rather than aged out, so a container that was off for a month comes back with its backups intact.

Updating

docker compose pull && docker compose up -d

Migrations run at startup, each in its own transaction. Take a copy of /data first anyway.