ADR-0056: Ship the Azure deployment surface as Terraform in a separate repository behind APIM Standard v2 with public_access
| Field | Value |
|---|---|
| Status | proposed |
| Date | 2026-10-10 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | org |
| Scope | org |
| Tags | architecture, distribution, azure, deployment, container, mcp |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| External refs | APIM inbound private endpoint: disable public access only after the endpoint exists; v2 custom-domain limit, APIM networking models by tier (v2 integration, Premium v2 injection), APIM MCP servers via REST/Bicep/Terraform: azurerm has no native resource, use azapi, Container Apps job trigger types and on-demand start |
| Relates to | 0007, 0014, 0016, 0030, 0032, 0044, 0051, 0052, 0054, 0055 |
| Affects | path:packages/server/**, path:Containerfile, path:scripts/container-entrypoint.sh, mbeacom/adrkit-azure:resource:azurerm_api_management, mbeacom/adrkit-azure:resource:azurerm_container_app_environment, mbeacom/adrkit-azure:resource:azurerm_container_app_job, mbeacom/adrkit-azure:resource:azapi_resource |
| Source | docs/adr/0056-ship-the-azure-deployment-surface-as-terraform-in-a-separate-repository-behind-a.md |
Status: proposed. Agent-drafted and not ratified. It deploys what ADR-0055 builds (the
serverselector of the image ADR-0032 publishes) and serves the portal bundle of ADR-0054. The repository it names,mbeacom/adrkit-azure, does not exist yet. Nothing here has been deployed or measured.
Context
Section titled “Context”ADR-0055 adds a read-only HTTP server that authenticates no one and must sit behind a gateway that does. A team that wants that server reachable, fully private or deliberately public, needs infrastructure this repository has never carried. Facts that shape where and how:
- This repository federates to no cloud subscription. Its workflows request
id-token: writefor npm Trusted Publishing (release.yml), Pages deployment (site.yml), and container provenance (container-release.yml), and for nothing else.docs/repository-trust-operations.md§4 names merge access as the boundary. Adding Azure federation here would widen what a merge can reach to a subscription. - ADR-0030 already moves surfaces out. Its rule keeps a surface here only if it adds no install cost, and it narrowed ADR-0007 Option C on a new ground rather than claiming ADR-0007’s revisit condition (independent contributors) was met. Infrastructure code is not an npm install cost, so ADR-0030 clause 1 does not decide this case by itself; the credential boundary and the adopter’s use (fork it, or consume it as a module) do.
- The server code and image stay here. ADR-0055 puts
packages/serverin this repository and the selector in the lockstep image (ADR-0032 clause 4: one published target). A deployment consumes an image tag, not source. - ADR-0055’s contract with a deployment is two things: the sidecar
written into each corpus directory, populated by swap so a load never sees
half a sync (its clauses 4 and 5); and the localhost default bind, overridden by
ADRKIT_SERVER_HOSTas the operator’s assertion that an authenticating gateway is in front (clause 14). Its routes areGET-only under/api/v1/corpora/..., plus/openapi.json,/healthz, and the portal at/. Itrealpath-resolves each corpus directory at start (clause 6). - Provider facts, each marked. Verified against Microsoft Learn on
2026-10-10 unless marked otherwise:
- Verified. APIM Standard v2 supports an inbound private endpoint, and combining it with outbound virtual network integration is documented as end-to-end isolation. Public network access can be disabled only after a private endpoint exists, and only on an existing instance, never at creation.
- Verified. VNet injection is a Premium v2 feature, configurable only at creation, and an instance cannot switch between injection and integration.
- Verified. In Standard v2 and Premium v2, a custom gateway domain must be publicly resolvable; the documented workaround for a private name is an Application Gateway in front.
- Verified. “REST API as MCP server” is available on Standard v2, exposes
operations as tools only (no resources or prompts), and is modelled as an
API of type
mcpwithapis/toolschildren whoseoperationIdpoints at the backing REST operation, at API version2025-09-01-previewor later. The azurerm provider has no native resource for it; Microsoft documentsazapi_resourceinstead. - Verified. Terraform Azure Verified Modules exist for Container Apps
managed environments (
avm-res-app-managedenvironment) and jobs (avm-res-app-job; it requires azapi~> 2.12and azurerm>= 4.73.0, < 5.0.0, and pulls amodtmtelemetry provider). An APIM Terraform AVM module also exists (avm-res-apimanagement-service0.9.0, found while scaffoldingadrkit-azure); the skeleton uses native resources for APIM anyway, because thepublicNetworkAccessstep and v2 outbound integration need direct control. azurerm 5.x is published, but both Container Apps AVM modules still require 4.x. - Verified. A Container Apps job has exactly one trigger type (
Manual,Schedule, orEvent), and any job can also be started on demand through ARMjobs/{name}/start, which needsMicrosoft.App/jobs/start/action. - By omission. The private-endpoint page’s tier list includes classic Basic but not Basic v2. This record treats Basic v2 as unable to be private on that evidence, not on a statement that it cannot.
- Unverified, load-bearing. azurerm’s
public_network_access_enabledonazurerm_api_managementis documented as applying to the management plane only and as requiredtrueat creation. Microsoft Learn shows disabling gateway public access on Standard v2 through the portal only; the RESTpublicNetworkAccessproperty is shown for classic tiers and Premium v2. That an ARM update ofpublicNetworkAccessworks on Standard v2 is unmeasured here. The maintainer states, from their own Azure use, that Standard v2 supports disablingpublicNetworkAccessonce the private endpoint exists; that is maintainer-asserted evidence, recorded on 2026-10-10, not a measurement of the ARM orazapipath this record uses, which action item 2 still measures. - Unverified. Whether azurerm’s
virtual_network_typeandvirtual_network_configurationexpress v2 outbound integration at all. Their documentation describes classic injection (port 3443). - Unverified. Whether APIM passes MCP protocol revision
2026-07-28(ADR-0055 clause 15), and whether a GitHubX-Hub-Signature-256HMAC can be verified inside an APIM policy.
Decision
Section titled “Decision”We will ship the Azure deployment surface as Terraform, using azurerm and azapi,
in a separate repository, mbeacom/adrkit-azure. It runs ADR-0055’s server
selector from a pinned image tag in an internal Azure Container Apps environment
behind APIM Standard v2. A public_access variable switches the gateway between
fully private and deliberately public.
- Repository and tool. Terraform (azurerm first, azapi where azurerm lags or
misreports), in
mbeacom/adrkit-azure, consumable as a root configuration to fork and as a module. No Terraform, Bicep, cloud credential, or OIDC federation to a subscription enters this repository. This narrows ADR-0007 Option C on a new ground, the cloud-credential boundary and module-shaped adoption, as ADR-0030 clause 6 did. ADR-0007’s revisit condition is still not met, and this record does not claim it is. - Image. The deployment pins an immutable
ghcr.io/mbeacom/adrkit:vX.Y.Ztag (by digest where the module allows) and runs theserverselector. It never trackslatestor the movingvX. It setsADRKIT_SERVER_HOST=0.0.0.0inside the internal environment. That is exactly the operator assertion ADR-0055 clause 14 describes, made true by clauses 3 and 6: the only path in is through APIM. - Compute. One Azure Container Apps environment on workload profiles,
internal in both modes, so flipping
public_accessnever recreates the backend. The server app runs with minimum replicas 1 (no cold start for MCP clients), a read-only corpus mount, and target port 8080. Its ingress hasexternal_enabled = true, which inside an internal environment means reachable from the virtual network and nothing wider; ingress limited to the environment would shut out APIM, which sits outside it. APIM requires a subscription key (anadrkitproduct) in both modes, in addition to the private network in private mode and the token policy in public mode. - Corpus sync, and agreement to ADR-0055’s sidecar. A Container Apps job
with a
Scheduletrigger shallow-clonesdocs/adrof each configured repository into an Azure Files share mounted read-only by the server. For each corpus key it writes the tree to<key>.next/, writes.adrkit-corpus.jsonwith the cloned commit last, then moves<key>/aside and moves<key>.next/into its place, because a rename onto a non-empty directory fails on POSIX. A symlink flip is not used, because ADR-0055 clause 6realpath-resolves directories at start and would never follow it. The two moves are not atomic, so a load can find the directory briefly absent between them. ADR-0055 clause 5 then keeps serving the old handle, which makes the window survivable. Whether directory rename on Azure Files (SMB) behaves this way, two moves and the brief absence, is unmeasured and is the first rung-1 item. This record agrees to the sidecar contract as ADR-0055 clause 4 states it, worded identically in both:.adrkit-corpus.jsonis a JSON object holding at least{ "commit": "<40 hex>" }, wherecommitis the full 40-hex SHA of the tree written; the populator is its only writer, writes it last, into a tree written aside and swapped in whole; a field added later is additive. Here the populator is this sync job. - Private repositories. The sync job authenticates as a GitHub App whose
private key is stored in Key Vault and read by the job’s managed identity.
The App is granted
contents: readon the configured repositories only. The server itself holds no GitHub credential and makes no outbound call (ADR-0055 clause 4). - Gateway: APIM Standard v2. It has an inbound private endpoint with a
Private DNS zone, and outbound virtual network integration into the subnet
that reaches the internal environment. It exposes two things, plus clause 9’s optional public-mode webhook: the
GETroutes of ADR-0055’s OpenAPI document (/api/v1/...,/openapi.json, and, in private mode only, the portal at/; see clause 8), and an API of typemcpwhose tools map those operations, created withazapi_resourcebecause azurerm has none./healthzis probed inside the environment and is not published. No operation other thanGET/HEADexists on the REST API, and a policy answers anything else with 405. The MCP API cannot beGET-only, because streamable HTTP usesPOST; each of its tools maps to one of the REST API’sGEToperations, so no tool can reach anything else. No policy readscontext.Response.Body, because that breaks streaming. public_access, a second step in one apply. APIM is created with public access enabled (Azure requires it). The private endpoint and DNS follow, and anazapi_update_resourcethat depends on them setspublicNetworkAccess. It setsDisabledwhenpublic_access = false, which is the default, andEnabledotherwise. Terraform orders this within oneterraform apply. The instance is still reachable publicly between creation and the update, so the first apply attaches no API until the update has succeeded: every API, policy, product, and backend depends on it. A global deny-allip-filterwas considered and dropped, because it would also block callers on the private endpoint and would have to be removed in the same apply. The azurerm attribute carrieslifecycle { ignore_changes = [public_network_access_enabled] }, so a plan does not try to revert the flip. Whether the ARM update works on Standard v2 is unverified (Context fact 5). If it does not, this clause falls back to a documented portal step and the record is revisited.- Public mode adds controls, not routes. With
public_access = true, every API runsvalidate-azure-ad-tokenagainst a configured tenant and audience, andrate-limit-by-keykeyed on the token’s subject. Front Door Premium with WAF, reaching APIM over Private Link, is optional and off by default. The backend, routes, and tools are identical in both modes. A browser loading the portal at/carries no bearer token, so in public mode the token policy alone makes the portal unreachable. The first version therefore serves/in private mode only and leaves it out of the public API. Public portal access would need an interactive sign-in in front, such as Front Door or Application Gateway with an identity provider. That is unmeasured and is a later change. - The webhook, public mode only, and bounded. In public mode, an optional
second APIM API accepts one
POSTfrom GitHub. It verifies theX-Hub-Signature-256HMAC in policy, forwards no part of the payload, and calls ARMjobs/starton the sync job. It authenticates with APIM’s managed identity, which holds onlyMicrosoft.App/jobs/start/actionon that one job. It is the one non-GETroute in the deployment. It writes nothing to any corpus and records no decision; it can only make the next sync sooner. In private mode GitHub.com cannot reach the gateway, so the schedule is the only trigger. ADR-0055’s commit-in-every-response is how a reader tells how stale an answer is. If HMAC verification in policy proves infeasible (unverified), the webhook is dropped, not weakened. - Private custom domain needs Application Gateway. A private custom gateway name is out of scope for the first version. A team that needs one adds an Application Gateway in front, which is Microsoft’s documented workaround for the v2 limit. The module exposes the APIM default hostname in private mode.
- No write path anywhere. Recording a person’s act (
accept,approve,object,resolve) stays a human-run CLI command and a pull request (ADR-0044, ADR-0051, ADR-0052). The deployment publishes no route, tool, or job that changes a record. The webhook in clause 9 changes only when the read-only copy refreshes. - Modules. Networking: VNet, subnets for the environment, private
endpoints, and APIM integration, plus Private DNS zones for APIM, Files,
and Key Vault. Then Log Analytics, the Container Apps environment (AVM where
it fits), the server app, the sync job (AVM
avm-res-app-jobwhere it fits), and a storage account with a Files share behind a private endpoint. Then Key Vault behind a private endpoint, APIM Standard v2 (azurerm for the service, azapi for thepublicNetworkAccessflip, MCP API, and tools, and for v2 outbound integration if azurerm cannot express it), and optional Front Door Premium.
Options considered
Section titled “Options considered”Option A: Bicep and azd in this repository
Section titled “Option A: Bicep and azd in this repository”This is Microsoft’s most-documented path, and every MCP sample comes in Bicep. Rejected: it puts subscription credentials and OIDC federation into this repository’s trust model (Context fact 1). It also couples a deployment’s cadence to the lockstep release, which ADR-0007 exists to avoid.
Option B: Terraform in this repository
Section titled “Option B: Terraform in this repository”The same tool as chosen, without a second repository. Rejected for the same credential reason, and because adopters would vendor a subdirectory of a TypeScript monorepo to get a module.
Option C: Terraform in a separate repository (chosen)
Section titled “Option C: Terraform in a separate repository (chosen)”This keeps cloud credentials out of this repository and gives the deployment its own cadence. Adopters can fork it or reference it as a module. The cost is cross-repository drift on two contracts, handled under Consequences.
Option D: APIM Premium v2 with VNet injection
Section titled “Option D: APIM Premium v2 with VNet injection”The gateway gets a private IP without a private endpoint, and injection covers inbound and outbound together. Rejected as the baseline: a much higher fixed cost, and injection is create-time only, so a later change of networking recreates the instance. Teams already on Premium v2 can adapt the module.
Option E: APIM Basic v2
Section titled “Option E: APIM Basic v2”The cheapest v2 tier. Rejected: the private-endpoint tier list omits it, so it cannot meet the fully private mode.
Option F: Static Web Apps for the portal
Section titled “Option F: Static Web Apps for the portal”A cheap static host. Rejected: it cannot be fully private in the way the gateway is, and it would be a second renderer host serving a second copy of the data. ADR-0055 clause 9 already serves the portal from the same handle and commit as the API.
Trade-offs
Section titled “Trade-offs”- The one apply is not atomic. Between creation and the
publicNetworkAccessupdate, the instance is publicly addressable. No API is attached during it, but the window exists. - azapi is pinned to a preview API version for MCP resources
(
2025-09-01-preview). A breaking change there breaks the MCP half of an apply, not the REST half. - The webhook is a non-
GETroute. It is bounded to one job start with no payload forwarded, but it is a write-adjacent surface the rest of the design avoids, and it exists only in public mode. - Two repositories, two cadences. A server change here can break a deployment there with no same-run signal (ADR-0030’s accepted loss).
Consequences
Section titled “Consequences”- Cost. APIM Standard v2 is a fixed monthly charge whether or not anything
calls it. Each private endpoint (APIM, Files, Key Vault) bills hourly, plus
Private DNS zones. Minimum replicas 1 keeps one server replica always on, and
the workload-profiles environment adds its own charge. Front Door Premium,
if enabled, is the largest optional line. The README must state these before
the first
terraform apply, and none are measured here. - Two-step operation.
public_accesschanges in place. The backend is never recreated, but the first apply of a new instance always passes through the public window above. - Drift, and how it is caught. Two contracts cross the repository boundary:
the image tag (with its
serverselector andADRKIT_SERVER_HOST) and the sidecar. Inadrkit-azure, Dependabot or Renovate proposes image-tag bumps. A CI job there runs the pinned image’sserverselector over a fixture corpus written by the sync script, and asserts that/api/v1/corporareturns the fixture’s commit and that a missing sidecar answers 503. It is observed failing first against a planted bad sidecar (ADR-0016). That is ADR-0030 clause 5’s conformance-fixture pattern applied to an image instead of a package. - Easier: a private, gateway-authenticated decision API and remote MCP tools for teams on Azure, with no cloud credential in this repository.
- Harder: a second repository to maintain, a preview API dependency, and costs that accrue while idle.
- How we would know this was wrong: a published operation other than
GETorHEADoutside the webhook. A server reachable without passing APIM. A response whose commit differs from the commit the sync job cloned. ThepublicNetworkAccessupdate failing on Standard v2. A plan that reverts the flip. A sync that a load observes half-written. - Revisit if: the ARM flip is unsupported on Standard v2; azurerm gains
native MCP or v2 outbound-integration support; APIM is measured against a
2026-07-28client;@adrkit/mcpgains streamable HTTP (ADR-0055 Option B); or adopters ask for a Bicep module.
Evidence rung
Section titled “Evidence rung”None yet; rung 1 at best when built under ADR-0014. Nothing is deployed. The
first rung-1 measurements are the unverified items in Context fact 5: the ARM
publicNetworkAccess update on Standard v2, v2 outbound integration through
azurerm or azapi, the two-move swap on Azure Files, HMAC verification in
policy, and APIM’s MCP protocol revisions. After those come the drift CI job
above, observed failing first. No reference-repository or external run exists.
Action items
Section titled “Action items”- Create
mbeacom/adrkit-azureand record its URL here, as ADR-0030 action item 6 does for the Backstage surface: https://github.com/mbeacom/adrkit-azure (created 2026-10-10, public, Apache-2.0). - Measure the
publicNetworkAccessARM update on a Standard v2 instance, and whether azurerm can express v2 outbound integration. Record both here and correct clause 7 or 12 if either fails. - Measure the
<key>.next/two-move swap on an Azure Files share against ADR-0055’s reload, under concurrent reads. - Land the drift CI job in
adrkit-azure, observed failing against a planted bad sidecar. - Decide the webhook after measuring HMAC verification in APIM policy; drop clause 9 if it cannot be verified there.
- Publish the cost lines in the
adrkit-azureREADME before the first tagged release. - Ratify or reject this record.