Skip to content

Operational configuration

  • Lesson 14
  • 45 min
  • Module 5: Deploy and clean up
Lesson 14 of 15 · Docker and Cloud Essentials for AI DevelopersOperational configuration

What you will be able to do

A managed platform runs your container, but it does not know your app. Tell it how to check the app, how large it may be, how far it may grow, and which settings it must have. Then a bad version stops before it reaches anyone.

Before you startWhat to know and to have readyAbout 45 minutes

What you need to know

What you need on your computer

Tested with Docker Desktop 4.81.0 (Docker Engine 29.6.1) on macOS, Apple silicon. The Azure steps were run with Azure CLI 2.88.0 and the containerapp extension 1.3.0b5, in region canadacentral. The Windows and Linux steps are not tested.

After Registry and deployment, Amira asks Azure how it checks her app:

Terminal (live)
az containerapp show -g $RG -n ticket-api --query "properties.template.containers[0].probes" -o json

The answer (live) is an empty list. With no probes, the platform only checks that something accepts connections on port 8000 (a TCP check). A process that is stuck, or a classifier that cannot classify, still passes that check.

“Serverless” does not mean “no configuration”. The platform manages the servers. It does not know what “working” means for your app, how much memory it needs, or which settings it must have. You tell it, in five parts:

Part Question it answers The ticket API’s answer
Ingress Who can reach the app, and on which port? External, HTTPS, target port 8000
Probes How does the platform know that the app works? /health for startup and liveness, /ready for readiness
Resources How large is one replica? 0.25 vCPU, 0.5 GiB
Scale How many replicas, at least and at most? 0 to 2
Required settings What must be set, or the app must not start? API_KEY from the secret, REQUIRE_API_KEY=true, SHOW_DOCS=false

Ingress is the platform’s front door: the part that takes requests from outside and sends them to your container. --ingress external --target-port 8000 from the last lesson set it. Azure gives the app an HTTPS address with a certificate, and it sends each request to port 8000 in a container. The app itself speaks plain HTTP, as on your computer.

Plain http:// is not served. Amira checks it (live):

Terminal (live)
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://ticket-api.nicewater-a307f95a.canadacentral.azurecontainerapps.io/health
301 -> https://ticket-api.nicewater-a307f95a.canadacentral.azurecontainerapps.io/health

301 is a permanent redirect to the HTTPS address. A key sent over plain HTTP could be read on the way, so the platform never answers there.

A probe is a request that the platform sends to the app again and again, to decide what to do with a replica. Container Apps has three kinds:

Probe Question If it fails The ticket API
Startup Has the app finished starting? Wait. After too many failures, restart the replica. /health, every 2 s, up to 30 times
Liveness Is the process still working? Restart the replica. /health, every 10 s, 3 failures
Readiness Can it do its job now? Send it no requests, but do not restart it. /ready, every 10 s, 3 failures

Why /ready only for readiness? Remember Module 3: when the database is down, /ready gives 503, and classification still works. A restart cannot repair a database. If liveness used /ready, a database outage would restart the app again and again, and stop classification too. So liveness asks “is the process alive?” (/health), and readiness asks “can it work now?” (/ready).

The startup probe protects a slow start. It allows up to 30 × 2 s = 60 s. Liveness checks start only after the startup probe passes, so a slow start is not counted as a dead process.

Amira does not set probes with many flags. She keeps every operational setting in one file, deploy/containerapp.yaml, and reviews it in Git like code:

deploy/containerapp.yaml
# The container settings of the ticket API on Azure Container Apps.
# Apply with:
# az containerapp update --resource-group rg-ticket-api-practice \
# --name ticket-api --yaml deploy/containerapp.yaml
# Replace REGISTRY_NAME with the name of your registry first.
# The secret api-key must already exist on the app (az containerapp secret set).
properties:
template:
containers:
- name: ticket-api
image: REGISTRY_NAME.azurecr.io/ticket-api:1.1.0
resources:
cpu: 0.25
memory: 0.5Gi
env:
- name: API_KEY
secretRef: api-key
- name: REQUIRE_API_KEY
value: "true"
- name: SHOW_DOCS
value: "false"
probes:
- type: Startup
httpGet:
path: /health
port: 8000
initialDelaySeconds: 1
periodSeconds: 2
failureThreshold: 30
- type: Liveness
httpGet:
path: /health
port: 8000
periodSeconds: 10
failureThreshold: 3
- type: Readiness
httpGet:
path: /ready
port: 8000
periodSeconds: 10
failureThreshold: 3
scale:
minReplicas: 0
maxReplicas: 2

The file holds no secret. secretRef: api-key names the app’s secret, and the value stays in Azure. You can commit this file.

Resources. On the Consumption plan, CPU and memory come in fixed pairs. 0.25 vCPU with 0.5 GiB is the smallest pair. The ticket API used about 57 MiB in the simulation, so 0.5 GiB leaves room.

Scale. A replica is one running copy of the app (see Cost awareness). minReplicas: 0 lets the app scale to zero: no replicas, and no compute charges, when nobody calls it for 300 s. maxReplicas: 2 is a limit on cost and on damage. If a program sends thousands of requests, the platform starts at most two replicas, not ten (the default maximum is 10).

Required settings. REQUIRE_API_KEY: "true" makes a missing key a failed start, not an open API. If the secret is missing, the app stops with Settings error: REQUIRE_API_KEY is true, but API_KEY is not set, the new revision never becomes ready, and the old one keeps serving. That is the safe failure.

Amira writes her registry name into the image line, then applies the file:

Terminal (live)
sed -i '' "s/REGISTRY_NAME/$ACR/" deploy/containerapp.yaml
az containerapp update -g $RG -n ticket-api --yaml deploy/containerapp.yaml --query "{rev:properties.latestRevisionName, prov:properties.provisioningState}" -o table

The registry name is not a secret, so it may be in the committed file. The output (live):

Rev Prov
------------------- ---------
ticket-api--0000001 Succeeded

A new revision, ticket-api--0000001. A revision is a fixed copy of the app’s image and settings. Every change to the template (the image, the settings, the probes) makes a new one. She checks that Azure has what the file says:

Terminal (live)
az containerapp show -g $RG -n ticket-api --query "properties.template.containers[0].{probes:probes[].type, env:env[].name}" -o json
{
"env": ["API_KEY", "REQUIRE_API_KEY", "SHOW_DOCS"],
"probes": ["Liveness", "Readiness", "Startup"]
}

Now the revisions, right after the update and 20 s later:

Terminal (live)
az containerapp revision list -g $RG -n ticket-api --query "[].{name:name, active:properties.active, replicas:properties.replicas, health:properties.healthState, traffic:properties.trafficWeight}" -o table
Name Active Replicas Health Traffic
------------------- -------- ---------- -------- ---------
ticket-api--nz14cir True 1 Healthy 0
ticket-api--0000001 True 1 None 100
Name Active Replicas Health Traffic
------------------- -------- ---------- -------- ---------
ticket-api--nz14cir True 1 Healthy 0
ticket-api--0000001 True 1 Healthy 100

The app is in single revision mode (az containerapp show … --query properties.configuration.activeRevisionsMode says Single). Only one revision gets the traffic. The platform starts the new one next to the old one, and removes the old one after the new one is ready. The lesson’s next failure shows what happens when “ready” never comes.

Worked example: the same ideas in the simulation

Section titled “Worked example: the same ideas in the simulation”

The simulation has no probes and no revisions, but it has three of the five parts. Amira looks at them in the running container.

A health check. Docker runs the healthcheck command in the container every 10 s. It uses Python’s urllib, because the slim image has no curl:

Terminal (simulated)
docker inspect ticket-api-cloud-sim-api-1 --format "{{json .State.Health}}"
{"Status":"healthy","FailingStreak":0,"Log":[{"Start":"2026-10-07T16:50:36.679051923Z","End":"2026-10-07T16:50:37.721244007Z","ExitCode":0,"Output":""}]}

Docker’s health check is like a liveness probe that only reports: docker ps shows (unhealthy), but Docker does not restart the container for it. The checks show in the app’s log as requests from 127.0.0.1, because they run inside the container, like Azure’s probes.

The size limits.

