compose.yaml
Rama main · réplica de lectura del repositorio original.
# ══════════════════════════════════════════════════════════════════════════════
# GoodWorkshop — On-Premise
#
# docker compose --profile tls up -d
#
# TWO THINGS THAT GO WRONG HERE FIRST:
#
# 1. Passkeys need HTTPS. Except on localhost, browsers refuse WebAuthn over
# http:// -- an installation on http://192.168.1.50:3000 has no passkeys at
# all. Which is why the `tls` profile is the recommended route and the e-mail
# token is a full second route, not a stopgap.
#
# 2. GW_RP_ID hangs off the hostname. Change it after passkeys have been
# registered and ALL registered passkeys are invalid. Decide it beforehand.
#
# Without HTTPS and without SMTP you still get in:
# docker compose exec app node scripts/cli.mjs login-link --email you@example.com
# ══════════════════════════════════════════════════════════════════════════════
# EVERY ROLE HAS ITS OWN PASSWORD, AND NONE OF THEM IS IN THIS FILE.
#
# On first start the `secrets` service generates one random password per role
# into a volume; db, migrate and app read them from there. So no secret sits in
# the .env, in `docker inspect`, in a dump or in a log excerpt -- and the
# separation of roles is enforced for the first time.
#
# Before this there was peer authentication over a shared socket, with the nicer
# promise of "no password, anywhere". It could not work: peer compares the OS
# user of the connecting PROCESS with the role name, and the app container runs
# as `node` (uid 1000) while the database container knows uid 70 as `postgres`
# and nobody at 1000. No connection across the container boundary could ever
# succeed -- and because the Postgres image's entrypoint does not initialise at
# all without a password or `trust`, the stack did not start on a fresh
# installation.
#
# `trust` would have been the wrong way out: it lets any process in any
# container on the network in as any role, superuser included. RLS, NOINHERIT
# and the revoke on the identity tables all rest on the application being
# exclusively gw_app.
#
# Two anchors rather than one. The web container gets gw_app and nothing else --
# the unprivileged role that RLS, NOINHERIT and the revoke on the identity
# tables were built for. Hand it the anchor as well and the superuser connection
# sits ready in the one container an attacker reaches first, and the whole wall
# is worth one line.
#
# `db` publishes no port. The database is reachable only from this compose
# network -- and there only with the password of the role in question.
x-app-db: &app-db
DATABASE_URL: postgres://gw_app@db:5432/goodworkshop
DATABASE_PASSWORD_FILE: /run/db-secrets/gw_app/password
x-migrate-db: &migrate-db
DATABASE_URL: postgres://gw_app@db:5432/goodworkshop
DATABASE_PASSWORD_FILE: /run/db-secrets/gw_app/password
MIGRATION_DATABASE_URL: postgres://gw_owner@db:5432/goodworkshop
MIGRATION_DATABASE_PASSWORD_FILE: /run/db-secrets/gw_owner/password
ADMIN_DATABASE_URL: postgres://postgres@db:5432/goodworkshop
ADMIN_DATABASE_PASSWORD_FILE: /run/db-secrets/postgres/password
services:
# Runs once and exits. Creates missing role passwords into the volume that db,
# migrate and app read them from -- and does not touch existing ones, so that
# a restart never locks out a running installation.
secrets:
image: ghcr.io/rolealpha/good-workshop:${GW_VERSION:?GW_VERSION must be set -- a moving tag is not a deployment}
restart: 'no'
# As root, and only this one service: a fresh Docker volume belongs to root,
# and otherwise nobody could write the first file into it. It does exactly
# one thing and exits; app and migrate keep running as `node`.
user: '0:0'
command: ['node', 'scripts/db-secrets.mjs']
# The one place where all of them lie together -- in a container that lives
# for seconds and accepts nothing from the network.
volumes:
- secret_postgres:/run/db-secrets/postgres
- secret_gw_owner:/run/db-secrets/gw_owner
- secret_gw_app:/run/db-secrets/gw_app
- secret_gw_ops:/run/db-secrets/gw_ops
- secret_app:/run/db-secrets/app
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: goodworkshop
POSTGRES_USER: postgres
# From the file, not from the environment: POSTGRES_PASSWORD would show up in
# `docker inspect`. The entrypoint demands one of the two, or it does not
# initialise the database at all.
POSTGRES_PASSWORD_FILE: /run/db-secrets/postgres/password
# `peer` for the local socket INSIDE the container -- that is the route
# `scripts/backup.sh` takes, and the reason for its `-u postgres`: over
# this socket the user name decides. Over TCP the default scram-sha-256
# applies, and without a published port this stack's network is the only
# place anybody can try it from.
POSTGRES_INITDB_ARGS: '--auth-local=peer'
volumes:
- pgdata:/var/lib/postgresql/data
- secret_postgres:/run/db-secrets/postgres:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d goodworkshop']
interval: 5s
timeout: 5s
retries: 20
depends_on:
secrets:
condition: service_completed_successfully
# Runs once and exits. `app` starts only once this has finished
# successfully -- so a rolling deploy with a pending migration ships nothing
# at all.
migrate:
image: ghcr.io/rolealpha/good-workshop:${GW_VERSION:?GW_VERSION must be set -- a moving tag is not a deployment}
restart: 'no'
# Four steps, and the order is the point: preflight only reads, and stops
# BEFORE migrate touches the first row. A migration that fails on the
# existing data would otherwise do so halfway through -- with a bare
# Postgres error, an `app` that never starts because of
# service_completed_successfully, and not a word about which rows are
# meant.
command: >-
sh -c "node scripts/db-bootstrap.mjs &&
node scripts/preflight.mjs &&
node scripts/migrate.mjs &&
node scripts/provision.mjs"
environment:
<<: *migrate-db
GW_APP_URL: ${GW_APP_URL:?GW_APP_URL must be set}
# On the very first run, creates an admin and prints a one-time
# sign-in link to stdout -- independently of the mail configuration.
GW_BOOTSTRAP_ADMIN_EMAIL: ${GW_BOOTSTRAP_ADMIN_EMAIL:-}
GW_BOOTSTRAP_ADMIN_FIRST_NAME: ${GW_BOOTSTRAP_ADMIN_FIRST_NAME:-}
GW_BOOTSTRAP_ADMIN_LAST_NAME: ${GW_BOOTSTRAP_ADMIN_LAST_NAME:-}
# Three, because this service needs three roles: superuser for the roles
# themselves, gw_owner for the schema, gw_app for provisioning through the
# same door as the application. It does not get gw_ops.
volumes:
- secret_postgres:/run/db-secrets/postgres:ro
- secret_gw_owner:/run/db-secrets/gw_owner:ro
- secret_gw_app:/run/db-secrets/gw_app:ro
depends_on:
db:
condition: service_healthy
app:
image: ghcr.io/rolealpha/good-workshop:${GW_VERSION:?GW_VERSION must be set -- a moving tag is not a deployment}
restart: unless-stopped
environment:
<<: *app-db
GW_APP_URL: ${GW_APP_URL:?GW_APP_URL must be set}
GW_RP_ID: ${GW_RP_ID:-}
# Optional. Empty means: mail follows what an admin configures under
# **Mail** in the interface -- the first run needs none, the setup screen
# shows the sign-in link itself. Never defaults to `console`: that prints
# complete, valid sign-in links to stdout, and anybody who can read
# `docker logs` could have one issued for any address. A decision one
# makes, not one that happens because nothing was said.
GW_MAIL_TRANSPORT: ${GW_MAIL_TRANSPORT:-}
# Either the URL itself -- or, better, SMTP_URL_FILE pointing at a Docker
# secret. An environment variable with a password shows up in `docker
# inspect`, in /proc/<pid>/environ and in every core dump; a secret lives
# on a tmpfs and only in the container's file system.
SMTP_URL: ${SMTP_URL:-}
SMTP_URL_FILE: ${SMTP_URL_FILE:-}
SMTP_FROM: ${SMTP_FROM:-}
# Microsoft Graph instead of SMTP. Many Microsoft 365 tenants have SMTP
# AUTH switched off -- there this is not the more convenient way but the
# only possibility. The secret is better given as GW_GRAPH_CLIENT_SECRET_FILE
# from a file, for the same reason as SMTP_URL_FILE.
GW_GRAPH_TENANT_ID: ${GW_GRAPH_TENANT_ID:-}
GW_GRAPH_CLIENT_ID: ${GW_GRAPH_CLIENT_ID:-}
GW_GRAPH_CLIENT_SECRET: ${GW_GRAPH_CLIENT_SECRET:-}
GW_GRAPH_CLIENT_SECRET_FILE: ${GW_GRAPH_CLIENT_SECRET_FILE:-}
GW_GRAPH_SENDER: ${GW_GRAPH_SENDER:-}
# The tag the .env says should run -- the same value that picks the image
# above. Without this line /api/health reports the ARG value from the
# Dockerfile (`dev` for a self-built image), and the one question the
# endpoint exists for -- which revision is running here -- is answered
# wrongly.
GW_VERSION: ${GW_VERSION}
GW_COLLAB_PORT: '3001'
# These were in .env.example and in the README but never reached the
# container: a documented variable that arrives nowhere is an instruction
# that leads nowhere. `check-docs.mjs` checks that now.
# Decrypts the credentials entered in the interface. Without a value the
# file from the volume is used; anybody who would rather manage the key
# themselves sets GW_SECRET_KEY in the .env.
GW_SECRET_KEY: ${GW_SECRET_KEY:-}
GW_SECRET_KEY_FILE: ${GW_SECRET_KEY_FILE:-/run/db-secrets/app/secret-key}
GW_COLLAB_URL: ${GW_COLLAB_URL:-}
GW_COLLAB_INTERNAL_URL: ${GW_COLLAB_INTERNAL_URL:-}
# Without this value /api/health answers only "ok" or "unhealthy" and
# which check failed -- the endpoint faces the internet. Set it and
# `x-ops-token` additionally returns the version, the migration state and
# the driver error in plain text.
GW_OPS_TOKEN: ${GW_OPS_TOKEN:-}
# How many proxies stand in front of the application. Throttling and logs only.
GW_TRUSTED_PROXIES: ${GW_TRUSTED_PROXIES:-1}
GW_SESSION_IDLE_DAYS: ${GW_SESSION_IDLE_DAYS:-14}
# Time zone for dates in the interface. Explicit rather than inferred:
# without it the server formats in the CONTAINER's zone and the browser in
# the visitor's, so the same timestamp renders two ways on one page.
GW_TIMEZONE: ${GW_TIMEZONE:-Europe/Berlin}
# Off by default. `1` only together with the override from the README
# ("Automatic updates"): it hands this container the migration roles too.
GW_MIGRATE_ON_START: ${GW_MIGRATE_ON_START:-}
# ONE. The web container knows the password of gw_app and of no other
# role -- that is the "two anchors" point from above, in file form.
# Were the superuser secret here too, the whole wall would be worth one line.
#
# Plus the application key: it decrypts the credentials an administrator
# enters in the interface. It belongs in the backup -- unlike the role
# passwords, which every installation can generate afresh.
volumes:
- secret_gw_app:/run/db-secrets/gw_app:ro
- secret_app:/run/db-secrets/app:ro
# Only on 127.0.0.1, never on 0.0.0.0: without the proxy in front nothing is
# reachable, not even with a misconfigured firewall.
#
# Two ports: the application, and the collaboration service. The second
# exists because Next cannot do a WebSocket upgrade from a route handler --
# but it runs in the same container, from the same image.
ports:
- '127.0.0.1:${GW_PORT:-3000}:3000'
- '127.0.0.1:${GW_COLLAB_PORT:-3001}:3001'
depends_on:
migrate:
condition: service_completed_successfully
# Automatic HTTPS. Without it, no passkeys -- see above.
caddy:
profiles: [tls]
image: caddy:2-alpine
restart: unless-stopped
# `{$GW_HOSTNAME}` in the Caddyfile is Caddy's own substitution INSIDE the
# mounted file, not Compose interpolation -- and Compose does not pass .env
# into containers by itself. Without this line the site block has no
# address, the proxy does not come up, and because `app` only listens on
# 127.0.0.1, the installation is unreachable from outside. The operator's
# next move would then be HTTP without TLS.
environment:
GW_HOSTNAME: ${GW_HOSTNAME:-}
# Checked here rather than with `${GW_HOSTNAME:?}` above: Compose interpolates
# the WHOLE file on load, including services whose profile is not active.
# A required field at this spot would therefore have broken every
# `docker compose up` without the tls profile -- and being able to start
# without HTTPS is an explicitly supported route. This way it fails loudly,
# but only for those who actually use the proxy.
command: >-
sh -c 'test -n "$$GW_HOSTNAME" || {
echo "GW_HOSTNAME must be set: the host name the TLS certificate is issued for." >&2
echo "It has to resolve publicly to this server, or issuing the certificate fails." >&2
exit 1
}
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile'
ports:
- '80:80'
- '443:443'
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
# Its own volume, because the log has an end date now (Caddyfile) and a
# rolled file that lives in the container layer is gone at the next
# upgrade -- which would delete it early rather than on time.
- caddy_logs:/var/log/caddy
depends_on: [app]
volumes:
pgdata:
# One volume per role, so that a service gets only what it needs.
secret_postgres:
secret_gw_owner:
secret_gw_app:
secret_gw_ops:
secret_app:
caddy_data:
caddy_config:
caddy_logs: