Replace Postgres with SQLite

Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.

The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:

- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
  declared type is what makes the driver return time.Time, and the
  fixed-width UTC string is what makes ordering and comparison against
  datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
  edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
  formula out, guarded with max(0.0, ...) because cancellation returns a
  tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
  pragma is set on every connection.

Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
This commit is contained in:
Esa Kataja
2026-08-02 20:47:41 +03:00
parent a9776c6dde
commit 1fe5211ae6
28 changed files with 440 additions and 389 deletions
+17 -19
View File
@@ -45,10 +45,12 @@ Members have an address because it is their login and because mail is a planned
## Stack
Go, Postgres, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
yt-dlp. One binary, one origin — there is no separate frontend to deploy.
Go, SQLite, `html/template`, HTMX + Alpine. Audio is converted with ffmpeg and downloaded with
yt-dlp. One binary, one origin, one container — there is no separate frontend and no database server
to deploy.
Go dependencies: `pgx/v5` and `golang.org/x/crypto`. No Node, no npm, no bundler.
Go dependencies: `modernc.org/sqlite` and `golang.org/x/crypto`. The SQLite driver is pure Go, so the
build stays `CGO_ENABLED=0`. No Node, no npm, no bundler.
## Running it
@@ -64,8 +66,7 @@ creates no users: log into the admin panel and mint an invite.
| Variable | Default | Notes |
|---|---|---|
| `POSTGRES_PASSWORD` | — | **Required by Compose.** Used to build `DATABASE_URL` for the app |
| `DATABASE_URL` | — | `postgres://user:pass@postgres:5432/levyraati` |
| `DB_PATH` | `$STORAGE_DIR/levyraati.db` | The SQLite file. Created on first start |
| `ADMIN_USER` | `admin` | Admin panel username |
| `ADMIN_PASSWORD` | — | **Required.** No default; the app refuses to start without it |
| `ADDR` | `:8080` | Member-facing listener |
@@ -77,16 +78,14 @@ creates no users: log into the admin panel and mint an invite.
### Local development
```sh
docker compose up -d postgres
export DATABASE_URL="postgres://levyraati:$POSTGRES_PASSWORD@localhost:5432/levyraati"
export ADMIN_PASSWORD=dev SECURE_COOKIES=false
go run .
```
Requires Go 1.24+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`.
Requires Go 1.25+, plus `ffmpeg`, `ffprobe`, and `yt-dlp` on `PATH`. There is nothing to start first:
the database is a file under `./storage`, created on the first run.
Tests that need a database are skipped unless `TEST_DATABASE_URL` points at a throwaway one — the
migration test drops and recreates the `public` schema, so never point it at anything you care about.
Tests get a fresh database file in a temp directory each, so they need no setup and touch nothing:
```sh
go test ./...
@@ -136,23 +135,22 @@ JSON to stdout, nothing else. There is no log table and no log viewer in the app
### Backups
Two paths hold everything:
`./storage` holds everything: audio files, avatars, and `levyraati.db`. It is a bind mount, so a copy
of that one directory is the whole backup. `storage/tmp/` is in-flight conversions and is safe to
skip; it's cleared on startup anyway.
- `./pgdata` — the database. Postgres 18 stores it under a version subdirectory (`18/docker`), so
the mount is `/var/lib/postgresql`, not `/var/lib/postgresql/data`
- `./storage` — audio files and avatars
Both are bind mounts. `storage/tmp/` is in-flight conversions and is safe to skip; it's cleared on
startup anyway.
Copying the file while the app is running is not a backup — WAL means the latest writes live in a
sidecar file. Ask SQLite for a consistent snapshot instead:
```sh
docker compose exec postgres pg_dump -U levyraati levyraati | gzip > backup-$(date +%F).sql.gz
docker compose exec app sqlite3 /storage/levyraati.db ".backup '/storage/tmp/backup.db'"
gzip -c storage/tmp/backup.db > backup-$(date +%F).db.gz && rm storage/tmp/backup.db
```
### Database shell
```sh
docker compose exec postgres psql -U levyraati levyraati
docker compose exec app sqlite3 /storage/levyraati.db
```
## Layout