ungleich-user/deploy/k8s/README.md
Sajeyks 5a2e612faa Replace env.sample with a shorter .env.example
It opens with the five required variables, then what production
usually sets (proxy header, PostgreSQL, mail relay, OTP), then every
optional setting commented out at its default. It lists exactly the 51
variables the code reads.
2026-11-11 21:36:51 +03:00

5.4 KiB

Kubernetes deployment

base/ holds the manifests for the account webapp: Deployment, Service, Ingress, PodDisruptionBudget and ConfigMap. Apply it with kubectl apply -k (kustomize is built into kubectl).

What the cluster needs to provide

  • PostgreSQL. Sessions, password-reset tokens and pending registrations live in the database, so more than one replica needs PostgreSQL. Point DATABASE_HOST at it. The base doesn't ship a database; use the cluster's managed or operator-run PostgreSQL.
  • An ingress controller with TLS. The app expects TLS to terminate at the ingress and trusts X-Forwarded-Proto (TRUST_X_FORWARDED_PROTO=True).
  • Network access to the LDAP servers, the SMTP relay and the OTP server.

Deploying

  1. Build and push the image from the repository root:

    docker build -t registry.example.com/ungleich-user:1.0.0 .
    docker push registry.example.com/ungleich-user:1.0.0
    
  2. Copy base/ into your environment's overlay (or edit it in place) and set:

    • images: in kustomization.yaml: the registry and a pinned tag
    • configmap.yaml: every value marked REQUIRED (.env.example lists all settings with their defaults)
    • ingress.yaml: the ingress class and the TLS secret or cert-manager annotations
  3. Create the Secret. It isn't part of the base, so credentials never go into git. See secret.example.yaml:

    kubectl create secret generic ungleich-user \
      --from-literal=SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(50))')" \
      --from-literal=LDAP_ADMIN_DN='cn=...' \
      --from-literal=LDAP_ADMIN_PASSWORD='...' \
      --from-literal=DATABASE_PASSWORD='...' \
      --from-literal=ADMIN_SEED='...'
    
  4. Apply: kubectl apply -k deploy/k8s/base (or your overlay).

How it behaves

Concern Behaviour
Migrations An init container runs manage.py migrate_locked in each pod. A PostgreSQL advisory lock makes pods that start together wait for the first one instead of migrating at the same time.
Probes /healthz (liveness) has no dependencies, so an LDAP outage never restarts pods. /readyz (readiness) needs the database and at least one LDAP server. Both bypass ALLOWED_HOSTS.
LDAP failover LDAPSERVER is a space-separated list. The first reachable server is used, and a failed server is skipped for 60 seconds.
Security Runs as uid 10001 with a read-only root filesystem, all capabilities dropped, no privilege escalation, the RuntimeDefault seccomp profile and no service account token. The image has no sshd.
Logs stdout/stderr only. LOG_LEVEL sets the level.
IPv6 gunicorn listens on [::]:8000, which accepts IPv6 and IPv4. LDAP, PostgreSQL and SMTP work over IPv6, including IPv6-literal URIs such as ldaps://[2a0a:…]:636. Tested on an IPv6-only cluster.
LDAP TLS ldaps:// and StartTLS verify the server certificate and hostname. For a private CA, mount the CA certificate and set LDAP_CA_FILE (see below).
Rollouts Rolling updates add one new pod before removing an old one (maxSurge: 1, maxUnavailable: 0). The PodDisruptionBudget keeps at least one pod running during node drains.

LDAP servers with a private CA

The system trust store is used by default. If the LDAP certificates come from a private CA, put the CA certificate in a ConfigMap and point LDAP_CA_FILE at it:

kubectl create configmap ldap-ca --from-file=ca.crt=./ldap-ca.crt
# in the Deployment, for both the migrate init container and the web container
env:
  - {name: LDAP_CA_FILE, value: /etc/ldap-ca/ca.crt}
volumeMounts:
  - {name: ldap-ca, mountPath: /etc/ldap-ca, readOnly: true}
# and under volumes:
  - {name: ldap-ca, configMap: {name: ldap-ca}}

The CA certificate must carry the usual CA extensions (basicConstraints and keyUsage). Python 3.13+ rejects CAs without them.

Before going live, check with the operators

  • The ConfigMap uses ldaps:// (port 636), which production serves. Confirm that ldap1 serves it too (it was checked on ldap2).
  • HSTS (SECURE_HSTS_SECONDS) is off. Enabling it, especially for subdomains, affects every *.ungleich.ch site.
  • Emails sent before the switch contain activation links in the old format. Those links stop working, and the affected users have to register again.

Local test cluster (dev overlay)

dev/ deploys the app into a kind cluster together with two independent test LDAP servers, PostgreSQL and Mailpit, which catches outgoing mail. All data is throwaway and every credential is a dev value.

kind create cluster --name ungleich-user
docker build -t ungleich-user:dev .
docker compose -f docker-compose.dev.yml build ldap
kind load docker-image ungleich-user:dev ungleich-user-dev-ldap:2.6 --name ungleich-user
kubectl apply -k deploy/k8s/dev
kubectl -n ungleich-user-dev rollout status deploy/ungleich-user

deploy/k8s/dev/e2e.sh     # probes, signup via SMTP, activation, login, LDAP failover

To test an IPv6-only cluster, like ungleich's, create it with networking: {ipFamily: ipv6} in a kind config file. Docker Desktop has no IPv6 internet access, so kind load every image (including postgres:18-alpine and axllent/mailpit:v1.27) before applying.

After rebuilding the image, run kind load again and then kubectl -n ungleich-user-dev rollout restart deploy/ungleich-user. To remove the cluster: kind delete cluster --name ungleich-user.