Skip to content

Azure Pre-Install Checklist

Work through this before the deployment session. Each row is something that has either blocked or silently broken a real deployment, and almost all of it is faster to settle in advance than to discover at a terminal with people waiting.

The Why it matters column is there so you can judge which rows are negotiable in your environment and which are not. Anything you cannot confirm is worth flagging to Enprivacy ahead of the session rather than leaving blank.

# Confirm Why it matters
A1 The subscription id, and that it is the intended one Everything is scoped to it. ARM_SUBSCRIPTION_ID must be exported explicitly — the azurerm 4.x provider does not infer it from the CLI’s default.
A2 The region, and that it is on your allowed-regions policy Defaults to southeastasia. Region choice also constrains A7 and B3, and — for Azure DevOps — whether hosted agents can be allow-listed on the state account (see C6).
A2a The resource group: one the bundle creates, or an existing one it adopts Adopting (foundation.resource_group_name) lets a principal scoped to a single group deploy everything; the bundle then never creates or destroys the group. Creating one needs resourceGroups/write on the subscription.
A3 The apply principal — which account or service principal runs terraform apply If it is a service principal rather than a user, the object-id lookups in the guide change (az ad signed-in-user show will not work), and keyvault.deployer_object_id must be the service principal’s object id — not the application registration’s. They are different objects, and the registration’s id produces a role assignment that grants nothing.
A3a Whether the deployment workstation can install the Azure CLI Its installer needs administrator rights on Windows, which can take days to arrange. Terraform itself does not — it is a single binary. Nothing in the bundle shells out to the CLI during an apply, so a service principal plus the portal is a complete path if the approval does not arrive; worth knowing before it becomes the critical path.
A4 That principal holds, on the resource group, Owner, or Contributor + Role Based Access Control Administrator The bundle creates role assignments and two custom role definitions. Contributor alone fails partway through, after resources exist. Neither role is needed above the group.
A5 Resource providers registered: Microsoft.App, Microsoft.CognitiveServices, Microsoft.DBforPostgreSQL, Microsoft.Insights, Microsoft.KeyVault, Microsoft.ManagedIdentity, Microsoft.Network, Microsoft.OperationalInsights, Microsoft.Storage (+ Microsoft.Security with Defender) The bundle never registers them — registration is a subscription-level write. Someone with subscription rights runs the bundle’s register-resource-providers.sh once; it can need a separate request with its own lead time.
A6 Creating custom role definitions in the resource group is permitted Two are created, for reading live container logs and for break-glass console access. Both are scoped to the bundle’s own resource group.
A7 The Azure Policy assignments that apply to this subscription See the warning below — this is the single most common cause of a failed first apply in a managed tenant.
A8 Whether access is granted just-in-time (Azure PIM), and if so the maximum activation window An eligible role grants nothing until activated, and the activation expires. The Application Gateway alone takes 6–15 minutes, so a window shorter than the session lapses mid-apply — messier than never having had access. Ask for at least two hours, a standing assignment for the deployment window, or a service principal, which holds permanent assignments.
A9 That the apply principal’s effective permissions have been checked, not just its role names Custom roles, notActions and deny assignments all change what a role actually grants, and a missing grant surfaces partway through a module rather than at the start. The bundle’s ado-preflight.sh checks all of it in one read-only pass; see also the box below.

Checking the effective permissions (A9). Role names stop being reliable once custom roles or deny assignments are involved, and terraform plan is not a permissions test — being read-only, it proves nothing about write or role-assignment rights. Ask for the action list itself:

Terminal window
az rest --method get \
--url "https://management.azure.com/subscriptions/<sub-id>/providers/Microsoft.Authorization/permissions?api-version=2022-04-01" \
--query "value[].{actions:actions,notActions:notActions}" -o json

