Cloud-Native & DevOps · Sep 2026 · 20 min read

Containerizing MultiLangua for Azure Container Apps

A real migration, not a toy example — MultiLangua's Firebase Hosting deployment only serves static files, so its AI chat backend never actually runs in production. Containerizing it for Azure Container Apps is what makes the full app work end to end.

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" }]
  }
}

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:


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"]

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

--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

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

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 }}

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"}'

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