Docker Compose¶
docker-compose.yaml
runs the application with the hardened PostgreSQL and Redis images on a single
host. It uses the same hardening as the Helm chart (non-root, read-only file
systems, no capabilities, resource limits, TLS between all components).
PostgreSQL and Redis sit on an internal network that only the application
reaches and that has no route to the internet. Compose does not provide
public HTTPS: put a TLS reverse proxy in front of it and firewall the host.
The application listens on 127.0.0.1:8080 only.
The reverse proxy and client addresses¶
Per-client rate limits need each request's real client address, which the
reverse proxy sends in X-Forwarded-For. The application trusts that header
only from FORWARDED_ALLOW_IPS. A proxy on the host connects to the published
port, and Docker forwards the connection from the gateway of the edge
network, 172.30.240.1, which is therefore the default. (Trusting
127.0.0.1 would match nothing: all clients would share the proxy's budget,
and a few requests per second would lock everyone out.)
Every process on the host connects from that address as well, so it could
claim any client address: keep untrusted users off the host. If the subnet
is taken on your host, set EDGE_SUBNET, EDGE_GATEWAY and
FORWARDED_ALLOW_IPS together. Check after the first start that the access
log shows your own address, not the gateway's:
Prepare¶
- Check out a release tag of this repository; Compose builds the application image from it. Build (or pull) the hardened PostgreSQL and Redis images (see Kubernetes) and note their digests.
- Create certificates for the services
postgresandredis, signed by a CA you control. On Linux, the PostgreSQL key must be readable by UID 70 and the Redis key by UID 999 (Compose keeps host file ownership). - Write
config.yaml(production settings, as in the Kubernetes example) withdatabase.ssl_ca_file: /app/tls/postgresql/ca.crt. - Put the secrets into an environment file readable only by you
(
chmod 600):
JWT_SECRET_KEY=<64 hex characters>
TOKEN_HASH_KEY=<64 different hex characters>
POSTGRES_ADMIN_PASSWORD=<hex>
POSTGRES_PASSWORD=<hex, runtime role>
REDIS_PASSWORD=<hex>
DATABASE_URL=postgresql+asyncpg://tokens:<POSTGRES_PASSWORD>@postgres:5432/tokens
RATE_LIMIT_STORAGE_URI=rediss://:<REDIS_PASSWORD>@redis:6379/0?ssl_ca_certs=/app/tls/redis/ca.crt&ssl_cert_reqs=required&ssl_check_hostname=true
POSTGRES_IMAGE=<registry>/blablador-token-generator-postgresql@sha256:<digest>
REDIS_IMAGE=<registry>/blablador-token-generator-redis@sha256:<digest>
POSTGRES_TLS_CERT_FILE=/etc/tokens/tls/postgres.crt
POSTGRES_TLS_KEY_FILE=/etc/tokens/tls/postgres.key
POSTGRES_TLS_CA_FILE=/etc/tokens/tls/ca.crt
REDIS_TLS_CERT_FILE=/etc/tokens/tls/redis.crt
REDIS_TLS_KEY_FILE=/etc/tokens/tls/redis.key
REDIS_TLS_CA_FILE=/etc/tokens/tls/ca.crt
# With OAuth sign-in (keeps the secret out of config.yaml):
# OAUTH_CLIENT_SECRET=<client secret>
config.yaml must be readable by the container's user (UID 10001); keep
secrets in the environment file rather than in it.
Run¶
docker compose --env-file /etc/tokens/tokens.env up -d
docker compose ps # all three services become "healthy"
curl -s http://127.0.0.1:8080/readyz
On first start, PostgreSQL creates the separate runtime role and refuses plaintext connections; the application then creates its schema.
Upgrade¶
git fetch --tags && git checkout v1.1.0 # the release to run
docker compose --env-file /etc/tokens/tokens.env up -d --build
--build rebuilds the application image from the new source; without it the
old build keeps running. Change POSTGRES_IMAGE/REDIS_IMAGE when their
images change. Database migrations run when the new application starts; read
Upgrades first.
Create the first administrator¶
With OAuth, list your subject in admin.admin_subjects and sign in. Without
OAuth (local accounts only):
docker compose exec app python -m app.manage create-local-admin \
--email admin@example.org --name "Administrator"
Backups¶
Compose has no scheduler. Run the same dump the Helm chart uses from cron on
the host, as the runtime role over verified TLS. Dumps contain password
hashes, token hashes and emails: this script keeps them private (umask
077), never leaves a half-written file under a final name, and passes the
password to docker compose through the environment instead of its command
line (which other users could read with ps):
#!/bin/sh
# /etc/tokens/backup.sh
set -eu
umask 077
cd /srv/tokens # the checkout with docker-compose.yaml
target=/var/backups/tokens/tokens-$(date -u +%Y%m%dT%H%M%SZ).dump
PGPASSWORD=$(sed -n 's/^POSTGRES_PASSWORD=//p' /etc/tokens/tokens.env)
export PGPASSWORD
docker compose --env-file /etc/tokens/tokens.env exec -T -e PGPASSWORD \
-e PGSSLMODE=verify-full -e PGSSLROOTCERT=/run/secrets/postgres_ca postgres \
pg_dump -h postgres -U tokens -d tokens --format=custom > "$target.partial"
mv "$target.partial" "$target"
find /var/backups/tokens -maxdepth 1 -name 'tokens-2*Z.dump' -mtime +14 -delete
Restoring is described in Backups.