Files
Levyraati26_go/docs/deployment.md
T
Esa Kataja 2af29fe999 Make the admin a member with a flag, and drop the second listener
The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.

is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.

Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.

An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.

This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
2026-09-05 13:40:26 +03:00

11 KiB
Raw Permalink Blame History

Deployment

How Levyraati gets onto a server and how it is changed once it is there. Configuration variables are tabulated in the README; this file is the procedures.

The whole deployment is one container and one directory. There is no database server, no migration step to run by hand, and no build on the target machine.


The server's compose file

The docker-compose.yml in the repository root builds from source — that is the development one, and it is what you want on a machine that has the code checked out. A server has no source, so it runs a published image instead. Keep this second file on the server; it is not in the repository because it describes one particular deployment rather than the app.

services:
  app:
    # Registry included. Pin a release tag, never :latest — a restart must not quietly change the
    # running version. Kept in .env so this file carries no host of yours.
    image: ${IMAGE:?set IMAGE in .env}
    environment:
      # Only read while the users table is empty: they create the first account and are ignored
      # from then on. Safe to remove once that account exists.
      ADMIN_EMAIL: ${ADMIN_EMAIL:-}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
      ADMIN_NAME: ${ADMIN_NAME:-Ylläpito}
      ADDR: ":8080"
      SECURE_COOKIES: ${SECURE_COOKIES:-true}
      PUBLIC_URL: ${PUBLIC_URL:-}
    # The SQLite file sits in here beside the audio, so this one mount is the whole backup.
    volumes:
      - ./storage:/storage
    ports:
      - "8080:8080"
    restart: unless-stopped

Two differences from the development file, and the reason for each:

Development Server
Source of the binary build: from the checkout image: pulled from the registry
Version VERSION build arg, dev by default baked into the tagged image

There is one port. /admin rides the member listener behind the same session cookie as everything else, so there is nothing extra to publish, tunnel or firewall.

Alongside it, a .env — same variables as .env.example, plus the image:

IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1
ADMIN_EMAIL=# first start only
ADMIN_PASSWORD=# first start only
SECURE_COOKIES=true
PUBLIC_URL=https://levyraati.example.com

What the server needs

  • Docker with the Compose plugin, or Podman with podman-compose.
  • Credentials for the registry holding the image (docker login <registry>), unless it is public.
  • A reverse proxy terminating TLS in front of port 8080. Cookies are Secure, so the members' site over plain HTTP will not keep anyone logged in.
  • Outbound network access: yt-dlp reaches YouTube, and the lyrics lookup reaches LRCLIB. Neither is fatal to lose — submissions fail with a visible message and lyrics stay empty.

Nothing else. No Go toolchain, no ffmpeg on the host — those live in the image.


Building and publishing a release

Done from a checkout, not on the server. The version reaches the binary only through the build arg, so it must match the tag or /healthz will lie about what is deployed:

git switch main && git merge dev
git tag 2026.08.02-1
podman build --build-arg VERSION=2026.08.02-1 \
  -t registry.example.com/owner/levyraati26-go:2026.08.02-1 \
  -t registry.example.com/owner/levyraati26-go:latest .
podman push registry.example.com/owner/levyraati26-go:2026.08.02-1
podman push registry.example.com/owner/levyraati26-go:latest

Check before pushing that the tag took: podman run --rm -p 8099:8080 -e ADMIN_PASSWORD=x IMAGE then curl localhost:8099/healthz should answer ok 2026.08.02-1, not ok dev.


First deployment

Two files go on the server — the compose file above and .env. Not a git clone; the source is not needed to run this.

mkdir -p /srv/levyraati && cd /srv/levyraati
# put docker-compose.yml and .env here
chmod 600 .env        # it holds the only admin credential there is
docker compose pull
docker compose up -d
docker compose logs -f app     # watch the migrations apply

The first start creates ./storage with audio/, avatars/, tmp/ and levyraati.db, applies every migration, and only then accepts connections. It creates no users — nobody can register until you mint an invite.

Confirm it is alive, and that the version is the one you meant to deploy:

curl -s localhost:8080/healthz     # -> ok 2026.08.02-1

Reverse proxy

Proxy your public hostname to 127.0.0.1:8080. Two things matter beyond the defaults:

  • Upload size. Submissions are capped at 50 MB by the app; a proxy with a 1 MB default body limit rejects them first, and the error is not the app's clear one. Raise it past 50 MB (client_max_body_size 64m in nginx, MaxRequestBodySize in Caddy).
  • Response buffering off, or at least generous timeouts, for /audio/{id} — it serves Range requests so the player can seek.

Admin access

Log in as your own account and open /admin. Nothing to tunnel, nothing extra to proxy: the page is part of the site and is gated on the is_admin flag on your user row. A signed-in member without the flag gets a 404 there, so the page does not advertise itself.

TLS at the proxy is what makes the invite Kopioi button work — the clipboard API needs a secure context, and https:// is one. Over plain HTTP on a real hostname the button will not fire.

