Everything runs on your own server, on your own network. The README's two-command install is the normal path; this document is the reference behind it.
One Compose file, docker-compose.yml, holds the whole stack:
flowchart LR
user((Browser))
user -- "http://SERVER-IP" --> nginx
subgraph stack["docker-compose.yml"]
nginx["proxy<br/>nginx · :80"]
app["app<br/>Next.js · :3000"]
cron["cron<br/>supercronic"]
migrate["migrate<br/>one-shot"]
db[("db<br/>supabase/postgres")]
auth["auth · GoTrue"]
rest["rest · PostgREST"]
rt["realtime"]
storage["storage-api"]
nginx --> app
nginx -- /auth/v1 --> auth
nginx -- /rest/v1 --> rest
nginx -- /realtime/v1 --> rt
nginx -- /storage/v1 --> storage
cron -. Bearer CRON_SECRET .-> app
migrate --> db
auth & rest & rt & storage --> db
end
Design choices worth knowing:
- One origin, plain HTTP. nginx routes the Supabase path prefixes
to the right service and everything else to the app. No CORS, no
certificates, no warnings: the stack is built for a private LAN and
the app's cookies follow the URL scheme (
lib/auth/cookie-secure.ts). If you front it with your own TLS proxy on an https:// address, Secure cookies switch back on by themselves. - No Kong, no studio, no pooler. The services enforce JWT auth
themselves; administrate the database with
psqlthroughdocker exec. - Migrations are a service. The one-shot
migratecontainer applies new files fromsupabase/migrations/on everyup, records them in the_firmabok.migrationstable, and the app is not allowed to start until it has finished. Updates can never outrun the schema. - Named volumes. Database (
db_data) and document archive (storage_data) live under Docker's management; nothing in the checkout is root-owned.
install-debian.sh generates .env on first install. The variables:
| Variable | Meaning |
|---|---|
DOMAIN |
The server's LAN IP (or a local DNS name) |
POSTGRES_PASSWORD |
Database password for all service roles |
JWT_SECRET |
HS256 secret every service verifies tokens against |
ANON_KEY / SERVICE_ROLE_KEY |
Supabase-style JWTs signed with JWT_SECRET |
SECRET_KEY_BASE / REALTIME_DB_ENC_KEY |
Realtime's internal secrets |
CRON_SECRET |
Bearer token the cron sidecar authenticates with |
AUTH_SIGNUPS_DISABLED |
true after the first account (install-debian.sh lock) |
IMAGE_TAG |
Optional: app image tag, defaults to latest |
Re-run the install commands from the README (or git pull in the
checkout and run ./install-debian.sh <ip> again): the script pulls
the newer app image, applies only the migrations that are new, and
restarts. Nothing else needs tending.
The named volumes are not files to copy - back up logically:
docker exec firmabok-db-1 pg_dump -U postgres -d postgres | gzip > firmabok-$(date +%F).sql.gzShip that off-host on a schedule. As a portable, vendor-neutral layer
on top, export each fiscal year as SIE via the app (Rapporter → SIE):
any Swedish bookkeeping system can re-import it. Documents live in the
storage_data volume; include it if you want file-level copies:
docker run --rm -v firmabok_storage_data:/data -v "$PWD":/out alpine tar czf /out/firmabok-documents.tgz -C /data .The published image is ghcr.io/mews-se/firmabok. To run your own
build instead:
docker build -t ghcr.io/mews-se/firmabok:local .
echo 'IMAGE_TAG=local' >> .env
./install-debian.sh <ip>The health wait times out. Look at the logs (from the checkout):
docker compose logs migrate app.
The usual causes are a migration error (migrate exits non-zero and the
app never starts) or wrong values in .env.
Port 80 is taken. Something else on the server owns it; stop it or change the proxy's port mapping in the Compose file.
Login loops back to the login page. The app URL and the address in
the browser must match: DOMAIN decides both. Check that you are
browsing http://<DOMAIN> exactly.
Signups are closed and I need another account. Set
AUTH_SIGNUPS_DISABLED=false in .env, run docker compose up -d,
create the account, then run ./install-debian.sh lock again.