Deploy a Container from ACR to Azure Container Apps

TQ
Tran Quang
September 10, 2026 · 12 min read
#Azure#Azure Container Apps#Azure Container Registry#.NET

You have an image in Azure Container Registry and you want it running behind an HTTPS endpoint without operating a Kubernetes cluster. Azure Container Apps is usually the shortest path: it runs containers on a managed environment, gives you ingress, autoscaling and revisions, and can pull from ACR using a managed identity instead of a stored password.

This guide goes from an empty resource group to a running ASP.NET Core API that pulls from ACR with a managed identity, scales on HTTP load, rolls out new versions with traffic splitting, and is updated from CI. It assumes you already have a registry with an image in it; if not, Introduction to Azure Container Registry covers creating one and pushing your first image.

Prerequisites and naming

The examples use a consistent set of names. Replace them with your own.

Resource Name
Resource group rg-apps
Container registry crappsdemo (login server crappsdemo.azurecr.io)
Container Apps environment cae-apps
Container app ca-orders-api
User-assigned identity id-orders-api
Image orders-api

Install or upgrade the Container Apps CLI extension and register the resource providers once per subscription:

bash
az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.OperationalInsights

Prepare the .NET container

Container Apps routes traffic to one target port per app. Since .NET 8, the official ASP.NET Core images listen on port 8080 by default and run as a non-root user, which fits Container Apps well. A multi-stage Dockerfile keeps the SDK out of the runtime image:

dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["OrdersApi/OrdersApi.csproj", "OrdersApi/"]
RUN dotnet restore "OrdersApi/OrdersApi.csproj"
COPY . .
RUN dotnet publish "OrdersApi/OrdersApi.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "OrdersApi.dll"]

Setting ASPNETCORE_HTTP_PORTS explicitly documents the contract, even though it matches the image default. Do not configure HTTPS inside the container; Container Apps terminates TLS at the ingress and forwards plain HTTP to your target port.

Add two health endpoints. Liveness should answer "is the process alive" and must not depend on downstream services; readiness can check dependencies such as the database.

csharp
using Microsoft.AspNetCore.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHealthChecks()
    .AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: ["live"]);
// Add dependency checks (database, cache) here with the "ready" tag.

var app = builder.Build();

app.MapHealthChecks("/healthz/live", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("live")
});

app.MapHealthChecks("/healthz/ready");

app.MapGet("/orders/{id:int}", (int id) => Results.Ok(new { id, status = "Created" }));

app.Run();

Build and push the image directly in the registry with ACR Tasks, so you do not need Docker locally:

bash
az acr build --registry crappsdemo --image orders-api:1.0.0 .

Create the Container Apps environment

The environment is the security and networking boundary that apps share. Apps in the same environment can talk to each other and share a Log Analytics workspace.

bash
az group create --name rg-apps --location westeurope

az containerapp env create \
  --name cae-apps \
  --resource-group rg-apps \
  --location westeurope

If you do not pass a workspace, the CLI creates a Log Analytics workspace for you. In production, create the workspace yourself (in Bicep or Terraform) and pass --logs-workspace-id and --logs-workspace-key, so retention and access are managed with the rest of your monitoring.

If your registry has public network access disabled and only allows private endpoints, the environment must be deployed into a virtual network that can resolve and reach the registry's private endpoint. Plan that before creating the environment, because the network configuration cannot be added afterwards.

Create the app with a managed identity pull

You can make Container Apps pull with the registry admin user by passing --registry-username and --registry-password, but that ties every app to a shared, long-lived credential that must be rotated manually. A managed identity with the AcrPull role is the better default.

Option 1: system-assigned identity

The simplest form lets the CLI enable a system-assigned identity on the app and grant it AcrPull on the registry:

bash
az containerapp create \
  --name ca-orders-api \
  --resource-group rg-apps \
  --environment cae-apps \
  --image crappsdemo.azurecr.io/orders-api:1.0.0 \
  --registry-server crappsdemo.azurecr.io \
  --registry-identity system \
  --ingress external \
  --target-port 8080 \
  --min-replicas 1 \
  --max-replicas 5

The account running this command needs permission to create role assignments on the registry, such as Owner, User Access Administrator or Role Based Access Control Administrator. In many organizations the CI identity does not have that, which leads to the second option.

Option 2: user-assigned identity

A user-assigned identity is created once, granted AcrPull by a platform team, and then reused. It also avoids the ordering problem where a system identity does not exist until the app does.

bash
az identity create --name id-orders-api --resource-group rg-apps

IDENTITY_ID=$(az identity show --name id-orders-api --resource-group rg-apps --query id --output tsv)
PRINCIPAL_ID=$(az identity show --name id-orders-api --resource-group rg-apps --query principalId --output tsv)
ACR_ID=$(az acr show --name crappsdemo --query id --output tsv)

az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role AcrPull \
  --scope "$ACR_ID"

az containerapp create \
  --name ca-orders-api \
  --resource-group rg-apps \
  --environment cae-apps \
  --image crappsdemo.azurecr.io/orders-api:1.0.0 \
  --user-assigned "$IDENTITY_ID" \
  --registry-server crappsdemo.azurecr.io \
  --registry-identity "$IDENTITY_ID" \
  --ingress external \
  --target-port 8080 \
  --min-replicas 1 \
  --max-replicas 5

Role assignments can take a few minutes to propagate. If the first revision fails with an unauthorized pull error right after the assignment, wait and create a new revision rather than falling back to admin credentials.

Once the app works, disable the registry admin user if nothing else depends on it:

bash
az acr update --name crappsdemo --admin-enabled false

Ingress and target port

--ingress external exposes the app on a public FQDN with a managed TLS certificate; --ingress internal makes it reachable only from within the environment (and its virtual network). The target port must match the port the container listens on, 8080 here. A mismatch is the most common reason a new app shows as running but returns errors or fails its probes.

bash
# Get the public URL
az containerapp show \
  --name ca-orders-api \
  --resource-group rg-apps \
  --query properties.configuration.ingress.fqdn \
  --output tsv

# Change ingress settings on an existing app
az containerapp ingress enable \
  --name ca-orders-api \
  --resource-group rg-apps \
  --type external \
  --target-port 8080 \
  --transport auto

Secrets and environment variables

Container Apps secrets are stored at the app level and referenced from environment variables with the secretref: prefix. Plain configuration goes straight into --env-vars. ASP.NET Core maps double underscores to configuration sections, so ConnectionStrings__Orders becomes ConnectionStrings:Orders.

bash
az containerapp secret set \
  --name ca-orders-api \
  --resource-group rg-apps \
  --secrets "orders-db=Server=tcp:sql-apps.database.windows.net;Database=orders;Authentication=Active Directory Default;"

az containerapp update \
  --name ca-orders-api \
  --resource-group rg-apps \
  --set-env-vars \
    "ASPNETCORE_ENVIRONMENT=Production" \
    "ConnectionStrings__Orders=secretref:orders-db"

For secrets that live in Azure Key Vault, reference them instead of copying values. The identity needs permission to read secrets from the vault (for example the Key Vault Secrets User role):

bash
az containerapp secret set \
  --name ca-orders-api \
  --resource-group rg-apps \
  --secrets "orders-db=keyvaultref:https://kv-apps.vault.azure.net/secrets/orders-db,identityref:$IDENTITY_ID"

A pitfall: changing a secret value does not automatically restart existing revisions. Restart the active revision or deploy a new one so the containers pick up the change.

Scaling rules and health probes

Scale rules and scale to zero

Each app scales between --min-replicas and --max-replicas, driven by scale rules. For an HTTP API, a concurrency-based HTTP rule is the natural choice: when the average number of concurrent requests per replica exceeds the threshold, Container Apps adds replicas.

bash
az containerapp update \
  --name ca-orders-api \
  --resource-group rg-apps \
  --min-replicas 1 \
  --max-replicas 10 \
  --scale-rule-name http-concurrency \
  --scale-rule-type http \
  --scale-rule-http-concurrency 50

Setting --min-replicas 0 enables scale to zero, which is attractive for internal tools and dev environments. The trade-off is a cold start on the first request after an idle period: the image is pulled if needed, the .NET runtime starts, and your readiness probe must pass. For a latency-sensitive public API, keep at least one replica. Background workers can scale on queue length instead, using KEDA-based custom rules such as Azure Service Bus or Storage Queue scalers.

Liveness, readiness and startup probes

Container Apps supports liveness, readiness and startup probes. The CLI does not expose dedicated probe flags, so configure them in YAML or in your Bicep template. The workflow with the CLI is to export the app, edit the container section and apply it:

bash
az containerapp show --name ca-orders-api --resource-group rg-apps --output yaml > ca-orders-api.yaml
# edit the probes section, then:
az containerapp update --name ca-orders-api --resource-group rg-apps --yaml ca-orders-api.yaml

The relevant part of the container definition:

yaml
properties:
  template:
    containers:
      - name: ca-orders-api
        image: crappsdemo.azurecr.io/orders-api:1.0.0
        probes:
          - type: Startup
            httpGet:
              path: /healthz/live
              port: 8080
            periodSeconds: 5
            failureThreshold: 30
          - type: Liveness
            httpGet:
              path: /healthz/live
              port: 8080
            periodSeconds: 10
          - type: Readiness
            httpGet:
              path: /healthz/ready
              port: 8080
            periodSeconds: 10

Keep liveness cheap and dependency-free. If liveness checks the database, a short database outage makes the platform restart every replica, which turns a partial outage into a full one. Readiness is the right place for dependency checks: a failing replica is taken out of rotation but not killed.

For anything beyond experiments, define the app in Bicep and keep probes, scale rules and registry settings in source control rather than editing exported YAML.

Revisions and traffic splitting

Every change to the template section (image, environment variables, scale rules, probes) creates a new revision. In the default single revision mode, the new revision replaces the old one once it is healthy. Multiple revision mode lets several revisions run at once and split traffic between them, which enables canary and blue-green rollouts.

bash
az containerapp revision set-mode \
  --name ca-orders-api \
  --resource-group rg-apps \
  --mode multiple

# Deploy a new version with a readable revision name
az containerapp update \
  --name ca-orders-api \
  --resource-group rg-apps \
  --image crappsdemo.azurecr.io/orders-api:1.1.0 \
  --revision-suffix v1-1-0

az containerapp revision list --name ca-orders-api --resource-group rg-apps --output table

# Send 10% of traffic to the new revision
az containerapp ingress traffic set \
  --name ca-orders-api \
  --resource-group rg-apps \
  --revision-weight ca-orders-api--v1-0-0=90 ca-orders-api--v1-1-0=10

Watch errors and latency, then shift to 100 percent and deactivate the old revision:

bash
az containerapp ingress traffic set \
  --name ca-orders-api \
  --resource-group rg-apps \
  --revision-weight ca-orders-api--v1-1-0=100

az containerapp revision deactivate \
  --name ca-orders-api \
  --resource-group rg-apps \
  --revision ca-orders-api--v1-0-0

Revision names follow the pattern <app>--<suffix>, and a suffix can only be used once per app. Derive it from the version or build number. Also remember that traffic splitting is per request, not per user; if a user must stay on one version, you need session affinity or a header-based routing layer in front.

Update the image from CI

With the app and identity in place, a deployment is a single command. The CI identity needs rights to update the container app (Contributor on the app or resource group) and AcrPush on the registry for the build step. It does not need rights to create role assignments.

bash
TAG="sha-${GITHUB_SHA::7}"

az acr build --registry crappsdemo --image "orders-api:${TAG}" .

az containerapp update \
  --name ca-orders-api \
  --resource-group rg-apps \
  --image "crappsdemo.azurecr.io/orders-api:${TAG}" \
  --revision-suffix "${TAG}"

Deploy an immutable tag rather than latest. Pushing a new latest does not trigger a new revision on its own, and replicas restarted later could pull a different image than the one you tested. Immutable tags also make rollback a matter of pointing traffic back to the previous revision.

View logs and troubleshoot

Stream console output from the running replicas, or look at system events such as image pull failures and probe results:

bash
# Application stdout and stderr
az containerapp logs show \
  --name ca-orders-api \
  --resource-group rg-apps \
  --follow \
  --tail 50

# Platform events: pulls, probe failures, restarts
az containerapp logs show \
  --name ca-orders-api \
  --resource-group rg-apps \
  --type system

For history and aggregation, query the Log Analytics workspace. With the default Log Analytics destination, console logs land in the ContainerAppConsoleLogs_CL table:

text
ContainerAppConsoleLogs_CL
| where ContainerAppName_s == "ca-orders-api"
| project TimeGenerated, RevisionName_s, Log_s
| order by TimeGenerated desc
| take 100

Common failures and their usual causes:

Symptom Likely cause
Revision stuck provisioning, unauthorized pull in system logs Identity missing AcrPull, wrong --registry-identity, or role assignment not yet propagated
Image not found Wrong tag or login server, or the tag was purged from the registry
Replicas restarting Liveness probe failing, or the app crashing at startup
Running but requests fail Target port does not match the port the app listens on
Pull times out Registry allows only private endpoints and the environment cannot reach them

Conclusion

Running an ACR image on Azure Container Apps comes down to a few decisions made well: pull with a managed identity that has AcrPull instead of the admin user, match the target port to what the container listens on, keep secrets in secret references or Key Vault, choose scaling bounds that match your latency needs, and use revisions with traffic splitting so a bad release only reaches a slice of users. Build with immutable tags, deploy with az containerapp update --image, and keep probes honest. Once that is in place, shipping a new version is one command in the pipeline and rolling back is one traffic change.

Found this useful?

Share it with someone who might need it.

Related articles All articles