date-website

Kubernetes Deployment Notes (production)

Purpose

Production runs on a Kubernetes cluster (k3s) managed with GitOps: Argo CD watches a private operator repository and applies everything — chart, per-site values, secrets wiring, ingress, blue-green standbys — from git. Nothing is deployed by hand against the cluster.

This guide describes the deployment flow for developers working on date-website. It intentionally does not include cluster addresses, access paths, or secret locations — those live in the private operator repository and its docs. If you are on the ops team, the operator repository is the authoritative source; keep this file in sync with it when the flow changes.

Use this together with:

Environment model

Image pipeline

Trigger Image tags pushed
push to main :qa, :<commit-sha>
SemVer tag vX.Y.Z :vX.Y.Z, :vX.Y, :vX, :prod, :latest
manual promote_production :prod, :latest from a chosen existing tag

Notes:

The Helm chart

The chart lives in charts/date-website/ and is published as an OCI chart to GHCR by helm_chart.yaml whenever chart files change on main. Bump charts/date-website/Chart.yaml version on any template or values change; deployments pin the immutable chart version.

The chart deploys:

Migrations run as a single migration Job per release. Two modes:

When runtime database traffic uses a transaction pooler, set migrations.job.databaseHost and migrations.job.databasePort to a direct PostgreSQL endpoint. These values override DB_HOST/DB_PORT only in the migration Job; web and celery continue using database.external. Leaving the migration host empty preserves the existing behavior. Direct routing protects schema migrations that need session-level PostgreSQL state from transaction pooling semantics.

Also set DB_DISABLE_SERVER_SIDE_CURSORS=True for web and worker processes that use transaction pooling. Django’s named server-side cursors are scoped to one PostgreSQL session, while a transaction pooler may assign a different session when the queryset fetches its next batch. Direct database connections retain the default (False).

Never enable web.migrateOnStartup in production: it couples schema mutation to pod readiness, re-runs on every pod restart, and races when the replica count is above one. Keep expand-contract migration rules so old and new pods can overlap during rollouts.

How production actually runs (summary)

Deploying a release (the flow)

  1. A SemVer tag is cut (or promote_production is run) — the image is now available as prod/latest.
  2. In the operator repository, the site’s values file pins the image tag (image.tag) and, for moving or custom tags, the image digest (image.digest). A release that includes database migrations carries that pin together with its migrations.
  3. Commit + push to the operator repository. Argo CD detects the change and syncs the release.
  4. Verify: site responds over HTTPS, /healthz/ and /readyz/ are green.

Static files

Every production association’s static is collected into the image at build time, one tree per variant under /code/static-collected/<PROJECT_NAME>. Settings pick the tree matching the runtime PROJECT_NAME, so every site serves build-time static and no web pod runs collectstatic at startup (web.collectstaticOnStartup defaults to false; keep it only for images built before this layout).

Separate trees are required because variants define the same logical paths with different content (e.g. date/css/homepage.css differs per association); a single merged collection would silently overwrite assets. This keeps one generic image for all variants: no per-association images, no startup collection, no way for an image and its values to disagree on the variant.

The build collects each variant’s static concurrently into its own tree, and collects django-unfold static only for the variants that run the unfold admin skin (USE_UNFOLD=True: date incl. qa, pulterit, sf, impuls); kk and biocum keep the stock admin. collectstatic only discovers app static dirs for apps in INSTALLED_APPS, so a variant collected without USE_UNFOLD=True renders the unfold admin with 500s on the missing unfold/fonts/inter/styles.css manifest entry (2026-08-30).

The image also carries compiled translations. Both the collected trees and the .mo files are only read by an image that serves the app from its own filesystem: dev and PR CI bind-mount the checkout over /code, so they build with BUILD_ASSETS=0 and skip the collection and compilation entirely. Anything built for a deployment must leave the default (BUILD_ASSETS=1).

If static-on-S3 (see issue) lands, collection moves from the image build to a release-time upload into the existing per-association media bucket, and pods stop carrying static entirely.

With static-on-S3 the runtime {% static %} lookups resolve through the manifest stored in the bucket (hashed filenames, immutable CDN caching). Two operational rules:

The web deployment probes are values-templated (web.livenessProbe/web.readinessProbe): default timeout 10 s and 5 failures, so a busy render cannot kill the pod through the probe budget (measured death spiral on qa 2026-08-30 with the old hardcoded 5 s/3).

The operator repository holds:

Redis ownership

PostgreSQL is shared across associations per database, and Redis must be treated the same way. Two sanctioned layouts:

Example: with one cluster Redis at redis://redis.internal:6379, site A uses redis.database: 0, site B redis.database: 1, and both releases of each site point at the same URL.

Ephemeral Redis implies an accepted task-loss model on broker loss: the database backup does not preserve queued Celery tasks. Tasks that must survive need a durable source of truth with reconciliation/re-enqueue, or a durable broker; keep the backup/restore pipeline for database data only.

Blue-green deploys (zero-downtime)

Every site has a standby release (same chart, fullnameOverride, shares the site’s database and secrets, no ingress) that is scaled to zero between deploys. A deploy:

  1. dumps the site’s database first (the rollback mechanism)
  2. sets the new image tag on the standby → Argo syncs it → the ordered migration Job runs first (sync wave 0), then the standby application pods start (sync wave 1) while the live site keeps serving
  3. smoke-tests the standby, then flips the ingress backend service names to the standby (in-place, no traffic gap)
  4. soaks, keeping the old stack as the rollback target, then scales the old stack to zero
  5. runs any post-cutover data cleanup the release documents, now that no pod runs the old code: for example python manage.py redact_harassment_logs after the release that stopped writing harassment report text into the admin log (see operations.md)

Database migration rule (important): the standby shares the site’s database, so a destructive migration breaks the live site the moment the standby migrates — not at cutover. Therefore:

Graceful termination

Each component gets a deliberate shutdown policy:

Verification

Before relying on this, verify once per environment:

Secrets

Static on S3 (optional)

Static files can be served from the same per-association public bucket as media (prefix static, CDN in front via the public custom domain) instead of the pod filesystem. Enable per site:

media:
  s3:
    staticEnabled: true
    staticCustomDomain: ""   # defaults to publicCustomDomain
migrations:
  job:
    enabled: true            # required: the Job performs the upload

Rollout ordering (critical): the migration Job (sync wave 0, from #1113) uploads static before application pods start. Enabling staticEnabled and the upload must happen in the same release, so pods never render S3 URLs before the bucket is populated. Rollback: flip staticEnabled back; the image still carries the build-time static trees, so old pods serve from the pod filesystem again.

Bucket requirements (one-time):

Backups

Operational notes