Terminal (simulated)
docker stats --no-stream ticket-api-cloud-sim-api-1
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
5567fda8c533 ticket-api-cloud-sim-api-1 2.14% 56.92MiB / 512MiB 11.12% 7.96kB / 6.1kB 0B / 0B 6

/ 512MiB is mem_limit: 512m. If the app used more, the kernel would stop it. cpus: 0.25 does not show here: docker inspect shows it as NanoCpus 250000000, a quarter of one CPU.

A restart policy, and a required setting. restart: on-failure restarts the container when the app stops with an error. Predict: if Amira stops the app cleanly, from inside, does Docker start it again?

Terminal (simulated)
docker exec ticket-api-cloud-sim-api-1 sh -c 'kill 1'
docker ps -a --filter name=ticket-api-cloud-sim --format "{{.Names}} {{.Status}}"
ticket-api-cloud-sim-api-1 Exited (0) 2 seconds ago

No. kill 1 sends the stop signal to process 1, the app. It shuts down cleanly, with exit code 0, and on-failure restarts only after an error. Now a real failure: she empties the key file, so the required setting is missing, and restarts the service. A Compose secret is mounted from the file, so the container sees the change.

Terminal (simulated)
cp secrets/api_key.txt secrets/api_key.txt.bak
: > secrets/api_key.txt
docker compose -f deploy/local-cloud.yaml up -d
docker compose -f deploy/local-cloud.yaml logs --tail 3 api
docker ps -a --filter name=ticket-api-cloud-sim --format "{{.Status}}"

The output (simulated; docker ps run a few times, a second apart):

api-1 |
api-1 |
api-1 | Settings error: REQUIRE_API_KEY is true, but API_KEY is not set
Restarting (1) Less than a second ago
Up Less than a second (health: starting)
Up 2 seconds (health: starting)
Restarting (1) Less than a second ago

The app stops with exit code 1, and Docker starts it again, and again. docker inspect ticket-api-cloud-sim-api-1 --format "{{.RestartCount}}" counts the restarts. In two test runs, it grew by one every 4 to 10 s: with a quarter of a CPU, the app needs a few seconds to reach the settings check. The API never answers without a key: nobody gets an open API. She puts the key back:

Terminal (simulated)
cp secrets/api_key.txt.bak secrets/api_key.txt
rm secrets/api_key.txt.bak

At the next restart, the app finds the key. About 25 s later, docker ps said Up 34 seconds (healthy), with no command from her. This is a small version of what Azure does with a revision that cannot start. There is one difference: Docker has only one container, so the API is down while it restarts. Azure keeps the old revision serving.

Guided practice: give the platform its instructions

Section titled “Guided practice: give the platform its instructions”
  1. Read deploy/containerapp.yaml. For each probe, write in docs/cloud-design.md its path and what the platform does when it fails.
  2. Live route: put your registry name in the file, and apply it with az containerapp update --yaml. Expected: a new revision, ticket-api--0000001, and Succeeded.
  3. Live route: list the revisions until the new one is Healthy with traffic 100.
  4. Live route: check the probes and the setting names with az containerapp show. Expected: Liveness, Readiness, Startup, and API_KEY, REQUIRE_API_KEY, SHOW_DOCS.
  5. Simulated route: check the health status with docker inspect … --format "{{json .State.Health}}". Expected: "Status":"healthy".
  6. Simulated route: check the memory limit with docker stats --no-stream. Expected: / 512MiB.
  7. Simulated route: empty the key file and watch the restarts, as in the worked example. Then put the key back, and wait for (healthy).
  8. Both routes: check that /docs gives 404, because SHOW_DOCS is false.
Show the expected notes for step 1
Probe Path When it fails
Startup /health, every 2 s The platform waits, up to 30 failures (60 s). Then it restarts the replica. Liveness and readiness wait for it.
Liveness /health, every 10 s After 3 failures, the platform restarts the replica.
Readiness /ready, every 10 s After 3 failures, the platform sends the replica no requests, but it does not restart it.

/ready is not a liveness check, because it fails when the database is down, and a restart cannot repair a database.

Your turn: docker kill and the restart policy

Section titled “Your turn: docker kill and the restart policy”

