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.
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
/tmpis 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
Hostheader, so probes don't need to matchALLOWED_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 setsDATABASE_HOSTitself.DATABASE_PASSWORDis used both to create the database and by the app. - Port: the app listens on 8000; set
PORTin.envto publish it elsewhere. - Updating:
git pull && docker compose up -d --build. Migrations run again automatically before the new app starts. - Data: lives in the
pgdatavolume. Back it up withdocker 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
emptyDirmounted 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
curl https://account.ungleich.ch/healthzshould return{"status": "ok"}./readyzshould show"database": "ok", "ldap": "ok".- 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.