ADR 0024 — App deployment infrastructure: Terraform (ACA + SWA + KV + self-hosted Redis), sharing the Container Apps environment with the harness¶
Amendment (2026-07-27, in place — no new ADR): the IaC CLI is now OpenTofu (
tofu), not Terraform. The stack, state, providers, and every decision below are otherwise unchanged — only the binary that reads them differs. See the "Amendment (2026-07-27) — OpenTofu replaces Terraform as the IaC CLI" section at the foot of this ADR. Read "Terraform" in the original text below as "the IaC CLI". (Deliberately not an anchor link: MkDocs and GitHub slugify the em dash in that heading differently, so any fragment is broken in one of the two renderers.)
- Status: Accepted (amended 2026-07-27 — OpenTofu replaces Terraform as the IaC CLI)
- Date: 2026-06-27
- Deciders: @TheurgicDuke771
- Related: ADR 0010 (Azure = one impl behind each seam), 0013 (BYOL distribution), 0018 (in-app surface → same-origin SPA), 0021 (the harness infra is out-of-repo and separate), 0023 (GHCR registry). Realizes the Week-7 deploy and supersedes the #379 scaffolding's ACR assumption.
Context¶
Week 7 is "go to cloud." The #379 scaffolding documented a topology but had no infra-as-code for the
app's own resources, and still assumed ACR (superseded by ADR 0023). Separately, the demo/datasource
infra already lives in an out-of-repo Terraform harness (ADR 0021) in the shared dataq-rg. We had
to decide: where the app's production IaC lives, how it stays isolated from the harness, and the
concrete service choices (registry pull, Redis, secrets, frontend↔API wiring, CI auth).
Constraint discovered at apply time: this subscription is hard-capped at one Container App
Environment, subscription-wide (MaxNumberOfGlobalEnvironmentsInSubExceeded — a free/trial-tier
limit; not per-region, so no region trick helps), and the harness already owns it. So a dedicated app
environment is impossible without a quota increase or switching the app to App Service (whose request/
HTTP model fits the Celery worker + self-hosted Redis broker poorly). We therefore share the harness's
Container Apps environment — the one deliberate exception to "subscription + RG only."
Decision¶
Provision the app's production resources with a dedicated, in-repo Terraform stack at
deploy/terraform/azure/, reusing the subscription, dataq-rg, and the single Container Apps environment;
everything else is a separate dataq-app-* resource.
- In-repo IaC, local gitignored state. Unlike the harness (out-of-repo, ADR 0021), this is the app's own deploy config and belongs with the code; state is gitignored (it holds the generated Postgres/Redis passwords). The provider lock is tracked.
- Separation by namespace + tag. All app resources are
dataq-app-*/purpose=dataq-app; harness resources staydataq-harness-*/purpose=dataq-harness. The one shared resource — the Container Apps environment — is renamed to the neutraldataq-caeand taggedpurpose=dataq-shared. The RG is referenced, never managed — an idempotentaz group createstep (preserving the existingproject=dataqtag) guarantees it exists without Terraform owning/destroying the shared RG. - Shared Container Apps environment (
dataq-cae). Owned/created by the harness Terraform (renamed there); the app stack references it via a data source and runs its own apps inside it. Because the env's region is fixed (westus2), the app's container resources land there too.dataq-app-api(external ingress),dataq-app-worker(Celery + embedded beat),dataq-app-migrate(manual Job:alembic upgrade head). All run the same GHCR image (ADR 0023), pulled anonymously (public package — no registry block/credential). - Redis = self-hosted
redis:7-alpineContainer App (internal TCP), not Azure Cache for Redis. The Celery broker is transient (no persistence SLA needed); self-hosting is materially cheaper for v1. It still requires a password (--requirepass) as defense-in-depth atop internal-only ingress. - Secrets. Key Vault (RBAC) is the app's runtime
SecretStore(SECRET_STORE=azure_key_vault), read via a user-assigned managed identity (Key Vault Secrets User). Boot-critical config (DATABASE_URL,REDIS_URL, App Insights) is injected as inline Container App secrets, not KV references — decoupling first-revision activation from KV-RBAC propagation delay. Webhook secrets are pre-seeded in KV. - Frontend ↔ API = SWA Standard + linked backend. The SPA uses a relative
/api/v1base URL, so the Static Web App links thedataq-app-apiContainer App as its backend and proxies/api/*same-origin (CORS middleware stays off — consistent with the existingstaticwebapp.config.jsonand ADR 0018's same-origin authz). Linking usesaz staticwebapp backends link(no azurerm resource covers arbitrary linked backends). - CI auth = AAD app registration + GitHub OIDC. A
dataq-github-deployapp registration with a federated credential (repo:…:environment:production) — no stored client secret. Its Contributor role is scoped to the three deploy targets, not the shared RG (least privilege; the RG holds harness resources). - Hardening toggles.
postgres_public_network_access(true for v1 over the allow-Azure-services firewall + TLS; flip to false for the VNet-private path) andkey_vault_purge_protection(false for bring-up name reuse; true for prod) are explicit variables, not silent defaults.
Consequences¶
Positive
- App IaC is reviewable, reproducible, and idempotent (plan is clean on re-run); app resources are
cleanly identifiable (az resource list … purpose=='dataq-app') and the harness's dataq-harness-*
resources stay separate.
- No Azure registry, no stored registry/CI secret (GHCR public + OIDC); cheapest viable Redis.
- Same-origin SPA↔API needs no CORS and matches the committed SWA config.
- Least-privilege CI principal can't reach harness resources in the shared RG.
- Sharing the one allowed Container Apps environment keeps the whole architecture (worker + broker +
job as first-class) within the subscription cap, at no extra cost.
Negative / watch
- Shared Container Apps environment. The app and harness share dataq-cae, so: (a) renaming/
rebuilding the env from either Terraform recreates both sides' apps (the rename to dataq-cae
forced a one-time harness rebuild); (b) the env's single Log Analytics workspace (dataq-harness-logs)
collects both sides' container stdout — the app's structured telemetry still lands cleanly in its own
dataq-app-ai; (c) the two stacks share the env's network + quota. The two Terraform states stay
independent; only the env resource is referenced (data source) from the app side. Revisit (dedicated
env) if a quota increase is obtained.
- Self-hosted Redis has no managed HA/persistence; a replica restart drops in-flight broker state
(acceptable for a Celery broker — tasks re-dispatch). Revisit Azure Cache if durability is needed.
(The shared env is exactly why the broker is password-protected.)
- Postgres public access (v1) is gated only by the allow-Azure-services rule + TLS + credentials;
the VNet-private path is the post-v1 hardening (variable already in place).
- Linked-backend via CLI (null_resource) is outside Terraform's graph — re-link by tainting it if
the API resource id changes.
- Cross-internet GHCR pulls add cold-start latency (already noted in ADR 0023).
Alternatives considered¶
- Dedicated app Container Apps environment. The original intent — rejected at apply time by the hard 1-env-per-subscription cap. Sharing the harness env was chosen over a quota increase (uncertain timing/approval on a free/trial subscription) and over App Service (full separation, but its HTTP-request model fights a non-HTTP Celery worker and forces managed Azure Cache for Redis + a Terraform rewrite). Reconsider a dedicated env if the quota is raised.
- Out-of-repo, harness-style (ADR 0021 precedent). Rejected: that precedent is for demo/datasource infra; the app's deploy config belongs with the app (state still gitignored, no secrets in files).
- Azure Cache for Redis (managed). Rejected for v1 on cost; the broker doesn't need a managed SLA.
- SWA Free + CORS (absolute API URL). Rejected: needs a frontend code change (relative
/api/v1baseURL) and re-introduces CORS; linked backend is the same-origin design already committed. - User-assigned identity + federated cred for CI (instead of an app registration). Viable and needs no app-reg rights; the app registration was chosen for a cleaner separation of the CI principal.
Amendment (2026-07-27) — OpenTofu replaces Terraform as the IaC CLI¶
- Status: Accepted
- Date: 2026-07-27
- Amends: this ADR's choice of IaC CLI only. Every other decision above stands.
- Related: ADR 0031 (MIT distribution + the standing no-source-available dependency guardrail, CONTRIBUTING rule 40), ADR 0021 (the out-of-repo harness stack).
Context¶
Terraform has been BUSL-1.1 since v1.6; the stack's floor (>= 1.9) and the version
actually in use (1.15.x) are both under it. ADR 0031 put DataQ on record rejecting
BSL-style licensing for itself and set a standing guardrail keeping source-available
licenses (explicitly naming BUSL) out of the dependency tree.
Terraform did not violate that guardrail. Rule 40 governs the dependency tree — what DataQ ships — and the CLI is a build-time tool that never enters an image or a release artifact. BUSL-1.1's additional use grant permits using it to deploy your own product; there is no legal exposure and nothing was out of compliance. This amendment is about coherence: the deploy toolchain was the one place a BUSL binary still touched a project that is explicit about not shipping one.
The practical trigger is #505 (AWS + GCP deploy IaC, post-v1). Converting one Azure stack is a contained change; converting three, after they are written against a second CLI, is not. This is the cheapest this decision will ever be.
Decision¶
Use OpenTofu (tofu) as the IaC CLI for every DataQ-owned stack — the in-repo
deploy/terraform/azure/ stack and the out-of-repo harness stack (ADR 0021) — converted
together.
- Directory path and file extensions are unchanged.
deploy/terraform/<cloud>/and*.tfstay.terraform {}is OpenTofu's own block name and.tfits own format; they are not Terraform leftovers. Renaming would churn every doc link, the.gitignorepath-independent globs, thedocs/compliance-posture.mdevidence links, and theaws//gcp/sibling convention #505 will follow, for zero functional gain. - State carries over untouched. Local backend, state format version 4, gitignored. No
migration step, no re-import, no
state mv. - Providers resolve from
registry.opentofu.org.tofu initrewrites.terraform.lock.hclaccordingly; the tracked lock is the record. Provider versions are preserved exactly — only the registry host and the hashes change (the OpenTofu project's provider builds are not byte-identical to HashiCorp's). - Both stacks convert; neither is applied as part of the change. The harness stack is
verified plan-only — a blanket harness
applyarms ADF triggers and drifts the warehouse (seedocs/ops-log.md).
Evidence — how this was verified¶
A clean plan was not the acceptance bar, because the stack already carries pre-existing
drift — the ADR 0034 lineage env vars (LINEAGE_PROVIDER, MARQUEZ_URL,
WAREHOUSE_LINEAGE_ENABLED) are live on prod but absent from containerapps.tf, so an
apply would delete them, while DBT_WEBHOOK_SECRET_NAME (ADR 0029) is in config but not
live. Filed as #1086; unrelated to this change and reproducing identically on both CLIs.
The bar was therefore equivalence: the same config and the same state must produce the
same plan under both CLIs.
Worth recording, because it nearly went in wrong: the azurerm provider diffs a container app's
envblock positionally, so the rendered plan reads as a 14-line shuffle of variable names and the actual content is invisible. Read from the rendering alone, the drift looks like "config vars that were never applied" — includingRATE_LIMIT_XFF_TRUSTED_HOPS, which is in fact present on both sides and merely moves index. Only theshow -jsonset-difference gives the real answer (2 removals + 1 addition per app, 28 unchanged). Another instance of the standing rule: the rendered form is not the evidence.
Both CLIs were run against live Azure with identical inputs, their plans exported with
-out and rendered via show -json, and the resource_changes projection normalized
(sorted by address, rendering stripped) and diffed:
| Terraform 1.15.8 | OpenTofu 1.12.5 | |
|---|---|---|
| resource_changes | 40 | 40 |
| action summary | no-op=38 update=2 |
no-op=38 update=2 |
| normalized diff | — | byte-for-byte identical |
| provider versions | azuread 3.9.0 · azurerm 4.79.0 · null 3.3.0 · random 3.9.0 · time 0.14.0 | identical |
The rendered text plans differ (branding prose, refresh-line ordering, column alignment,
and how each CLI counts # (N unchanged attributes hidden)). Those are presentation
artifacts; the semantic comparison is the one that carries weight, and eyeballing the
rendered diff would not have settled it.
Consequences¶
Positive - The deploy toolchain matches the project's stated licensing posture end to end. - No BUSL binary anywhere in the workflow; no future HCP Terraform upsell surface. - Unlocks OpenTofu state encryption — a real gain here, since the local state holds the generated Postgres password and secret values in plaintext on one machine. Tracked as a deliberate follow-up, not folded into this change. - Done while there is exactly one stack pair, before #505 adds two more.
Negative / watch - The swap was convention, not enforcement — until #1087 closed that. As written, this amendment left the config Terraform-parseable so the change stayed reversible, and named state encryption as the separate one-way door.
Update (2026-07-27, #1093, closes #1087): that door is now closed.
versions.tfcarries anencryption {}block (AES-GCM + PBKDF2 over state and plan files), which Terraform cannot parse — so the stack can no longer be run withterraform, and the OpenTofu migration is now structural rather than conventional. The passphrase lives in the gitignoredterraform.tfvars, deliberately not in Key Vault (which this state manages, making it circular) and not in.env(whichscripts/setup.shregenerates). Two operational consequences: the state can no longer be grepped — usetofu show -json— and-input=falseis mandatory, since a missing required variable otherwise prompts on stdin and hangs invisibly when stdout is redirected. - Two stacks, one resource group. The app and harness stacks sharedataq-rg. Converting only one would have left two CLIs and a wrong-binary hazard on shared resources; they were converted together for that reason. - Ecosystem material (module docs, tutorials, assistant output) still defaults toterraform; occasional mental translation ondeploy/contributions. - The pre-existing drift found while establishing the baseline is unrelated to this change and is filed separately — it is not resolved here, and this amendment deliberately does not apply the stack.
Alternatives considered¶
- Stay on Terraform. Zero work, zero risk, and no compliance problem to fix — the honest null option. Rejected on coherence plus the #505 timing argument, not on necessity.
- A new ADR (0040) instead of amending in place. The index convention pairs an amendment with a new ADR, but that convention exists for decisions large enough to stand alone. This changes one tool in one decision and would read as an orphan record; amending 0024 keeps the rationale where a reader of the deploy ADR will actually find it. Precedent: ADR 0038 §5, amended in place by #952.
- Rename
deploy/terraform/→deploy/opentofu/. Rejected — see Decision above. - Fold state encryption into the same change. Rejected: it mixes a provably-zero-diff swap with a state-rewriting change and burns the rollback path.