-
Notifications
You must be signed in to change notification settings - Fork 0
Production
Article30 ships pre-built multi-arch (linux/amd64 + linux/arm64) container images to GitHub Container Registry on every release. The reference deployment is Docker Compose pulling those images. Building from source is supported as an explicit overlay for forks, debugging, or environments without GHCR access.
Exposing this app to untrusted networks requires additional hardening that the compose file alone does NOT provide - see Hardening checklist below.
- Docker Engine 24+ and Docker Compose v2 (
docker compose versionshould report 2.x). - An SMTP relay if you want password-reset emails to send (otherwise set
SMTP_ENABLED=falseand admins issue resets via the CLI fallback - see Recovering a forgotten password). - The repo cloned locally (gives you
docker-compose.yml,.env.prod.example, and thebuild/overlays).
The .env.prod.example template lists every required and optional variable with comments. Copy it once:
cp .env.prod.example .env.prodRequired secrets to fill before first start:
| Var | Generate with |
|---|---|
DB_PASSWORD |
openssl rand -base64 32 |
REDIS_PASSWORD |
openssl rand -base64 32 |
SESSION_SECRET |
openssl rand -base64 32 (>= 32 chars) |
AUDIT_HMAC_SECRET |
openssl rand -base64 32 (>= 32 chars) |
S3_ACCESS_KEY / S3_SECRET_KEY
|
any random tokens (the bundled RustFS uses them) |
SMTP_* |
your provider's values, OR SMTP_ENABLED=false
|
CORS_ORIGIN, FRONTEND_URL, NEXT_PUBLIC_API_URL
|
the public URL of your frontend |
DATABASE_URL and REDIS_URL are computed by the compose file from DB_USER + DB_PASSWORD and REDIS_PASSWORD; you do not need to edit the placeholders in .env.prod.example.
docker compose --env-file .env.prod up -d
docker compose --env-file .env.prod --profile admin run --rm \
-e ALLOW_SEED=1 backend-tools seed # first run onlyThe first command pulls ghcr.io/ipsec-dev/article30/backend:latest and .../frontend:latest, starts Postgres / Redis / RustFS / backend / frontend, and applies any pending Prisma migrations on backend startup. The second command runs the seed via the version-pinned backend-tools image (see Admin operations), populating reference data (GDPR recitals + articles in 5 languages, default RSS feeds, the default Organization row). The seed is idempotent by design - re-running it is safe but unnecessary; the prod backend CMD intentionally does not run it on every start. ALLOW_SEED=1 is required in production as an explicit opt-in, since accidental re-seeding could overwrite manually-edited reference rows.
| Service | URL |
|---|---|
| Frontend | http://localhost:3000 |
| Backend API | http://localhost:3001 |
The first user to sign up automatically receives the ADMIN role.
The base compose file references ghcr.io/ipsec-dev/article30/{backend,frontend}:${ARTICLE30_VERSION:-latest}. Set ARTICLE30_VERSION in .env.prod (or pass it inline) to pin a specific tag:
ARTICLE30_VERSION=1.2.3 docker compose --env-file .env.prod up -dTag patterns published by release-publish.yml:
| Tag | Meaning |
|---|---|
1.2.3 |
exact patch |
1.2 |
latest patch of 1.2.x
|
1 |
latest of 1.x.x
|
latest |
most recent published release |
To upgrade:
# Edit .env.prod and bump ARTICLE30_VERSION (or leave it at latest).
docker compose --env-file .env.prod pull
docker compose --env-file .env.prod up -dBackend startup applies any new Prisma migrations automatically. Seed is not re-run; if a release ships new reference data, the release notes will call out a one-off docker compose --env-file .env.prod --profile admin run --rm -e ALLOW_SEED=1 backend-tools seed step. The backend-tools tag follows ARTICLE30_VERSION so admin scripts always match the running backend's prisma schema.
To roll back, set ARTICLE30_VERSION to the previous tag, pull, and up -d. Migrations do not auto-revert; verify the previous schema is forward-compatible before downgrading.
Build the prod images locally instead of pulling from GHCR. Useful for testing a local change against prod containers, deploying a fork, or running in an environment that cannot reach ghcr.io.
docker compose -f docker-compose.yml -f build/prod.compose.yml --env-file .env.prod up -d --build
docker compose -f docker-compose.yml -f build/prod.compose.yml --env-file .env.prod \
--profile admin run --rm -e ALLOW_SEED=1 backend-tools seed # first run onlyThe overlay tags the local builds as article30-{backend,frontend}:local so they don't shadow the GHCR :latest tag in your local image cache.
One-off backend scripts (seed, password reset, backfills) ship in a separate version-pinned image, ghcr.io/ipsec-dev/article30/backend-tools, published alongside backend / frontend on every release. The compose file defines a backend-tools service under profiles: [admin] so it never starts with up -d; you invoke it explicitly with compose run.
# Seed (first install or after a release that ships new reference data).
docker compose --env-file .env.prod --profile admin run --rm \
-e ALLOW_SEED=1 backend-tools seed
# Reset a user's password (token mode - prints a one-time URL).
docker compose --env-file .env.prod --profile admin run --rm \
backend-tools password:reset --email admin@example.com
# Reset directly (sets password, destroys all sessions for the user).
docker compose --env-file .env.prod --profile admin run --rm \
backend-tools password:reset --email admin@example.com --password 'NewStrongPass12'
# Any other backend package.json script works the same way.
docker compose --env-file .env.prod --profile admin run --rm \
backend-tools <script-name> [args...]backend-tools uses the same .env.prod and the same network as the running stack, so DATABASE_URL / REDIS_URL / S3 credentials all resolve identically to the live backend. The image carries pnpm, the generated Prisma client, the backend source, and devDeps - so anything in backend/package.json's scripts block runs out of the box.
Why a separate image. The runtime backend image is intentionally slim: prod-only node_modules, compiled dist/, no pnpm or ts-node. That keeps the production attack surface and image size small but means docker compose exec backend pnpm seed cannot work. backend-tools is the dual: same source/schema/version, full devDeps, only invoked deliberately.
ALLOW_SEED=1. The seed script refuses to run when NODE_ENV=production unless ALLOW_SEED=1 is explicitly set. This is a deliberate two-key launch: prevents accidental re-seeding when an operator copies a command from a Slack thread or runs against the wrong stack. The seed is itself idempotent (upserts keyed on stable business identifiers, no destructive ops), so a deliberate re-run is safe; the guard exists to make "deliberate" the only path.
Reclaiming disk when you're not running admin scripts. backend-tools carries the full devDeps + Prisma engines + pnpm so it can run any backend script - that's ~1.25 GB at rest. The image isn't part of the running stack (it's profiles: [admin], only spawned by compose run), so removing it has zero impact on the live services. Docker will re-pull it the next time you invoke compose run backend-tools, and because the tag is pinned to ARTICLE30_VERSION, you'll get the exact build that matches your running schema.
docker image rm ghcr.io/ipsec-dev/article30/backend-tools:${ARTICLE30_VERSION:-latest}Worth doing on disk-constrained hosts after the initial seed, or after a release where you've finished any one-off backfills. Skip it if you reset passwords frequently from the host - the re-pull is the only cost, but it's a few hundred MB over the wire each time.
The compose file alone is NOT a hardened production deployment. Before exposing the stack to untrusted networks, address:
-
TLS / reverse proxy. Front the stack with Caddy / nginx / Traefik terminating TLS. Forward only
:3000(frontend) and:3001(backend) to the proxy; keep:5432,:6379,:9000,:9001bound to127.0.0.1(the compose file already does this). -
NODE_ENV=productionandCOOKIE_SECURE=truein.env.prod. Both ship correct in.env.prod.example; do not flip them off in production. -
Secret rotation. All
*_PASSWORD/*_SECRET/*_HMAC_SECRETvalues are sensitive. Rotate on a schedule and after any suspected exposure. -
Backups. The
pgdataandrustfsdataDocker volumes hold the audit-log, register data, and uploaded documents. Snapshot them on a recurring schedule and store off-host. -
Object storage stays private. RustFS's
:9000(S3) port must remain bound to127.0.0.1(or unpublished entirely). The backend is the only client, and all document / attachment downloads stream through/api/documents/:id/downloadand/api/follow-up/attachments/:id/download. See Storage for the full model. -
SMTP.
SMTP_*must point at a real outbound MTA before you can send invites / password resets. SetSMTP_ENABLED=falseonly if you accept that admins must issue resets via the CLI fallback.
This section is the fallback path when email isn't an option: SMTP_ENABLED=false, delivery is broken, or the forgetful user is the sole admin. For the standard SMTP-on flows, see Authentication - Forgotten password. Pick the path that matches your situation:
| Situation | Path |
|---|---|
| Forgetful user is not an admin, OR there are 2+ admins | Another admin issues the reset from /users
|
| Sole admin forgot their password (admin-reset of self returns 403) | CLI recovery from the backend host |
| No backend host access AND only one admin | Locked out - restore from backup |
Admin-issued reset by another admin: A second admin signs in, opens /users, clicks Reset password next to the target user, and copies the one-time reset URL the dialog displays. The URL is valid for 60 minutes. The admin shares it out-of-band (chat, in person, sealed envelope). When the target user opens it, they pick a new password via the normal /reset-password page. This works regardless of SMTP state - the URL is always returned to the calling admin. Requires at least one other admin - admins cannot reset their own password via this endpoint.
CLI recovery (sole-admin scenario or no other admin available): Run from the backend host with shell access. Production stacks invoke this through backend-tools (see Admin operations); for a local checkout you can also call the script directly via pnpm:
# Production stack (canonical) - runs against the running compose network.
docker compose --env-file .env.prod --profile admin run --rm \
backend-tools password:reset --email admin@example.com
docker compose --env-file .env.prod --profile admin run --rm \
backend-tools password:reset --email admin@example.com --password 'NewStrongPass12'
# Local checkout (dev or build-from-source host) - same script, host-level pnpm.
pnpm --filter backend password:reset --email admin@example.com
pnpm --filter backend password:reset --email admin@example.com --password "NewStrongPass12"Token mode prints a reset URL valid for 60 minutes; works with SMTP on or off. Direct mode sets the password immediately, destroys all sessions for that user, validates against the same password policy as the signup form (12-128 characters, at least two of: lowercase, uppercase, digit, symbol) and requires DATABASE_URL + REDIS_URL to reach the running stack (the compose form satisfies this automatically).
Resilience tip: After initial bootstrap, invite a secondary admin from /users (Invite user, role: ADMIN) and store its credentials somewhere safe (password manager, sealed envelope). If the primary admin loses access, the secondary admin can reset them via /users without needing shell access to the server.