The worked example stopped the app from inside. Now stop it from outside, with docker kill ticket-api-cloud-sim-api-1. docker kill ends the process at once, and the exit code is not 0.

Predict: does restart: on-failure start the container again? Run it, then check with docker ps -a. Afterwards, start the simulation again with docker compose -f deploy/local-cloud.yaml up -d.

Show the answer

No. The output (simulated):

ticket-api-cloud-sim-api-1 Exited (137) 3 seconds ago

Exit code 137 means “ended by the signal SIGKILL” (128 + 9). It is a failure code, but Docker does not restart the container. A stop that you ask for, with docker stop or docker kill, is a manual stop, and Docker never restarts a container after a manual stop. A restart policy is for failures that nobody asked for. Azure Container Apps is different: a replica that stops is always started again, and the liveness probe can also restart a replica that runs but does not answer.

Common mistake

The deployment succeeded, but the new version never runs

Revision 0000001, image 1.1.0 for amd64, is Healthy with traffic 100. Amira runs az containerapp update with the image 1.1.0-arm64, and Azure answers Succeeded. Azure starts revision 0000002 and tries to pull the image: ImagePullUnauthorized, then ImagePullFailure. Revision 0000002 stays Activating and never becomes ready, and revision 0000001 still serves: /health gives 200. Amira updates with the image 1.1.0 again. Azure starts revision 0000003, pulls the image and runs the startup probe. 0000003 is ready, Azure moves the traffic to it (100), and 0000001 is Deprovisioning.
Single revision mode. The old revision keeps the traffic until a new one is ready. A new revision that cannot start never gets any traffic.
Text description of the diagram

In single revision mode, a new revision gets the traffic only when it is ready. Revision 0000002 had an image for the wrong CPU, so its image pull failed again and again, and it stayed Activating. All this time, revision 0000001 served the requests. When Amira deployed the correct image, revision 0000003 started, became ready and took the traffic, and the platform removed 0000001.

Symptom (live): Amira built an image only for linux/arm64, with the tag 1.1.0-arm64, and deployed it. The command said Succeeded:

Terminal (live)
docker buildx build --platform linux/arm64 -t $ACR.azurecr.io/ticket-api:1.1.0-arm64 --push .
az containerapp update -g $RG -n ticket-api --image $ACR.azurecr.io/ticket-api:1.1.0-arm64 --query "{rev:properties.latestRevisionName, prov:properties.provisioningState}" -o table
Rev Prov
------------------- ---------
ticket-api--0000002 Succeeded

But 60 s later, the new revision was still not running, and the old one had the traffic:

Name Active Replicas Health Running Traffic
------------------- -------- ---------- -------- ---------- ---------
ticket-api--0000001 True 1 Healthy Running 0
ticket-api--0000002 True 1 None Activating 100

/health still gave 200: the old revision answered. (The Traffic column shows the target, 100 for the new revision. The requests still went to the old one, because the new one was not ready.)

Diagnosis: the system logs. Use --type system without --format text (with it, the command says ERROR: --type: only json logs supported for system logs):

Terminal (live)
az containerapp logs show -g $RG -n ticket-api --type system --tail 15

The output (live; one JSON object per line, shown here as time, Reason and Msg):

2026-10-07 16:18:24 PullingImage | Pulling image 'ticketapi4821.azurecr.io/ticket-api:1.1.0-arm64'
2026-10-07 16:18:34 ImagePullUnauthorized | Pull image: ticketapi4821.azurecr.io/ticket-api:1.1.0-arm64 failed with unauthorized exception.
2026-10-07 16:18:34 ContainerTerminated | Container 'ticket-api' was terminated with exit code '' and reason 'ImagePullFailure'
2026-10-07 16:18:54 PullingImage | Pulling image 'ticketapi4821.azurecr.io/ticket-api:1.1.0-arm64'
2026-10-07 16:19:03 ImagePullUnauthorized | Pull image: ticketapi4821.azurecr.io/ticket-api:1.1.0-arm64 failed with unauthorized exception.
2026-10-07 16:19:03 ContainerTerminated | Container 'ticket-api' was terminated with exit code '' and reason 'ImagePullFailure'

