Infrastructure — the Kubernetes substrate¶
What this covers: the AKS cluster that runs the Acme Platform stack — node pools and why
they are sized the way they are, the namespace layout, the ingress + TLS path (including the
one NSG rule without which nothing on the internet reaches the cluster), the NetworkPolicy
posture, storage classes, and the Terraform module/state layout that provisions all of it.
Everything here is read out of the Terraform under infra/ and the Helm/ArgoCD manifests
under charts/. Where the running cluster and the declared code disagree, that is called out
rather than smoothed over. Environment-by-environment differences live in
07-environments.md.
1. Deployment topology¶
C4Deployment
title Deployment — Acme Platform dev cluster, Azure UK South
Deployment_Node(az, "Azure UK South", "Sponsorship subscription") {
Deployment_Node(vnet, "VNet development-acme-vnet", "10.0.0.0/16") {
Deployment_Node(aks, "AKS development-acme-aks", "Azure CNI Overlay + Calico, AAD RBAC, OIDC issuer") {
Deployment_Node(syspool, "system pool", "2x Standard_E2as_v6, CriticalAddonsOnly") {
Container(addons, "Cluster add-ons", "kube-system, calico-system", "CoreDNS, CNI, CSI, node monitors")
}
Deployment_Node(userpool, "user pool", "4x Standard_E2ads_v6") {
Container(apps, "7 ArgoCD Applications", "Argo Rollouts", "5 BC bundles + gateway + ai")
Container(edge, "Traefik v3", "traefik-system", "LoadBalancer, TLS termination")
Container(obs, "Observability stack", "monitoring", "OTel Collector, Loki, Tempo, Mimir, Grafana")
Container(gitops, "ArgoCD + External Secrets", "argocd, external-secrets", "AppOfApps, KV sync")
Container(broker, "RabbitMQ", "rabbitmq", "topic exchanges, work queues, DLQs")
}
Deployment_Node(arcpool, "arcpool", "1x Standard_D2as_v7, taint workload=ci:NoSchedule") {
Container(arc, "Actions Runner Controller", "arc-system", "self-hosted CI runners + BuildKit")
}
}
ContainerDb(pg, "PostgreSQL Flexible Server 16", "B_Standard_B2ms", "delegated subnet 10.0.7.0/28")
}
ContainerDb(redis, "Azure Cache for Redis 6.0", "Basic C1", "private endpoint, TLS 1.2+ on 6380")
Container(kv, "Key Vault development-acme-kv", "secrets", "read via Workload Identity")
Container(acr, "ACR developmentacmeacr", "images", "AcrPull via attached AKS identity")
Container(dns, "Azure DNS acme-example.co.uk", "public zone", "apex + wildcard A records")
}
Deployment_Node(gh, "GitHub", "initech-trading-platform/acme") {
Container(repo, "main branch", "charts/ + infra/", "single source of truth")
Container(ci, "Actions pipeline", "platform-pipeline.yml", "build, scan, image-tag writeback")
}
Rel(apps, pg, "TCP 5432, sslmode=require")
Rel(apps, redis, "TLS 6380")
Rel(gitops, kv, "OIDC token exchange, refresh 1h")
Rel(gitops, repo, "reconcile: webhook + 3-min poll")
Rel(ci, acr, "push sha-XXXXXXX tags")
Rel(acr, apps, "kubelet image pull")
Rel(edge, apps, "HTTP to service ports")
Rel(dns, edge, "A record to LB public IP")
Takeaways:
- One cluster, three node pools, one purpose each — system add-ons, platform workloads, CI runners. The CI pool is tainted so nothing else can land on it.
- The data tier is managed, not in-cluster. PostgreSQL Flexible Server and Azure Cache
for Redis are Azure PaaS reached over the VNet; only RabbitMQ runs inside the cluster.
In-cluster CloudNativePG and per-BC Redis Sentinel exist in
charts/as inert values files that no Application references (ADR-0037 — POC-gated CNPG per-BC data tier); committing them deploys nothing. - No static credentials in the runtime path. Pods reach Key Vault through per-namespace
Workload Identity federation; the kubelet pulls from ACR via the cluster's attached
managed identity, so
imagePullSecretsis empty (ADR-0035 — Workload Identity end-to-end). - GitHub holds desired state; the cluster converges on it. CI never
kubectl applys workloads — it writes an image tag tomainand ArgoCD does the rest (ADR-0032 — single-branch GitOps with AppOfApps).
Invariant: the cluster is disposable, the git repo is not. Every namespace, Application,
policy and issuer is declared under charts/; a cold rebuild is terraform apply plus one
kubectl apply -f charts/argocd/root-app.yaml.
2. Node pools¶
| Pool | SKU (declared) | Count | vCPU / RAM | Role |
|---|---|---|---|---|
system |
Standard_E2as_v6 |
min=max=2 | 4 / 32 GiB | K8s system components, CriticalAddonsOnly |
user |
Standard_E2ads_v6 |
min=max=4 | 8 / 64 GiB | Platform services + infra components |
arcpool |
Standard_D2as_v7 |
1 (autoscale 1..1) | 2 / 8 GiB | CI runners, tainted workload=ci:NoSchedule |
Declared in infra/modules/aks/ with explicit overrides in
infra/environments/development-platform/main.tf; the CI pool comes from infra/modules/arc/
invoked from infra/environments/development/main.tf.
The SKU choice is not a performance decision, it is a quota decision, and the reasoning is worth carrying forward because it is the kind of constraint that silently invalidates a design:
- Azure enforces two independent vCPU quotas — a regional
corespool and a per-VM-family cap. The regional pool was self-service raised (20 → 30); the per-family caps were not. - The original HA design put both pools on one
D*as_v7family and computed its budget against the regional quota. The binding limit was the family cap of 10, so the 16-vCPU end-state was never fittable. Azure declined the family raise and backlogged it with no ETA. - ADR-0055 (Platform dev AKS node-pool HA topology — multi-family E-series floor) resolves it by spreading the two pools across two distinct families that each sit at 0/10, and by choosing memory-optimised E-series: the cap is denominated in vCPU, so RAM is effectively free against it. 2 vCPU / 16 GiB per node fixes the memory-failover blocker that a same-vCPU D2 could not (measured live user-pool load was ~2,885m CPU / ~9,820 MiB against a D2's ~6.5 GiB allocatable).
- Both unattended upgrade paths are disabled —
automatic_upgrade_channel = "none"andnode_os_upgrade_channel = "None". Four user nodes plus one upgrade surge node is exactly 10/10 on that family; an unattended node-image reimage would breach the cap. The cost is no automatic CVE patching on dev, mitigated by an operator patch SLA.
Verification gap: the platform's own infrastructure document still records a live cluster
of 2× Standard_D4as_v5, captured before the pivot; the Terraform above is the declared target
and the user-pool move requires a manual blue/green (a vm_size change is ForceNew and the
provider's own rotation would breach the family cap mid-flight). Treat the table as declared,
not as observed, unless you have just run kubectl get nodes.
3. Network layout¶
+------------------------------------------------------------------------+
| VNet: development-acme-vnet (10.0.0.0/16) |
| |
| +----------------------+ +----------------------+ |
| | postgres | | legacy-api | |
| | 10.0.1.0/24 (251) | | 10.0.2.0/24 (251) | |
| | legacy PostgreSQL | | legacy App Service | |
| +----------------------+ +----------------------+ |
| |
| +----------------------+ +----------------------+ |
| | github-runner | | domain-api | |
| | 10.0.3.0/28 (11) | | 10.0.4.0/23 (507) | |
| | CI VM | | Container Apps | |
| +----------------------+ +----------------------+ |
| |
| +----------------------+ +----------------------+ |
| | aks-nodes | | pg-flexible | |
| | 10.0.6.0/26 (62) | | 10.0.7.0/28 (11) | |
| | AKS node VMs | | delegated to PG | |
| +----------------------+ +----------------------+ |
| |
| Pod network: Azure CNI Overlay 172.16.0.0/16 |
| Service CIDR: 10.1.0.0/24 DNS service IP: 10.1.0.10 |
| Traefik LB public IP: 203.0.113.10 |
+------------------------------------------------------------------------+
The legacy stack (legacy-api App Service, legacy-web, domain-api Container Apps, legacy PostgreSQL) shares the VNet with the new cluster. That is deliberate: it keeps the migration path private-network-internal, and it is why the AKS subnet is a narrow /26 carved out of an address plan that predates it.
3.1 North-south path, and the rule that is easy to miss¶
flowchart TB
C["Client browser"] --> DNSR["Azure DNS acme-example.co.uk<br/>dev.platform + *.dev.platform A records"]
DNSR --> PIP["Azure Standard LoadBalancer<br/>public IP 203.0.113.10"]
PIP --> NSG{"Subnet NSG<br/>development-acme-nsg-aks-nodes"}
NSG -->|"allow-internet-web p300<br/>TCP 80,443 from Internet"| NODE["AKS node<br/>10.0.6.0/26"]
NSG -->|"no such rule<br/>falls through to deny-all-inbound p4096"| DROP1["SYN dropped<br/>80 AND 443 time out"]
NODE --> TRF["Traefik pod<br/>traefik-system"]
TRF -->|"TLS terminated, HTTP upstream"| NP{"Calico NetworkPolicy<br/>target namespace"}
NP -->|"allow ns=traefik-system + app=traefik"| GW["gateway :3000"]
NP -->|"default-deny"| DROP2["dropped — pod stays Ready<br/>but is unreachable via ingress"]
GW --> SVC["BC service :3001-:3021"]
SVC --> PGDB[("PostgreSQL :5432 TLS")]
SVC --> RDS[("Redis :6380 TLS")]
SVC --> MQ[("RabbitMQ :5672")]
Takeaways:
- Public ingress needs an explicit rule on the Terraform-owned subnet NSG. AKS's
cloud-controller writes the LoadBalancer allow only into the NSG it manages (the
MC_…agentpool/NIC NSG). It never touches a custom subnet NSG. The rule isallow-internet-web, priority 300,Internet → TCP 80,443, gated behindenable_public_web_ingress(default off;trueonly for the dev environment). - The failure mode is invisible to every health gate. Client source IPs survive the LB, so
they match neither
AzureLoadBalancernorVirtualNetworkand fall through todeny-all-inboundat priority 4096. Both 80 and 443 time out with no SYN-ACK, while kubelet probes (node-local) and ArgoCD sync status stay green. - Port 80 is not optional. It carries the Let's Encrypt HTTP-01 challenge (validated from arbitrary source IPs) plus the permanent 80→443 redirect.
- Egress is default-deny too.
deny-all-outboundat 4096, with narrow allows: 443 to specific service tags, VNet-internal any, and SMB 445 / NFS 2049 scoped toStorage.UKSouth— the regional tag, not the globalStoragetag, so a compromised node cannot mount a storage account in another region.
Invariant: any environment that faces the internet must set enable_public_web_ingress
and keep the deny-all rules; the NSG is the only place where "reachable from the internet" is
expressed, and nothing in Kubernetes will tell you it is missing.
3.2 East-west: NetworkPolicy posture¶
Every service rendered by charts/platform-base gets a default-deny ingress policy plus an
explicit allow set (charts/platform-base/templates/networkpolicy.yaml).
flowchart LR
subgraph src["Permitted ingress sources"]
GWP["gateway pod<br/>(platform-gateway ns)"]
PEER["any pod labelled<br/>part-of=platform"]
SAME["same-namespace pods<br/>(intra-BC)"]
MON["monitoring ns<br/>(scrape)"]
BB["blackbox-exporter pod<br/>(monitoring ns)"]
TRK["traefik pod<br/>(traefik-system)"]
end
POD["service pod"]
subgraph dst["Permitted egress destinations"]
KDNS["kube-system :53 UDP/TCP"]
SVCS["platform namespaces<br/>:3000-:3021"]
OTLP["monitoring :4317/:4318"]
EXTRA["extraEgress<br/>(PG, Redis, RabbitMQ, KV)"]
end
GWP -->|"app port"| POD
PEER -->|"app port"| POD
SAME -->|"app port"| POD
MON -->|":9464 metrics only"| POD
BB -->|"app port, health probe"| POD
TRK -->|"app port, only if ingress.enabled"| POD
POD --> KDNS
POD --> SVCS
POD --> OTLP
POD --> EXTRA
Takeaways:
- Two modes of cross-namespace ingress, selected by
networkPolicy.crossNamespaceIngress: the defaultplatformmode admits any pod labelledpart-of=platform; the hardenedgatewaymode admits only the gateway pod. The hardened mode exists because the gateway injects identity headers that downstream guards trust without a signature — so any pod able to reach the app port directly can forge identity. Usegatewaywherever the gateway is the sole cross-namespace HTTP caller. - Scraping is port-scoped. The monitoring namespace is allowed to
:9464only; a separate, pod-scoped rule lets the blackbox exporter hit the app port for/health. Without that second rule the default-deny silently drops probes and every "service down" alert fires falsely. - Traefik's allow is coupled to
ingress.enabled. A service that renders an IngressRoute always renders the matching allow — you cannot ship a routed service that is policy-blocked. - Namespaces are PSA-labelled
restricted(enforce/audit/warn), matching the chart'srunAsNonRoot,drop: ["ALL"],readOnlyRootFilesystem,seccompProfile: RuntimeDefaultdefaults. Only/tmpis writable, via emptyDir.
4. Namespaces¶
| Namespace | Owner | Contents |
|---|---|---|
platform-gateway, platform-ai |
ApplicationSet (standalone) | one service each |
platform-identity, platform-trading, platform-finance, platform-comms, platform-ops |
ApplicationSet (bundles) | 2–3 services per BC bundle |
platform-frontend |
dedicated Application | the SPA served same-origin |
argocd, argo-rollouts, external-secrets, cert-manager, reloader |
root AppOfApps | GitOps + secrets + certs control plane |
traefik-system |
standalone Helm | edge proxy — not ArgoCD-reconciled |
rabbitmq, rabbitmq-system |
manifests / operator | broker + topology operator |
monitoring |
AppOfApps | OTel Collector, Loki, Tempo, Mimir, Grafana |
arc-system |
AppOfApps | CI runners + BuildKit |
kube-system, calico-system, tigera-operator |
AKS / operator | platform system |
The seven workload namespaces are declared as manifests in git
(charts/argocd/platform-namespaces.yaml) rather than created by ArgoCD's
CreateNamespace=true. Two annotations carry the whole rationale:
sync-wave: '-1'— namespaces must exist before the bundle ApplicationSets target them.sync-options: Prune=false— a namespace deletion takes every workload in it with it, so it must never be cascade-pruned. This file exists because six namespaces once vanished when a staged production ApplicationSet was accidentally applied to the dev cluster, and deletion wedged on message-broker topology finalizers.
Per-namespace ResourceQuota and LimitRange are rendered by the chart
(resourcequota.yaml, limitrange.yaml) and sized per environment overlay.
Verification gap: openbao appears in the manifest set but I could not confirm from the
repository whether it is deployed on the live cluster; ADR-0051 (cloud-native provider-neutral
secrets and identity) treats it as the forward path, ESO + Key Vault as today's.
5. Ingress and TLS¶
Traefik is a standalone Helm release (traefik/traefik v39 in traefik-system, values at
charts/infrastructure/traefik-values.yaml). It is deliberately not an AppOfApps child, which
means config changes require a manual helm upgrade — the single most common source of "I
changed it in git and nothing happened" on this platform.
Two ACME owners share the edge, with non-overlapping responsibilities (ADR-0068 — wildcard TLS via cert-manager DNS-01 with Azure DNS Workload Identity):
flowchart LR
subgraph edgens["traefik-system"]
TRF2["Traefik v3"]
ACMEJ["acme.json<br/>RWO PVC, managed-csi"]
STORE["TLSStore default"]
WSEC["Secret wildcard-dev-tls"]
end
subgraph cmns["cert-manager — ArgoCD-managed"]
CMP["cert-manager v1.20.2"]
ISS["ClusterIssuer letsencrypt-dns01"]
CRT["Certificate<br/>*.dev.platform.acme-example.co.uk"]
end
LE["Let's Encrypt<br/>acme-v02 production"]
ZONE["Azure DNS zone<br/>acme-example.co.uk"]
UAMI["UAMI + federated credential<br/>DNS Zone Contributor, zone-scoped"]
TRF2 -->|"HTTP-01 on :80 — apex + customer domains"| LE
LE -->|"single-SAN cert"| ACMEJ
CMP --> ISS
ISS --> CRT
CRT -->|"DNS-01 challenge"| LE
CMP -->|"Workload Identity, no stored secret"| UAMI
UAMI -->|"write _acme-challenge TXT"| ZONE
CRT --> WSEC
WSEC --> STORE
STORE --> TRF2
Takeaways:
- Wildcards require DNS-01. Let's Encrypt will not issue
*.dev.platform.acme-example.co.ukover HTTP-01, and tenant-subdomain routing (ADR-0066 — hostname-based tenant resolution) needs a wildcard or every new tenant pays a cold ACME round-trip against a 50-cert/week registered-domain limit. - Credentials never enter the edge proxy. cert-manager authenticates to Azure DNS via a
user-assigned managed identity federated to its ServiceAccount, scoped to one zone. The
alternative — Traefik's embedded
azurednsprovider — would put zone credentials in the internet-facing pod's environment. Rejected on security placement. - Customer-owned domains stay on HTTP-01. A domain like
trading.partner-example.co.ukis outside the managed zone, so DNS-01 is impossible; a per-domain router withcertResolver: letsencrypthandles it with no delegation required. - Traefik does not retry a failed
obtain. The pod must be restarted to trigger another attempt — a real operational quirk, and part of why wildcard renewal was moved to cert-manager, whose renewals are controller-driven and alertable oncertmanager_certificate_expiration_timestamp_seconds. - DNS is Terraform-managed but cross-subscription. Both the apex and wildcard A records
point at the same LB IP and are declared with a
shared_dnsprovider alias; neither carriesprevent_destroy, because destroying the dev cluster should remove the records that point at it. The zone itself is protected in thesharedstate.
Per-service routing is rendered by charts/platform-base/templates/ingress.yaml (a Traefik
IngressRoute) together with three middlewares in the same chart: compress, ratelimit,
security-headers.
6. Storage¶
| Class | Backing | Used by |
|---|---|---|
managed-csi |
Azure Disk (standard) | Traefik acme.json, RabbitMQ 20Gi, Tempo, in-cluster Redis values |
managed-csi-premium |
Azure Disk (premium) | BuildKit layer cache; CloudNativePG POC clusters |
azurefile-csi-premium |
Azure Files Premium (SMB) | ARC shared npm/Nx cache, 1 TiB (ADR-0041) |
Two constraints follow from these choices and are worth stating explicitly:
- Azure Disk is ReadWriteOnce. Traefik's
acme.jsonlives on an RWO PVC, so the Traefik deployment cannot roll with surge — a second pod cannot attach the same disk. This is the structural reason a shared cert store scales badly and another reason wildcard issuance moved to a Kubernetes Secret. - Azure Files needs SMB egress. The RWX CI cache mounts over port 445, which the default-deny
egress rule blocks until
allow-egress-storage-smb(priority 140,Storage.UKSouth) is present. The symptom ismount error(115): Operation now in progress— not an obvious networking error.
7. Terraform layout¶
infra/
├── environments/ # one remote state file per directory
│ ├── shared/ # DNS zones, communication services (cross-subscription)
│ ├── development/ # VNet, subnets, NSGs, Key Vault, RG, legacy apps, ARC pool
│ ├── development-platform/ # AKS, PG Flexible, Redis, ACR, managed identities, DNS records
│ ├── prod-acme-legacy/ # legacy production: App Service, Container Apps, PG, Front Door
│ └── dev-ephemeral/ # ephemeral per-developer stack (dev-{alias} state)
├── modules/ # ~40 modules; every module: main.tf, variables.tf, outputs.tf
│ ├── aks/ # cluster, node pools, upgrade channels, diagnostics
│ ├── network/ # VNet, subnets, NSGs (incl. enable_public_web_ingress)
│ ├── postgres-flexible/ # PG 16, VNet delegation, prevent_destroy
│ ├── redis/ # Azure Cache for Redis, private endpoint + private DNS zone
│ ├── arc/ # CI node pool, Azure Files cache, runner identities
│ ├── platform-cert-manager-dns/# UAMI + federated credential for DNS-01
│ ├── platform-key-vault-secrets/
│ ├── github-app/ # deploy identity (PEM stored in Key Vault)
│ └── ...
└── .terraform-version
flowchart TD
subgraph states["Terraform roots — isolated state files"]
SHARED["shared<br/>DNS zones, comms"]
DEV["development<br/>VNet, subnets, NSGs, KV, RG, ARC pool"]
DVP["development-platform<br/>AKS, PG, Redis, ACR, identities"]
PRD["prod-acme-legacy<br/>legacy production stack"]
EPH["dev-ephemeral<br/>ephemeral, dev-alias state"]
end
DEV -->|"1. apply first<br/>creates aks-nodes + pg-flexible subnets"| DVP
DEV -.->|"terraform_remote_state:<br/>subnet ids, vnet id, KV id, RG"| DVP
SHARED -.->|"terraform_remote_state:<br/>log analytics workspace, DNS zone"| DVP
SHARED -.->|"DNS zone"| PRD
DVP -->|"data lookup by name<br/>aks cluster for ARC pool"| DEV
CHECK{"check aks_subnet_provisioned<br/>assert subnet id is not null"}
DVP --> CHECK
Takeaways:
- State isolation is a blast-radius decision. AKS provisioning takes 30+ minutes and is non-atomic; a failed apply must not be able to lock or corrupt the state that holds the legacy production platform.
- Apply order is enforced in code, not documentation.
developmentmust apply beforedevelopment-platform, and acheckblock asserts the subnet id is non-null with an error message naming the exact fix. Preconditions like this convert a confusing mid-apply failure into a one-line diagnosis. - There is a circular reference but not a circular dependency.
development-platformcreates the AKS cluster;developmentattaches the CI node pool to it via adatalookup by name. That is why the CI pool lives in the other root. -targethygiene matters here. Modules that need the cluster's OIDC issuer read it from a data source, not frommodule.aks.oidc_issuer_url, specifically so a targeted apply does not drag the whole cluster resource into the plan.- The CI service principal is
Contributor, notOwner. It cannot createazurerm_management_lockorazurerm_role_assignment. Those — and the cross-subscription DNS Zone Contributor grant for cert-manager — are created out of band by an operator, and the Terraform records the exact command in the module header rather than pretending it is automated.
Invariant: anything Terraform cannot create because of a permission boundary is represented
in code as a gated variable plus an output that feeds the manual command (for example
manage_role_assignments = false with principal_id / dns_zone_id outputs). The gap is
declared, not hidden.
8. Deciding ADRs¶
| ADR | Title | What it fixes here |
|---|---|---|
| ADR-0013 | Per-BC PostgreSQL schema isolation | one database, schema per bounded context |
| ADR-0015 | Zero-downtime deploys — rolling update + init container + PDB | probe/drain contract |
| ADR-0017 | RabbitMQ unified messaging, Redis cache-only | broker in-cluster, Redis is disposable |
| ADR-0019 | OpenTelemetry + Grafana stack | monitoring namespace contents |
| ADR-0020 | External Secrets Operator + SOPS for secrets | Key Vault → K8s Secret path |
| ADR-0032 | Single-branch GitOps with AppOfApps | one main, no gitops/* branches |
| ADR-0033 | BC-aligned bundle deploy units | 7 Applications, 7 namespaces |
| ADR-0034 | Argo Rollouts canary with SLO analysis | in-cluster auto-rollback |
| ADR-0035 | Workload Identity end-to-end | no static credentials in the deploy path |
| ADR-0037 | POC-gated CloudNativePG per-BC data tier | why charts/databases/ is inert |
| ADR-0039 | ARC on AKS for self-hosted runners | arcpool, taints, labels |
| ADR-0041 | Azure Files RWX cache for ARC runners | SMB egress rule, premium file share |
| ADR-0043 | Platform dev cluster vendor selection (+ amendment: stay on Azure) | why the cluster did not move |
| ADR-0051 | Cloud-native, provider-neutral secrets and identity | forward path off Azure-coupled secrets |
| ADR-0055 | Platform dev AKS node-pool HA topology — multi-family E-series floor | the node pool table above |
| ADR-0066 | Hostname-based tenant resolution | why a wildcard host is needed |
| ADR-0068 | Wildcard TLS via cert-manager DNS-01 (Azure DNS Workload Identity) | the two-ACME-owner split |