Azure Container Apps or AKS for a Regulated Workload: Who Patches What

The platform question for a new regulated workload on Azure usually arrives already answered. Someone has decided it will run on Kubernetes, because Kubernetes is what serious systems run on, and the design work that follows is about making AKS acceptable to security and audit. It is worth stopping one step earlier. For a regulated system, the platform choice is mostly a choice about who patches what, who can prove it, and how many moving parts sit inside the audit boundary.

This article compares Azure Container Apps, AKS Automatic and AKS Standard for a workload that handles clinical or otherwise regulated data. It does it with two artefacts: a ledger of thirteen operational duties and who performs each, and the same security baseline written twice in Terraform, once per platform, with tests. Then it gives a decision rule short enough to argue with.

What “regulated” changes

  • No public endpoints. The platform and the workload are reachable only from the organisation’s network.
  • No standing secrets. Workloads and people authenticate with identities, not stored passwords or shared keys.
  • Controlled egress. Outbound traffic leaves through a firewall the security team owns.
  • Patching with evidence. Every layer has a named owner, a timescale and a record. “The cloud does it” is acceptable only where it is true.
  • Resilience to a zone failure, with capacity that does not depend on a scale-up during the incident.
  • Logs somewhere they cannot be quietly edited.
  • A small enough platform that the people who answer for it understand it. This is the requirement that is never written down and matters most.

The ledger: who does the work

Table of thirteen operational duties across Azure Container Apps, AKS Automatic and AKS Standard, marking each as done by Microsoft, by you, shared, not available or not applicable. Four duties are yours on Container Apps, six on AKS Automatic and twelve on AKS Standard.
Thirteen duties across the three platforms. The bottom four rows are yours whatever you choose.

The ledger is kept as data in the sample, so it can be challenged line by line:

{
  "_comment": "Who does the work, for each operational duty. An assessment from Microsoft's documentation as read for this article, not a contractual statement. 'you' includes work you can automate but must configure, monitor and answer for.",
  "duties": [
    {"duty": "Kubernetes control plane availability and patching", "aca": "microsoft", "aks_automatic": "microsoft", "aks_standard": "microsoft"},
    {"duty": "Kubernetes minor version upgrades", "aca": "microsoft", "aks_automatic": "microsoft", "aks_standard": "you"},
    {"duty": "Node operating system patching", "aca": "microsoft", "aks_automatic": "microsoft", "aks_standard": "you"},
    {"duty": "Node sizing, scaling and repair", "aca": "microsoft", "aks_automatic": "microsoft", "aks_standard": "you"},
    {"duty": "Ingress controller and its upgrades", "aca": "microsoft", "aks_automatic": "microsoft", "aks_standard": "you"},
    {"duty": "Add-ons and their compatibility with each upgrade", "aca": "microsoft", "aks_automatic": "shared", "aks_standard": "you"},
    {"duty": "Admission policy and pod security settings", "aca": "microsoft", "aks_automatic": "shared", "aks_standard": "you"},
    {"duty": "Network policy between workloads", "aca": "not_available", "aks_automatic": "you", "aks_standard": "you"},
    {"duty": "Cluster access control (RBAC roles and bindings)", "aca": "not_applicable", "aks_automatic": "you", "aks_standard": "you"},
    {"duty": "Container base image patching", "aca": "you", "aks_automatic": "you", "aks_standard": "you"},
    {"duty": "Application identity, secrets and data access", "aca": "you", "aks_automatic": "you", "aks_standard": "you"},
    {"duty": "Egress control and firewall rules", "aca": "you", "aks_automatic": "you", "aks_standard": "you"},
    {"duty": "Evidence for auditors that the above is done", "aca": "you", "aks_automatic": "you", "aks_standard": "you"}
  ]
}

Three readings of it. First, the gap between Container Apps and AKS Standard is eight duties, and they are the recurring, interrupt-driven ones: version upgrades, node patching, ingress and add-on upgrades. Automation exists for most of them on AKS, and you still configure it, watch it and explain to an auditor what happened when it failed. Second, AKS Automatic closes most of that gap: Microsoft’s documentation describes node provisioning, node image upgrades, cluster upgrades and a set of security defaults as preconfigured. Third, four duties never move. Your container image, your application’s identity and data access, your egress rules and your evidence are yours on every platform. No platform choice removes them, and a programme that treats the platform decision as its security decision has misplaced the risk.

One row runs the other way. Network policy between workloads is marked not available on Container Apps: it does not expose the Kubernetes API, so there is no NetworkPolicy object to create. The isolation boundary on Container Apps is the environment. If two services must not be able to reach each other, they go in different environments or you need AKS.

The same baseline, twice

Statements about platforms are cheap. Here is the baseline as configuration, starting with Container Apps. The module takes a subnet and a Log Analytics workspace from the landing zone and creates an internal environment and one application.

# A regulated API on Azure Container Apps. The platform is an internal, zone-redundant environment on a
# landing-zone subnet; the workload is one container app with a managed identity and no public ingress.

locals {
  prefix = "${var.name}-${var.environment}"

  tags = {
    workload    = var.name
    environment = var.environment
    managed-by  = "terraform"
  }
}

resource "azurerm_user_assigned_identity" "api" {
  name                = "id-${local.prefix}-api"
  resource_group_name = var.resource_group_name
  location            = var.location
  tags                = local.tags
}

resource "azurerm_container_app_environment" "this" {
  name                = "cae-${local.prefix}"
  resource_group_name = var.resource_group_name
  location            = var.location

  infrastructure_subnet_id       = var.subnet_id
  internal_load_balancer_enabled = true
  public_network_access          = "Disabled"
  zone_redundancy_enabled        = true
  mutual_tls_enabled             = true
  log_analytics_workspace_id     = var.log_analytics_workspace_id

  workload_profile {
    name                  = "dedicated"
    workload_profile_type = "D4"
    minimum_count         = 3
    maximum_count         = 6
  }

  tags = local.tags
}

resource "azurerm_container_app" "api" {
  name                         = "ca-${local.prefix}-api"
  resource_group_name          = var.resource_group_name
  container_app_environment_id = azurerm_container_app_environment.this.id
  revision_mode                = "Single"
  workload_profile_name        = "dedicated"

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.api.id]
  }

  # The image is pulled with the managed identity: no registry password exists.
  registry {
    server   = var.registry_server
    identity = azurerm_user_assigned_identity.api.id
  }

  ingress {
    external_enabled           = false
    allow_insecure_connections = false
    target_port                = 8080
    transport                  = "http"

    traffic_weight {
      latest_revision = true
      percentage      = 100
    }
  }

  template {
    min_replicas = 3
    max_replicas = 10

    container {
      name   = "api"
      image  = var.image
      cpu    = 0.5
      memory = "1Gi"

      liveness_probe {
        transport = "HTTP"
        port      = 8080
        path      = "/healthz"
      }

      readiness_probe {
        transport = "HTTP"
        port      = 8080
        path      = "/ready"
      }
    }
  }

  tags = local.tags
}

And on AKS Standard, with the same inputs:

# The same regulated API platform on AKS Standard: a private, zone-spread cluster on a landing-zone subnet,
# Entra-only access, workload identity, Cilium network policy, Azure Policy, Defender, and automatic
# upgrades inside maintenance windows. The workload itself is Kubernetes manifests, which are not shown.

locals {
  prefix = "${var.name}-${var.environment}"

  tags = {
    workload    = var.name
    environment = var.environment
    managed-by  = "terraform"
  }
}

resource "azurerm_user_assigned_identity" "cluster" {
  name                = "id-${local.prefix}-aks"
  resource_group_name = var.resource_group_name
  location            = var.location
  tags                = local.tags
}

resource "azurerm_kubernetes_cluster" "this" {
  name                = "aks-${local.prefix}"
  resource_group_name = var.resource_group_name
  location            = var.location
  dns_prefix          = "aks-${local.prefix}"
  kubernetes_version  = var.kubernetes_version
  sku_tier            = "Standard"

  # No public API server, no local accounts, no static credentials.
  private_cluster_enabled             = true
  private_cluster_public_fqdn_enabled = false
  local_account_disabled              = true

  azure_active_directory_role_based_access_control {
    tenant_id              = var.tenant_id
    azure_rbac_enabled     = true
    admin_group_object_ids = var.admin_group_object_ids
  }

  oidc_issuer_enabled       = true
  workload_identity_enabled = true

  # Patching is yours on AKS Standard. These settings hand the routine part to the platform.
  automatic_upgrade_channel = "patch"
  node_os_upgrade_channel   = "NodeImage"

  maintenance_window_auto_upgrade {
    frequency   = "Weekly"
    interval    = 1
    day_of_week = "Sunday"
    start_time  = "02:00"
    duration    = 4
    utc_offset  = "+00:00"
  }

  maintenance_window_node_os {
    frequency   = "Weekly"
    interval    = 1
    day_of_week = "Sunday"
    start_time  = "02:00"
    duration    = 4
    utc_offset  = "+00:00"
  }

  azure_policy_enabled             = true
  image_cleaner_enabled            = true
  image_cleaner_interval_hours     = 48

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.cluster.id]
  }

  default_node_pool {
    name                         = "system"
    vm_size                      = "Standard_D4ds_v5"
    node_count                   = 3
    zones                        = ["1", "2", "3"]
    vnet_subnet_id               = var.subnet_id
    os_sku                       = "AzureLinux"
    only_critical_addons_enabled = true

    upgrade_settings {
      max_surge = "33%"
    }
  }

  # Node pools are declared here, not provisioned on demand by the platform.
  node_provisioning_profile {
    mode = "Manual"
  }

  network_profile {
    network_plugin      = "azure"
    network_plugin_mode = "overlay"
    network_data_plane  = "cilium"
    network_policy      = "cilium"
    outbound_type       = "userDefinedRouting"
  }

  microsoft_defender {
    log_analytics_workspace_id = var.log_analytics_workspace_id
  }

  oms_agent {
    log_analytics_workspace_id      = var.log_analytics_workspace_id
    msi_auth_for_monitoring_enabled = true
  }

  key_vault_secrets_provider {
    secret_rotation_enabled = true
  }

  tags = local.tags
}

resource "azurerm_kubernetes_cluster_node_pool" "workload" {
  name                  = "workload"
  kubernetes_cluster_id = azurerm_kubernetes_cluster.this.id
  vm_size               = "Standard_D4ds_v5"
  mode                  = "User"
  os_sku                = "AzureLinux"
  zones                 = ["1", "2", "3"]
  vnet_subnet_id        = var.subnet_id

  auto_scaling_enabled = true
  min_count            = 3
  max_count            = 9

  upgrade_settings {
    max_surge = "33%"
  }

  tags = local.tags
}

Both modules end with the same output, a map of named controls computed from the resources. This is the Container Apps one:

# The same control names are reported by the AKS module, so one test can ask both the same questions.
output "controls" {
  description = "Whether each control in the baseline is configured."
  value = {
    no_public_endpoint         = azurerm_container_app_environment.this.internal_load_balancer_enabled && azurerm_container_app_environment.this.public_network_access == "Disabled" && !azurerm_container_app.api.ingress[0].external_enabled
    zone_redundant             = azurerm_container_app_environment.this.zone_redundancy_enabled
    workload_identity          = azurerm_container_app.api.identity[0].type == "UserAssigned"
    no_static_credentials      = alltrue([for registry in azurerm_container_app.api.registry : registry.password_secret_name == null || registry.password_secret_name == ""])
    encrypted_between_services = azurerm_container_app_environment.this.mutual_tls_enabled
    logs_to_workspace          = azurerm_container_app_environment.this.log_analytics_workspace_id == var.log_analytics_workspace_id
    at_least_three_replicas    = azurerm_container_app.api.template[0].min_replicas >= 3
  }
}

output "platform_id" {
  description = "Resource id of the Container Apps environment."
  value       = azurerm_container_app_environment.this.id
}
Two cards: the Container Apps module has 3 resources, 50 arguments and 78 lines including the application, with 7 of 7 baseline controls as settings; the AKS Standard module has 3 resources, 72 arguments and 106 lines plus Kubernetes manifests, with 6 of 7 controls as cluster settings.
What each module asks you to decide. The Container Apps count includes the application; the AKS count stops at the cluster.
Container AppsAKS Standard
Resources33
Arguments set5072
Lines of HCL78106
Includes the applicationYesNo: Deployment, Service, NetworkPolicy, ServiceAccount and bindings are extra
Baseline controls that are a platform setting7 of 76 of 7
Tests passing, Terraform 1.16.3 and OpenTofu 1.12.66 and 68 and 8

The line counts flatter AKS, because the Container Apps module contains the whole workload and the AKS module stops where Kubernetes begins. The arguments are the better measure of what it is like to own each one. Twenty-two more decisions sit in the cluster resource, and most of them are security decisions with a wrong answer: whether local accounts exist, which upgrade channel, which network policy engine, where egress goes.

The control that differs is encryption between services. On a Container Apps environment it is one argument, mutual_tls_enabled. On AKS it is not a cluster setting at all. You get it from a service mesh, or from the Advanced Container Networking Services option that Microsoft lists as optional on both AKS modes, and either is a component you select, operate and upgrade. The AKS module reports that control as false, and its test expects exactly that:

# No Azure subscription: the provider is mocked. Identifiers passed between resources are well-formed,
# because the provider checks their format even when mocked.

mock_provider "azurerm" {
  mock_resource "azurerm_user_assigned_identity" {
    defaults = {
      id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id-mock"
    }
  }

  mock_resource "azurerm_kubernetes_cluster" {
    defaults = {
      id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.ContainerService/managedClusters/aks-mock"
    }
  }
}

variables {
  name                       = "records"
  environment                = "prod"
  location                   = "northeurope"
  resource_group_name        = "rg-records-prod"
  subnet_id                  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Network/virtualNetworks/vnet-mock/subnets/snet-apps"
  log_analytics_workspace_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.OperationalInsights/workspaces/log-mock"
  tenant_id                  = "11111111-1111-1111-1111-111111111111"
  admin_group_object_ids     = ["22222222-2222-2222-2222-222222222222"]
}

run "one_baseline_control_is_not_a_cluster_setting" {
  command = apply

  assert {
    condition     = [for name, met in output.controls : name if !met] == ["encrypted_between_services"]
    error_message = "Expected every control except in-cluster encryption to be configured: ${jsonencode(output.controls)}"
  }
}

run "api_server_is_private_and_entra_only" {
  command = plan

  assert {
    condition     = azurerm_kubernetes_cluster.this.private_cluster_enabled && azurerm_kubernetes_cluster.this.local_account_disabled
    error_message = "The API server must be private and local accounts must be disabled."
  }

  assert {
    condition     = azurerm_kubernetes_cluster.this.azure_active_directory_role_based_access_control[0].azure_rbac_enabled
    error_message = "Kubernetes authorisation must use Azure RBAC."
  }
}

run "egress_leaves_through_the_landing_zone_route" {
  command = plan

  assert {
    condition     = azurerm_kubernetes_cluster.this.network_profile[0].outbound_type == "userDefinedRouting"
    error_message = "Outbound traffic must follow the subnet's route table to the firewall."
  }
}

run "network_policy_engine_is_on" {
  command = plan

  assert {
    condition     = azurerm_kubernetes_cluster.this.network_profile[0].network_policy == "cilium" && azurerm_kubernetes_cluster.this.network_profile[0].network_data_plane == "cilium"
    error_message = "A network policy engine must be enabled so traffic between workloads can be restricted."
  }
}

