# 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: ```sh 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,`: where registrations are created | | `LDAPSEARCH` | `ou=customer, ou=users,`: 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. ```sh 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. ```sh 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: ```yaml 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.