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:
charts/date-website/ — the Helm chart this repository publishesdocs/dev/operations.md — day-to-day app operationsREADME.md — local development and Docker Compose workflowsmain is the development branch. Every push to main builds a
container image and publishes it to GHCR as <sha> and qa.qa first, then promoted to production.vX.Y.Z) are the production promotion
mechanism. A release tag promotes the already-built image to the
production aliases (prod, latest, and the vX.Y.Z/vX.Y/vX
family) — no rebuild.promote_production.yaml workflow can also promote an arbitrary
existing tag (e.g. a QA-tested SHA) to prod/latest manually.| 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:
qa, prod, and latest are moving aliases — code should not assume they
are immutable. Deployment targets pin what they want (see below).main build,
then reuses that digest instead of rebuilding — tag the commit whose
main build went green.docker_build.yaml, release_tag.yaml,
promote_production.yaml.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:
Ingress, or Gateway API resourcesThe web deployment runs migrateOnStartup in the production values. If the
web replica count is ever raised above 1, move migrations out of startup
into the migration Job so two pods cannot race.
PROJECT_NAME, hosts,
settings module, media prefixes, database, and secrets.postgresql.enabled: false, database.external used).media.s3.privateCustomDomain /
media.s3.publicCustomDomain, host only, no scheme) switch media URLs to a
domain served by a Cloudflare Worker in the operator repository. The Worker
forwards requests to the S3 endpoint, rewriting the Host header to the S3
endpoint host (the hostname the URL was signed against) and preserving path
and query. Public media URLs become unsigned https://<domain>/<location>/<key>;
private media URLs stay presigned, with only the host swapped, so the SigV4
signature still validates.promote_production is run) — the image is now
available as prod/latest.image.tag). A release that includes database migrations carries that
pin together with its migrations./healthz/ and /readyz/ are green.The operator repository holds:
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:
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:
secret.existingSecret. Nothing secret goes in
values files or git.values-hetzner.yaml are based on observed
production usage: web is the largest process (~300–450Mi), Celery ~250Mi,
ASGI ~90Mi. Revisit after sustained traffic or big uploads.