run "patching_is_automatic_inside_a_window" {
  command = plan

  assert {
    condition     = azurerm_kubernetes_cluster.this.automatic_upgrade_channel == "patch" && azurerm_kubernetes_cluster.this.node_os_upgrade_channel == "NodeImage"
    error_message = "Cluster patch upgrades and node image upgrades must be automatic."
  }

  assert {
    condition     = azurerm_kubernetes_cluster.this.maintenance_window_auto_upgrade[0].day_of_week == "Sunday" && azurerm_kubernetes_cluster.this.maintenance_window_node_os[0].day_of_week == "Sunday"
    error_message = "Upgrades must be confined to the agreed maintenance window."
  }
}

run "policy_and_threat_detection_are_on" {
  command = plan

  assert {
    condition     = azurerm_kubernetes_cluster.this.azure_policy_enabled && length(azurerm_kubernetes_cluster.this.microsoft_defender) == 1
    error_message = "Azure Policy and Defender for Containers must be enabled."
  }
}

run "system_pool_is_reserved_and_spread" {
  command = plan

  assert {
    condition     = azurerm_kubernetes_cluster.this.default_node_pool[0].only_critical_addons_enabled && length(azurerm_kubernetes_cluster.this.default_node_pool[0].zones) == 3
    error_message = "The system pool must run only critical add-ons and span three zones."
  }
}

run "rejects_an_unknown_environment" {
  command = plan

  variables {
    environment = "production"
  }

  expect_failures = [var.environment]
}

Patching, in practice

On AKS Standard the two settings that matter are in the module above: automatic_upgrade_channel = "patch" keeps the cluster on the latest patch of its minor version, and node_os_upgrade_channel = "NodeImage" replaces node images as Microsoft publishes them, both inside a maintenance window. That is good practice and it is not the end of the duty. Minor version upgrades are still a project every few months, because each one can deprecate APIs your manifests or add-ons use. Node image upgrades drain and replace nodes, so pod disruption budgets and surge capacity have to be right. And when an upgrade stalls, it is your on-call engineer who finds out.

On AKS Automatic those are preconfigured: Microsoft’s documentation describes automatic cluster upgrades and node image upgrades as defaults, with a planned maintenance schedule to control timing. On Container Apps there is no Kubernetes version or node image exposed to you at all. In both cases the workload still has to tolerate replicas being moved, and the base image inside your container is still yours to rebuild.

⚠️
A MANAGED PLATFORM DOES NOT PATCH YOUR IMAGE

Every row of the ledger above the last four can be Microsoft’s and a critical vulnerability in your base image is still running in production. Image rebuild and redeploy on a schedule, with a scanner in the pipeline and a record of both, is the patching control an auditor should ask about first, on any of the three platforms.

A decision rule you can argue with

The decision script encodes the rule in about forty lines of logic. Hard requirements decide first: anything that needs the Kubernetes API or the nodes rules out Container Apps, and anything that needs control of node configuration or upgrade staging rules out AKS Automatic. Team skills produce cautions, never the recommendation.

"""Recommend a container platform for a regulated workload from a short list of facts about it.

    python decide.py requirements.json

The rules are deliberately few and written down, so the recommendation can be argued with. Hard
requirements (things one platform cannot do) decide first; only then do team and cost factors apply.
Prints the recommendation and every reason. Exit code 0.
"""
from __future__ import annotations

import json
import pathlib
import sys

# A true answer to any of these needs the Kubernetes API or node-level access, which Container Apps does not expose.
NEEDS_KUBERNETES = {
    "needs_kubernetes_api": "The workload or its tooling uses the Kubernetes API (operators, custom resources, Helm charts that create them).",
    "needs_network_policy_between_services": "Traffic between services on the same platform must be restricted by policy.",
    "needs_daemonsets_or_privileged": "Something has to run on every node or with elevated privileges (agents, eBPF tools, device plugins).",
    "needs_custom_service_mesh": "A specific service mesh or sidecar set is mandated.",
    "needs_windows_containers": "Some containers are Windows containers.",
}
# A true answer to any of these needs choices AKS Automatic makes for you.
NEEDS_STANDARD = {
    "needs_custom_node_configuration": "Node pools need specific images, kernel settings, taints or sizes chosen by you.",
    "needs_own_upgrade_schedule_per_pool": "Upgrades must be staged by you, pool by pool, on your own schedule.",
    "needs_bring_your_own_cni": "A CNI other than the built-in options is required.",
}