A bare * with empty notActions is Owner. * with Microsoft.Authorization/*/Write under notActions is Contributor, which is not sufficient. The actions worth confirming individually are Microsoft.Authorization/roleAssignments/write and Microsoft.Authorization/roleDefinitions/write on the resource group, Microsoft.Resources/subscriptions/resourceGroups/write if the bundle creates the group, and the blob data write on the state account. In the portal, Access control (IAM) → View my access is the short version, and its Deny assignments tab is worth a glance — a deny overrides every grant.

A Reader role cannot deploy any of this, but it is enough for most of the reconnaissance on this page — the policy assignments (A7), the provider registration states (A5) and the subscription identity — so it is worth activating early even while the real grant is still being arranged.

A tagging regime is usually three kinds of policy working together, and only the first is obvious:

  • Require a <X> tag on resource groups has a Deny effect. On its own it would stop the first apply, because the resource group is the first thing created.
  • Add a <X> tag to resource groups has a Modify effect and supplies a default value. Azure evaluates Modify before Deny, so where both are assigned the tag is added and the deny passes. The first apply is not blocked.

The problem is the third kind. Inherit a <X> tag from the resource group is also Modify, and it adds those tags to every child resource. Terraform then sees tags it did not set, proposes removing them on the next plan, policy re-adds them, and you have a permanent diff to fight on every release.

The fix is to declare the same tags yourself, in the common: section of your environment yaml, with the values the policies inject:

common:
tags:
Application: "..."
Environment: "..."
Owner: "..."
Data Classification: "..."

Because the inherited value is the resource group’s value, config and reality then agree and the drift disappears. Read the values out of the policy assignments rather than guessing:

Terminal window
az policy assignment list --query "[?contains(displayName,'tag')].{name:displayName,params:parameters}" -o json

Tags set in common.tags are applied to every resource in every module, and a key there overrides a stack default that matches it case-insensitively — which matters because Azure refuses two tag names differing only by case. A convention requiring Environment is therefore safe to adopt as-is.

Quota requests can take days. Check these against the region in A2.

# Confirm Why it matters
B1 Container Apps Consumption vCPU quota in the region The four runtime apps (web, job, OCR, inference) default to 4 vCPU each, plus one-shot jobs.
B2 E-series quota, if you may need the dedicated apps profile Note that az quota requires Microsoft.Quota registered on the subscription — often it is not, in which case check the portal.
B3 The model and version you intend to deploy exists in the region as GlobalStandard, and its available TPM Model version availability is region-limited. Capacity is a rate limit, not a reservation: GlobalStandard bills per token, so a high ceiling costs nothing until used. The bundle’s default of 10 is roughly one document-grounded chat turn per minute before requests start returning 429 — adequate for a smoke test and nothing else.
B4 PostgreSQL Flexible Server vCore quota One server, VNet-integrated.
B5 Public IP quota, if using the Application Gateway One Standard IP.
# Confirm Why it matters
C1 Who owns the state storage account — your subscription or Enprivacy’s State should normally live in your subscription. The bundle’s bootstrap.sh creates a suitable account and is the only thing created outside Terraform. It takes SUBSCRIPTION and SA as required environment variables and has no default subscription, so it cannot quietly build in the wrong tenant.
C2 Its three coordinates: resource group, account name, container These go in backend.hcl, which is the single source for both terraform init and the modules’ cross-reads. Account names are globally unique.
C3 Blob versioning is on It is what lets you recover a clobbered state file. bootstrap.sh enables it.
C4 The apply principal holds Storage Blob Data Contributor on the account State is accessed with your Azure CLI session, not an account key.
C4a Who can read the state account State holds the database passwords and the storage and AI account keys. The Key Vault’s own secrets never reach it. Treat read access to state as access to those.
C5 The workspace name for this environment The Terraform workspace names the environment and is spliced into every resource name. It must match ^[a-z][a-z0-9]{0,8}$ — nine characters, lowercase letters and digits only. Pick it now: changing it later renames everything.
C6 If deploying from Azure DevOps: the region of the state account and the Key Vault relative to the hosted agents An IP rule cannot admit an agent in the same region as the account. The pipeline then opens the account to all networks for the job, with Entra ID and RBAC as the only controls, or fails if you tick the refusal. Decide in advance whether that window is acceptable, or plan a self-hosted agent in the VNet. See Same-region agents.

D. The workstation that drives the deployment

Section titled “D. The workstation that drives the deployment”

Often overlooked, and on a managed or virtual desktop it is frequently the thing that stops the session before it starts. If the deployment will be driven from a Cloud PC, VDI, jump host or bastion, confirm all of it.

# Confirm Why it matters
D1 The operating system and shell The bundle’s task runner and helper scripts are POSIX shell. On Windows they need WSL2 or a Linux host; on a managed Windows image, WSL2 is often blocked by policy.
D2 You can install a toolchain, or that Terraform 1.15 and the Azure CLI are already present The bundle uses mise to pin both, but any equivalent install works. Administrator rights and installer allow-listing are the usual obstacles.
D3 Outbound access to the Terraform registry The Azure Verified Modules download at terraform init. A TLS-inspecting proxy commonly breaks this; the alternatives are a module mirror or a pre-populated .terraform directory.
D4 How the bundle gets onto the machine — a git clone, or a transferred archive If the machine cannot reach the source repository, arrange the archive in advance.
D5 How files and text get onto the machine — clipboard, file redirection, or neither You will need to place backend.hcl, an environment yaml, a certificate, and several secret values. Locked-down clipboard redirection makes this materially slower.
D6 Whether Docker is available Needed to mint the inference adapter’s key pair with its own image (how). Enprivacy can generate the pair out of band instead.
D7 What happens to a long-running command when the session disconnects Idle timeouts are common, and gateway creation can outlast a short one. Run it under tmux or screen, or as a detached job.
D8 The machine’s public egress IP, and whether it is stable See the warning below.
# Confirm Why it matters
E1 The address space for the virtual network Defaults to 10.0.0.0/16. Check it against your address plan before the first apply — subnetting after the fact is disruptive.
E2 Whether the VNet must peer with a hub, and any required route tables A user-defined route sending egress to a hub firewall conflicts with the bundle’s NAT gateway. Reconcilable, but a design conversation, not a setting.
E3 Whether private DNS zones are centrally managed The bundle creates zones for its private endpoints. Central management with policy auto-linking needs reconciling first.
E4 Whether outbound egress must traverse an inspecting proxy or firewall The bundle’s posture is a NAT gateway plus a restrictive subnet NSG, with no Azure Firewall. FQDN-scoped filtering is the documented tightening path.
E5 Any Network Watcher constraint in the region Required if you enable VNet flow logs. Azure auto-creates it per region.
# Confirm Why it matters
F1 The public hostname users will reach the app on Feeds EP_HOST, which drives link generation and the OAuth callback.
F2 Who fronts that hostname — Cloudflare, or something else The optional edge module is built for Cloudflare in front of an Application Gateway, with Cloudflare providing the WAF. A different front door (including Azure Front Door) changes the certificate and origin-lock design.
F3 Where the origin certificate comes from The Cloudflare path uses a free Cloudflare Origin CA certificate. Your own PKI works too, but needs its chain trusted by whatever terminates in front.
F4 Who can create the DNS record and set SSL/TLS mode to Full (strict) Needed at the end of the edge step, and it needs the gateway’s address, so it cannot be done up front.
F5 The sign-in callback URLs are registered with the identity provider https://<your-hostname>/auth/callback and the matching logout URL. A missing callback URL presents as a successful deployment where nobody can log in.
# Confirm Why it matters
G1 You have the operator secrets ready to set: the OIDC client id and secret, the GHCR pull token and its username, and the inference adapter’s key pair and TLS certificate Set in Key Vault after the second module — by hand or by the Azure DevOps Secrets stage — never through Terraform. The GHCR token gates every image pull in the stack and has an expiry date: agree who replaces it, and when. The session secret is generated, not supplied.
G2 Key escrow for the generated encryption keys The keyvault module generates the keys that protect encrypted database columns. They are unrecoverable, and the vault holds the only copy — Terraform state does not. Include them in your escrow procedure before storing production data.
G3 Purge protection stays on for anything holding real data Purging the vault means losing the encryption keys, which means losing every encrypted column. Irreversible once enabled, so disable it only for a throwaway environment you intend to destroy.
G4 Whether a management lock should go on the storage accounts once names are final Recommended for production; left off in the bundle so test environments stay destroyable.
# Confirm Why it matters
H1 Azure egress permits ghcr.io and pkg-containers.githubusercontent.com The Enprivacy application and inference adapter images. The registry is private and the pull token gates it.
H2 Azure egress permits quay.io and docker.io Third-party images: the OCR service from Quay, and PostgreSQL and curl from Docker Hub. Three registries, not one.
H3 Whether policy requires images to be pulled from your own ACR The Enprivacy images are not exported to a client registry today. If ACR-only pulls are mandatory, raise it with Enprivacy early — it is a supply-chain decision, not a configuration change.
H4 The release digest and inference adapter digest to deploy, and that you can verify their signatures Everything is pinned by digest; no floating tags. See verifying image signatures.

These are recorded in the bundle with safe defaults, but they are yours to make. Settling them before the session avoids a deployment that has to pause for a policy conversation.

# Decision What it turns on
I1 PII sent to a vendor-hosted model The system agents send PII-bearing content to the Azure AI Foundry model for detection and classification. This is the thing a self-hosted model avoids. It needs explicit, written risk acceptance.
I2 Defender for Containers Off by default because billing is subscription-scoped — enabling it for this stack affects the whole subscription.
I3 Egress tightening The shipped posture allows outbound 443. FQDN-scoped filtering via a proxy is the documented next step, if your policy requires it.
I4 Database targets, if you will connect Enprivacy to databases Reached through a separate adapter service, which this bundle does not yet provision. Discuss the topology if in scope.
I5 Database role model On by default: three least-privilege roles, with the app holding DML only. A simplified single-role install is available and skips the manual database steps.

J. Operational access, after the deployment

Section titled “J. Operational access, after the deployment”
# Confirm Why it matters
J1 The AAD users or groups who should read logs They get Log Analytics Reader plus a custom role for tailing live container logs. Read-only: no console, no secrets. Supplying object ids in advance means it is configured during the deployment rather than after.
J2 Who may hold break-glass console access A second custom role exists for interactive container access. Terraform assigns it to nobody — it is granted per incident and removed afterwards.
J3 Whether diagnostics should stream to a SIEM Everything already goes to Log Analytics. An Event Hub authorization rule id adds your SIEM alongside it.
  • The subscription id, the chosen region, and the workspace name.
  • The state account’s three coordinates.
  • The apply principal’s object id (az ad signed-in-user show --query id -o tsv).
  • The workstation’s public IP, re-checked that morning.
  • The operator secret values, and the inference adapter’s key pair and certificate.
  • The release and inference adapter image digests.
  • The public hostname, and access to the DNS zone and the identity provider configuration.