Infrastructure
This document covers the Terraform-managed edge and Cloudflare infrastructure for quant-app.
For how Kubernetes resources are generated from Gradle, see build-system.md. For CI/CD pipeline details, see ci-cd.md.
Overview
Infrastructure is split across four Terraform stacks:
terraform/edge/oci— OCI and Cloudflare-managed production edge: tunnel, DNS, Zero Trust access policiesterraform/edge/contabo— Contabo VPS and Cloudflare-managed staging edge: VPS, firewall, tunnel, DNS, Zero Trust access policies (mirrors the OCI stack)terraform/edge/local— dev edge stack for the local cluster: equivalent Cloudflare tunnel deploymentterraform/kubernetes— in-cluster monitoring and application workloads that sit behind the edge
Terraform Variable Loading
Each Terraform stack loads variable values into the shell before running any terraform command. The file to source depends on the stack and target environment:
terraform/edge/oci(production) andterraform/edge/contabo(staging) each use a.envfile created in the stack directory (see each stack's README).terraform/edge/local(dev) is driven by inlineexportcommands rather than a sourced env file — see its README.terraform/kubernetesuses.envfor thedefault(production) workspace and.env.stgfor thestg(local dev) workspace.
Source the appropriate file before running Terraform:
# Production
cd terraform/edge/oci
source .env && terraform plan
# Staging
cd terraform/edge/contabo
source .env && terraform plan
This applies to all four stacks (terraform/edge/oci, terraform/edge/contabo, terraform/edge/local, terraform/kubernetes). Never run terraform plan, terraform apply, or terraform destroy in any of these directories without sourcing the correct env file (or exporting the required variables for the local stack) first — commands will fail or use incorrect variable values.
Terraform Edge (terraform/edge)
Production Edge (terraform/edge/oci)
- Reads application hosts from
gradle.prod.properties. - Fails fast if any app host does not belong to the configured Cloudflare zone.
- Grafana remains Terraform-owned via
grafana_subdomain; it is not sourced from Gradle properties.
Staging Edge (terraform/edge/contabo)
- Provisions a Contabo VPS bootstrapped with K3s +
cloudflaredvia cloud-init, mirroring the OCI production stack. - K3s version pinning: K3s is pinned to a single release via the shared
terraform/modules/versionsmodule (k3soutput), consumed by both the OCI (production) and Contabo (staging) edge stacks and passed to the installer asINSTALL_K3S_VERSION. The k8s integration tests run against the same release via theconventions.kubernetes.integration-testconvention plugin, which generates theK3S_IMAGEbuild config field into thek8sIntegrationTestsource set from thek3s-testversion catalog entry. The local dev cluster (k3d) is operator-managed and not pinned by Terraform. - A Contabo firewall (default-deny allowlist) permits only SSH (from
ssh_allowed_ips) and ICMP; all application ingress flows through the Cloudflare Tunnel. - First-boot firewall window (accepted exception): the Contabo API/provider cannot attach a firewall before the instance boots —
contabo_firewallpolls forstatus=="running"before assigning, so the instance briefly sits on Contabo's default-open inbound policy on first boot. This is mitigated by a persistent host-levelnftablesfirewall (theqapp_hostfwtable, K3s/Flannel-aware: it allows pod traffic oncni0/flannel.1in bothinputandforwardwhile dropping unsolicited public-IP→pod/NodePort forwarding) loaded by a dedicatedqapp-hostfw.servicesystemd unit, plus key-only SSH. The unit manages only its own table and is never applied via a global flush, so it does not clobber K3s's own iptables/nft rules; it is orderedBefore=network-pre.target k3s.serviceso it is active before networking/K3s on reboots (on first boot it is enabled from cloud-initruncmd, the earliest point after thenftablespackage is installed). Steady state is therefore stricter than OCI production (which has no host firewall): Contabo network firewall + persistent host firewall + key-only SSH. Assumptions that must hold or the ruleset breaks K3s: single-node cluster (no cross-node VXLAN/8472 allow — add it before multi-node), default Flannel (cni0/flannel.1), ClusterIP-only ingress (cloudflared→traefik.kube-system.svc.cluster.local), and remotekubectlto 6443 is not reachable (use SSH port-forwarding — parity with OCI). The distronftables.serviceis disabled and masked so it can never run a global flush that would eraseqapp_hostfwon reboot;qapp-hostfw.serviceis the single authoritative nftables unit. The bootstrap runs underset -eand asserts the unit is active and the table is loaded before installing K3s, so a firewall activation failure fails closed rather than proceeding unprotected. Theinputchain explicitly allows DHCPv4/v6 (UDP 67/68 and 546/547) so the pre-network firewall cannot drop lease acquisition/renewal on a DHCP-based image; the K3s installer is downloaded and validated before execution (acurl | shpipeline masks curl failures) and pinned viaINSTALL_K3S_VERSION; SSH hardening (sshd -t+ reload/restart) fails closed underset -e. The residual first-boot window beforeruncmdis an inherent Contabo platform limitation; seeISSUES.md(QAPP-114). - Reads application hosts from
gradle.stg.properties. - Fails fast if any app host does not belong to the configured Cloudflare zone.
- Grafana remains Terraform-owned via
grafana_subdomain. - The Contabo
product_id(plan, e.g. "VPS 4") is a required variable with no default — look it up at the Contabo product list before applying.
Local Dev Edge (terraform/edge/local)
- Reads application hosts from
gradle.dev.properties(the dev build environment). - Deploys
cloudflared-localinto the local cluster. - Uses
devas the Cloudflare tunnel/app/policy environment label plus the Terraform-owned Grafana host. - Dev app hosts are
localhostand are NOT published to Cloudflare (the dev cluster is accessed locally). Only the Terraform-ownedd-suffixed Grafana subdomain (grafanad) is published through the dev tunnel. - Real (non-localhost) app hosts still fail fast if they do not belong to the configured Cloudflare zone; the shared
app_hostsmodule exempts onlylocalhost/loopback hosts from publishing and zone validation. - Owns the
d-suffixed host namespace, distinct from staging'ss-suffix (apps,dbs, …) and production's unsuffixed hosts. The earlier conflict with the staging stack (QAPP-113) is resolved.
Shared Modules
Shared edge logic lives in child modules. Prefer updating these rather than the stack-local files when changing cross-cutting behavior:
terraform/modules/app_hosts— hostname parsing and validationterraform/modules/cloudflare_edge— tunnel publishing, Grafana UI IP allowlistterraform/modules/versions— shared runtime version pins
Configure the Grafana UI IP allowlist through allowed_ips in the shared module. Override the hostname label only if the public Grafana subdomain changes.
Terraform and Provider Version Pinning
required_version and required_providers { version = ... } accept only literal strings — they cannot reference variables, locals, or tfvars — so there is no native single file for them. Each root stack pins only the providers it uses, and scripts/check_terraform_versions.py (run in CI right after terraform fmt) enforces consistency: the same provider source must be pinned to the same version in every root, required_version must be identical across roots, roots must pin every provider they declare, and shared modules stay source-only (the root decides the version, per HashiCorp module guidance). Runtime versions (K3s, cloudflared) are the exception — they are values, not constraints, and live in terraform/modules/versions.
GitHub Actions installs the exact Terraform version pinned in the workflow-level TERRAFORM_VERSION env (.github/workflows/build.yml) rather than the latest release. The CI version-consistency step passes that pin to scripts/check_terraform_versions.py --ci-version, which fails if the pin stops satisfying the roots' required_version — so bumping required_version without updating TERRAFORM_VERSION (or vice versa) breaks CI loudly instead of drifting silently.
Cloudflare API Token Requirements
The API token used by all edge stacks must include: Zone:Read, DNS:Edit, Tunnel:Edit, and Access: Apps and Policies:Edit.
Terraform Kubernetes (terraform/kubernetes)
- Owns in-cluster monitoring and application workloads behind the edge.
- Important: To prevent overwriting the OCI production state, use Terraform workspaces. The production state resides in the
defaultworkspace. When applying to the local cluster, you must switch to or create a separate workspace (e.g.,terraform workspace new stgorterraform workspace select stg) before runningsource .env.stg && terraform apply.
Token Broker Secrets
terraform/kubernetes owns the Kubernetes Secrets consumed by backend-token-broker. Apply Terraform before deploying the broker for the first time.
OAuth credentials
upstox-oauth-credentials supplies UPSTOX_CLIENT_ID and UPSTOX_CLIENT_SECRET to the pod as Secret-backed environment variables. Provide their environment-specific values through the sensitive, required upstox_client_id and upstox_client_secret Terraform inputs. The generated Deployment contains only the Secret name and keys; the secret values reside in Terraform state.
Provisioned tokens
token-broker-provisioned-tokens mounts provisioned token JSON files into the pod.
- Secret data keys:
upstox-sandbox.json,upstox-analytics.json - File format:
{"token": "....", "expiry": "2027-07-15T00:00:00Z"} - Variables:
upstox_sandbox_provisioned_token_json,upstox_analytics_provisioned_token_json(both sensitive, required — no defaults) - Terraform validation enforces that both
tokenandexpiryare non-empty JSON strings and thatexpiryis a valid RFC 3339 timestamp — catching malformed values atterraform plan/applytime rather than deferring failures to the broker - The token JSON values are stored in Terraform state — this is acceptable only if Terraform state storage is already treated as sensitive infrastructure data
- Rotation: After updating the Terraform inputs and reapplying, the kubelet refreshes the mounted files and the token broker recovers on its next poll cycle — no pod restart is required
Sync Postgres metrics
sync_metrics_password is a required, sensitive Terraform input for the sync_metrics role used by
the backend-sync Postgres exporter. Set a unique value in the target Kubernetes environment file before
sourcing it and running Terraform:
export TF_VAR_sync_metrics_password="<unique-secret>"
Do not commit the value, Gradle properties, or rendered manifests containing it. Terraform creates the
backend-sync-metrics Secret; apply terraform/kubernetes before deploying the backend-sync manifest
for the first time. Updating the value and reapplying Terraform rolls backend-sync, whose bootstrap init
container updates the database role before the exporter starts.
Deployment Environments
| Environment | Cluster | Edge stack | Properties source | Env file |
|---|---|---|---|---|
| Production | OCI k3s | terraform/edge/oci |
gradle.prod.properties |
.env |
| Staging | Contabo k3s | terraform/edge/contabo |
gradle.stg.properties |
.env |
| Dev | local k3d | terraform/edge/local |
gradle.dev.properties |
.env.stg |
Related Documents
- build-system.md — Gradle build types and JKube resource generation
- ci-cd.md — how resources are applied in CI/CD pipelines
- backend-conventions.md — per-module Kubernetes manifest details