# ══════════════════════════════════════════════════════════════════════════════ # 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//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: