Docker Deployment Guide
Application: MOC & PCR Management System
Runtime: Node.js 20 (Alpine Linux)
Target Platform: Azure Container Apps / Azure Container Instances / any Linux container host
Audience: IT Administrator / DevOps Engineer
Before You Start
This guide assumes you have already completed all four prerequisite guides in order. The Docker image depends on every one of them:
| # | Milestone | Guide |
|---|---|---|
| 1 | MongoDB Atlas Database | mongodb-atlas-setup.md |
| 2 | Azure SSO Authentication | azure-sso-setup.md |
| 3 | Azure Blob Storage | azure-blob-storage-setup.md |
| 4 | Microsoft Graph Email | azure-email-setup.md |
Do not proceed with this guide until all four guides above are complete. Each one produces environment variable values that are required here.
All MongoDB, Azure, and application URL credentials must be available before building or running the container.
How the Image Works
The Dockerfile uses a three-stage multi-stage build:
| Stage | Purpose |
|---|---|
deps | Install all npm dependencies (dev + prod) |
builder | Compile the Next.js application with next build |
runner | Minimal runtime — contains only the compiled server, static assets, and no source code |
The final image:
- Contains no source code and no dev dependencies
- Runs as a non-root user (
nextjs:nodejs, UID 1001) - Is based on
node:20-alpine— a hardened, minimal Linux base - Uses Next.js standalone output mode — the Node.js server is fully self-contained
Prerequisites
| Requirement | Details |
|---|---|
| Docker Engine 24+ | Installed on the build machine |
| Access to the application source code | The repository root |
| All environment variable values | See the complete list below |
Environment Variables Reference
Build-time variables (must be passed as --build-arg)
These are inlined into the compiled JavaScript bundle by Next.js at build time. They cannot be changed after the image is built without rebuilding.
| Variable | Example | Description |
|---|---|---|
NEXT_PUBLIC_APP_URL | https://mocpcr.yourcompany.com | Full HTTPS URL of the application. Used in email links and internal routing. |
NEXT_PUBLIC_BASE_URL | mocpcr.yourcompany.com | Hostname only (no scheme). Used in select email templates. |
Runtime variables — required (injected at container start)
These are read by the Node.js server at runtime. They should be stored in a secret manager and injected via your orchestrator (see Part 3 for Azure Key Vault).
| Variable | Example | Source guide | Description |
|---|---|---|---|
REMOTE_URL | mongodb+srv://user:pass@cluster.mongodb.net/mocpcr | mongodb-atlas-setup.md | MongoDB Atlas connection string (use private endpoint hostname) |
APP_URL | https://mocpcr.yourcompany.com | azure-sso-setup.md | Full public URL of the app — must match Azure redirect URI exactly |
JWT_SECRET | (output of openssl rand -hex 32) | azure-sso-setup.md | Secret used to sign and verify internal session JWTs |
AZURE_TENANT_ID | 10091248-3181-4b2c-... | azure-sso-setup.md | Azure AD tenant ID (SSO + email) |
AZURE_CLIENT_ID | 3dd121a7-9c06-4f1a-... | azure-sso-setup.md | Azure AD app client ID (SSO + email) |
AZURE_CLIENT_SECRET | mXK8Q~8vJ... | azure-sso-setup.md | Azure AD app client secret — Value only, not the Secret ID |
EMAIL_PROVIDER | graph | azure-email-setup.md | Must be the literal string graph |
GRAPH_MAIL_SENDER | no-reply@yourcompany.com | azure-email-setup.md | Licensed Exchange Online mailbox used as the email sender |
AZURE_STORAGE_ACCOUNT_NAME | mocpcrstorageprod | azure-blob-storage-setup.md | Azure Blob Storage account name |
AZURE_STORAGE_ACCOUNT_KEY | abc123... | azure-blob-storage-setup.md | Azure Blob Storage account key |
AZURE_STORAGE_CONTAINER | uploads | azure-blob-storage-setup.md | Blob container name |
HSEQ_EMAIL | hseq@yourcompany.com | — | Email address of the HSEQ officer (used for system routing) |
Variables you must NOT set
The following variables exist in legacy configurations but are no longer used by the application. Do not include them in your .env.production file or container environment.
| Variable | Reason |
|---|---|
CONNECTION_STRING_PASSWORD | Replaced by REMOTE_URL (full Atlas connection string) |
NEXT_PUBLIC_S3_BUCKET_URL | AWS S3 replaced by Azure Blob Storage |
NEXT_PUBLIC_SETTINGS_KEY | No longer used in the application |
AWS_LAMBDA_EMAIL_URL | AWS Lambda email replaced by Microsoft Graph (EMAIL_PROVIDER=graph) |
Security rule: Runtime variables must never be baked into the image. Always inject them at container start via
-e,--env-file, or a secrets manager. See Part 3 for production-grade secret injection on Azure.
⚠️ Quote warning for
--env-file: Do not wrap values in quotes in your.env.productionfile. Docker's--env-filepasses quotes as literal characters, which will break Azure authentication. Write values without surrounding quotes:AZURE_CLIENT_SECRET=mXK8Q~8vJ5... ✔
AZURE_CLIENT_SECRET="mXK8Q~8vJ5..." ✘
Part 1 — Building the Image
Run the following command from the repository root. Replace the NEXT_PUBLIC_* values with your actual production domain.
docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://mocpcr.yourcompany.com \
--build-arg NEXT_PUBLIC_BASE_URL=mocpcr.yourcompany.com \
-t mocpcr:latest \
.
What the build does
- Installs all npm dependencies inside the container.
- Runs
next build— compiles TypeScript, bundles the client, and generates the standalone server. - Throws away the source code and dev dependencies.
- Produces a lean final image containing only what is needed to run.
Tagging for a registry
If you are pushing to Azure Container Registry (ACR):
# Tag the image
docker tag mocpcr:latest <your-acr-name>.azurecr.io/mocpcr:latest
# Push to ACR
docker push <your-acr-name>.azurecr.io/mocpcr:latest
Part 2 — Running the Container (Local / Staging)
Using an env file
Create a file called .env.production (never commit this file). Do not wrap any values in quotes — Docker passes them literally:
# ── Database ────────────────────────────────────────────────────────────────
REMOTE_URL=mongodb+srv://<user>:<password>@<cluster>.mongodb.net/<database>?retryWrites=true&w=majority
# ── Application ─────────────────────────────────────────────────────────────
APP_URL=https://mocpcr.yourcompany.com
JWT_SECRET=<output of: openssl rand -hex 32>
# ── Azure SSO + Email ───────────────────────────────────────────────────────
AZURE_TENANT_ID=<tenant-id>
AZURE_CLIENT_ID=<client-id>
AZURE_CLIENT_SECRET=<client-secret-value-no-quotes>
EMAIL_PROVIDER=graph
GRAPH_MAIL_SENDER=no-reply@yourcompany.com
# ── Azure Blob Storage ──────────────────────────────────────────────────────
AZURE_STORAGE_ACCOUNT_NAME=<storage-account-name>
AZURE_STORAGE_ACCOUNT_KEY=<storage-account-key>
AZURE_STORAGE_CONTAINER=uploads
# ── Other ───────────────────────────────────────────────────────────────────
HSEQ_EMAIL=hseq@yourcompany.com
Then run:
docker run \
--rm \
-p 3000:3000 \
--env-file .env.production \
--name mocpcr-app \
mocpcr:latest
The application will be available at http://localhost:3000.
Using Docker Compose (convenience for staging)
services:
app:
image: mocpcr:latest
restart: unless-stopped
ports:
- "3000:3000"
environment:
REMOTE_URL: ${REMOTE_URL}
APP_URL: ${APP_URL}
JWT_SECRET: ${JWT_SECRET}
AZURE_TENANT_ID: ${AZURE_TENANT_ID}
AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET}
EMAIL_PROVIDER: graph
GRAPH_MAIL_SENDER: ${GRAPH_MAIL_SENDER}
AZURE_STORAGE_ACCOUNT_NAME: ${AZURE_STORAGE_ACCOUNT_NAME}
AZURE_STORAGE_ACCOUNT_KEY: ${AZURE_STORAGE_ACCOUNT_KEY}
AZURE_STORAGE_CONTAINER: ${AZURE_STORAGE_CONTAINER}
HSEQ_EMAIL: ${HSEQ_EMAIL}
Save this as docker-compose.yml and run:
docker compose --env-file .env.production up -d
In production on Azure you do not use Docker Compose. See Part 4 for Azure hosting options.
Part 3 — Secret Management with Azure Key Vault
For production deployments on Azure, do not pass secrets as plain environment variables. Use Azure Key Vault to store them, and have your container runtime retrieve them at startup.
How it works with Azure Container Apps
Azure Container Apps has native Key Vault integration:
-
Create the secrets in Key Vault:
- Sign in to the Azure Portal → Key Vault → your vault → Secrets.
- Create one secret per variable (e.g.,
REMOTE-URL,AZURE-CLIENT-SECRET, etc.).
-
Grant the container app access to Key Vault:
- In your container app, enable a system-assigned managed identity.
- In Key Vault → Access policies (or RBAC), grant the managed identity the Key Vault Secrets User role.
-
Reference secrets in Container Apps:
- In the Container App → Secrets tab, add each secret as a Key Vault reference (using the secret URI).
- In the Environment variables tab, reference each secret by name.
The container app resolves the values from the vault at each startup. No secret is ever stored in the container image or in the Container Apps configuration in plaintext.
How it works with GitHub Actions / Azure DevOps CI/CD
For CI/CD pipelines that build and push the image:
GitHub Actions:
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Get secrets from Key Vault
uses: Azure/get-keyvault-secrets@v1
with:
keyvault: your-keyvault-name
secrets: "REMOTE-URL, APP-URL, JWT-SECRET, GRAPH-MAIL-SENDER, HSEQ-EMAIL"
id: keyvaultSecrets
- name: Build Docker image
run: |
docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://mocpcr.yourcompany.com \
--build-arg NEXT_PUBLIC_BASE_URL=mocpcr.yourcompany.com \
-t ${{ secrets.ACR_LOGIN_SERVER }}/mocpcr:${{ github.sha }} \
.
Runtime secrets are never used during the build step — they are only injected when the container starts on the target platform.
Part 4 — Hosting on Azure
Recommended: Azure Container Apps
Azure Container Apps is the recommended way to host this application on Azure. It is a fully managed serverless container platform built on Kubernetes, but without the operational overhead.
Why Container Apps for this project
| Requirement | Container Apps capability |
|---|---|
| Stateless Next.js server | Horizontally scalable — sessions are stored in JWT cookies, files in Blob Storage |
| HTTPS with custom domain | Built-in TLS termination and custom domain binding |
| Secret injection from Key Vault | Native managed identity + Key Vault reference integration |
| Auto-scaling | Scales to zero when idle; scales out under load |
| No infrastructure management | Fully managed — no VMs or Kubernetes clusters to maintain |
Setup steps
-
Create an Azure Container Registry (ACR):
az acr create --resource-group your-rg --name yourAcrName --sku Basic -
Push the image to ACR:
az acr login --name yourAcrName
docker tag mocpcr:latest yourAcrName.azurecr.io/mocpcr:latest
docker push yourAcrName.azurecr.io/mocpcr:latest -
Create a Container Apps environment:
az containerapp env create \
--name mocpcr-env \
--resource-group your-rg \
--location eastus -
Deploy the container app:
az containerapp create \
--name mocpcr-app \
--resource-group your-rg \
--environment mocpcr-env \
--image yourAcrName.azurecr.io/mocpcr:latest \
--target-port 3000 \
--ingress external \
--min-replicas 1 \
--max-replicas 5 \
--registry-server yourAcrName.azurecr.io -
Set environment variables from Key Vault (as described in Part 3).
-
Bind a custom domain:
- In the Azure Portal → Container App → Custom domains → Add custom domain.
- Follow the DNS verification steps.
- TLS certificates are managed automatically (free via Azure-managed certificates or bring your own).
Alternative: Azure Container Instances (ACI)
For simpler single-instance deployments (e.g., internal tools with low traffic), Azure Container Instances provides the quickest path:
az container create \
--resource-group your-rg \
--name mocpcr-aci \
--image yourAcrName.azurecr.io/mocpcr:latest \
--dns-name-label mocpcr-app \
--ports 3000 \
--environment-variables \
EMAIL_PROVIDER=graph \
--secure-environment-variables \
REMOTE_URL=<mongodb-uri> \
APP_URL=<full-public-url> \
JWT_SECRET=<jwt-secret> \
AZURE_TENANT_ID=<tenant-id> \
AZURE_CLIENT_ID=<client-id> \
AZURE_CLIENT_SECRET=<client-secret-value> \
GRAPH_MAIL_SENDER=<sender-email> \
AZURE_STORAGE_ACCOUNT_NAME=<account-name> \
AZURE_STORAGE_ACCOUNT_KEY=<account-key> \
AZURE_STORAGE_CONTAINER=<container-name> \
HSEQ_EMAIL=<hseq-email>
ACI does not auto-scale. Use Container Apps for production workloads.
Architecture Diagram
Internet
│
▼ HTTPS
Azure Load Balancer (managed by Container Apps)
│
▼
Container Apps Ingress (TLS termination, custom domain)
│
▼
Container: mocpcr (Node.js 20, port 3000)
│
├──▶ MongoDB Atlas (REMOTE_URL)
├──▶ Azure Blob Storage (file uploads/downloads)
├──▶ Microsoft Graph API (email delivery)
├──▶ Microsoft Entra ID (SSO token validation)
└──▶ Firebase Firestore (real-time chat)
Part 5 — Health Check and Readiness
The application starts and is ready to serve requests when the Node.js process is running on port 3000. Container Apps performs HTTP health checks automatically on the ingress endpoint.
If you want an explicit health endpoint (recommended for Container Apps liveness probes), the /api path returns 404 by default for unknown routes — a simple check on /login (which always returns 200) works as a liveness probe:
# Example liveness check
curl -f https://mocpcr.yourcompany.com/login
Part 6 — Updating the Application
To deploy a new version:
-
Rebuild the image with a new tag:
docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://mocpcr.yourcompany.com \
--build-arg NEXT_PUBLIC_BASE_URL=mocpcr.yourcompany.com \
-t yourAcrName.azurecr.io/mocpcr:v2 \
.
docker push yourAcrName.azurecr.io/mocpcr:v2 -
Update the Container App:
az containerapp update \
--name mocpcr-app \
--resource-group your-rg \
--image yourAcrName.azurecr.io/mocpcr:v2
Container Apps performs a rolling update — the old container stays alive until the new one is healthy. Zero-downtime deployments are automatic.
Troubleshooting
| Symptom | Likely cause | Resolution |
|---|---|---|
| Container exits immediately | Missing required env var (e.g., AZURE_TENANT_ID) | Check container logs: az containerapp logs show --name mocpcr-app --resource-group your-rg |
| Login page errors / SSO fails | AZURE_TENANT_ID, AZURE_CLIENT_ID, or AZURE_CLIENT_SECRET wrong | Verify values match the app registration; check no quotes in env file |
AADSTS7000215: Invalid client secret | Quotes included around AZURE_CLIENT_SECRET in --env-file | Remove quotes — write AZURE_CLIENT_SECRET=value not AZURE_CLIENT_SECRET="value" |
| SSO redirect goes to wrong URL | APP_URL does not match the Redirect URI in Azure | Set APP_URL to the exact URI registered in Azure → App registrations → Authentication |
| Sessions invalid after redeploy | JWT_SECRET changed between builds | Never change JWT_SECRET after going live — it logs out all users |
| Database connection fails | REMOTE_URL wrong or private endpoint not configured | Follow mongodb-atlas-setup.md; ensure private endpoint status is Available in Atlas |
| File uploads fail | AZURE_STORAGE_* variables wrong or container not created | Follow azure-blob-storage-setup.md |
| Emails not sent | GRAPH_MAIL_SENDER missing or Mail.Send permission not granted | Follow azure-email-setup.md |
| Email links point to wrong domain | NEXT_PUBLIC_APP_URL was wrong at build time | Rebuild the image with the correct --build-arg |
| 502 / 503 from ingress | Container crashed or not yet ready | Check logs; verify all env vars are set |
Last updated: March 2026