Deployment
Docker image
Official images are published to Docker Hub:
quent1l/skol-arena:latest
quent1l/skol-arena:<version> # e.g. 1.20.1
The image is built from the repository’s multi-stage Dockerfile: it compiles the
shared types package, builds the Vue frontend, bundles the Bun backend, and copies
only the production artifacts into a minimal oven/bun:1.4-slim runtime running
as a non-root user.
Platform note: published images are currently linux/amd64 only. If you’re on
ARM (Raspberry Pi, or Apple Silicon without Rosetta-backed Docker Desktop), build
the image yourself from the Dockerfile at the repo root:
docker build -t skol-arena:local .
Docker Compose
A minimal setup with Postgres alongside the app:
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: skol
POSTGRES_PASSWORD: change-me
POSTGRES_DB: skol_arena
volumes:
- skol-db-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U skol -d skol_arena']
interval: 5s
timeout: 5s
retries: 10
app:
image: quent1l/skol-arena:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- '3000:3000'
environment:
DATABASE_URL: postgres://skol:change-me@db:5432/skol_arena
BETTER_AUTH_SECRET: change-me-to-a-long-random-string
BETTER_AUTH_URL: http://localhost:3000
volumes:
skol-db-data:
The app has no other stateful dependency — no upload directory or extra volume is needed beyond the Postgres data volume.
The healthcheck and condition: service_healthy matter more than they look. A
plain depends_on: - db only waits for the database container to start, not for
Postgres to accept connections — the app would then fail its startup migrations and,
under restart: unless-stopped, crash-loop until the database happens to be ready.
Add SMTP, VAPID, or Keycloak variables from the Environment Variables reference as needed; none of them are required to get a working instance running.
First boot
Two things happen automatically the first time the container starts against an empty database:
-
Migrations run. The app applies all pending migrations before accepting any HTTP traffic, one transaction per migration. If one fails, the process exits — check the container logs and fix the underlying issue rather than retrying blindly. On a first boot against an empty database there is nothing to lose; on an upgrade there is, so read Upgrading before you pull a new tag.
-
An initial super-admin account is created, but only if the
appUserstable is completely empty. The account email isINITIAL_ADMIN_EMAIL(defaults toadmin@skol-arena.local), and a random password is generated and printed to the container logs:docker compose logs app | grep "INITIAL ADMIN CREDENTIALS"As long as that account has never logged in, a new password is generated and logged on every restart, and the previous one stops working. Missing the log line the first time is therefore harmless — restart the container and read the fresh password. Rotation stops permanently on the first successful login.
Log in with the last logged email/password at
/login?native=true, then change the password from the account settings. The generated password appears in cleartext in the logs, so treat those logs as a secret until you have replaced it.
Upgrading
Back up the database first
docker compose exec -T db pg_dump -U skol -Fc skol_arena > skol-$(date +%F).dump
Do this every time, before pulling a new tag. It is the only way back.
Why it matters
Migrations are applied one transaction per migration. A migration that fails is rolled back whole, but the migrations that ran before it are already committed, and they stay committed. The database is then somewhere between two releases, and the log says so:
Migration failed. The migrations before it are committed and stay applied;
restoring the previous release requires a database backup.
The log line names the migration that failed, its position in the batch, and how many were applied — start there.
Running the whole batch in a single transaction would avoid this, and that is what the app used to do. It had to go: Postgres refuses to use an enum value inside the same transaction that added it, which made a first boot from an empty database impossible. Per-migration transactions are the price of being able to create a new instance at all.
Rolling back
There are no down-migrations. Going back to an earlier image means restoring the dump:
docker compose stop app
docker compose exec -T db pg_restore -U skol -d skol_arena --clean --if-exists < skol-2026-08-23.dump
Then start the previous tag. This is true whether the upgrade failed halfway or succeeded: a completed migration is not reversible either.
Multiple replicas
Only one container migrates. The others block on a Postgres advisory lock until it is done, then find nothing pending and carry on booting. Scaling the app service during an upgrade is safe; two instances cannot interleave migrations.
Recovering a lost admin password
The rotation described above only covers accounts flagged as awaiting their first login. Instances created before that behaviour existed are never flagged, so they keep whatever password they already have — restarting them does not print a new one.
If you are locked out — the initial password was lost and no other super-admin can log in — re-arm the rotation by hand against the database:
UPDATE app_users SET bootstrap_pending = true WHERE role = 'super_admin';
Restart the container and read the new password from the logs, exactly as on a first boot. The flag clears itself again on the next successful login.
This is deliberately a manual step. The app cannot tell an admin who has never logged in from one who simply has not logged in recently — sessions expire and are deleted — so automatically re-arming the rotation would invalidate working passwords on healthy instances.
The same procedure works as a general admin password reset, for example if SSO becomes unavailable and no local credentials are known.
Single-container frontend serving
The image already points FRONTEND_BUILD_PATH at the frontend build it bundles, so
the backend serves the Vue app itself — static assets plus an SPA fallback to
index.html — and a single container on a single port covers both the API and the
web app. Nothing to configure.
Setting the variable to an empty value disables it: the container then only
exposes the API, and / returns a plain placeholder response. That is only
useful if you serve the frontend separately (a CDN or a reverse proxy), in which
case set FRONTEND_URL so CORS and the auth trusted origins allow it.