Skip to content

Phase 5: self-hosted deployment (Helm, Terraform, SOPS, CD on tag) - #11

Merged
tthanhtung92 merged 9 commits into
mainfrom
feat/phase-5-deployment
Aug 5, 2026
Merged

Phase 5: self-hosted deployment (Helm, Terraform, SOPS, CD on tag)#11
tthanhtung92 merged 9 commits into
mainfrom
feat/phase-5-deployment

Conversation

@tthanhtung92

Copy link
Copy Markdown
Owner

Phase 5: self-hosted deployment

Closes the Phase 5 paragraph in docs/ROADMAP.md section 5. See ADR-0018 for the decisions.

What's here

  • Helm chart (deploy/helm/finmy): API Deployment/Service/Ingress/HPA/PDB wired to /health/live and /health/ready, plus in-cluster Postgres/Redis/MinIO and a nightly pg_dump backup CronJob.
  • Migrations and Wolverine resources setup run from one plain, non-hook Job (see ADR-0018 for why a Helm hook deadlocks either way).
  • Terraform (deploy/terraform): Hetzner VPS, firewall, DNS, k3s + cert-manager via cloud-init.
  • SOPS + age encrypted production secrets (deploy/values-prod.sops.yaml), closing TECH-DEBT Bump the nuget-minor-and-patch group with 25 updates #15.
  • Program.cs sets ResourceAutoCreate = AutoCreate.None in production, closing TECH-DEBT build(deps): bump docker/login-action from 3 to 4 #4. docker-compose.yml gains a wolverine-setup service so plain docker compose up keeps working.
  • New CI job (chart-and-infra) validates the chart and Terraform on every PR.
  • release.yml publishes the migrator image and adds a tag-triggered deploy job, inert behind a DEPLOY_ENABLED repo variable.

Verified this session

  • dotnet build/dotnet test (112 tests) / scripts/coverage.ps1 (64.26%/56.36%, above the 60/54 floor) all green.
  • docker compose down -v && up --build: wolverine-setup creates the wolverine schema itself, api starts clean, both health endpoints return 200 — confirms AutoCreate.None actually took effect.
  • Real helm, terraform, sops and age binaries (none installed beforehand, added via winget for this session): helm lint clean, helm template renders all 14 resources, kubeconform -strict validates every one, terraform validate passes against real provider schemas, and the full SOPS-decrypt-into-helm template pipeline round-trips correctly.

Not verified (no cluster exists)

Ingress, cert-manager issuance, HPA scaling, PDB behavior, the backup CronJob's restore path, terraform apply, and the deploy job end to end. All flagged in ADR-0018 and deploy/terraform/README.md. "Deployed and reachable over HTTPS" stays unchecked in ROADMAP.md section 6 by design — Terraform is written and validated, never applied.

Also flagged, not fixed

TECH-DEBT #17 (docker-compose.local.yml drift) widens by one more service (wolverine-setup) added to the base compose file. No phase assigned to #17; left alone per the session brief.

Production now sets JasperFx.Profile.ResourceAutoCreate = AutoCreate.None
inside the existing CritterStackDefaults call, next to GeneratedCodeMode.
The wolverine schema comes from an explicit `resources setup` deploy
step from here on, not from a race between replicas at startup
(TECH-DEBT #4). Development is unaffected: FinmyApiFactory boots against
a fresh Testcontainers Postgres and needs auto-create to keep working.

docker-compose.yml adds a one-shot wolverine-setup service, built from
the same final image with command ["resources", "setup"], running
after migrate and before api. Without it, ASPNETCORE_ENVIRONMENT=Production
in that file would now start api against a wolverine schema that does
not exist, breaking the 'clone and docker compose up, no manual steps'
guarantee.

Verified end to end: docker compose down -v to wipe the volume, then
up --build. wolverine-setup's logs show it creating the wolverine schema
and its tables; api starts clean afterward and /health/live and
/health/ready both return 200.
One chart, no sub-charts, templates named flat by component. Covers the
Deployment/Service/Ingress/HPA/PDB the roadmap asks for, wired to the
existing /health/live and /health/ready endpoints, plus Postgres, Redis
and MinIO as StatefulSets/a Deployment on k3s's local-path storage class,
and a nightly pg_dump CronJob for backup (ADR-0018).

EF migrations and Wolverine's 'resources setup' run from one plain,
non-hook Job rather than a Helm lifecycle hook: a pre-install hook blocks
the whole release before Postgres exists in the same chart, and a
post-install hook under --wait never fires because Helm is already
blocked on the Deployment's rollout. Everything applies together instead,
and the API's readiness probe (already checking GetPendingMigrationsAsync
per ADR-0014) is what gates traffic on the Job finishing. The API's
RollingUpdate strategy sets maxUnavailable: 0 so old replicas keep
serving during that window.

readOnlyRootFilesystem stays false: TypeLoadMode.Auto (ADR-0013) writes
generated handler code to /app/Internal on first handler invocation,
which a read-only rootfs would break at runtime, not startup.

Verified with real helm and kubeconform binaries (installed via winget,
none present in this environment beforehand): helm lint passes, helm
template renders all 14 resources with dummy secrets, and every rendered
manifest validates cleanly under kubeconform -strict.
Provisions one hcloud_server (default cx22, nbg1, Ubuntu 24.04), a
firewall (22 and 6443 restricted to an admin CIDR, 80/443 open), and
A/AAAA records via the Hetzner DNS provider. k3s and cert-manager install
through the server's own cloud-init rather than a separate Ansible or
Helm-from-a-workstation step: k3s's bundled Traefik, local-path and
metrics-server are left at their defaults, and cert-manager plus a
Let's Encrypt ClusterIssuer land as manifests k3s auto-applies on its
own.

Written and terraform validate-clean against real provider schemas
(confirmed with the actual hcloud and hetznerdns providers via
terraform init/validate, installed via winget for this session), but
never applied: no server exists yet, so 'deployed and reachable over
HTTPS' stays open in ROADMAP.md section 6. deploy/terraform/README.md
says so plainly and documents the one manual step this configuration
deliberately does not automate (copying the post-apply kubeconfig into
the KUBE_CONFIG Actions secret).

.gitignore gains .terraform/, *.tfstate*, *.tfvars (keeping
*.tfvars.example) and *.agekey; the Terraform lock file is committed.
deploy/values-prod.sops.yaml holds the four values values.yaml cannot
ship a real default for: Jwt__SigningKey, the Postgres password, and the
MinIO root credentials. Keys stay readable in diffs; only the values are
encrypted. .sops.yaml at the repo root points at one age recipient; the
matching private key lives only in the SOPS_AGE_KEY GitHub Actions secret
and the repo owner's password manager, never in this repository.

Closes TECH-DEBT #15: the compose file's inline Jwt__SigningKey default
stays exactly what it always was, a local-development convenience, no
longer standing in for a real secret-management story anywhere.

The path_regex uses a [/\] character class instead of a literal
forward slash, because sops resolves the target path with the host OS's
separator before matching, so a literal '/' silently fails to match on
Windows (reproduced and fixed in this session; the same rule works
unmodified in Linux CI).

Verified end to end with real sops and age binaries (installed via
winget, neither present in this environment beforehand): generated the
actual age keypair used here, encrypted deploy/values-prod.sops.yaml
against its public key, decrypted with the private key to confirm the
round-trip, and fed the decrypted output into helm template to confirm
it satisfies the chart's required-value checks.
New chart-and-infra job, parallel to build-and-test, no .NET involved.
This is the only place any of deploy/ gets checked: no helm, kubectl,
terraform, sops or age binary is assumed to exist on a dev machine.

helm lint, then helm template with placeholder secret values piped
through kubeconform -strict for schema validation against real
Kubernetes API schemas (no live cluster needed for either check).
terraform fmt -check and terraform init -backend=false && validate
against the real provider schemas. A final grep confirms
deploy/values-prod.sops.yaml is still actually encrypted, so a plaintext
secret landing in the repo fails the build rather than merging quietly.
The Helm migrate Job needs an image for its EF migration initContainer,
and release.yml only ever published target: final. Adds a second
build-push-action step publishing target: migrator to
ghcr.io/<repo>/migrator:<tag>.

New deploy job, needs: publish, gated on the DEPLOY_ENABLED repository
variable so tagging a release does not fail red while no cluster exists
yet to deploy to (deploy/terraform has never been applied). Decrypts
deploy/values-prod.sops.yaml with the SOPS_AGE_KEY secret, configures
kubectl from the KUBE_CONFIG secret, and runs helm upgrade --install
with --wait. environment: production scopes both secrets to this job.
Records the four decisions the roadmap's Phase 5 paragraph asked for:
Postgres/Redis/MinIO in-cluster with a pg_dump CronJob (versus
CloudNativePG or managed services), SOPS+age (versus Sealed Secrets or a
cloud KMS), Actions-on-tag helm upgrade (versus Argo CD), and k3s's
bundled Traefik (versus ingress-nginx). Also records the migrate Job
ordering decision (plain Job, not a Helm hook, and why both hook
lifecycles deadlock on a fresh cluster) and states plainly what none of
this has been verified against: a live cluster.
…E.md

Marks Phase 5 done in ROADMAP.md with what actually shipped and what
did not (no server, so 'deployed and reachable over HTTPS' stays open
in section 6). Drops TECH-DEBT #4 and #15, both closed this phase, and
notes #17's drift widened by one more compose service. Adds deploy/ to
README's project layout and ADR-0018 to its architecture-decisions list.
CLAUDE.md's 'What exists'/'What does not exist yet' sections describe
the deploy/ shape and its verification status.
deploy/README.md and deploy/terraform/README.md each document their own
piece; neither sequences the full path from having a Hetzner account to
the app answering HTTPS on a real domain. RUNBOOK.md is that sequence:
provision, make the GHCR images pullable, extract a kubeconfig for
Actions, confirm SOPS_AGE_KEY, point the Ingress at the real host, flip
DEPLOY_ENABLED, tag, verify.

Written against no live cluster, same caveat as the rest of deploy/ this
phase; the closing section names the three failure modes ADR-0018
already flagged as unverified so a first-deploy troubleshooter starts
there instead of assuming the chart is wrong.
@tthanhtung92
tthanhtung92 merged commit 49a993c into main Aug 5, 2026
4 checks passed
@tthanhtung92
tthanhtung92 deleted the feat/phase-5-deployment branch August 5, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant