Skip to content

Deploy on Azure with Terraform

Enprivacy 3.0 runs on Azure as a set of Azure Container Apps inside a private virtual network, backed by Azure Database for PostgreSQL, Azure Blob Storage, Azure Key Vault, and Azure AI Foundry as the LLM backend. The Terraform bundle provisions all of it in seven independently applied modules.

flowchart TD
    NET["Internet"] -->|"HTTPS"| CF["<b>Cloudflare</b><br/>client TLS · WAF<br/>Authenticated Origin Pulls"]
    CF -->|"HTTPS"| AGW["<b>Application Gateway v2</b><br/>own certificate<br/>edge module, optional"]
    AGW -->|"HTTPS, internal"| WEB

    subgraph aca ["Container Apps environment — internal only, no public load balancer"]
        WEB["<b>Web</b><br/>Thruster + Puma"]
        JOB["<b>Job</b><br/>Solid Queue"]
        OCR["<b>OCR</b><br/>Docling"]
        INF["<b>Inference adapter</b><br/>embed · rerank · detect"]
        ONE["<b>One-shot jobs</b><br/>db-bootstrap · db-migrate · db-repair"]
    end

    WEB -.->|"extract"| OCR
    JOB -.->|"extract"| OCR
    WEB -.->|"embed · rerank · detect"| INF
    JOB -.->|"embed · rerank · detect"| INF

    subgraph data ["Private data plane — private endpoints, VNet-integrated"]
        PG["<b>PostgreSQL 18</b><br/>pgvector · hstore"]
        STORE["<b>Storage</b><br/>documents in blob, keyless"]
        AI["<b>Azure AI Foundry</b><br/>system-agent LLM"]
        KV["<b>Key Vault</b><br/>every secret, delivered<br/>as Key Vault references"]
        LAW["<b>Log Analytics</b><br/>all diagnostics<br/>+ optional SIEM Event Hub"]
    end

    WEB --> PG
    WEB --> STORE
    WEB --> AI
    JOB --> PG
    JOB --> STORE
    JOB --> AI
    ONE --> PG

Key properties of the reference architecture:

  • No public data plane. The Container Apps environment is internal-only; Key Vault, Storage, and the AI Foundry account are reached over private endpoints, and PostgreSQL is VNet-integrated. Public traffic enters only via Cloudflare and the Application Gateway.
  • Secrets never leave Key Vault. Every application secret is delivered as a Key Vault reference resolved by a user-assigned managed identity — no raw values in app configuration. The vault’s own secrets are write-only in Terraform, so none of their values reaches Terraform state.
  • Offline models. The OCR and inference-adapter images carry the model weights they load, with all offline switches set — nothing in Azure contacts the Hugging Face Hub, and each image digest pins its models.
  • Digest-pinned images. All images are pinned by @sha256: digest; the digest is the provenance handle (see Supply Chain Security).

Each concern is a root module with its own remote state, applied in order — every module reads its upstream modules’ outputs via terraform_remote_state:

foundation → keyvault → storage → database → ai → app → edge
Module Provisions
foundation Resource group (created, or an existing one adopted), VNet + subnets, NSGs, NAT gateway egress, the internal Container Apps environment, Log Analytics
keyvault Azure Key Vault (RBAC, private endpoint), the app’s user-assigned identity, the generated encryption keys and session secret, and placeholders for your supplied secrets
storage The customer-document account (blob, RA-GRS), plus an optional demo-content account
database PostgreSQL 18 Flexible Server, extension allow-list, database, and the three-role least-privilege model (on by default)
ai Azure AI Foundry account and model deployment for the system agents. Its own module because the lifecycle is durable: it holds a regional quota allocation, its name soft-deletes on destroy, and it must survive app-layer teardowns
app Web, Job, OCR and inference-adapter container apps, and the db-migrate and db-repair jobs
edge Optional Application Gateway v2 public ingress for Cloudflare to target (Cloudflare provides the WAF layer)

Three rules govern everything below, and they are worth reading before the first command:

The Terraform workspace names the environment. TF_WORKSPACE=prod suffixes every resource name, selects the configuration file, and keeps state in per-environment blobs. The default workspace is refused. The name must match ^[a-z][a-z0-9]{0,8}$ — at most nine characters, starting with a lowercase letter, and lowercase letters and digits only — because it is spliced into names with tight Azure limits, and because storage account names strip other characters (so lab-01 and lab01 would collide).

One file holds every non-secret setting for every module: environments/<workspace>.yaml, with a section per module. Changing an environment means editing that file and re-applying. There are no per-module variables.tf to fill in and no terraform.tfvars. Copy environments/example.yaml — it is fully commented — and keep it complete, because that file is the reviewable record of how your environment differs from the defaults.

Secret values never pass through Terraform variables. The keyvault module generates the encryption keys and the session secret, and seeds each operator secret as REPLACE_ME. Every vault secret is write-only and written once: Terraform sends the value at creation and never again, so an apply never reverts a value you set, and none reaches Terraform state. You set the operator values in Key Vault directly, or through the Azure DevOps pipeline. Terraform state still holds the database passwords and the storage and AI account keys, so treat the state account as sensitive.

  • An Azure subscription, az login, and export ARM_SUBSCRIPTION_ID=<sub-id> (the azurerm 4.x provider requires it explicitly).
  • Terraform 1.15 (1.11 at minimum: the bundle uses write-only arguments and ephemeral resources).
  • A resource group to deploy into. The foundation module creates one, or adopts an existing group named in foundation.resource_group_name — then it never creates or destroys it. Adoption suits a principal scoped to one group.
  • The apply principal needs, on that resource group, Contributor plus Role Based Access Control Administrator — or Owner, which is both. The bundle creates several role assignments and two custom role definitions scoped to the group; Contributor alone fails late, after resources exist. Creating the group also needs resourceGroups/write on the subscription. It also needs Storage Blob Data Contributor on the state storage account, without which terraform init fails before anything is created.
  • Resource providers registered on the subscription, once, by someone with subscription rights — the bundle never registers them itself: Microsoft.App, Microsoft.CognitiveServices, Microsoft.DBforPostgreSQL, Microsoft.Insights, Microsoft.KeyVault, Microsoft.ManagedIdentity, Microsoft.Network, Microsoft.OperationalInsights, Microsoft.Storage (plus Microsoft.Security with Defender for Containers). The bundle’s .azuredevops/bin/register-resource-providers.sh does this idempotently.
  • A storage account for Terraform state (any resource group; the bundle’s backend.hcl.example documents the three values needed). bootstrap.sh creates one, and takes its target explicitly — there is no default subscription, deliberately:
    Terminal window
    SUBSCRIPTION=<subscription-id> SA=<globally-unique-account-name> ./bootstrap.sh
  • From Enprivacy: the release image digest (ghcr.io/enprivacyhq/enprivacy@sha256:…), the inference adapter image digest, a read-only GHCR pull token and the username it belongs to (see Container Image), your OIDC client id/secret, and your allowed organisation codes. Verify image signatures first — see the Deployment Guide.
  • A public hostname for the app (e.g. enprivacy.client.example) and, if you terminate at Cloudflare, the ability to issue a Cloudflare Origin CA certificate for it.
  • Outbound access from wherever you run Terraform to the Terraform registry (the AVM modules download at init) and, from Azure, to ghcr.io, quay.io and docker.io.

The Azure Pre-Install Checklist turns all of this into a form to work through before the deployment session.

Step 1 — configure state and pick a workspace

Section titled “Step 1 — configure state and pick a workspace”
Terminal window
cd terraform/azure
cp backend.hcl.example backend.hcl # fill in your state storage account
az login
export ARM_SUBSCRIPTION_ID=<subscription-id>
export TF_WORKSPACE=<env> # e.g. prod — see the naming rule above
cp environments/example.yaml environments/$TF_WORKSPACE.yaml

Every module is initialised against the same backend with a per-module key:

Terminal window
cd foundation
terraform init -backend-config=../backend.hcl -backend-config="key=azure/foundation.tfstate"
terraform workspace select -or-create "$TF_WORKSPACE"

The bundle ships mise tasks that do this for every module in dependency order — mise run init:all, then mise run plan|apply <module>…. The examples below name the module so either route is clear.

Terminal window
mise run apply foundation

Review the foundation: section of your environment yaml first: location (default southeastasia), vnet_address_space if 10.0.0.0/16 collides with your address plan, and resource_group_name to adopt an existing group (set location to that group’s region).

The app-subnet NSG implements a reduced-egress posture: outbound is denied except DNS, NTP, intra-VNet, and 443 (required for GHCR pulls, the OIDC back-channel, and mandatory Container Apps platform traffic).

Set two things in the keyvault: section of your environment yaml before applying, both load-bearing when you apply from outside the VNet:

Setting Purpose
deployer_object_id Object id of the principal running apply (az ad signed-in-user show --query id -o tsv). Grants it Key Vault Secrets Officer and Certificates Officer — the latter is what lets you import the App Gateway certificate in Step 7.
deployer_ip_allowlist Your public IP (bare, no /32), allowed onto the otherwise private-endpoint-only vault. Without it, every secret write from this module onwards returns 403. Leave empty in production, where applies run from inside the VNet.
purge_protection true by default — keep it for production. false makes test environments fully destroyable (otherwise the vault name stays reserved for 90 days after destroy). Cannot be disabled on an existing vault.
Terminal window
mise run apply keyvault

The module generates the three Active Record encryption keys (enc-primary-key, enc-index-key, enc-key-derivation-salt) and the Rails session secret (secret-key-base) inside Key Vault. Never set or rotate these by hand. It seeds the operator secrets as REPLACE_ME; set their real values now — directly in the vault, or with the Azure DevOps pipeline’s Secrets stage:

Terminal window
VAULT=enprivacy-kv-$TF_WORKSPACE
az keyvault secret set --vault-name "$VAULT" --name auth-client-id \
--value "<client id from Enprivacy>"
az keyvault secret set --vault-name "$VAULT" --name auth-client-secret \
--value "<client secret from Enprivacy>"
az keyvault secret set --vault-name "$VAULT" --name ghcr-token \
--value "<GHCR pull token from Enprivacy>"

Terraform writes each value once and never again, so later applies never revert them. ghcr-token gates every image pull in the stack — if it is wrong, expired or still REPLACE_ME, containers fail to start with a pull error and nothing downstream works. Replace it before it expires: set a new version, then restart each app’s revision, since references resolve when a revision starts.

The inference adapter has two more operator secrets, its API key pair, set before its first start. Inference Adapter explains minting them; on Azure they go into the vault as:

Secret Holds
inference-api-key The key half from keygen — the platform’s
inference-api-keys The entry half — the adapter’s

The adapter’s TLS certificate is not an operator secret. The app module has Key Vault generate it, self-signed and valid for 24 months, for the names the platform dials. Its private key is created in the vault and never leaves it. Web and Job mount the certificate alone; only the adapter mounts the key. To rotate it before it expires (inference_certificate output), raise app.inference_tls_generation by one, apply app, and restart the adapter, Web and Job. The bundle’s RUNBOOK.md (“The inference adapter’s key pair and certificate”) has the details.

Terminal window
mise run apply storage database

The database module provisions PostgreSQL 18 with the extensions the schema needs — hstore, vector and plpgsql — on the azure.extensions allow-list (see the Deployment Guide).

Least-privilege by default: the module creates a three-role model — pgadmin (bootstrap only), enprivacy_migrator (schema changes), and enprivacy_app (runtime, DML-only) — plus a one-shot db-bootstrap job to create the roles. Run it after the apply:

Terminal window
az containerapp job start -n enprivacy-db-bootstrap-<env> -g <resource-group>

From Azure DevOps, the pipeline’s Operations · Run DB bootstrap does the same and waits for the result. Terraform never starts this job, so it has no execution history until one of these runs.

Setting enable_dml_role = false opts into a simplified install instead: the app connects with the admin credentials, migrates itself at boot, and no manual database job steps are needed.

Terminal window
mise run apply ai

This provisions the Azure AI Foundry account and one model deployment for the system agents. Set in the ai: section:

Setting Value
llm_model_name / llm_model_version The model and version. Version availability is region-limited — confirm the pair exists in your region as GlobalStandard before applying.
llm_model_capacity Thousands of tokens per minute. This is a rate limit, not a reservation: GlobalStandard bills per token, so a high ceiling costs nothing until it is used. The default of 10 is roughly one document-grounded chat turn per minute before requests start returning 429 — raise it for anything beyond a smoke test.

