Contoso Airlines' most awkward component lives on vm-motor-disponibilidad-dev. The availability engine works out, for an origin, a destination and a date, which seats are still free and at what fare; it is a small, stateless service that the Availability API calls thousands of times a day. But it was deployed by hand onto a virtual machine that Marta Ríos patches every month, that nobody knows how to recreate from scratch and whose runtime version nobody dares touch because "it works". In production the problem was papered over by multiplying instances in vmss-api-disponibilidad-pro, which does not solve it: it only multiplies it.

This lesson turns that engine into a container: a self-contained unit that includes the code and everything it needs in order to run, that is built once, stored with a version in a registry and runs identically on Diego Salas' laptop, on the pipeline agent and in production. You will then publish it to Azure Container Registry and take it to Azure Container Apps, the option that gives Contoso most of the value of orchestration without the cost of operating a cluster.

Contents

  1. Container versus virtual machine
  2. Image, layers, container and registry
  3. The availability engine's Dockerfile
  4. Building and testing locally
  5. Azure Container Registry
  6. The Azure container services landscape
  7. Azure Container Apps in detail
  8. Deploying ca-motor-disponibilidad
  9. Azure Container Instances for one-off tasks
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. Container versus virtual machine

A virtual machine virtualizes hardware: each one carries its own complete operating system. A container virtualizes the operating system: it shares the host's kernel and isolates only the file system, the network and the processes. The difference in weight is not a technical detail, it is what changes the way you work.

Virtual machine Container
Contains Full OS + application Application + its dependencies
Typical size 10-30 GB 80-300 MB
Startup Minutes Seconds or less
OS patching Yours, monthly, in place You rebuild the image and redeploy
Density per server Dozens Hundreds
Isolation Strong (hypervisor) Good (shared kernel)
"Works on my machine" Still happens Over: the environment travels inside

The availability engine is the perfect candidate for four concrete reasons: it is stateless (every request is resolved with the database and a rebuildable cache), it starts fast, its load is very uneven — spikes when seats go on sale, almost nothing in the small hours — and its problematic dependency is the runtime, which is exactly what a container freezes. What you would not migrate this light-heartedly are stateful databases or the legacy crew system that requires access to specific hardware.

  1. Image, layers, container and registry

Four concepts and the relationship between them:

  • Image: an immutable, read-only template with the file system and the startup metadata. It does not run; it is the mold.
  • Layer: every Dockerfile instruction produces a layer stacked on top of the previous one. Layers are cached and shared: if ten images start from the same base, that base is downloaded once.
  • Container: a running instance of an image, with an ephemeral writable layer on top. When you delete it, that layer disappears.
  • Registry: the image store. Docker Hub is the public one; acrcontosopro.azurecr.io is Contoso's private one, which you already know from module 5 as the registry for Bicep modules.

The fact that layers are cached has an enormous practical consequence: order the Dockerfile from what changes least to what changes most. If you copy the source code before restoring the dependencies, any one-line change invalidates the restore cache and every build takes minutes longer.

  1. The availability engine's Dockerfile

A Dockerfile is the declarative recipe for the image. This is the engine's, with a multi-stage build, a non-root user and a small base image:

# ---------- Stage 1: build ----------
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /source

# Project files first: they rarely change and the restore gets cached
COPY MotorDisponibilidad.sln .
COPY src/MotorDisponibilidad/*.csproj src/MotorDisponibilidad/
RUN dotnet restore

# Now, at last, the complete source code
COPY . .
RUN dotnet publish src/MotorDisponibilidad/MotorDisponibilidad.csproj \
    -c Release -o /published --no-restore

# ---------- Stage 2: runtime ----------
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final
WORKDIR /app

# Unprivileged user: never run as root
RUN adduser --disabled-password --home /app --gecos "" motor && chown -R motor /app
USER motor

COPY --from=build /published .

ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "MotorDisponibilidad.dll"]

Line by line, what matters:

  • FROM ... AS build opens the first stage with the full SDK (around 800 MB): compilers, tools and everything needed to build. None of this will reach production.
  • COPYing the .csproj files before the code is the cache optimization the previous section talked about. As long as the dependencies do not change, dotnet restore is skipped.
  • FROM ... AS final opens the second stage starting from nothing: only the ASP.NET runtime on Alpine, around 110 MB. The alpine variant uses a minimal distribution; if your code depends on native glibc libraries, use -jammy instead.
  • adduser + USER motor is the line most often missing from real-world Dockerfiles. By default a container runs as root, and an application vulnerability turns into root inside the container. Defender for Cloud will flag it; do it from the start.
  • COPY --from=build brings across only the published output of the first stage. The source code, the SDK and the build dependencies stay outside: less weight and a far smaller attack surface.
  • ENTRYPOINT defines the main process. When that process ends, the container ends.

Alongside the Dockerfile goes a .dockerignore with bin/, obj/, .git/ and **/appsettings.Development.json. That last line is not cosmetic: it stops a local configuration file with credentials from ending up inside the image.

  1. Building and testing locally

