DEV Community

Cover image for Containerizing MultiLangua for Azure Container Apps
Raphael Gab-Momoh
Raphael Gab-Momoh

Posted on Originally published at raphaelgmomoh.pages.dev

Containerizing MultiLangua for Azure Container Apps

The Real Problem, Not a Hypothetical One

MultiLangua is a live product — a language-learning app with an AI conversation partner (Nova, Mama Ade, Tunde) powered by Google's Gemini API. It's a Vite + React frontend with a real Express backend (server.ts) that has an /api/chat endpoint calling Gemini, and an /api/health endpoint for monitoring.

Here's the part that isn't obvious from visiting the live site: the Express server never actually runs in production. firebase.json is configured like this:

{
  "hosting": {
    "public": "dist",
    "rewrites": [{ "source": "**", "destination": "/index.html" }]
  }
}
Enter fullscreen mode Exit fullscreen mode

No Cloud Functions rewrite — Firebase Hosting is serving dist/ as static files only. The SPA shell loads fine; the /api/chat route it tries to call has nothing behind it. This is a genuinely common gap: an app gets built with a real backend during development, gets deployed as "just the frontend" for speed, and the backend quietly never ships.

This article containerizes the real MultiLangua repository and deploys it to Azure Container Apps — the actual fix, not a demonstration on a toy app.

Before the how, the what — two terms this migration leans on:

  • Multi-stage Docker build — a Dockerfile with more than one FROM line, where each stage can use the previous stage's output without carrying its full contents forward. Here, a build stage runs npm ci and npm run build with all the devDependencies (TypeScript, esbuild, Vite) it needs; the final runtime stage copies only the built dist/ output, discarding the build tooling entirely from the shipped image.
  • --packages=external — an esbuild flag (already present in MultiLangua's own build script) that leaves import statements for npm packages as real require() calls instead of bundling their code inline. This is why the runtime container still needs node_modules installed — the bundled server.cjs is small, but it expects express, @google/genai, and friends to exist as real packages next to it at runtime.

01 — The Dockerfile

FROM node:22-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

# ---

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY --from=build /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/server.cjs"]
Enter fullscreen mode Exit fullscreen mode

Two details worth calling out:

  1. npm ci --omit=dev in the runtime stage, not npm ci. The build stage needs TypeScript/Vite/esbuild to produce dist/; the runtime stage only needs what server.cjs actually require()s at runtime — express, @google/genai, dotenv, firebase. Installing dev tooling into the final image would bloat it for zero benefit.
  2. ENV NODE_ENV=production matters beyond convention here — MultiLangua's server.ts has an explicit branch on it: in development it uses Vite's middleware mode, in production it serves dist/ as static files and falls back to index.html for client-side routing. Forgetting this env var means the container tries to boot a Vite dev server that doesn't exist in the runtime image, and crashes.

02 — Provisioning Azure Container Registry and the Container App

az group create --name rg-multlingua --location eastus

az acr create \
  --resource-group rg-multlingua \
  --name acrmultlingua \
  --sku Standard \
  --admin-enabled false

az containerapp env create \
  --resource-group rg-multlingua \
  --name env-multlingua \
  --location eastus
Enter fullscreen mode Exit fullscreen mode

--admin-enabled false on the registry, same as the Container Apps CI/CD article — no shared admin password, managed identity only.


03 — The Gemini API Key: A Secret, Not an Env Var

The /api/chat route needs GEMINI_API_KEY. Container Apps has a real distinction between a plain environment variable and a secret — secrets are encrypted at rest and referenced by name rather than exposed in plaintext in the app's configuration:

az containerapp create \
  --resource-group rg-multlingua \
  --name multlingua-app \
  --environment env-multlingua \
  --image mcr.microsoft.com/azuredocs/containerapps-helloworld:latest \
  --ingress external --target-port 3000 \
  --secrets gemini-api-key=<your-gemini-api-key> \
  --env-vars GEMINI_API_KEY=secretref:gemini-api-key
Enter fullscreen mode Exit fullscreen mode

The initial --image here is a placeholder — swap it for the real image in the first deploy, or just let the CI/CD pipeline's first run replace it immediately. It's necessary because az containerapp create won't let you create a Container App without pointing at some image, and at this point in the walkthrough the real MultiLangua image hasn't been built and pushed yet — Section 04 and Section 05 haven't run. Microsoft's own containerapps-helloworld image exists specifically to fill that gap: a small, public, always-available placeholder so the resource can be created now and repointed at the real image the moment it exists.


04 — Identity-Based ACR Connection

az containerapp identity assign \
  --resource-group rg-multlingua \
  --name multlingua-app \
  --system-assigned

az role assignment create \
  --assignee "$(az containerapp identity show --resource-group rg-multlingua --name multlingua-app --query principalId -o tsv)" \
  --role AcrPull \
  --scope "$(az acr show --resource-group rg-multlingua --name acrmultlingua --query id -o tsv)"

az containerapp registry set \
  --resource-group rg-multlingua \
  --name multlingua-app \
  --server acrmultlingua.azurecr.io \
  --identity system
Enter fullscreen mode Exit fullscreen mode

Same pattern as the Container Apps CI/CD article — the app's own identity gets exactly one role (AcrPull), scoped to exactly one registry.


05 — CI/CD: GitHub Actions with OIDC

name: Build, Push, and Deploy to Azure Container Apps

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

env:
  ACR_NAME: acrmultlingua
  IMAGE_NAME: multlingua
  RESOURCE_GROUP: rg-multlingua
  CONTAINER_APP_NAME: multlingua-app

jobs:
  build-push-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC, no stored secrets)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Log in to ACR using the Azure session
        run: az acr login --name ${{ env.ACR_NAME }}

      - name: Build and push the image
        run: |
          docker build -t ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }} .
          docker push ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}

      - name: Deploy the new image to Container Apps
        run: |
          az containerapp update \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --name ${{ env.CONTAINER_APP_NAME }} \
            --image ${{ env.ACR_NAME }}.azurecr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