It is a separate module because its lifecycle is durable. The account holds a regional quota allocation, and its name soft-deletes on destroy — a destroyed account must be purged before the name can be reused:

Terminal window
az cognitiveservices account purge -l <location> -g <rg> -n enprivacy-ai-<env>

Set at minimum, in the app: section of your environment yaml:

Setting Value
enprivacy_image The release image digest from Enprivacy
inference_image The inference adapter image digest from Enprivacy
ghcr_username The username that owns the GHCR pull token, from Enprivacy
public_hostname The public hostname users will reach the app on — feeds EP_HOST, which drives link generation and the OAuth callback
allowed_organization_codes Sign-in allowlist (provided by Enprivacy)
admin_team_source / admin_team_emails How the Auth Admins team is granted (emails, auth or entra) and, for emails, the comma-separated bootstrap administrators
Terminal window
mise run apply app

Run migrations now — and after every release (not needed only on the simplified enable_dml_role = false path, where the app migrates itself):

Terminal window
az containerapp job start -n enprivacy-db-migrate-<env> -g <resource-group>

Then the post-deploy repairs (bin/rails doctor:repair), once the migration has succeeded and before web and job start. Each repair is idempotent and prints a count:

Terminal window
az containerapp job start -n enprivacy-db-repair-<env> -g <resource-group>

From Azure DevOps, tick Run DB migrate on Terraform · Plan & apply: it stops web and job, applies app if ticked, runs the migration and then the repairs, waiting for each, then starts them again. Terraform never starts either job, so neither has execution history until one of these runs.

The Web, Job, OCR and inference services deploy at 0 replicas by default (web_replicas / job_replicas / ocr_replicas / inference_replicas), so nothing boots against an empty schema. Once migration has succeeded and the inference adapter’s secrets are set, raise inference_replicas first and re-apply; once the adapter reports ready, raise the other three. The web and job apps do not start without a reachable adapter:

Terminal window
mise run apply app

The web and job apps resolve their configuration automatically — database DSN, OCR and inference endpoints, encryption keys — from module outputs and Key Vault references. Blob storage is keyless: the apps authenticate to the document account with their managed identities, so no storage key is ever delivered to them. The LLM is wired end-to-end too: the stack deploys an Azure AI Foundry model dedicated to this workload — the system agents send PII-bearing content to it, so nothing else should share the account — and points the app at its OpenAI-compatible endpoint (EP_LLM_PROVIDER=azure) with the account key delivered as a Key Vault reference (EP_LLM_API_KEY, isolated from any AZURE_OPENAI_* settings used for other models). Keyless (managed-identity) LLM auth is a planned follow-up; the required role assignments are already provisioned. The full set of application environment variables is documented in the Deployment Guide.

The edge module provisions an Application Gateway v2 (Standard_v2 — no WAF on the gateway, since Cloudflare provides the WAF layer in this design; set the appgw_sku_* variables to WAF_v2 for an OWASP policy as defence-in-depth) in front of the internal environment. It is gated off by default; to enable:

  1. Issue a Cloudflare Origin CA certificate for your public hostname, bundle it as a PFX, and import it into the Key Vault; grant the gateway’s user-assigned identity access to it.
  2. In the edge: section, set enable_app_gateway: true, appgw_frontend_hostname (your public hostname), a unique appgw_public_ip_domain_label, and appgw_certificate_key_vault_secret_id — the certificate’s unversioned secret id, so a re-import rotates the certificate without a Terraform change.
  3. mise run apply edge. Gateway creation takes 6–15 minutes; “Still creating…” at the ten-minute mark is normal — do not interrupt it. Then point a proxied Cloudflare DNS record at the gateway’s public IP and set Cloudflare SSL/TLS mode to Full (strict).
  4. Enforce Cloudflare-only ingress — strongly recommended. Paste Cloudflare’s public origin-pull CA certificate (PEM) into edge.cloudflare_origin_pull_ca to enable Authenticated Origin Pulls (TF_VAR_appgw_cloudflare_origin_pull_ca overrides it for a one-off): the listener then requires and validates Cloudflare’s client certificate (mTLS), so traffic sent straight to the gateway’s public IP is rejected. Since Cloudflare provides the WAF layer in this design, this is what prevents attackers from bypassing the WAF entirely; also enable Authenticated Origin Pulls on the Cloudflare zone.

The commands above assume a POSIX shell. Nothing about the stack requires one: terraform and az both run natively on Windows, and the bundle’s mise tasks are only convenience wrappers around them. This section is the PowerShell equivalent, including the handful of translations that are not mechanical.

Prefer PowerShell 7 (pwsh) over Windows PowerShell 5.1 where you have the choice — curl is an alias for Invoke-WebRequest in 5.1, and the PEM-to-PFX conversion below needs .NET 5+.

Take an archive, not a git clone. Beyond keeping repository history off a client machine, a clone on Windows with core.autocrlf=true — the Git for Windows default — rewrites the .tf files with CRLF, and Terraform does not strip CR from a heredoc. The one-shot job scripts would reach their containers as set -e\r, failing at run time rather than at plan time.

Terminal window
# Extract to a SHORT path: `terraform init` builds deep .terraform\modules\…
# trees for the Azure Verified Modules that can exceed the 260-character
# MAX_PATH limit under a user profile directory.
Expand-Archive -Path $HOME\Downloads\enprivacy-iac.zip -DestinationPath C:\ep
Get-ChildItem -Recurse C:\ep | Unblock-File # clears the mark-of-the-web flag
cd C:\ep\terraform\azure

Keep backend.hcl and environments\<env>.yaml outside the extracted tree, so re-extracting a newer bundle does not take them with it.

Terminal window
winget install --id Hashicorp.Terraform # must be 1.15.x
winget install --id Microsoft.AzureCLI
az extension add --name containerapp

Terraform needs no administrator rights — it is a single binary, so if winget is blocked you can extract terraform.exe into a user directory and add it to PATH. The Azure CLI is the opposite: its MSI requires an administrator. Where that approval is slow, python -m pip install --user azure-cli is a user-scope install, Azure Cloud Shell needs no install at all, and a service principal removes the CLI from Terraform’s path entirely.

Terminal window
az login
$env:ARM_SUBSCRIPTION_ID = "<subscription-id>"
az account set --subscription $env:ARM_SUBSCRIPTION_ID
$env:TF_WORKSPACE = "<env>"

With a service principal instead of an interactive login, and no az login step:

Terminal window
$env:ARM_TENANT_ID = "<tenant-id>"
$env:ARM_CLIENT_ID = "<application (client) id>"
$env:ARM_CLIENT_SECRET = "<client secret>"
$env:ARM_USE_CLI = "false"

Paste these once per session. The $LASTEXITCODE check is the load-bearing part — PowerShell does not stop when a native command fails, so without it a failed apply carries straight on into the next module.

Terminal window
$roots = @("foundation","keyvault","storage","database","ai","app","edge")
function Init-Roots {
param([string[]]$Modules = $roots)
foreach ($m in $Modules) {
Write-Host "==> terraform init $m" -ForegroundColor Cyan
Push-Location $m
terraform init -backend-config=../backend.hcl -backend-config="key=azure/$m.tfstate"
$code = $LASTEXITCODE; Pop-Location
if ($code -ne 0) { throw "init failed in $m" }
}
}
function Invoke-Roots {
param([Parameter(Mandatory)][ValidateSet("plan","apply","destroy")][string]$Action,
[Parameter(Mandatory)][string[]]$Modules)
$extra = if ($Action -eq "plan") { @() } else { @("-auto-approve") }
foreach ($m in $Modules) {
Write-Host "==> terraform $Action $m [workspace $env:TF_WORKSPACE]" -ForegroundColor Cyan
Push-Location $m
terraform $Action @extra
$code = $LASTEXITCODE; Pop-Location
if ($code -ne 0) { throw "$Action failed in $m" }
}
}

The deployment sequence then follows the steps above, module by module:

Terminal window
Init-Roots
Invoke-Roots -Action apply -Modules foundation
Invoke-Roots -Action apply -Modules keyvault
# … set the operator secrets, then …
Invoke-Roots -Action apply -Modules storage,database,ai
# … run the bootstrap job, then app, db-migrate, the replica raise, and edge …
Invoke-Roots -Action plan -Modules $roots # the "no changes" check at the end

