Skip to content

Verify and remove

  • Lesson 15
  • 50 min
  • Module 5: Deploy and clean up
Lesson 15 of 15 · Docker and Cloud Essentials for AI DevelopersVerify and remove

What you will be able to do

“Succeeded” is not proof. Send the requests that show the deployment works and fails safely, and read what it logged. Then remove all of it, with a list that proves that you forgot nothing.

Before you startWhat to know and to have readyAbout 50 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.

Smoke checks: what this deployment must prove

Section titled “Smoke checks: what this deployment must prove”

A smoke check is a short set of requests that you send right after a deployment. pytest already tested the code. The smoke check tests this deployment: this image, these settings, this secret, this address. Each check proves one thing:

Request Expected It proves
GET /health 200 {"status":"ok"} The process runs and the ingress reaches it.
GET /ready 200 {"status":"ready"} The classifier can work.
POST /v1/classify with the key 200 and a category The secret reached the app.
POST /v1/classify without the key 401 The API is not open.
GET /docs 404 SHOW_DOCS=false reached the app.
http://…/health (live only) 301 to https:// No plain HTTP.
GET /v1/history with the key 503 history_unavailable Expected: there is no database in this deployment.

Two checks expect a failure. A deployment that answers 200 without a key is broken, even if every other check passes.

Amira sets the address once. The first request comes after a quiet time, so she measures it:

Terminal (live)
URL=https://ticket-api.nicewater-a307f95a.canadacentral.azurecontainerapps.io
time curl -s $URL/health

The output (live, shortened):

{"status":"ok"}
real 18.4s

18 s for one small request. The app had zero replicas, so the platform had to start one first: a cold start. The next requests took less than a second. Wake the app with /health before a timed check, so that a cold start does not look like a slow API.

Then the other checks. $KEY is the key from Registry and deployment: Step 6: create the container app, still in the terminal:

Terminal (live)
curl -s $URL/ready
curl -s -X POST $URL/v1/classify -H 'Content-Type: application/json' -H "X-API-Key: $KEY" -d '{"subject":"Charged twice for March"}'
curl -s -o /dev/null -w "%{http_code}\n" -X POST $URL/v1/classify -H 'Content-Type: application/json' -d '{"subject":"x"}'
curl -s -o /dev/null -w "%{http_code}\n" $URL/docs
curl -s $URL/v1/history -H "X-API-Key: $KEY"

The output (live):

{"status":"ready"}
{"category":"billing","priority":1,"confidence":0.7,"model_version":"keywords-1.0"}
401
404
{"error":{"code":"history_unavailable","message":"The history is not available. Try again later.","request_id":"a6c8601cf7fb","fields":[]}}

Every answer is the expected one. The http:// check gave 301, as in Operational configuration: Ingress. The history gives 503, because the cloud design has no database: a managed PostgreSQL server costs money every hour, and the practice does not need it. The API says so in its standard error shape, with a request ID. DATABASE_URL is an optional setting: with it, the same image would keep a history.

The Postman collection from the API course works too: set the Deployed environment’s baseUrl to the address, as in Verify and clean up: Worked example: the Deployed environment.

The same checks, on http://127.0.0.1:8080. The key comes from the secret file, so it is not typed:

Terminal (simulated)
URL=http://127.0.0.1:8080
KEY=$(cat secrets/api_key.txt)
curl -s $URL/health
curl -s $URL/ready
curl -s -X POST $URL/v1/classify -H 'Content-Type: application/json' -H "X-API-Key: $KEY" -d @requests/billing.json
curl -s -o /dev/null -w "%{http_code}\n" -X POST $URL/v1/classify -H 'Content-Type: application/json' -d '{"subject":"x"}'
curl -s -o /dev/null -w "%{http_code}\n" $URL/docs
curl -s -o /dev/null -w "%{http_code}\n" $URL/v1/history -H "X-API-Key: $KEY"

The output (simulated):

{"status":"ok"}
{"status":"ready"}
{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}
401
404
503

There is no cold start in the simulation (the container always runs) and no 301 (there is no HTTPS). Write that in the evidence: these two checks are live only.

Live: the app’s own lines. The app writes the same log lines as on your computer. Azure collects them:

Terminal (live)
az containerapp logs show -g $RG -n ticket-api --tail 12 --format text

The output (live, shortened):

2026-10-07T16:17:09.83371 Connecting to the container 'ticket-api'...
2026-10-07T16:17:09.93183 Successfully Connected to container: 'ticket-api' [Revision: 'ticket-api--0000001', Replica: 'ticket-api--0000001-84c5f7d4bb-9zszh']
2026-10-07T16:16:54.6157582Z stderr F INFO ticket_api: GET /health 200 1ms id=caf6c4a82e30
2026-10-07T16:16:54.6158236Z stdout F INFO: 127.0.0.1:52216 - "GET /health HTTP/1.1" 200 OK
2026-10-07T16:17:04.6139872Z stderr F INFO ticket_api: GET /ready 200 2ms id=81dbb27c35d7
2026-10-07T16:17:04.6140104Z stdout F INFO: 127.0.0.1:59104 - "GET /ready HTTP/1.1" 200 OK
2026-10-07T16:17:05.7192419Z stderr F INFO ticket_api: POST /v1/classify 200 2ms id=95f853debb5b
2026-10-07T16:17:05.7193523Z stdout F INFO: 100.100.0.60:49458 - "POST /v1/classify HTTP/1.1" 200 OK
2026-10-07T16:17:06.5050443Z stderr F INFO ticket_api: GET / 404 0ms id=71d34007b1a1
2026-10-07T16:17:06.5051432Z stdout F INFO: 100.100.0.60:49464 - "GET / HTTP/1.1" 404 Not Found

Read the client addresses. 127.0.0.1 is the probes: they run inside the replica. 100.100.0.60 is the platform’s ingress: every request from outside comes through it. stderr F and stdout F name the stream, and F means a full line.

Look at the last request: GET / with 404. Nobody on the team sent it. It came a few minutes after the app got its public address, and more came at 16:19 and 16:27. These are requests that you did not send, for example from programs that scan the internet for new addresses. Each one wakes the app, and starts the 300-second wait before it scales to zero again. A public address is public from the first minute. That is why the first revision already required the key.

Live: the system logs. The platform’s own events (image pulls, starts, probe failures) are separate. You used them in Operational configuration: az containerapp logs show … --type system, without --format text.

Live: Log Analytics. The workspace keeps the lines, and you can search them with a query. They arrived about 2 minutes later:

Terminal (live)
WS=$(az monitor log-analytics workspace list -g $RG --query "[0].customerId" -o tsv)
az monitor log-analytics query -w $WS --analytics-query "ContainerAppConsoleLogs_CL | where Log_s has 'classify' | project TimeGenerated, RevisionName_s, Log_s | take 5" -o table

The output (live, shortened):

Log_s RevisionName_s TimeGenerated
------------------------------------------------------------------ ------------------- ---------------------------
INFO ticket_api: POST /v1/classify 200 2ms id=95f853debb5b ticket-api--0000001 2026-10-07T16:17:07.067693Z
INFO: 100.100.0.60:49458 - "POST /v1/classify HTTP/1.1" 200 OK ticket-api--0000001 2026-10-07T16:17:07.067693Z
...

Each line has its revision. After a deployment, this tells you which version answered a request. To follow up one error, search for its request ID: where Log_s has 'a6c8601cf7fb'.

Simulated: Compose logs. A client sends a request without a key, and gets a request ID in the error:

Terminal (simulated)
curl -s -X POST http://127.0.0.1:8080/v1/classify -H 'Content-Type: application/json' -d '{"subject":"Parcel lost"}'
docker compose -f deploy/local-cloud.yaml logs api | grep 0008b89dda79

The output (simulated):

{"error":{"code":"unauthorized","message":"A valid X-API-Key header is required.","request_id":"0008b89dda79","fields":[]}}
api-1 | INFO ticket_api: POST /v1/classify 401 7ms id=0008b89dda79

In PowerShell, use Select-String 0008b89dda79 instead of grep. The simulation’s requests come from an address like 172.21.0.1 (the Compose network’s gateway), and its health checks from 127.0.0.1, like Azure’s probes.

A practice deployment is finished only when it is gone. The rule: list first, delete second, check third. A list made before the deletion is the only way to know later that nothing was forgotten.

Delete in Azure: 1, inventory with az resource list -g rg-ticket-api-practice. 2, delete the group with az group delete --yes --no-wait. 3, gone? az group exists; if true, ask again later. When it says false, the group is gone. Find what is left in Azure: 4, a soft-deleted vault? az keyvault list-deleted; if yes, purge it with az keyvault purge. 5, check again with az group list and az role assignment list. When nothing is left: on your computer, 6, remove the local copies with docker image rm and docker logout. 7, record the clean-up in DEPLOY.md.
The clean-up order, with a check after each deletion. The inventory comes first. The group deletion runs in the background, and you check its result instead of waiting.
Text description of the diagram

First list every resource in the group. Then delete the group, without waiting for the command, and ask Azure later whether the group still exists. When it is gone, look for what a group deletion leaves: a soft-deleted Key Vault, which you purge. Check that no group and no role assignment is left. Then remove the copies on your computer, and record every result.

Terminal (live)
az resource list --resource-group $RG -o table

The output (live, with the optional Key Vault from Module 4):

Name ResourceGroup Location Type Status
--------------------------------- ---------------------- ------------- ------------------------------------------------ ---------
ticketapi4821 rg-ticket-api-practice canadacentral Microsoft.ContainerRegistry/registries Succeeded
id-ticket-api rg-ticket-api-practice canadacentral Microsoft.ManagedIdentity/userAssignedIdentities Succeeded
workspace-rgticketapipracticeXXXX rg-ticket-api-practice canadacentral Microsoft.OperationalInsights/workspaces Succeeded
cae-ticket-api rg-ticket-api-practice canadacentral Microsoft.App/managedEnvironments Succeeded
ticket-api rg-ticket-api-practice canadacentral Microsoft.App/containerApps Succeeded
kv-ticket-api-4821 rg-ticket-api-practice canadacentral Microsoft.KeyVault/vaults Succeeded

Copy this table into DEPLOY.md before you delete anything. Compare it with the design in docs/cloud-design.md: the workspace is the resource that nobody asked for. The role assignments are not in this list; they go with the resources that they are on.

Terminal (live)
az group delete --name $RG --yes --no-wait

The command prints nothing and returns at once. Without --no-wait, it waits until the deletion is finished: in the test, that was about 28 minutes, from 16:36 to 17:04. The Container Apps environment was the last resource to go. While it runs, az group show -n $RG --query properties.provisioningState -o tsv says Deleting. Later, ask:

Terminal (live)
az group exists --name $RG

true means that the deletion is not finished: ask again in a few minutes. The output at the end (live):

false

Step 3: what a group deletion leaves behind

Section titled “Step 3: what a group deletion leaves behind”

If you made the Key Vault, it is not completely gone:

Terminal (live)
az keyvault list-deleted --query "[].{name:name, deleted:properties.deletionDate, purge:properties.scheduledPurgeDate}" -o table

The output (live):

Name Deleted Purge
------------------ ------------------------- -------------------------
kv-ticket-api-4821 2026-10-07T16:36:40+00:00 2027-01-05T16:36:40+00:00

The vault is soft-deleted: Azure keeps it, with its secrets, for 90 days, so that a mistake can be undone. It costs nothing. But the secret still exists, and nobody can make a new vault with that name. Purge it:

Terminal (live)
az keyvault purge --name kv-ticket-api-4821
az keyvault list-deleted --query "length(@)"

The purge prints nothing (exit code 0). The output of the count (live):

0
Terminal (live)
az group list --query "[?contains(name,'ticket-api')].name" -o tsv
az role assignment list --all --query "[?contains(scope,'rg-ticket-api-practice')].{role:roleDefinitionName,scope:scope}" -o table

Both printed nothing (live). No group, and the role assignments were removed with the registry and the vault.

Terminal (live)
docker image rm $ACR.azurecr.io/ticket-api:1.1.0 $ACR.azurecr.io/ticket-api:1.1.0-arm64
docker logout $ACR.azurecr.io

The output (live):

Untagged: ticketapi4821.azurecr.io/ticket-api:1.1.0
Untagged: ticketapi4821.azurecr.io/ticket-api:1.1.0-arm64
...
Removing login credentials for ticketapi4821.azurecr.io

Leave out the 1.1.0-arm64 tag if you did not make it, and add any other tag that you pushed. docker logout deletes the registry token from Docker’s credential store. Keep ticket-api:1.1.0: it is your own image, and the final assignment uses it. In the test, the resources existed for about 54 minutes. The expected cost: one day of the Basic registry (US$0.17) and a few cents or less of compute, inside the free grant.

The same rule: list, delete, check.

Terminal (simulated)
docker ps -a --filter name=ticket-api --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
docker volume ls --filter name=ticket-api-registry

The output (simulated):

NAMES IMAGE STATUS
ticket-api-registry registry:3.1.2 Up 16 seconds
ticket-api-cloud-sim-api-1 ticket-api:1.1.0 Up 3 minutes (healthy)
DRIVER VOLUME NAME
local ticket-api-registry-data

If your Module 3 database is running, its containers are in the list too. Leave them. Then delete only the simulation and the registry:

Terminal (simulated)
docker compose -f deploy/local-cloud.yaml down
docker rm -f ticket-api-registry
docker volume rm ticket-api-registry-data
docker image rm 127.0.0.1:5000/ticket-api:1.1.0 127.0.0.1:5000/ticket-api:1.1.0-arm64 registry:3.1.2

Add any other tag that you pushed, such as 1.1.0-native.

The output (simulated, shortened):

Container ticket-api-cloud-sim-api-1 Removed
Network ticket-api-cloud-sim_default Removed
ticket-api-registry
ticket-api-registry-data
Untagged: 127.0.0.1:5000/ticket-api:1.1.0
...
Untagged: registry:3.1.2

The registry keeps its images in the volume ticket-api-registry-data: 71.24MB in the test, the compressed size of the amd64 image (docker system df -v shows it). Without -v ticket-api-registry-data:… in the docker run command, Docker makes a volume with a random name, and docker rm -f leaves it behind. A named volume makes the inventory clear. Check that the list commands from the start now print no simulation lines.

  1. Live route: wake the app with /health, and write down the time of the first request.
  2. Run the smoke checks for your route. Compare each answer with the table in Smoke checks.
  3. Find your classify request in the logs. Live: az containerapp logs show --format text. Simulated: docker compose … logs api | grep <request ID>.
  4. Live route: look for a request that you did not send, such as GET /.
  5. Make the inventory: az resource list -g $RG -o table, or the docker ps -a and docker volume ls lists. Copy it into DEPLOY.md.
  6. Delete. Live: az group delete --name $RG --yes --no-wait. Simulated: the four commands of the simulated clean-up.
  7. Check. Live: az group exists until false, then az keyvault list-deleted (purge if needed), az group list and az role assignment list. Simulated: the two list commands again.
  8. Remove the local copies and sign out of the registry (live).
  9. Record each check and its result in DEPLOY.md.
Show the expected result

Every smoke check gives its expected status: 200, 200, 200, 401, 404, and (live) 301, with 503 history_unavailable for the history. az group exists says false, az keyvault list-deleted --query "length(@)" says 0, and the group and role lists are empty. Simulated: no ticket-api-cloud-sim or ticket-api-registry lines are left. DEPLOY.md holds the inventory from before the deletion and the result of each check, as in the Module practice answer.

Your turn: a smoke check that you can repeat

Section titled “Your turn: a smoke check that you can repeat”

Write smoke-check.sh (or smoke-check.ps1), which runs the checks for /health, /ready, classify with and without the key, and /docs. It takes the address from URL and the key from TICKET_API_KEY, never from the file. It prints PASS or FAIL for each check, and ends with exit code 1 if any check failed. What must it do when TICKET_API_KEY is not set?

Show one answer for macOS and Linux
smoke-check.sh
#!/bin/sh
# Smoke checks for the deployed ticket API.
# Set URL and TICKET_API_KEY first. The key never goes in this file.
if [ -z "$URL" ] || [ -z "$TICKET_API_KEY" ]; then
echo "Set URL and TICKET_API_KEY first." >&2
exit 2
fi
fail=0
check() {
name=$1; want=$2; shift 2
got=$(curl -s -o /dev/null -w "%{http_code}" "$@")
if [ "$got" = "$want" ]; then
echo "PASS $name $got"
else
echo "FAIL $name: got $got, expected $want"
fail=1
fi
}
check health 200 "$URL/health"
check ready 200 "$URL/ready"
check classify 200 -X POST "$URL/v1/classify" -H "Content-Type: application/json" \
-H "X-API-Key: $TICKET_API_KEY" -d '{"subject":"Charged twice for March"}'
check no-key 401 -X POST "$URL/v1/classify" -H "Content-Type: application/json" -d '{"subject":"x"}'
check docs-hidden 404 "$URL/docs"
exit $fail

The output (simulated), first with the right key, then with a wrong one:

$ TICKET_API_KEY=$(cat secrets/api_key.txt) URL=http://127.0.0.1:8080 sh smoke-check.sh
PASS health 200
PASS ready 200
PASS classify 200
PASS no-key 401
PASS docs-hidden 404
$ TICKET_API_KEY=wrong URL=http://127.0.0.1:8080 sh smoke-check.sh
PASS health 200
PASS ready 200
FAIL classify: got 401, expected 200
PASS no-key 401
PASS docs-hidden 404

The exit codes were 0 and 1. Without the key, the script stops at once with exit code 2 and a clear message: it does not send requests that must fail. The check function sends one request and compares only the status code. That is enough for a smoke check: pytest checked the bodies before the deployment. The same script works for both routes. Only URL changes.

Common mistake

The search by tag finds nothing, but the resources are there

Symptom (live): Amira tagged the group with purpose=practice. To find all practice resources, she searches by the tag:

Terminal (live)
az resource list --tag purpose=practice --query "[].{name:name, type:type}" -o table

The command prints nothing. But az resource list -g rg-ticket-api-practice lists six resources. A clean-up based on the tag search would say “nothing to delete” while the registry charges every day.

Cause: tags on a resource group are not passed to the resources in it. The group has the tag. The registry, the app and the others do not. --tag searches the resources’ own tags.

Fix:

  1. Make the inventory from the resource group: az resource list -g $RG -o table. A group is the boundary that you delete, so it is the boundary that you list.
  2. If you need tags on resources, set them on each resource, for example with --tags on each create command. Azure Policy can also require or copy tags, which is outside this course.
  3. After the clean-up, check with commands that cannot miss a resource: az group exists and az group list.

For a real AI service: GPU servers and model storage cost much more than this registry. A team that cleans up “everything with the tag experiment” can leave a GPU server running for weeks. An inventory that you can prove complete (one group per experiment, listed before deletion) protects the budget better than a search.

Check your understanding

Why does a smoke check include requests that must fail, such as classify without a key and /docs?

Show the answer

They prove that the safety settings reached the deployment. A wrong deployment can pass every “happy” check: if the secret or REQUIRE_API_KEY is missing, classify without a key could answer 200, and the API would be open to anyone on the internet. If SHOW_DOCS is wrong, /docs shows the whole API to strangers. Only a request that must fail can show that. A deployment that answers these with 200 is broken, even if everything else passes.

Check your understanding

In the live logs, some requests come from 127.0.0.1 and others from 100.100.0.60. Which is which, and why does a GET / that you never sent matter?

Show the answer

127.0.0.1 is the probes: the platform runs them inside the replica, so they come from the replica itself. 100.100.0.60 is the platform’s ingress, and every request from outside comes through it, yours too. A GET / that nobody on the team sent shows that strangers find and call a public address within minutes. Programs that scan the internet do this. It wakes the app (and restarts the 300-second wait before it scales to zero), and it would reach an API without a key. That is why the first revision already required the key.

Guided practice → independent task. You saw each step with Amira in the three lessons. Now do the whole module on your own: a live deployment if you have an Azure subscription, or the local simulation with the same configuration concepts. The evidence is one file, DEPLOY.md, in the project. It has three parts:

  1. Deployment checklist: each step with the command that you ran and the result that you saw. The image name with its digest, and its platform.
  2. Resource inventory: every resource, made before the deletion, with what it can cost and how you delete it.
  3. Clean-up record: each deletion and each check after it, with its result.

Label every result live or simulated. Do not copy the lesson’s outputs: use yours.

