ungleich-user/docker.md
Sajeyks 0f9b3e3caa Add docker.md, a deployment guide for the image
Explains what the image does, which variables are required (five),
usually set, or already defaulted to production, and lists the secrets.
Then deployment three ways: plain docker run with SQLite on a volume,
Docker Compose with PostgreSQL, and Kubernetes with Helm, with
configuration injected as environment and migrations as a separate
pre-install/pre-upgrade Job. Ends with checks after deploying and a
troubleshooting table. Linked from the README.
2026-11-11 21:41:43 +03:00

10 KiB

Deploying the account webapp

This is the app behind account.ungleich.ch. It ships as one Docker image, which you configure only through environment variables. Database migrations are a separate command that you run before starting (or updating) the app. The image never migrates on its own.

There are three ways to run it below: plain docker run, Docker Compose, and Kubernetes with Helm. They all use the same image and the same variables.

What the image does

  • Serves the app with gunicorn on port 8000, IPv6 and IPv4.

  • Runs as uid 10001. It works with a read-only root filesystem as long as /tmp is writable.

  • Serves its own static files, so you don't need a web server in front of it inside the pod or container. TLS is the ingress's or proxy's job.

  • Logs to stdout and stderr only.

  • Health endpoints:

    • GET /healthz: the process is up. It has no dependencies, so use it for liveness.
    • GET /readyz: the database answers and an LDAP server accepts the admin login. It returns 503 with the failing check otherwise. Use it for readiness.

    Both answer on any Host header, so probes don't need to match ALLOWED_HOSTS.

  • Two commands:

    • gunicorn dal.wsgi:application, the default: runs the app.
    • python manage.py migrate_locked: applies database migrations. It takes a PostgreSQL lock first, so it's safe even if several copies start at once.

Build it from the repository root:

docker build -t ungleich-user:1.0.0 .

Configuration

Copy .env.example to .env and fill it in. .env.example lists every variable. These are the ones that matter.

Required

The app refuses to start, and names the missing variable, if one of these isn't set.

Variable What it is
SECRET_KEY Secret. A long random string, for example from python3 -c 'import secrets; print(secrets.token_urlsafe(50))'. Keep it stable: changing it logs everyone out.
LDAPSERVER The LDAP server(s), space separated: ldaps://ldap1.ungleich.ch ldaps://ldap2.ungleich.ch. The app uses the first one that answers.
LDAP_ADMIN_DN The DN the app uses to search the directory and to create or change customer entries.
LDAP_ADMIN_PASSWORD Secret. Its password.
EMAIL_FROM_ADDRESS The sender of activation and password-reset emails.

Usually set in production

Variable When you need it
TRUST_X_FORWARDED_PROTO=True Behind an HTTPS ingress or proxy. Without it, links in emails start with http://. Only set it if the proxy always sets X-Forwarded-Proto.
DATABASE_HOST, DATABASE_PASSWORD (secret), optionally DATABASE_NAME, DATABASE_USER (both default to ungleich_user), DATABASE_PORT, DATABASE_SSLMODE PostgreSQL. Required for more than one replica, because sessions, reset tokens and pending registrations live in the database. Without DATABASE_HOST the app uses SQLite at /data/db.sqlite3: fine for a single container with a volume on /data.
EMAIL_HOST, EMAIL_PORT, EMAIL_USE_TLS, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD (secret) The mail relay. The default is localhost:25 with STARTTLS.
OTPSERVER, then ADMIN_SEED (secret), ADMIN_NAME, ADMIN_REALM, USER_REALM, ALLOWED_REALMS The OTP integration. Optional: without OTPSERVER, new accounts get no OTP seed and the seed pages say OTP isn't configured. If OTPSERVER is set, the next four are required.

Already defaulted to production

Leave these out unless you need something different.

Variable Default
ALLOWED_HOSTS account.ungleich.ch (comma separated)
CSRF_TRUSTED_ORIGINS https:// plus each allowed host
ENTIRE_SEARCH_BASE dc=ungleich,dc=ch. For a different tree (such as the test server, dc=my-domain,dc=com), this is usually the only layout setting you change.
LDAP_CUSTOMER_DN ou=customer,<ENTIRE_SEARCH_BASE>: where registrations are created
LDAPSEARCH ou=customer,<base> ou=users,<base>: where logins are looked up
LDAP_CUSTOMER_GROUP_ID 10004
LDAP_DEFAULT_START_UID 10000. New uidNumbers always go above the highest existing one.
LDAP_USER_OBJECT_CLASSES inetOrgPerson posixAccount ldapPublicKey. Drop ldapPublicKey on a server without the openssh-lpk schema.
LDAP_TLS_VERIFY, LDAP_CA_FILE Certificates are verified against the system trust store. Set LDAP_CA_FILE (a path inside the container) for a private CA.

Everything else (log levels, timeouts, gunicorn worker counts, HSTS) is in .env.example, with its default.

