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

227 lines
10 KiB
Markdown

# 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,<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.
```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.