az acr login right after the OIDC azure/login step is the detail worth noticing — it reuses the same Azure CLI session's identity to authenticate Docker against ACR, so there's no second, separate credential to manage for the registry push step.

Why docker build + docker push here, rather than az acr build as in the ORIN/Azure App Service article: the two articles make different calls because the environments are different, not because one is more correct. This workflow runs on GitHub's ubuntu-latest runner, which ships with a Docker daemon already installed and ready — so docker build costs nothing extra here. az acr build earns its keep specifically when you don't control the runner's environment or don't have a local daemon at all, which was true for the machine that wrote the ORIN article. On a GitHub-hosted runner, either command works; this one uses the simpler, more widely-recognized pair since there's no daemon problem to route around.

Tagging the image with ${{ github.sha }} rather than latest means the image running in production is always traceable back to the exact commit that produced it — the same discipline as the Container Apps CI/CD article's pipeline.


06 — Verifying the Fix

curl https://multlingua-app.<region>.azurecontainerapps.io/api/health
# {"status":"ok","app":"MultiLangua"}

curl -X POST https://multlingua-app.<region>.azurecontainerapps.io/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message":"How do I say good morning?","character":"Nova","targetLanguage":"Yoruba"}'
Enter fullscreen mode Exit fullscreen mode

The second call is the actual proof this migration mattered — that endpoint returns a real Gemini-generated response now, something the Firebase Hosting deployment could never do.


Closing Thoughts

The interesting part of this migration isn't the Dockerfile — it's noticing the gap in the first place. A frontend that loads and looks complete is easy to mistake for a fully-working app, especially when the broken part is a backend route nobody's actively testing. Containerizing forced the actual runtime behavior (production static-serving, Gemini calls, health checks) to be verified end to end, instead of assumed from "the site loads."

GitHub Repository: Multlingua — the real, live app repository, now with the Dockerfile, CI/CD workflow, and deployment docs committed directly to it.

Reviewed against current Azure CLI (az containerapp, az acr) as of September 2026.

Azure Container Apps · Docker · Multi-Stage Builds · GitHub Actions · OIDC · CI/CD


Originally published on my portfolio.

Top comments (0)