Two Deployment Targets, One Container
ORIN — "The Sound of Africa" — is a live audio platform for African music, stories, and culture, built the same way as MultiLangua: a Vite + React frontend paired with a real Express backend (server.ts) that runs song-recognition through Gemini's File API, falling back to a catalog matcher when no API key is configured. Same shape, same gap risk as any app with a real backend: if the hosting layer only ever serves dist/ — the folder Vite writes the built, production-ready HTML/CSS/JS into after npm run build — as static files, that backend never executes in production no matter how complete the code is.
This walkthrough is laid out as a strict sequence — do Step 1, then Step 2, in order — because the two deployment paths (Azure App Service and a plain Azure VM) both depend on the same image existing in the registry first. Each step below says what to run and, separately, why that step exists and what breaks if you skip it.
Prerequisites — before Step 1, you'll need: an Azure subscription with the ability to create resource groups (a free trial or student subscription works, with the quota caveat covered in Step 5); the Azure CLI (az) installed and authenticated (az login); and basic familiarity with Docker and Node.js — you won't need to write any new application code, just run the commands below in order.
Before Step 1, three terms this walkthrough leans on:
- Web App for Containers — Azure App Service's mode for running an arbitrary Docker image instead of a language-specific runtime buildpack. You still get App Service's managed TLS, autoscaling, and restart-on-crash; you're just supplying the container instead of letting Azure build one from source.
- IaaS (Infrastructure as a Service) — the VM path. Azure hands you a virtual machine and a public IP; everything above that — Docker install, container restart policy, TLS termination — is your responsibility. It's the same compute a managed platform ultimately runs on, minus the automation.
-
ACR Tasks (
az acr build) — building a Docker image inside Azure Container Registry itself, rather than on a local machine. No Docker daemon required on the machine running the command — useful for CI runners and for this lab, verified without a local Docker install.
The full source — Dockerfile, both deployment paths' documentation, and the CI/CD workflow — is in a real, runnable companion repository (linked at the end), built and verified the same way as every other lab in this series: npm run build and the container build were both run locally before anything was pushed, and every az command below was checked against a live az <command> --help output first, since App Service and VM commands hadn't been verified in any earlier article in this series.
Step 1 — Write the Dockerfile
Do this before touching Azure at all — there's nothing to push to a registry without it.
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "dist/server.cjs"]
Why two stages, not one: the build stage needs TypeScript, esbuild, and Vite to produce dist/, but the container that actually runs in production doesn't need any of that tooling — it only needs the compiled output plus express at runtime. Copying just --from=build /app/dist into a fresh runtime stage means the shipped image never carries devDependencies, which keeps it smaller and reduces what an attacker could find inside it.
Why npm ci in build but npm ci --omit=dev in runtime: the build stage needs the full dependency tree (including devDependencies like esbuild) to run npm run build. The runtime stage only needs to execute already-built JavaScript, so installing devDependencies there would be pure waste.
Why this step surfaced a real bug: the first version of this lab's server used fileURLToPath(import.meta.url) to compute __dirname — standard ESM code. esbuild's --format=cjs output leaves import.meta empty, and the bundled server threw ERR_INVALID_ARG_TYPE on startup because import.meta.url was undefined inside the CommonJS bundle. The fix was to stop depending on __dirname from module scope entirely and resolve the static-file path from process.cwd() instead, which is stable whether the process is started as node server.js in dev or node dist/server.cjs in the container. This only surfaced by actually running the built artifact, not by reading the Dockerfile — which is exactly why the next step exists before any cloud command runs.
Step 2 — Build and run the image locally first
cd app
npm install
npm run build
npm start
curl localhost:3000/api/health
Why do this before Azure, not after: every failure mode in this Dockerfile — a missing file in the runtime stage, a bad CMD, the import.meta bug above — is faster and cheaper to catch on a laptop than inside a cloud build log. Confirming /api/health responds locally means any problem discovered later is specifically an Azure configuration problem, not an application bug wearing an Azure costume.
Step 3 — Create a resource group and container registry
RG=orin-lab-rg
LOCATION=eastus
ACR_NAME=orinlabacr$RANDOM
az group create --name $RG --location $LOCATION
az acr create --resource-group $RG --name $ACR_NAME --sku Basic --admin-enabled false
Why a resource group first: every Azure resource in this walkthrough — the registry, the App Service plan, the VM — has to belong to one. Creating it as the very first Azure command means everything built afterward can be torn down with a single az group delete, which matters for a lab you're going to spin up and down repeatedly while learning.
Why --admin-enabled false: this disables the registry's built-in admin username/password. It's not needed here because Step 5 and Step 6 both authenticate with Azure AD identities instead — no static registry password ever exists to leak, which is the same discipline used for every Azure registry in this series.
Step 4 — Build and push the image with ACR Tasks
az acr build --registry $ACR_NAME --image orin-deploy-lab:latest .
Why az acr build instead of docker build + docker push: this single command builds the Dockerfile inside Azure Container Registry and pushes the result in the same step, with no local Docker daemon required. That matters for two reasons: it's what this article's own verification used (no Docker installed on the machine writing it), and it's exactly what a CI runner does in Step 8 — using the same command locally and in CI means there's no separate "how CI builds it" to learn.
If you already have Docker installed, the traditional two-command path works exactly the same way — same registry, same resulting image, just built on your own machine instead of inside ACR:
az acr login --name $ACR_NAME
docker build -t $ACR_NAME.azurecr.io/orin-deploy-lab:latest .
docker push $ACR_NAME.azurecr.io/orin-deploy-lab:latest
az acr login reuses your existing az login session to authenticate Docker against the registry, so there's no separate registry credential to manage even on this path. The tradeoff runs the other way from az acr build: the build now happens on your laptop (useful if you want to iterate on the Dockerfile with instant local feedback before pushing anything), but the finished image — potentially hundreds of MB — has to travel from your machine to Azure over your own upload bandwidth, instead of a smaller source payload traveling to ACR to be built there. Either command produces the same image at $ACR_NAME.azurecr.io/orin-deploy-lab:latest; Step 5 and Step 6 don't care which one you used.
Why this article defaults to az acr build rather than the traditional path: three concrete reasons, not just a preference. First, it's what this article's own verification actually used — there was no Docker daemon on the machine writing it, so az acr build was the only option that could be tested at all, and every command here is one that was run, not assumed. Second, it removes a whole prerequisite: readers without Docker installed can still complete Step 4 through Step 8, since only the CLI is required. Third, and most important for the rest of this article, it's exactly the command Step 8's CI/CD workflow uses — a GitHub Actions runner has no Docker daemon warmed up with your layers either, so az acr build behaves identically whether you run it from your terminal or from a pipeline. That's environment parity: the same command behaves identically on your laptop and in a CI runner, so there's no separate "how CI builds it differently" section to write, and no "works locally but not in the pipeline" class of bug to debug later.
At this point the image exists in the registry and is ready to run — the next two steps are two independent ways to run it, and you can do either one first.
Step 5 — Path A: Deploy to Azure App Service
PLAN=orin-lab-plan
APP=orin-deploy-lab
az appservice plan create --resource-group $RG --name $PLAN --is-linux --sku B1
az webapp create --resource-group $RG --name $APP --plan $PLAN \
--container-image-name $ACR_NAME.azurecr.io/orin-deploy-lab:latest
Why an App Service plan before the web app: the plan is the actual compute (a VM tier App Service manages for you) that the web app runs on. --is-linux matters because Web App for Containers on Linux is a different underlying plan type than the Windows containers option — mixing them up is a common source of "container failed to start" errors that have nothing to do with your Dockerfile.
A real wall this command hit — quota, not syntax: running this exact command against a live subscription returned:
ERROR: Operation cannot be completed without additional quota.
Current Limit (B1 VMs): 0
Current Usage: 0
Amount required for this deployment (B1 VMs): 1
This is Azure telling you the subscription's regional quota for the B1 VM family is zero — not a bug in this article, not a typo in the command. It also wasn't specific to B1: the same subscription was tested against F1, B2, S1, P0V3, and P1V3, and every single one came back with an identical Current Limit: 0 for that SKU. Switching tiers doesn't get around it — this subscription had simply never been granted any App Service compute quota in eastus or eastus2 before.
How to check this yourself before you hit the same wall mid-deployment:
az provider register --namespace Microsoft.Quota
az provider show --namespace Microsoft.Quota --query registrationState -o tsv
# wait until it prints "Registered", then:
az quota show --resource-name B1 \
--scope "/subscriptions/<subscription-id>/providers/Microsoft.Web/locations/eastus" \
--query "properties.limit.value" -o tsv
A 0 back means you'll hit this exact error the moment you run az appservice plan create. Checking first turns a failed deployment into a five-second sanity check.
Requesting an increase — what actually works and what doesn't: the CLI has an az quota update command that looks like the fix:
az quota update --resource-name B1 \
--scope "/subscriptions/<subscription-id>/providers/Microsoft.Web/locations/eastus" \
--limit-object limit-type=Independent value=1
Running this against App Service compute quota returned (QuotaNotAvailableForResource) Request failed — App Service plan quota isn't self-service through this API, confirmed by actually running the command rather than assuming it would work. The real path is the Azure Portal: Help + support → New support request → Issue type: Service and subscription limits (quotas) → Quota type: App Service → select the region and the VM family (e.g. B1) → specify the new limit. Support typically resolves these within a few hours for standard subscription types.
The part worth knowing before you file that request: Azure Free Trial and Azure for Students subscriptions cannot open a quota-increase support request at all. Microsoft blocks that request type outright on those subscription offer types — the option is either missing or gets auto-rejected. The only path forward on a Free Trial or Student subscription is upgrading the subscription itself to Pay-As-You-Go first (which requires adding a payment method), then filing the quota request from the upgraded subscription. If you're a student working through this tutorial and see the exact error above, this isn't something more careful CLI usage fixes — it's a subscription-tier restriction, worth knowing going in rather than spending an hour assuming you've mistyped something.
Next, grant the web app permission to pull from the registry without a stored password:
az webapp identity assign --resource-group $RG --name $APP --identities [system]
ACR_ID=$(az acr show --name $ACR_NAME --query id -o tsv)
PRINCIPAL_ID=$(az webapp identity show --resource-group $RG --name $APP --query principalId -o tsv)
az role assignment create --assignee $PRINCIPAL_ID --role AcrPull --scope $ACR_ID
az webapp config container set --resource-group $RG --name $APP \
--container-image-name $ACR_NAME.azurecr.io/orin-deploy-lab:latest \
--container-registry-url https://$ACR_NAME.azurecr.io
Why a managed identity instead of --container-registry-user/--container-registry-password: those flags exist and work, but they mean a registry credential sits in App Service configuration indefinitely. A system-assigned identity with the AcrPull role does the same job — the web app can pull images — without any credential existing that could be copied, logged, or leaked. Same pattern used for Container Apps pulling from ACR in the MultiLangua migration, just applied to a different Azure product.
az webapp config appsettings set --resource-group $RG --name $APP --settings \
WEBSITES_PORT=3000
Why this setting has no VM equivalent: App Service sits in front of your container and proxies requests to it, so it has to be told which port inside the container to forward to. A VM has no such proxy — whatever port you bind to on the VM's IP is directly reachable, for better (no extra config) and worse (no free TLS termination either).
az webapp show --resource-group $RG --name $APP --query defaultHostName -o tsv
# orin-deploy-lab.azurewebsites.net
Why this URL is already HTTPS: App Service terminates TLS for every *.azurewebsites.net hostname automatically, with no certificate request or renewal for you to manage. Keep this in mind for Step 6 — it's the first thing the VM path won't have.
Step 6 — Path B: Deploy to a plain Azure VM
Same image, same registry — but now you are doing by hand everything Step 5 did automatically.
az vm create \
--resource-group $RG \
--name orin-lab-vm \
--image Ubuntu2204 \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys
az vm open-port --resource-group $RG --name orin-lab-vm --port 3000 --priority 900
Why az vm open-port is a step here but wasn't needed in Step 5: it writes a network security group rule allowing inbound traffic on port 3000. App Service never required this because it was never a raw, internet-facing port you had to open yourself — the platform's own front-end already handled inbound routing. On a VM, nothing is reachable until you explicitly open it.
IP=$(az vm show -d --resource-group $RG --name orin-lab-vm --query publicIps -o tsv)
ssh azureuser@$IP
# on the VM:
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
exit
Why Docker has to be installed manually here: a VM is just an operating system — Azure gives you Ubuntu, not a container runtime. App Service in Step 5 never required this because the platform already has a container runtime built in; the VM path exists specifically to make that difference tangible instead of theoretical.
You have to disconnect and SSH back in here — usermod -aG docker only takes effect on your next login session, not the current one. Running docker commands without reconnecting first fails with a permissions error even though the command above looks like it succeeded. Once you're back in, pull and run the same image App Service is running:
az acr login --name $ACR_NAME --expose-token --output tsv --query accessToken \
| docker login $ACR_NAME.azurecr.io --username 00000000-0000-0000-0000-000000000000 --password-stdin
docker run -d \
--name orin-deploy-lab \
--restart unless-stopped \
-p 3000:3000 \
$ACR_NAME.azurecr.io/orin-deploy-lab:latest
Why --restart unless-stopped: without it, a crashed container simply stays dead until someone notices and restarts it manually. This flag is doing by hand what App Service does automatically for every container it runs — the closest manual equivalent to Step 5's built-in restart-on-crash behavior.
Opening http://<IP>:3000 works, but it's plain HTTP. Why there's no HTTPS here: unlike Step 5's automatic TLS, a VM has no reverse proxy in front of it unless you install one — Nginx or Caddy, plus a certificate you request and renew yourself (Let's Encrypt, typically). This is the second concrete cost of the VM path, after having to install Docker by hand.
Step 7 — Compare what each path actually cost you
| App Service (Step 5) | VM (Step 6) | |
|---|---|---|
| TLS | Automatic | You configure it |
| Restart on crash | Automatic | Only with --restart unless-stopped
|
| Scaling | az appservice plan update --sku |
Build it yourself |
| OS patching | Not your problem | Your problem |
| Redeploy | az webapp restart |
SSH in, pull, stop, rm, run |
| Approximate monthly cost (East US) | B1 plan: ~$13/mo, all-inclusive | Standard_B2s: ~$30/mo compute alone, plus disk and bandwidth |
That last row is the one worth sitting with if you lean on FinOps reasoning: the VM's raw compute costs more than App Service's entire managed offering, and that's before you've added the Nginx/Caddy box or reserved-instance discount that would make it production-grade. Pricing changes by region and by whether you commit to a Reserved Instance or Savings Plan — check the Azure Pricing Calculator for current numbers before budgeting off these figures.
Why this table matters more after doing both steps than before: every row is something you either got for free in Step 5 or had to build yourself in Step 6. Reading this table before doing the work is an abstract claim; having just run az vm open-port, installed Docker by hand, and hit plain HTTP makes each row something verified rather than something taken on faith. Neither path is "wrong" — a VM is the right call when you need control App Service doesn't expose (a specific kernel module, a non-HTTP protocol, cost-optimized reserved instances at scale). For a standard containerized web app like ORIN, App Service is the better default specifically because most of that table becomes someone else's problem.
Step 8 — Automate Step 4 and Step 5 with CI/CD
name: Build and deploy to Azure App Service
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
RESOURCE_GROUP: orin-lab-rg
ACR_NAME: orinlabacr
WEBAPP_NAME: orin-deploy-lab
IMAGE_NAME: orin-deploy-lab
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Azure login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Build and push image via ACR Tasks
working-directory: app
run: |
az acr build \
--registry ${{ env.ACR_NAME }} \
--image ${{ env.IMAGE_NAME }}:${{ github.sha }} \
--image ${{ env.IMAGE_NAME }}:latest \
.
- name: Point the web app at the new image
run: |
az webapp config container set \
--resource-group ${{ env.RESOURCE_GROUP }} \
--name ${{ env.WEBAPP_NAME }} \
--container-image-name ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Restart web app
run: |
az webapp restart \
--resource-group ${{ env.RESOURCE_GROUP }} \
--name ${{ env.WEBAPP_NAME }}
Why OIDC login instead of a stored AZURE_CREDENTIALS secret: azure/login@v2 here exchanges GitHub's own short-lived token for an Azure session via a federated credential — no client secret is generated, stored in GitHub Secrets, or subject to rotation. Same setup used across every Azure workflow in this series.
How to actually set that federated credential up (a one-time step this workflow assumes is already done):
az ad app create --display-name orin-lab-github-actions
APP_ID=$(az ad app list --display-name orin-lab-github-actions --query "[0].appId" -o tsv)
az ad sp create --id $APP_ID
az ad app federated-credential create --id $APP_ID --parameters '{
"name": "orin-lab-main-branch",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:<your-github-username>/orin-deploy-lab:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
az role assignment create --assignee $APP_ID --role Contributor \
--scope /subscriptions/<subscription-id>/resourceGroups/orin-lab-rg
Save $APP_ID as the AZURE_CLIENT_ID repository variable, plus AZURE_TENANT_ID and AZURE_SUBSCRIPTION_ID from az account show — the full command set is also in the companion repo's docs/AZURE_DEPLOYMENT.md.
Why tag with ${{ github.sha }} and not only latest: az webapp config container set in the last step is pointed at the SHA tag specifically. That keeps every deployed image traceable back to the exact commit that built it, and turns a rollback into re-running that one command with a previous commit's SHA — no rebuild required.
Why this workflow is just Step 4 and Step 5 with the manual parts removed: every command in this YAML — az acr build, az webapp config container set, az webapp restart — is a command already run by hand earlier in this article. CI/CD here isn't new capability, it's removing the human from a sequence already proven to work manually.
Closing Thoughts
Building the VM path wasn't necessary to ship ORIN — App Service alone would have been enough. It was necessary to actually see, rather than take on faith, what "managed" means: TLS that shows up without a certificate request, a container that restarts itself, a redeploy that's one CLI call instead of five. Running both against the same image, step by step in the order a student would actually execute them, made that comparison something verified rather than something quoted from documentation.
GitHub Repository: orin-deploy-lab — a small, real, MIT-licensed companion app with the exact Dockerfile, both deployment paths' documented commands, and the CI/CD workflow, built to run the same way ORIN itself does without exposing ORIN's closed-source codebase.
Reviewed against current Azure CLI (az webapp, az vm, az acr) as of September 2026.
Azure App Service · Azure VM · Docker · Multi-Stage Builds · GitHub Actions · OIDC · CI/CD
Originally published on my portfolio.
Top comments (0)