diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..10a2ed8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# The build needs the source and nothing else. storage/ holds the live database and audio, .env +# holds the admin password, and pgdata is a leftover from the Postgres era that the build cannot +# even read. +.git +.env +storage/ +pgdata/ +levyraati +levyraati26-go diff --git a/README.md b/README.md index 370a0be..6db24d7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Invite-only, no public registration. Built for about ten friends. | [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only | | [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept | +| [docs/deployment.md](docs/deployment.md) | Running it on a server: the compose file, releases, upgrades, backups | ## Branches and releases diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..6ccb756 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,277 @@ +# Deployment + +How Levyraati gets onto a server and how it is changed once it is there. Configuration variables are +tabulated in the [README](../README.md#configuration); 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. + +```yaml +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: + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} + ADDR: ":8080" + # The admin listener binds the container's own interface. What keeps it private is the + # published port below, bound to the host's loopback. + ADMIN_ADDR: ":8081" + 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" + # Loopback only. The admin panel is Basic Auth and nothing else, so it must never be + # reachable from the network — reach it over an SSH tunnel, below. + - "127.0.0.1:8081:8081" + restart: unless-stopped +``` + +Three 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 | +| Admin port | `8081:8081`, reachable, convenient locally | `127.0.0.1:8081:8081`, loopback only | + +**The admin port is the one that matters.** Published as `8081:8081` it binds every interface, and +the admin panel has HTTP Basic Auth and nothing else — no session, no lockout, no second factor. On +a server that must be `127.0.0.1:8081:8081`. + +Alongside it, a `.env` — same variables as [.env.example](../.env.example), plus the image: + +```sh +IMAGE=registry.example.com/owner/levyraati26-go:2026.08.02-1 +ADMIN_USER=admin +ADMIN_PASSWORD=… +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 `), 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: + +```sh +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. + +```sh +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: + +```sh +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. + +Do **not** proxy port 8081. + +### Admin access + +Bound to the host's loopback, so reach it through an SSH tunnel: + +```sh +ssh -L 8081:127.0.0.1:8081 you@server +# then open http://localhost:8081 +``` + +Localhost also happens to be a secure context, which is what makes the invite *Kopioi* button work. + +From the panel: mint invites, reset passwords, ban members, delete songs, read feedback. + +**Lost the admin password?** Edit `.env`, `docker compose up -d`. There is no recovery endpoint and +no recovery key — the credentials *are* the environment. + +--- + +## Upgrading + +```sh +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: + +```sh +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 + +```sh +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 + +```sh +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 + +```sh +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 2–3 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 | `ADMIN_PASSWORD` unset. The log says so, and it is deliberate — an admin panel that silently opens 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 panel answers from another machine | The admin port is published on all interfaces — it must be `127.0.0.1:8081:8081` | diff --git a/docs/spec.md b/docs/spec.md index b0baad4..8f2605b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -4,7 +4,8 @@ What the app does. This file and the code must never disagree; when behaviour ch with it. Terms are defined in [CONTEXT.md](../CONTEXT.md), decisions and their reasons in [decisions.md](./decisions.md), and anything explicitly not in v1 in [later.md](./later.md). -Stack, configuration, and operations are in the [README](../README.md). +Stack and configuration are in the [README](../README.md); running it on a server is in +[deployment.md](./deployment.md). ---