Cause: the message says “unauthorized”, but the identity and its role were correct: the tag 1.1.0 from the same repository pulled without a problem. The image had no linux/amd64 variant. Azure reported the missing platform as a failed pull. The simulated route shows the real cause, in clear words, with the same image:

Terminal (simulated)
docker pull --platform linux/amd64 127.0.0.1:5000/ticket-api:1.1.0-arm64
Error response from daemon: no matching manifest for linux/amd64 in the manifest list entries: no match for platform in manifest: not found

Fix:

  1. Before you deploy, check the platform: docker buildx imagetools inspect $ACR.azurecr.io/ticket-api:<tag> must show linux/amd64.
  2. Deploy the last good image again: az containerapp update -g $RG -n ticket-api --image $ACR.azurecr.io/ticket-api:1.1.0 --query properties.latestRevisionName -o tsv. It printed ticket-api--0000003.
  3. List the revisions until the new one is Healthy with traffic 100. After 45 s:
Name Active Replicas Health Running Traffic
------------------- -------- ---------- -------- -------------- ---------
ticket-api--0000001 True 1 Healthy Deprovisioning 0
ticket-api--0000003 True 1 Healthy Running 100

For a real AI service: “the command succeeded” only means that the platform accepted your request. A model server with a large image, or a model file that fails to load, fails the same way: after the command, during the start. Always check the revision’s health and run the smoke checks after a deployment. And when a pull error makes no sense, check the image’s platform first.

Check your understanding

Why does the ticket API use /health for liveness and /ready for readiness, and not /ready for both?

Show the answer

A failed liveness probe restarts the replica. A failed readiness probe only stops the requests to it. /ready fails when a dependency, such as the history database, is down. A restart cannot repair the database. With /ready as liveness, a database outage would restart the app again and again. Classification, which still works, would stop too. /health fails only when the process itself does not answer, and then a restart can help.

Check your understanding

Predict: you deploy a new revision, but you forgot to create the secret api-key. REQUIRE_API_KEY is true. What do the users of the API see?

Show the answer

They see no change: the old revision keeps serving. The new revision cannot start with a missing key: the app stops with Settings error: REQUIRE_API_KEY is true, but API_KEY is not set, the startup probe never passes, and the platform never moves the traffic to it. Without REQUIRE_API_KEY=true, the new revision would start without a key, and the API would be open to everybody. A required setting turns a dangerous mistake into a failed deployment. (In the test, a missing secret was not tried live. The simulation shows the same settings error and the restarts.)

Check your understanding

The help desk lead says: it is serverless, so it costs nothing and needs no settings. Give two settings from deploy/containerapp.yaml that show why both parts are wrong.

Show the answer

minReplicas: 0 is a setting, and it is what makes the compute cost zero when nobody calls the API. With minReplicas: 1, a replica runs (and charges) all month. maxReplicas: 2 limits the cost when many requests arrive: the default allows 10 replicas. The probes, the size (cpu: 0.25, memory: 0.5Gi) and REQUIRE_API_KEY are also settings that the platform cannot guess. Serverless means that someone else manages the servers. You still decide the cost limits, the checks and the required settings. And the registry and the logs charge even when the app has no replicas.

Recap

  • Serverless still needs configuration: ingress, probes, resources, scale limits and required settings.
  • Ingress gives one HTTPS address. Plain HTTP gets 301 to HTTPS.
  • Startup and liveness use /health. Readiness uses /ready, so a database outage stops traffic but never restarts the app.
  • 0.25 vCPU with 0.5 GiB is the smallest pair. minReplicas: 0 lets it scale to zero. maxReplicas: 2 limits cost and damage.
  • deploy/containerapp.yaml keeps these settings in Git, with no secret in it. az containerapp update --yaml applies it and makes a new revision.
  • In single revision mode, a new revision gets the traffic only when it is ready. “Succeeded” is not proof: check the revision and the system logs.
  • The simulation shows the same ideas: a health check, the limits in docker stats, and a restart policy that restarts after a failure, not after a manual stop.

The next lesson proves that the deployment works, reads its logs, and then deletes everything with a checked inventory.

Next: Verify and remove