Secrets, all in one place: SECRET_KEY, LDAP_ADMIN_PASSWORD, DATABASE_PASSWORD, EMAIL_HOST_PASSWORD, ADMIN_SEED.

Option 1: plain docker run

The simplest setup: one container, SQLite on a named volume.

docker volume create ungleich-user-data

# 1. migrations (once, and again after every update)
docker run --rm --env-file .env -v ungleich-user-data:/data \
  ungleich-user:1.0.0 python manage.py migrate_locked

# 2. the app
docker run -d --name ungleich-user --restart unless-stopped \
  --env-file .env -v ungleich-user-data:/data -p 8000:8000 \
  --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \
  ungleich-user:1.0.0

For PostgreSQL instead, put DATABASE_HOST and DATABASE_PASSWORD in .env and leave out the volume.

To update: build or pull the new tag, run step 1 with it, then replace the container (docker rm -f ungleich-user, then step 2 with the new tag).

Option 2: Docker Compose

compose.yaml runs PostgreSQL, a one-off migrate service and the app. The app starts only once the migrations have succeeded.

cp .env.example .env          # fill in the required values and DATABASE_PASSWORD
docker compose up -d --build
docker compose ps             # db healthy, migrate "exited (0)", web healthy
  • Configuration: comes from .env. Compose sets DATABASE_HOST itself. DATABASE_PASSWORD is used both to create the database and by the app.
  • Port: the app listens on 8000; set PORT in .env to publish it elsewhere.
  • Updating: git pull && docker compose up -d --build. Migrations run again automatically before the new app starts.
  • Data: lives in the pgdata volume. Back it up with docker compose exec db pg_dump -U ungleich_user ungleich_user > backup.sql.
  • Logs: docker compose logs -f web.

Option 3: Kubernetes with Helm

This works like dynamicweb: the chart injects the configuration as environment variables, and migrations run as their own step, outside the app's pods.

Environment. Put the five secrets above in a Secret and everything else in a ConfigMap, then load both into the container with envFrom. The minimum a chart has to set is the five required variables, plus DATABASE_HOST and DATABASE_PASSWORD for more than one replica, and TRUST_X_FORWARDED_PROTO=True behind the ingress.

Migrations. Run python manage.py migrate_locked with the same image and the same environment before the new pods start. A Helm hook Job is the usual way:

apiVersion: batch/v1
kind: Job
metadata:
  name: ungleich-user-migrate
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: registry.example.com/ungleich-user:1.0.0
          command: ["python", "manage.py", "migrate_locked"]
          envFrom:
            - secretRef: {name: ungleich-user}
            - configMapRef: {name: ungleich-user}

One catch: a pre-install hook runs before the chart's regular resources are created. If the chart itself creates the Secret and ConfigMap, give them a hook annotation with a lower helm.sh/hook-weight so they exist first. Or create them outside the chart. An init container in the Deployment works too. migrate_locked makes it safe for several pods to run it at the same time.

The app's pods need:

  • Port and probes: container port 8000; liveness probe /healthz; readiness probe /readyz (allow it about 10 seconds, since it talks to LDAP).
  • Security context: runAsUser: 10001, runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, all capabilities dropped.
  • Volumes: an emptyDir mounted on /tmp.
  • Resources: about 100m CPU and 192 MiB to request; a 512 MiB memory limit is plenty.

IPv6-only clusters are fine. The app is dual-stack, and its LDAP, PostgreSQL and mail connections all work over IPv6.

deploy/k8s/base/ contains a complete, tested version of this setup as plain Kubernetes manifests (kustomize). Copy from it freely.

After deploying

  1. curl https://account.ungleich.ch/healthz should return {"status": "ok"}.
  2. /readyz should show "database": "ok", "ldap": "ok".
  3. Log in with an existing account, and register a test account to check that mail arrives and the activation link works.

When something is wrong

You see Likely cause
The container exits at once with X not found. Declare it as envvar A required variable is missing; the message names it.
400 Bad Request for every page The hostname isn't in ALLOWED_HOSTS.
403 CSRF verification failed on login The address in the browser isn't a trusted origin, usually because the site is reached under a hostname that isn't in ALLOWED_HOSTS, or because CSRF_TRUSTED_ORIGINS is set explicitly without it.
Links in emails start with http:// TRUST_X_FORWARDED_PROTO=True is missing behind the ingress.
/readyz reports "ldap": "failed" The app's log names the reason: a wrong admin DN or password, a certificate it can't verify (set LDAP_CA_FILE for a private CA), or no route to the LDAP servers.
/readyz reports "database": "failed" Wrong DATABASE_* values, or the database is unreachable.
Pages fail with no such table or relation … does not exist The migrations haven't run for this release: run migrate_locked.
Registration fails with an objectClass error The LDAP server lacks the openssh-lpk schema. Load it, or set LDAP_USER_OBJECT_CLASSES="inetOrgPerson posixAccount".

One-time note for the first release: activation emails sent by the old version contain links in a format the new version no longer accepts. People who signed up just before the switch need to register again.