Cloud-Native & DevOps · Sep 2026 · 10 min read
Testing a CloudLab Against a Real App: Deploying to Azure Container Apps
I ran the SKILL.SCH "Deploy Your Application to Azure Container Apps" CloudLab against a real Vite game instead of an app built to fit it. Mission 1 passed live; reviewing the rest turned up a Dockerfile that breaks every static frontend, a deploy command that fails with a private registry, and six smaller gaps, all now fixed in the lab.
A Lab Is Only as Good as the App You Bring to It
I write CloudLabs for SKILL.SCH, and one of them is Deploy Your Application to Azure Container Apps: clone an app, containerise it, push it to a private Azure Container Registry, deploy it, then automate the whole thing with GitHub Actions and OIDC. On paper it read fine. But every command in it had only ever been checked against the kind of app I had in my head when I wrote it.
So I picked a real one that I didn't write the lab around: Paradox, a small React + Vite reality game. Then I started following my own instructions, one step at a time, the way a student would.
It didn't go the way I expected. Some of that was the lab. Some of it was my machine and my Azure subscription. I'll be clear about which was which, because a lab review that hides what didn't actually run isn't worth much.
Mission 1: clone it and run it
git clone https://github.com/raphgm/paradox.git
cd paradox
cat package.json
The lab tells you to read package.json before anything else. That turned out to be the most important instruction in the whole lab, and it didn't say why. Here are the scripts:
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build"
Two things jump out. The dev server is on port 3000, which matches the lab, but only because the author set it; plain Vite defaults to 5173. And build is just vite build, which means the output is a folder of static files, not a Node server. Keep that in mind for Mission 2.
First real wall: my default Node was 14.21.3, and Vite 6 won't run on it. I switched to Node 20 and carried on. The lab never said which Node version it needs, which is an easy way to lose a student in the first ten minutes.
npm install
npm run dev
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000
200
Mission 1 passed.
Mission 2: the Dockerfile that only works for one kind of app
The lab gives you a multi-stage Dockerfile, and this is its last line:
CMD ["node", "dist/index.js"]
Paradox's build output is dist/index.html and a folder of assets. There's no dist/index.js. The image would build without complaint, then the container would exit with Cannot find module '/app/dist/index.js' the moment it started.
That's not a Paradox quirk. Most React, Vue or Svelte apps a student brings to this lab will fail the same way. The Dockerfile I wrote only works for a compiled Node backend, and I never said so.
Here's what a static frontend actually needs. It still listens on 3000, so nothing later in the lab has to change:
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
RUN printf 'server {\n listen 3000;\n root /usr/share/nginx/html;\n location / { try_files $uri /index.html; }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 3000
The try_files line matters for single-page apps: without it, refreshing on any client-side route gives you a 404.
Second real wall, and this one was mine: when I ran docker build, it died with read-only file system. My Mac was down to under 1 GB free, and Docker Desktop's VM had locked itself read-only. I freed some space, but Docker never came back up cleanly, and a colima fallback filled the disk again while downloading its VM image. So I never saw this Dockerfile build on my own machine. It's a lesson worth putting in the lab anyway: check you have at least 10 GB free before you start.
Two smaller things in this mission: npm ci fails without a package-lock.json, and the final "commit and push" step assumes you own the repo. If you cloned someone else's, you need to fork it first, or Mission 5's OIDC trust won't match your repo.
Missions 3 to 5: where I had to stop running and start reading
Third real wall: the first write to Azure came back with this:
ERROR: (ReadOnlyDisabledSubscription) The subscription is disabled and therefore marked as read only.
My Visual Studio subscription had run out of credit. az account show still listed it, which is exactly why this one is easy to miss. I've since added a check to the start of Mission 3:
az account show --query "{name:name,state:state}" -o table
With no Azure and no Docker, I went through the remaining missions by reading every command against how Azure CLI 2.90 actually behaves. That found the most serious problem in the lab.
The deploy command that can't pull its own image
Mission 3 creates the registry with --admin-enabled false, which is the right call, because it means no shared password. Mission 4 then deploys with this:
az containerapp create ... --registry-server <registry>.azurecr.io --system-assigned
That gives the app an identity, but it doesn't tell Container Apps to use that identity to pull from the registry. With no username, no password and the admin user disabled, the create fails. The flag that does what the lab claims is --registry-identity system: it creates the identity and grants it AcrPull in one go.
az containerapp create --name student-app --resource-group student-aca-rg \
--environment student-aca-env \
--image <registry>.azurecr.io/<your-app>:1.0 \
--target-port 3000 --ingress external \
--registry-server <registry>.azurecr.io \
--registry-identity system
The step before it, "Grant the environment pull access", didn't grant anything. It set a registry option that doesn't apply to the Basic tier and fetched an ID nothing used. I cut it.
The smaller stuff in Mission 5
The OIDC setup itself held up well: a federated credential scoped to the repo and branch, and Contributor on the resource group only. But a few things would have tripped people up:
- The lab told students to paste
$(az account show --query tenantId -o tsv)into GitHub's secrets page. That page doesn't run shell commands. It now usesgh secret set, which does. - The workflow called
az containerapp updatewithout installing the Container Apps extension on the runner. I added a step for it. - The final check said "confirm all three jobs succeed". There's one job. It also told students to find a revision "matching the commit SHA", but revision names are random suffixes. The SHA is in the image tag, so the check now queries for that:
az containerapp revision list -n student-app -g student-aca-rg \
--query "[].{name:name,image:properties.template.containers[0].image,active:properties.active}" -o table
And there was no cleanup step at all, so the registry and Log Analytics would keep billing after the lab ended. There is now:
az group delete --name student-aca-rg --yes --no-wait
az ad app delete --id $APP_ID
What changed in the lab
All of this is now fixed in the published lab: the Node version, two Dockerfile variants, the fork note, the subscription check, the registry naming rules, --registry-identity system, the removed pull-access step, gh secret set, the extension install, the corrected checks, a log command for when curl fails, and the cleanup step.
The overall shape of the lab was right: deploy by hand first, then automate the exact same path. The security choices held up too: no admin password, managed-identity pulls, and OIDC instead of stored secrets. What it lacked was contact with an app I didn't design it around. One real repository found more in an afternoon than rereading my own steps ever did.
Once my subscription is back and I have disk to spare, I'll run Missions 2 to 5 end to end against Paradox and update this post with the real output.