Write the deployment manual, and keep the build context clean
docs/deployment.md is the server-side procedures: the compose file a server runs, building and publishing a release, first deployment, reverse proxy, upgrades and rollback, backups and restore, and a troubleshooting table. The compose file lives in the manual rather than in the repository, because the one at the root builds from source and is what development wants. The server's pulls a published image, pins a release tag, and publishes the admin port on the host's loopback instead of every interface — the panel is Basic Auth and nothing else, so where that port is bound is the whole of its security. .dockerignore keeps the image build off storage/ (the live database and the audio), .env (the admin password) and the leftover pgdata, which the build cannot read anyway and which fails it outright.
This commit is contained in:
@@ -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 <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:
|
||||
|
||||
```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` |
|
||||
Reference in New Issue
Block a user