Show a model answer (shortened)
DEPLOY.md (live route, shortened)
# Deployment record: ticket-api 1.1.0
Route: live (Azure Container Apps, canadacentral).
## Checklist
- [x] Image built for linux/amd64 and pushed:
ticketapi4821.azurecr.io/ticket-api:1.1.0@sha256:cfd04194eb1b...
imagetools inspect: linux/amd64 (+ attestation)
- [x] App created: https://ticket-api.nicewater-a307f95a.canadacentral.azurecontainerapps.io
- [x] deploy/containerapp.yaml applied: revision ticket-api--0000001, Healthy, traffic 100
- [x] Smoke checks (live): /health 200 (18 s cold start), /ready 200,
classify 200, no key 401, /docs 404, http 301, history 503 (no database, expected)
## Inventory (before deletion)
| Resource | Type | Can it charge? | How to stop it |
|---|---|---|---|
| ticketapi4821 | Container registry, Basic | Yes, US$0.17 per day | Delete the group |
| ... | ... | ... | ... |
## Clean-up record (live)
| Step | Command | Result |
|---|---|---|
| Delete the group | az group delete --name rg-ticket-api-practice --yes --no-wait | started 16:36 |
| Group gone | az group exists --name rg-ticket-api-practice | false (17:04) |
| ... | ... | ... |

The full example, with the simulated variant, is the DEPLOY.md of the snapshot end-of-m05. A reviewer marks it demonstrated when three things are true. The smoke checks include the failure paths. The inventory lists the automatic workspace. Every clean-up line has a check with its result, not only the delete command.

Check your understanding

Retrieval: name the three probe types of Azure Container Apps, the endpoint that the ticket API uses for each, and what the platform does when each one fails.

Show the answer

Startup on /health: the platform waits (up to 30 failures, 60 s), then restarts the copy; the other probes wait for it. Liveness on /health: after 3 failures, the platform restarts the copy. Readiness on /ready: after 3 failures, the platform sends the copy no requests, but does not restart it. A new revision gets the traffic only when it is ready.

Check your understanding

Predict: you run az group delete --name rg-ticket-api-practice --yes and the command ends without an error. Which costs and which risks can still be left, and how do you check?

Show the answer

If the command ended, the group’s resources are gone, so the registry, the app and the workspace no longer charge. But a soft-deleted Key Vault is still there for 90 days: it costs nothing, but its secrets exist and its name is taken. Check with az keyvault list-deleted and purge it. Resources in other groups were never in the inventory, so check az group list for a practice group that you forgot. On your computer, the registry token and the pushed tags are still there until docker logout and docker image rm. With --no-wait, the command ends at once, so check az group exists until it says false.

Check your understanding

Completion check: show your DEPLOY.md. Does it document working access, the resource inventory and verified clean-up steps?

Show the answer

A complete DEPLOY.md has: the address (live) or http://127.0.0.1:8080 (simulated), and the image with its digest and platform; the smoke checks with their results, including 401 without a key and 404 for /docs; the inventory made before the deletion, with the automatic Log Analytics workspace (live) or the registry volume (simulated), and what each item can cost; and for each deletion, a check with its result: az group exists → false, az keyvault list-deleted → 0, empty group and role lists, docker logout (live), or empty docker ps -a and docker volume ls lists (simulated). Every result is labelled live or simulated. If a check is missing, the clean-up is assumed, not verified.

Recap

  • A smoke check proves this deployment, and it includes the requests that must fail: no key → 401, /docs → 404.
  • Wake an app at zero replicas before a timed check: the first request was a cold start of 18 s.
  • The app’s logs show your requests, the probes (127.0.0.1) and requests that you did not send. Search by request ID.
  • Clean up from an explicit inventory: list the group, delete it with --no-wait, check az group exists, purge a soft-deleted Key Vault, check the groups and roles, remove the local copies. Record each result in DEPLOY.md.
  • Tags on a group are not passed to its resources. Make the inventory from the group.

Reset. To start this module again: live, the resource group must be gone (az group exists → false); simulated, run the simulated clean-up above. Your project and ticket-api:1.1.0 stay. To reset the project files, copy the snapshot end-of-m04. To see what Docker still stores, run docker system df.

The course. You began with “it works on my computer”. Now you can package an API in an image, with its operating system, Python and packages. You can build it fast and without secrets, and run it with its settings and a database. You can map it to cloud building blocks with their costs, deploy it with checks and limits, and remove it with proof. The final assignment asks you to do it again, on your own, from a fresh environment.

Next: Final assignment