From the page: mint invites, reset passwords, ban members, delete songs, read feedback.

First start. On an empty database the app creates one account from ADMIN_EMAIL / ADMIN_PASSWORD and marks it admin. Once it exists those variables do nothing; drop them from .env if you would rather not keep a password there.

Lost the admin password? There is no recovery endpoint and no recovery key. Set a new bcrypt hash directly in the SQLite file — re-running the app with ADMIN_PASSWORD will not help, because seeding only fires on an empty users table.


Upgrading

cd /srv/levyraati
# back up first — see below; it takes a second and this is exactly when you want it
$EDITOR .env                   # point IMAGE at the new tag
docker compose pull
docker compose up -d
curl -s localhost:8080/healthz # confirm the new version is answering

Migrations run at startup, inside the new container, before it serves. There is no separate step.

Expect a few seconds of downtime. One container, one SQLite file, no rolling deploy — and a restart deliberately fails every in-flight submission. Deploy when nobody is mid-review.

Rolling back

Point IMAGE at the previous tag and docker compose up -d. Only safe if the release you are leaving added no migration — migrations are forward-only and the old binary will not understand a schema it has never seen. Check migrations/ between the two tags first; if one landed, restore the backup taken before the upgrade instead.

What a restart does to work in progress

Conversions run as goroutines inside the process, so a restart kills them. This is handled, not ignored: the startup sweep marks every submission still queued, downloading or converting as failed with "interrupted by restart", so nothing is stuck saying "converting" forever. The submitter sees the failure and can retry a URL submission or re-upload a file. Published songs and reviews are untouched.


Backups

./storage holds everything — audio, avatars, and levyraati.db.

Do not just copy the database file while the app is running. WAL mode means recent writes live in levyraati.db-wal, and a bare copy can miss them or catch a torn state. Ask SQLite for a consistent snapshot instead — it is safe against a live, writing database:

cd /srv/levyraati
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
gzip -c storage/tmp/backup.db > /backups/levyraati-$(date +%F).db.gz
rm storage/tmp/backup.db
tar czf /backups/levyraati-audio-$(date +%F).tar.gz -C storage audio avatars

storage/tmp/ is in-flight conversions and is safe to skip; it is cleared at startup anyway.

A daily cron of those four lines is a complete backup strategy for this app.

Restoring

docker compose down
gunzip -c /backups/levyraati-2026-08-02.db.gz > storage/levyraati.db
rm -f storage/levyraati.db-wal storage/levyraati.db-shm   # stale sidecars of the old file
tar xzf /backups/levyraati-audio-2026-08-02.tar.gz -C storage
docker compose up -d

Deleting the -wal and -shm files matters: left behind, they belong to the database you just replaced, and SQLite will try to apply them to the restored one.

Audio and rows are backed up separately but must be restored together — a song row whose .ogg is missing gives a broken player, and an orphan .ogg is invisible to everyone.


Operations

Logs

docker compose logs -f app

JSON to stdout, nothing else. Every line carries a ctx field (startup, auth, songs, submissions, invites, reports) to filter on.

Two startup warnings are worth reading rather than skipping: submissions interrupted by restart says the sweep cleaned up after a restart, and failed submissions present is often the first sign that yt-dlp has gone stale.

yt-dlp goes stale

yt-dlp rots against YouTube — routine maintenance, not an incident. It comes from Alpine's community repository in the image, so the fix is a rebuild, which means publishing a new image rather than anything on the server. Rebuild monthly. Failures show the yt-dlp error to the submitter, so members usually notice before you read a log.

Database shell

docker compose exec app sqlite3 /storage/levyraati.db

Writes here are unaudited and unvalidated — the schema holds the constraints, but the app's rules (the review window, the reveal rule, the lock) are in Go. Prefer the admin panel.

Disk

Audio is Opus at 96 kbps: roughly 23 MB per song, so a hundred songs is a few hundred megabytes. storage/tmp/ briefly holds a 50 MB upload plus its converted copy per in-flight submission, bounded by the two conversion slots.


Troubleshooting

Symptom Cause
Container exits immediately on a first start ADMIN_EMAIL or ADMIN_PASSWORD unset on an empty database. The log says so; a site nobody can log into is worse than one that will not boot
set IMAGE in .env Compose has no image to run; IMAGE is required and unset
/healthz says ok dev The image was built without --build-arg VERSION, so what is deployed cannot be identified
Login never sticks Plain HTTP with SECURE_COOKIES=true. Terminate TLS, or set it false for a local test
Uploads fail near 50 MB The reverse proxy's body limit, not the app's
Invite links are relative PUBLIC_URL unset
Everything 500s after a restore -wal/-shm sidecars from the replaced database were left in place
Submissions all fail at download yt-dlp is stale; rebuild and publish the image
/admin returns 404 while logged in That account has no is_admin. Set it in the database; nothing in the UI grants it
Setting ADMIN_PASSWORD again changes nothing Seeding only fires on an empty users table. Reset the hash in the database instead