def decide(facts: dict) -> dict:
    unknown = set(facts) - set(NEEDS_KUBERNETES) - set(NEEDS_STANDARD) - {"team_operates_kubernetes_today", "workload"}
    if unknown:
        raise ValueError(f"unknown facts: {', '.join(sorted(unknown))}")
    kubernetes = [text for key, text in NEEDS_KUBERNETES.items() if facts.get(key)]
    standard = [text for key, text in NEEDS_STANDARD.items() if facts.get(key)]
    operates = bool(facts.get("team_operates_kubernetes_today"))
    cautions: list[str] = []

    if not kubernetes and not standard:
        platform = "Azure Container Apps"
        reasons = ["Nothing in the requirements needs the Kubernetes API or node access.",
                   "Microsoft patches the nodes, the Kubernetes layer and the ingress; your duties shrink to the image, identity, egress and evidence."]
        if operates:
            cautions.append("The team already runs Kubernetes. That is a reason AKS would be affordable, not a requirement for it.")
    elif not standard:
        platform = "AKS Automatic"
        reasons = kubernetes + ["Nothing requires control over node configuration or upgrade staging, so let the platform manage nodes and upgrades."]
        if not operates:
            cautions.append("The team does not run Kubernetes today. Budget for the skills: you own RBAC, network policy and manifests on day one.")
    else:
        platform = "AKS Standard"
        reasons = kubernetes + standard
        if not operates:
            cautions.append("The team does not run Kubernetes today, and AKS Standard leaves upgrades and node patching with you. Staff it before you choose it.")
        if not kubernetes:
            cautions.append("Only node-level control points to AKS. Check whether a dedicated workload profile on Container Apps would meet the underlying need.")
    return {"platform": platform, "reasons": reasons, "cautions": cautions}


def ownership_counts(path: pathlib.Path) -> dict[str, dict[str, int]]:
    duties = json.loads(path.read_text(encoding="utf-8"))["duties"]
    counts: dict[str, dict[str, int]] = {}
    for platform in ("aca", "aks_automatic", "aks_standard"):
        counts[platform] = {}
        for duty in duties:
            counts[platform][duty[platform]] = counts[platform].get(duty[platform], 0) + 1
    return counts


