Skip to main content

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:

#MilestoneGuide
1MongoDB Atlas Databasemongodb-atlas-setup.md
2Azure SSO Authenticationazure-sso-setup.md
3Azure Blob Storageazure-blob-storage-setup.md
4Microsoft Graph Emailazure-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:

StagePurpose
depsInstall all npm dependencies (dev + prod)
builderCompile the Next.js application with next build
runnerMinimal 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

RequirementDetails
Docker Engine 24+Installed on the build machine
Access to the application source codeThe repository root
All environment variable valuesSee 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.

VariableExampleDescription
NEXT_PUBLIC_APP_URLhttps://mocpcr.yourcompany.comFull HTTPS URL of the application. Used in email links and internal routing.
NEXT_PUBLIC_BASE_URLmocpcr.yourcompany.comHostname 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).

VariableExampleSource guideDescription
REMOTE_URLmongodb+srv://user:pass@cluster.mongodb.net/mocpcrmongodb-atlas-setup.mdMongoDB Atlas connection string (use private endpoint hostname)
APP_URLhttps://mocpcr.yourcompany.comazure-sso-setup.mdFull public URL of the app — must match Azure redirect URI exactly
JWT_SECRET(output of openssl rand -hex 32)azure-sso-setup.mdSecret used to sign and verify internal session JWTs
AZURE_TENANT_ID10091248-3181-4b2c-...azure-sso-setup.mdAzure AD tenant ID (SSO + email)
AZURE_CLIENT_ID3dd121a7-9c06-4f1a-...azure-sso-setup.mdAzure AD app client ID (SSO + email)
AZURE_CLIENT_SECRETmXK8Q~8vJ...azure-sso-setup.mdAzure AD app client secret — Value only, not the Secret ID
EMAIL_PROVIDERgraphazure-email-setup.mdMust be the literal string graph
GRAPH_MAIL_SENDERno-reply@yourcompany.comazure-email-setup.mdLicensed Exchange Online mailbox used as the email sender
AZURE_STORAGE_ACCOUNT_NAMEmocpcrstorageprodazure-blob-storage-setup.mdAzure Blob Storage account name
AZURE_STORAGE_ACCOUNT_KEYabc123...azure-blob-storage-setup.mdAzure Blob Storage account key
AZURE_STORAGE_CONTAINERuploadsazure-blob-storage-setup.mdBlob container name
HSEQ_EMAILhseq@yourcompany.comEmail 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.

VariableReason
CONNECTION_STRING_PASSWORDReplaced by REMOTE_URL (full Atlas connection string)
NEXT_PUBLIC_S3_BUCKET_URLAWS S3 replaced by Azure Blob Storage
NEXT_PUBLIC_SETTINGS_KEYNo longer used in the application
AWS_LAMBDA_EMAIL_URLAWS 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.production file. Docker's --env-file passes 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

  1. Installs all npm dependencies inside the container.
  2. Runs next build — compiles TypeScript, bundles the client, and generates the standalone server.
  3. Throws away the source code and dev dependencies.
  4. 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:

  1. 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.).
  2. 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.
  3. 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

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

RequirementContainer Apps capability
Stateless Next.js serverHorizontally scalable — sessions are stored in JWT cookies, files in Blob Storage
HTTPS with custom domainBuilt-in TLS termination and custom domain binding
Secret injection from Key VaultNative managed identity + Key Vault reference integration
Auto-scalingScales to zero when idle; scales out under load
No infrastructure managementFully managed — no VMs or Kubernetes clusters to maintain

Setup steps

  1. Create an Azure Container Registry (ACR):

    az acr create --resource-group your-rg --name yourAcrName --sku Basic
  2. 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
  3. Create a Container Apps environment:

    az containerapp env create \
    --name mocpcr-env \
    --resource-group your-rg \
    --location eastus
  4. 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
  5. Set environment variables from Key Vault (as described in Part 3).

  6. Bind a custom domain:

    • In the Azure Portal → Container App → Custom domainsAdd 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:

  1. 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
  2. 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

SymptomLikely causeResolution
Container exits immediatelyMissing 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 failsAZURE_TENANT_ID, AZURE_CLIENT_ID, or AZURE_CLIENT_SECRET wrongVerify values match the app registration; check no quotes in env file
AADSTS7000215: Invalid client secretQuotes included around AZURE_CLIENT_SECRET in --env-fileRemove quotes — write AZURE_CLIENT_SECRET=value not AZURE_CLIENT_SECRET="value"
SSO redirect goes to wrong URLAPP_URL does not match the Redirect URI in AzureSet APP_URL to the exact URI registered in Azure → App registrations → Authentication
Sessions invalid after redeployJWT_SECRET changed between buildsNever change JWT_SECRET after going live — it logs out all users
Database connection failsREMOTE_URL wrong or private endpoint not configuredFollow mongodb-atlas-setup.md; ensure private endpoint status is Available in Atlas
File uploads failAZURE_STORAGE_* variables wrong or container not createdFollow azure-blob-storage-setup.md
Emails not sentGRAPH_MAIL_SENDER missing or Mail.Send permission not grantedFollow azure-email-setup.md
Email links point to wrong domainNEXT_PUBLIC_APP_URL was wrong at build timeRebuild the image with the correct --build-arg
502 / 503 from ingressContainer crashed or not yet readyCheck logs; verify all env vars are set

Last updated: March 2026