Files
verdande/docs/self-hosting.md
T
Kristian WindandClaude Opus 5 20b8c12b4a build: imaget hentes fra vores egen Gitea, og runen peger derind
GitHub er utilgængeligt i ti dage. Koden ligger allerede begge steder; det, der
manglede, var imaget og et sted at bygge det.

**Imaget ligger i Gitea nu**, begge arkitekturer. Kopieret med
`docker buildx imagetools create`, som flytter hele manifest-indekset mellem to
registre uden at hente lagene ned — en `docker pull` på denne maskine ville have
hentet arm64 og efterladt serveren uden en amd64 at køre.

**Og det kan hentes uden legitimation, mens repoet bliver ved med at være
privat.** Det var ikke givet: panelets `PullImage` sætter aldrig `RegistryAuth`,
så et privat register er udelukket, og Gitea gør normalt en container-pakke privat
sammen med det repo, den hører til. Men en pakke bindes kun til et repo, når den
skubbes med et repo-link — og denne er ikke, så dens synlighed følger brugeren
`kw`, som er offentlig.

Bevist frem for antaget: `docker --config /tmp/tom pull` hentede den med samme
digest som GHCR havde. En bar `curl` mod `/v2/…/manifests/…` svarer 401 også for
et læsbart image, fordi registret uddeler et anonymt token først — så den er ikke
prøven, og den kostede en runde at gennemskue.

**Runen er bumpet til version 5.** Ikke fordi felterne ændrede sig, men fordi en
rune ikke opdaterer sig selv: panelet skriver rune-rækken fra en UI-handling, så
ændringen kræver **Runes → verdande → Update**, og katalog-listen caches ti
minutter.

**Et Actions-workflow til Gitea** ligger i `.gitea/workflows/`. Sin egen fil frem
for en delt: `secrets.GITHUB_TOKEN` findes ikke der, registret er et andet, og der
er ingen Releases-side at skrive til. En delt fil ville være fire betingede led,
der hver kun gælder ét af de to steder. Den laver ingen release, fordi
opdateringsnotitsen i appen læser GitHubs — skal den flyttes med, er det
`internal/update`, der skal have en ny adresse.

**Det virker ikke endnu:** der er ingen runner registreret på Gitea. Indtil der er,
bygges og skubbes imaget i hånden herfra.

Dokumentationen og landingssiden siger den nye adresse. Rune-siden er skrevet om,
hvor den fortalte om GHCR-pakkeindstillinger, og siger nu, hvordan man prøver
efter på den måde, der faktisk svarer på spørgsmålet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:53:18 +02:00

3.5 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

!!! note "If the pull is refused" The image is published from a self-hosted Gitea and is readable without credentials — the package is not linked to a repository, so its visibility follows the account rather than the (private) source repo. If it is ever set back to private, docker login gitea.nolimit.dk before pulling — and see the Rune page, because a private image fails differently and much more confusingly under Yggdrasil.

docker run -d --name verdande \
  --restart unless-stopped \
  -p 8080:8080 \
  -v verdande-data:/data \
  -e VERDANDE_BASE_URL=https://todo.example.dk \
  gitea.nolimit.dk/kw/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: gitea.nolimit.dk/kw/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: verdande@example.dk
      VERDANDE_SMTP_PASS: ${SMTP_PASSWORD}
      VERDANDE_SMTP_FROM: verdande@example.dk

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.