def main() -> int:
    facts = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))
    result = decide(facts)
    print(f"Recommendation: {result['platform']}")
    for reason in result["reasons"]:
        print(f"  because  {reason}")
    for caution in result["cautions"]:
        print(f"  caution  {caution}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    A[Regulated workload on Azure] --> B{Needs the Kubernetes API, network policy between services, node agents, a mandated mesh or Windows containers?}
    B -->|No| C[Azure Container Apps: internal environment, workload profiles]
    B -->|Yes| D{Needs custom node configuration, your own upgrade staging or your own CNI?}
    D -->|No| E[AKS Automatic, private, custom virtual network]
    D -->|Yes| F[AKS Standard, with the patching duties staffed]
    C --> G[Yours on every path: image, identity, egress, evidence]
    E --> G
    F --> G
    classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
    classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
    classDef net fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
    classDef policy fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
    classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
    class A,B,D compute
    class C net
    class E policy
    class F danger
    class G store

For the example in the sample, a stateless patient-records API with a database and a queue, the script recommends Container Apps and gives the reasons. The rule the script refuses to apply is the common one: “the team knows Kubernetes” appears only as a caution that this makes AKS affordable, not necessary.

Where each platform is the wrong choice

PlatformDo not choose it when
Container AppsServices on the same platform must be isolated from each other by policy; you depend on operators, custom resources or Helm charts that create them; a security agent has to run on every node; a specific mesh is mandated
AKS AutomaticYou need to choose node images or kernel settings, stage upgrades pool by pool, or bring a CNI; an add-on you depend on is not compatible with its defaults
AKS StandardNobody is funded to own upgrades and node patching; the only reason is that it is the default in the organisation’s reference architecture

Alternatives considered

OptionAdvantagesRisksOperational implication
Azure Container AppsSmallest audit boundary; encryption between services, ingress and scaling are settingsNo Kubernetes API or network policy; environment is the isolation unitPlatform team is small; isolation by environment multiplies environments
AKS AutomaticFull Kubernetes API with nodes and upgrades managed; secure defaults onYou still own RBAC, policy content and manifests; opinionated defaultsKubernetes skills needed for workloads, less for the platform
AKS StandardEvery setting is yoursEvery setting is yours; patching and upgrade failures are your incidentsA funded platform team with on-call
App Service for containersSimple for a single web appNot a multi-service platformFits one or two services, not a suite
⚖️
EVIDENCE, NOT ARCHITECTURE, PASSES AN AUDIT

For each row of the ledger marked “you”, an auditor will ask for the control, the owner and the record. Keep three things regardless of platform: the platform configuration in version control with tests like the ones here, pipeline records showing image rebuilds and scans, and platform logs in a workspace the workload team cannot alter. Where a row says Microsoft, the evidence is Microsoft’s compliance documentation for the service and your record of which settings you enabled. Data residency is decided by the region you pass in, and that choice belongs in the data protection impact assessment, not in a module default.

Cost

Compute price is the smallest part of this comparison and the easiest to get wrong, so no price is quoted here. A dedicated workload profile on Container Apps and a node pool on AKS are both virtual machines you pay for by the hour, and AKS Standard adds a charge for the cluster’s uptime tier. The cost that separates the platforms is people. Twelve duties on AKS Standard against four on Container Apps is the difference between a platform team with an on-call rota and a platform that a product team can own alongside its service. Price that honestly before the per-vCPU rate.

Key takeaways

  • Choose on hard requirements first. If nothing needs the Kubernetes API or the nodes, Container Apps gives the smallest platform to patch and to audit.
  • Of thirteen operational duties, four are yours on Container Apps, six on AKS Automatic and twelve on AKS Standard. Image patching, application identity, egress and evidence are yours on all three.
  • The same baseline took 50 arguments on Container Apps including the application, and 72 on AKS Standard before any Kubernetes manifest.
  • Encryption between services is one setting on Container Apps and a component you operate on AKS. Network policy between workloads is the reverse: available on AKS, not on Container Apps.
  • AKS Automatic removes most of the patching work that makes AKS Standard expensive to run. Consider it before AKS Standard whenever Kubernetes is required.

If you have taken a regulated workload through audit on one of these platforms and a row of the ledger is wrong, leave a comment and say which.

ℹ️
HOW THIS WAS CHECKED

Both Terraform modules were validated against the AzureRM provider 5.2.0 schema and tested with a mocked provider on Terraform 1.16.3 and OpenTofu 1.12.6 in GitHub Actions (run 43337336490): 6 tests for Container Apps and 8 for AKS on each tool, and 9 of 9 checks for the decision script. The listings are the files in the samples repository. Nothing was applied to an Azure subscription, so no claim is made that either configuration provisions as written. The ownership ledger and the decision rules are a reading of Microsoft’s documentation for Container Apps and AKS, not statements by Microsoft, and no pricing was measured.

Glossary

  • Container Apps environment— The boundary that Container Apps share: one virtual network, one log destination, one set of platform settings.
  • Workload profile— The compute a Container Apps environment runs on: consumption, or dedicated virtual machines of a chosen size.
  • AKS Automatic— An AKS mode in which node provisioning, upgrades and a set of security defaults are preconfigured and managed.
  • Private cluster— An AKS cluster whose API server has no public endpoint.
  • Workload identity— Federation that lets a pod or app obtain Microsoft Entra tokens without a stored secret.
  • Node image upgrade— Replacing node virtual machines with ones built from a newer, patched operating system image.
  • Network policy— Kubernetes rules restricting which workloads may talk to which.
  • Audit boundary— The set of components an organisation must evidence controls for.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.