# Build, tagging with a name and a version
docker build -t motor-disponibilidad:1.0.0 .

# Run, mapping the container's port 8080 to 5080 on the laptop
docker run --rm -p 5080:8080 \
  -e ConexionReservas="Server=localhost;Database=reservas-local;..." \
  --name motor-local motor-disponibilidad:1.0.0

# Check the health endpoint, get inside and read the logs
curl http://localhost:5080/salud
docker exec -it motor-local sh
docker logs motor-local

--rm deletes the container when you stop it, so nothing piles up. -e injects configuration through an environment variable, which is how a container is configured: never put configuration inside the image, because then you would need a different image per environment and you would lose module 5's "build once, deploy many" guarantee.

  1. Azure Container Registry

A private registry becomes mandatory as soon as the image contains your own code. Contoso already has acrcontosopro:

az acr create \
  --resource-group rg-contoso-reservas-pro \
  --name acrcontosopro \
  --sku Premium --location westeurope \
  --tags entorno=produccion proyecto=contoso-reservas \
         centro-coste=CC-1042 [email protected]
Feature Basic Standard Premium
Included storage 10 GB 100 GB 500 GB
Throughput Low Medium High
Geo-replication No No Yes
Private Link / private endpoints No No Yes
Retention policies and content trust No No Yes
Recommended scope Testing Small teams Production

Contoso picks Premium for two reasons that have nothing to do with capacity: the private endpoint, so images never travel over the Internet, and geo-replication to North Europe, so the recovery plan does not depend on a single region.

Building in the cloud with az acr build

az acr build uploads the context, builds in Azure and leaves the image in the registry. No Docker installation is needed, which solves the pipeline agent problem in one stroke:

az acr build \
  --registry acrcontosopro \
  --image motor-disponibilidad:1.0.0 \
  --image motor-disponibilidad:latest \
  --file Dockerfile .

Tagging and versioning

The latest tag is a dangerous convenience: it is mutable, so two "deployments of latest" can be different things and rollback stops being reproducible. Contoso's convention: always tag with the build identifier (motor-disponibilidad:$(Build.BuildId)), which is immutable and traceable back to the exact commit, and add latest only as a convenience for local development.

Authentication: managed identity, never the admin user

ACR ships with an admin user disabled by default. Leave it disabled. It is a shared credential, not traceable and not rotatable in practice. The right thing to do is to assign the AcrPull role to the managed identity that consumes the images and AcrPush to the service connection that publishes them:

ACR_ID=$(az acr show --name acrcontosopro --query id -o tsv)

# The application's identity can only pull
az role assignment create \
  --assignee-object-id $(az identity show -g rg-contoso-seguridad-pro \
      -n id-contoso-api-pro --query principalId -o tsv) \
  --assignee-principal-type ServicePrincipal \
  --role AcrPull --scope $ACR_ID

Vulnerabilities, replication and retention

  • Defender for Cloud (04-05) scans every image when it is published, and continuously scans the ones that are running, with its container plan. It finds outdated bases and vulnerable packages; its output is a real work list, not decoration.
  • Geo-replication: az acr replication create --registry acrcontosopro --location northeurope. The image is pulled from the nearest replica under the same host name.
  • Retention: with no policy, the registry grows without limit and you pay per GB. az acr config retention update --registry acrcontosopro --status enabled --days 30 --type UntaggedManifests cleans up untagged manifests. Round it out with a scheduled task that purges old tags, always protecting the production ones.

  1. The Azure container services landscape

This is the question everybody asks, and it is worth answering before you choose:

Service What it is Scale to zero Complexity When to choose it
Container Instances (ACI) A single container, no orchestrator Manual Minimal One-off tasks, batch jobs, burst capacity
Container Apps (ACA) Managed platform on top of Kubernetes, without exposing it Yes Low Microservices and APIs with uneven load
App Service for Containers Your image inside App Service No Low You already use App Service and only want to change the packaging
Kubernetes Service (AKS) Managed Kubernetes, with full access With effort High You need the whole ecosystem and you have a team to operate it

The criterion in one sentence: start with Container Apps and move up to AKS only when you have a concrete need that Container Apps does not cover, not just in case. Contoso takes the engine to Container Apps and will reserve AKS for the flight operations platform (06-02), which does have such a need.

  1. Azure Container Apps in detail

Container Apps is Kubernetes underneath, but you never see it: there are no nodes to size and no cluster versions to upgrade.

  • Environment (cae-contoso-pro): the isolation boundary. All the apps in an environment share a virtual network and a Log Analytics workspace, and they call each other by internal name.
  • App: a microservice, with its image, its resources and its scaling rules.
  • Revision: every change to the image or the configuration creates an immutable revision. You can keep several active at once.
  • Traffic splitting: it shares out percentages between revisions. This is where canary deployment lives, and it is the fine-grained version of the slots you used in 05-04.

Scaling with KEDA and scale to zero

Scaling is defined with KEDA rules, which measure something real — requests per second, the length of a queue, a custom metric — rather than CPU alone:

# Scale on concurrent HTTP requests
az containerapp update --name ca-motor-disponibilidad -g rg-contoso-reservas-pro \
  --min-replicas 1 --max-replicas 20 \
  --scale-rule-name http-concurrencia --scale-rule-type http \
  --scale-rule-http-concurrency 50

With --min-replicas 0 the app scales to zero and stops being billed when there is no traffic: ideal for ca-motor-disponibilidad in development. The price is a cold start of a few seconds on the first request, unacceptable on the production purchase path. That is why Contoso leaves min-replicas 1 in production and 0 in development.

Networking, ingress, secrets and identity

  • Virtual network: the environment is integrated into snet-integracion-app (10.20.5.0/24), and from there the apps reach pe-sql-reservas and pe-kv-contoso without going out to the Internet.
  • Ingress: --ingress external publishes a name with a managed TLS certificate that renews itself; --ingress internal leaves the app reachable only inside the environment, which is the right choice for the engine, whose only client is the Availability API. For a custom domain, az containerapp hostname add plus a managed certificate.
  • Secrets: they are declared at the app level and can reference kv-contoso-pro, resolved with the managed identity. No connection strings in plain environment variables.
  • Managed identity: id-contoso-api-pro is assigned, and with it the image is pulled from ACR and db-reservas is accessed, with no key at all. It is exactly the pattern from 04-02.

  1. Deploying ca-motor-disponibilidad

IDENTITY_ID=$(az identity show -g rg-contoso-seguridad-pro -n id-contoso-api-pro --query id -o tsv)
SUBNET_ID=$(az network vnet subnet show -g rg-contoso-red-pro \
  --vnet-name vnet-contoso-pro -n snet-integracion-app --query id -o tsv)

# 1. The environment, integrated into the virtual network and linked to Log Analytics
az containerapp env create \
  --name cae-contoso-pro --resource-group rg-contoso-reservas-pro \
  --location westeurope --infrastructure-subnet-resource-id $SUBNET_ID \
  --logs-workspace-id $(az monitor log-analytics workspace show \
      -g rg-contoso-seguridad-pro -n log-contoso-pro --query customerId -o tsv)

# 2. The app, with managed identity and internal ingress
az containerapp create \
  --name ca-motor-disponibilidad --resource-group rg-contoso-reservas-pro \
  --environment cae-contoso-pro \
  --image acrcontosopro.azurecr.io/motor-disponibilidad:1.0.0 \
  --registry-server acrcontosopro.azurecr.io \
  --registry-identity $IDENTITY_ID --user-assigned $IDENTITY_ID \
  --ingress internal --target-port 8080 \
  --cpu 0.5 --memory 1.0Gi --min-replicas 1 --max-replicas 20 \
  --secrets conexion-reservas=keyvaultref:https://kv-contoso-pro.vault.azure.net/secrets/conexion-reservas,identityref:$IDENTITY_ID \
  --env-vars ConexionReservas=secretref:conexion-reservas \
  --tags entorno=produccion proyecto=contoso-reservas \
         centro-coste=CC-1042 [email protected]

Note --registry-identity: the image pull is authorized by the identity, not by a password. And note keyvaultref:, which means the secret is never written into the resource, only the reference is.

Updating it from the 05-04 pipeline is one more stage, with its environment approval:

- stage: DeployEngine
  jobs:
    - deployment: Engine
      environment: produccion
      strategy:
        runOnce:
          deploy:
            steps:
              - task: AzureCLI@2
                inputs:
                  azureSubscription: sc-contoso-pro
                  scriptType: bash
                  scriptLocation: inlineScript
                  inlineScript: |
                    az containerapp update \
                      --name ca-motor-disponibilidad \
                      --resource-group rg-contoso-reservas-pro \
                      --image acrcontosopro.azurecr.io/motor-disponibilidad:$(Build.BuildId) \
                      --revision-suffix b$(Build.BuildId)
                    # Canary: 10% of the traffic to the new revision
                    az containerapp ingress traffic set \
                      --name ca-motor-disponibilidad -g rg-contoso-reservas-pro \
                      --revision-weight latest=10 $(previousRevision)=90

The sc-contoso-pro service connection with workload identity federation carries no secrets, and the revision suffix makes every build identifiable and reversible: returning 100% to the previous revision is a single command.

  1. Azure Container Instances for one-off tasks

ACI runs a container with no orchestrator and no scaling, and bills per second of CPU and memory. It fits where Container Apps is overkill: a data migration that runs once, a nightly batch job.

az container create \
  --resource-group rg-contoso-reservas-dev --name aci-migracion-tarifas \
  --image acrcontosopro.azurecr.io/migracion-tarifas:2.1.0 \
  --acr-identity $IDENTITY_ID --restart-policy Never --cpu 2 --memory 4 \
  --tags entorno=desarrollo proyecto=contoso-reservas centro-coste=CC-1042

--restart-policy Never is what turns it into a job: it runs, it finishes and it does not start again. Delete it afterwards with az container delete, because a stopped container still reserves resources and still bills.

Common Mistakes and Tips

  • Running as root. It is the most repeated mistake and the one Defender for Cloud will point out. Always add the unprivileged user.
  • Copying the code before restoring dependencies. It invalidates the cache on every build and multiplies build times by ten.
  • Deploying latest to production. A mutable tag: you do not know what is running and rollback is not reproducible. Use the build identifier.
  • Enabling the ACR admin user "just for testing". It stays forever. Managed identity from day one.
  • Putting secrets in the image. They stay in the layers and anyone with access to the registry can extract them with docker history. They belong in Key Vault.
  • Not setting CPU and memory limits, or setting them at random. In Container Apps the CPU/memory pair has specific valid combinations (0.5 vCPU with 1 Gi, 1 vCPU with 2 Gi); check before you deploy.
  • Tip: define /salud as both a readiness and a liveness probe. Without them, the platform sends traffic to replicas that are not ready yet. And in rg-contoso-reservas-dev, --min-replicas 0: a development app with no traffic should cost nothing.
  • Tip: do not publish the image from a laptop. If only the pipeline has AcrPush, whatever is in the registry is always traceable to a commit.

Exercises

Exercise 1. Diego has written this Dockerfile for the Availability API:

FROM mcr.microsoft.com/dotnet/sdk:8.0
WORKDIR /app
COPY . .
RUN dotnet restore && dotnet publish -c Release -o /app/output
ENV ClaveApiPagos=k7Xq92mfLp
CMD ["dotnet", "/app/output/Api.dll"]
  1. List four problems.
  2. Rewrite it applying the lesson's good practices.
  3. Why does removing the ENV line in a later version not fix the leak?

Exercise 2. Contoso wants to deploy the engine in development at the lowest possible cost, knowing that it is used in bursts during the working day and not at all overnight.

  1. Write the az containerapp create for ca-motor-disponibilidad-dev in rg-contoso-reservas-dev, with the tags of the secondary "Contoso Miles" project.
  2. Justify the minimum and maximum replicas and explain what trade-off you are accepting.
  3. Which KEDA scaling rule would you use if the engine were fed by a queue instead of HTTP?

Exercise 3. Choose the service for each case and justify it in one sentence: (a) a process that converts the punctuality reports to PDF, runs at 03:00 and takes 20 minutes; (b) the new seat recommendation service, with unpredictable traffic and a need for canary releases; (c) app-contoso-reservas-pro, which already runs on App Service and only wants to move to an image; (d) the flight operations platform, with twelve microservices, service meshes and its own operators.