The \ line continuations in the examples earlier break when pasted into PowerShell, whose continuation character is a backtick. Single-line forms:

Terminal window
$vault = "enprivacy-kv-$env:TF_WORKSPACE"
az keyvault secret set --vault-name $vault --name auth-client-id --value "<client id>"
az keyvault secret set --vault-name $vault --name auth-client-secret --value "<client secret>"
az keyvault secret set --vault-name $vault --name ghcr-token --value "<GHCR pull token>"

Your public IP, for the Key Vault allowlist

Section titled “Your public IP, for the Key Vault allowlist”

keyvault.deployer_ip_allowlist accepts IPv4 only, and a lookup from a dual-stack machine can answer with IPv6 — which Key Vault will not accept and which silently will not match. Force v4:

Terminal window
$ip = Invoke-RestMethod -Uri "https://api4.ipify.org"
az keyvault network-rule add --name "enprivacy-kv-$env:TF_WORKSPACE" --ip-address $ip

PowerShell 7 can bundle the Cloudflare Origin CA certificate as a PFX natively:

Terminal window
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPemFile("origin-cert.pem","origin-key.pem")
[IO.File]::WriteAllBytes("origin.pfx", $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12))
az keyvault certificate import --vault-name "enprivacy-kv-$env:TF_WORKSPACE" -n frontend-cert -f origin.pfx

CreateFromPemFile needs .NET 5+; on PowerShell 5.1 use the openssl that ships with Git for Windows (C:\Program Files\Git\usr\bin\openssl.exe).

The Application Gateway takes 6–15 minutes, longer than some idle timeouts. There is no tmux here and none is needed: disconnecting from a remote desktop session leaves it running, so an apply survives a dropped connection. What kills it is a sign-out — confirm the idle policy disconnects rather than logs off. If it does force a logoff, start the long step as a scheduled task so it is not a child of your session.

  1. Health: https://<public-hostname>/up returns 200.
  2. Inference adapter: its replica reports ready, and its boot log names a CPU budget read from the cgroup that matches inference_cpu.
  3. Sign-in: complete an OIDC login with a user in an allowed organisation.
  4. Background jobs: the job app’s log stream shows Solid Queue polling; recurring jobs appear on schedule.
  5. Document round-trip: upload a document to a workspace and confirm it lands in the active-storage container of the document storage account.
  6. No drift: mise run plan:all reports no changes in every module.

Per release:

  1. Verify the new image signature, then set enprivacy_image in your environment yaml to the new digest.
  2. mise run apply app.
  3. Three-role model only: run the db-migrate job immediately after, then the db-repair job. On the simplified path, run db-repair once the rollout has finished and each app shows one active revision.

A new inference adapter release is the same: set inference_image to its digest and apply app. Its models come with the image, so nothing is staged.

Each lives in the named module’s section of environments/<workspace>.yaml.

Setting Module Default Effect
enable_dml_role database on Three-role least-privilege database model + bootstrap/migrate jobs; false = simplified admin-DSN install
enable_app_gateway edge off Application Gateway v2 public ingress
enable_defender_for_containers app off Microsoft Defender for Containers (subscription-scoped, billable)
siem_eventhub_authorization_rule_id common off Stream diagnostics to your SIEM Event Hub in addition to Log Analytics
enable_apps_workload_profile foundation on Declare an E8 profile for the runtime apps, unused until web_workload_profile / job_workload_profile name it. Free at zero nodes, and the escape hatch from Consumption’s 8 GiB per-replica ceiling. Changeable in place
enable_demo_content storage off A third storage account for operator-uploaded demo documents — the only account in this stack reachable from outside the VNet
resource_group_name foundation empty Empty creates <prefix>-<workspace>; a name adopts that existing group, which the bundle then never creates or destroys
inference_hf_analyser app off Offer the transformer NER analyser on the inference adapter. Costs ~0.4 GB resident; re-check inference_memory
enable_vnet_flow_logs foundation off VNet flow logs + traffic analytics — records all network flows (including internet egress) into storage + Log Analytics; the detective complement to the NSG egress rules
appgw_sku_name / appgw_sku_tier edge Standard_v2 WAF_v2 adds an OWASP WAF policy on the gateway (defence-in-depth behind Cloudflare)