Docker Deployment
Stoic Budget ships a production Docker Compose setup in the repository root. CD publishes signed backend and frontend images to GHCR for every release tag.
Configuration
Generate a root .env with fresh secrets:
npm run setup:env # or: make setup
The generated file targets development, so review it before production. Compose refuses to start without:
POSTGRES_USER,POSTGRES_PASSWORDJWT_SECRET,JWT_REFRESH_SECRETINTEGRATIONS_ENCRYPTION_KEY
Compose builds DATABASE_URL from the POSTGRES_* values. For a public deployment also set:
FRONTEND_URL(defaults tohttp://localhost:3000) orCORS_ORIGINSto the public frontend origin — the backend refuses to start in production without an allowed originAUTH_COOKIE_SAMESITE=none(HTTPS only) when the frontend and API sit on different registrable domains, andAUTH_COOKIE_DOMAINto share cookies across subdomainsMETRICS_AUTH_TOKENif you scrape/api/v1/metrics
Start services
Run the published images:
docker compose pull
docker compose up -d
Pin a release with IMAGE_TAG in .env. make start builds the images locally instead
(docker compose up -d --build).
This starts:
- PostgreSQL 14
- Redis 7
- NestJS backend
- React frontend on vinext
Migrations run automatically when the backend starts (RUN_MIGRATIONS=true). PostgreSQL and Redis publish their
ports on 127.0.0.1 only.
Upgrading
make upgrade # newest release (or IMAGE_TAG from .env)
make upgrade VERSION=v1.2.0 # a specific release
make upgrade does four things:
- Dumps the database to
backup_<timestamp>.sqlin the repository root. - Pulls the backend and frontend images from GHCR.
- Recreates the containers.
- Waits up to five minutes for
/api/v1/health/ready.
The backend applies any pending migrations when it starts. Your volumes and .env are left as they are. Without
make, run the same two steps that make upgrade runs after the backup:
docker compose pull
docker compose up -d
To roll back, pin the previous tag with make upgrade VERSION=<old tag>. If the newer release has already migrated
the schema, also restore the backup into an empty database:
docker compose stop backend frontend
docker exec seneca-postgres sh -c 'dropdb -U "$POSTGRES_USER" "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
make db-restore file=backup_<timestamp>.sql
make upgrade VERSION=<old tag>
The make upgrade VERSION=<old tag> at the end takes a fresh backup of the restored database before it starts the
old release.
Updating from Settings
Instance admins see the running version and the newest release under Settings → Advanced → Changelog. To install a release from there, turn on the updater container once:
echo 'COMPOSE_PROFILES=updater' >> .env
docker compose up -d
Update to vX.Y.Z then runs the same steps as make upgrade: it backs up the database to backup_<timestamp>.sql,
pulls the images, recreates the backend and frontend and waits for the backend. The page follows the progress and
reloads once the new version answers. A failed step is reported with its error output and the backup's file name.
The updater mounts /var/run/docker.sock, which gives it root on the host. It publishes no port and only reads
release tags (vX.Y.Z) that the backend writes to a shared volume. Only users with the admin role who are signed
in to the app can ask for an update; API keys cannot. Leave the profile off if you prefer to update over SSH.
Releases that change docker-compose.yml or infra/updater/updater.sh still need a git pull on the server, as with
make upgrade.
Optional: receipt maps
docker compose --profile maps --profile geocoder up -d
Then set TILESERVER_URL=http://tileserver:8080 and GEOCODER_URL=http://nominatim:8080 for the backend and
restart it. MAP_PBF_URL selects the OpenStreetMap extract. See Receipt Maps.
Reverse proxy
Terminate HTTPS in front of the stack and forward X-Forwarded-For and X-Forwarded-Proto. The backend trusts one
proxy hop by default, so rate limits key on the real client IP.
That trust is only safe while the proxy is the way in. Compose publishes the API on BACKEND_PORT (3001) on all
interfaces, and a client that connects to it directly can send its own X-Forwarded-For and choose the IP the login
and password-reset limits count against. Either keep that port closed to everything but the proxy (firewall, or
"127.0.0.1:${BACKEND_PORT:-3001}:3001" in docker-compose.yml when the browser reaches the API through the proxy), or
set TRUST_PROXY to the proxy's networks instead of a hop count:
# proxy on this host or in the Docker network
TRUST_PROXY=loopback, linklocal, uniquelocal
# or: proxy elsewhere in a private network
TRUST_PROXY=10.0.0.0/8
Direct clients with a public address are then never believed. Have the proxy replace, not append to, a client's own
X-Forwarded-For (Caddy does this by default; in nginx use proxy_set_header X-Forwarded-For $remote_addr).
Health checks
curl http://localhost:3001/api/v1/health # liveness
curl http://localhost:3001/api/v1/health/ready # also checks the database
The production backend and frontend images define Docker HEALTHCHECKs.
Storage
Data lives in named volumes: postgres_data, redis_data, and backend_uploads (mounted at /app/uploads),
plus map_data and nominatim_data when the map profiles run. Back them up or map them to persistent storage.
To check that every statement still has its file, run these from backend/ in a source checkout (the production
image has no npm):
npm run storage:verify # reports statements whose file is neither on disk nor in the database
npm run storage:repair # also restores to disk the files only the database still holds
Logs
make logs
make logs-backend
Next: CI/CD