Solutions

Solution 1:

  1. (a) The final image is based on the SDK: around 800 MB against 110 MB, with compilers and source code included in production. (b) It runs as root. (c) An embedded secret with ENV. (d) COPY . . before restore destroys the layer cache, and on top of that, with no .dockerignore, bin/, obj/ and .git/ get copied in.
  2. The solution is the structure from section 3: a build stage with the SDK, copying the .csproj files, dotnet restore, copying the rest and publish; a final stage on aspnet:8.0-alpine, creating a user, USER, COPY --from=build and ENTRYPOINT. The key goes outside: a Container Apps secret with keyvaultref: to kv-contoso-pro.
  3. Because images are stacked, immutable layers: the layer containing the key still exists in the registry inside every earlier tag, and it can be read with docker history. You have to purge those tags from the registry and rotate the key, treating it as compromised.

Solution 2:

  1. The same command as in section 8, replacing the resource group with rg-contoso-reservas-dev, the environment with a development one, the image with the corresponding tag, --min-replicas 0 --max-replicas 3, --cpu 0.25 --memory 0.5Gi, the development identity and vault kv-contoso-dev, and the tags entorno=desarrollo proyecto=contoso-millas centro-coste=CC-2077 [email protected].
  2. Minimum 0 because there is no traffic at night and a stopped development app should cost nothing; maximum 3 because the bursts are from testing, not production, and a low ceiling stops an infinite loop in a test from blowing up the bill. The trade-off is the cold start: the first request after a quiet spell will take a few seconds. In development that is acceptable; in production it would not be.
  3. An azure-queue rule pointing at the queue in stoperacionescontosopro, with a target length per replica (20 messages, for example). It is the scaler that makes KEDA genuinely interesting: it scales on backlog, not on CPU, which is a signal that arrives late.

Solution 3: (a) Container Instances: a task that starts, finishes and needs neither orchestration nor ingress. (b) Container Apps: traffic-based scaling, scale to zero outside peak hours and traffic splitting between revisions, ready for the canary. (c) App Service for Containers: it keeps the familiar slots, scaling and configuration while changing only the packaging, at almost zero migration cost. (d) AKS: only there do you get operators, service meshes and full control of the data plane, and the team justifies it.

Conclusion

You have turned Contoso's legacy problem into a modern unit. You can tell a container from a virtual machine and you know why the availability engine — stateless, fast to start, with uneven load and tied to its runtime — was the ideal candidate. You understand image, layer, container and registry, and why the order of the Dockerfile instructions decides how long your builds take. You have written that Dockerfile with a multi-stage build, a non-root user and a small base image, you have tested it locally and you have learned that configuration comes in through environment variables so that the image is the same in every environment.

In Azure Container Registry you know how to choose a tier, build without local Docker using az acr build, version with the build identifier instead of latest, authenticate with a managed identity while leaving the admin user disabled, scan for vulnerabilities with Defender for Cloud and keep growth under control with replication and retention. You have the map of the four container services and the criterion for choosing between them: start simple and move up only for a reason. And you have deployed ca-motor-disponibilidad to Container Apps with its cae-contoso-pro environment integrated into snet-integracion-app, internal ingress, secrets by reference to kv-contoso-pro, managed identity, KEDA scaling and revisions with traffic splitting updated from the module 5 pipeline. The vm-motor-disponibilidad-dev virtual machine can be switched off.

Container Apps covers Contoso's case with very little effort, and that is precisely its appeal. But it has a ceiling: when you need to control pod scheduling, install operators, apply service meshes, define fine-grained network policies or run workloads with specific hardware requirements, the abstraction becomes too small for you. That is exactly where Contoso's flight operations team is right now. In the next lesson, Azure Kubernetes Service, you will drop down a layer: you will see what AKS manages and what remains yours, you will build aks-contoso-operaciones and you will learn the essentials of Kubernetes needed to operate it sensibly, including the part almost nobody mentions at the beginning — how much it really costs and how to keep it from spiraling.

Azure Course

Module 1: Introduction to Azure

Module 2: Core Azure Services

Module 3: Azure Databases

Module 4: Security in Azure

Module 5: Azure DevOps

Module 6: Advanced Azure Services

Module 7: Monitoring and Management

Module 8: Cost Management and Optimization

Module 9: Case Studies and Best Practices

© Copyright 2026. All rights reserved