Full walkthrough: Docker image → Azure Container Registry → Container Apps with managed SSL and GitHub Actions.
March 31, 2026

Payload CMS is a Node.js application — it runs anywhere Node runs. Azure Container Apps is one of the more developer-friendly ways to run containers on Azure: managed SSL, auto-scaling, pay-per-use pricing, and no Kubernetes expertise required. This guide walks through the entire deployment, from local Docker build to a live HTTPS endpoint.
# Install Azure CLI
brew install azure-cli # macOS
# or download from https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
# Log in
az login
# Set your subscription
az account set --subscription "your-subscription-id"
# Resource group
az group create \
--name blog-rg \
--location centralindia
# Container Registry
az acr create \
--resource-group blog-rg \
--name blogregistry \
--sku Basic \
--admin-enabled true
# PostgreSQL Flexible Server
az postgres flexible-server create \
--resource-group blog-rg \
--name shared-postgres-2025 \
--location centralindia \
--admin-user blogadmin \
--admin-password "YourStrongPass!" \
--sku-name Standard_B1ms \
--tier Burstable \
--public-access 0.0.0.0
# Create the database
az postgres flexible-server db create \
--resource-group blog-rg \
--server-name shared-postgres-2025 \
--database-name blogdb
# Storage account for media
az storage account create \
--name blogmediastorage \
--resource-group blog-rg \
--location centralindia \
--sku Standard_LRS
az storage container create \
--name media \
--account-name blogmediastorage \
--public-access blob
# apps/cms/Dockerfile
FROM node:20-alpine AS base
RUN npm install -g pnpm
# Dependencies
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/cms/package.json ./apps/cms/
RUN pnpm install --frozen-lockfile
# Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/cms/node_modules ./apps/cms/node_modules
COPY . .
RUN cd apps/cms && pnpm build
# Runner
FROM node:20-alpine AS runner
WORKDIR /app
RUN npm install -g pnpm
ENV NODE_ENV=production
COPY --from=builder /app/apps/cms/.next/standalone ./
COPY --from=builder /app/apps/cms/.next/static ./apps/cms/.next/static
COPY --from=builder /app/apps/cms/public ./apps/cms/public
COPY apps/cms/start.sh ./start.sh
RUN chmod +x start.sh
EXPOSE 3000
CMD ["./start.sh"]
# apps/cms/start.sh
#!/bin/sh
node apps/cms/server.js
# Get ACR credentials
ACR_SERVER=$(az acr show --name blogregistry --query loginServer -o tsv)
ACR_PASSWORD=$(az acr credential show --name blogregistry --query passwords[0].value -o tsv)
# Log in to ACR
docker login $ACR_SERVER -u blogregistry -p $ACR_PASSWORD
# Build and push
docker build -t $ACR_SERVER/payload-cms:latest -f apps/cms/Dockerfile .
docker push $ACR_SERVER/payload-cms:latest
# Create Container Apps environment
az containerapp env create \
--name blog-env \
--resource-group blog-rg \
--location centralindia
# Get connection string for PostgreSQL
DB_URI="postgresql://blogadmin:YourStrongPass!@shared-postgres-2025.postgres.database.azure.com:5432/blogdb?sslmode=require"
# Get storage connection string
STORAGE_CONN=$(az storage account show-connection-string \
--name blogmediastorage \
--resource-group blog-rg \
--query connectionString -o tsv)
# Deploy the container app
az containerapp create \
--name payload-cms \
--resource-group blog-rg \
--environment blog-env \
--image $ACR_SERVER/payload-cms:latest \
--registry-server $ACR_SERVER \
--registry-username blogregistry \
--registry-password $ACR_PASSWORD \
--target-port 3000 \
--ingress external \
--min-replicas 1 \
--max-replicas 3 \
--env-vars \
DATABASE_URI="$DB_URI" \
PAYLOAD_SECRET="your-long-random-secret" \
PAYLOAD_PUBLIC_SERVER_URL="https://cms.arunabh.me" \
AZURE_STORAGE_CONNECTION_STRING="$STORAGE_CONN" \
NODE_ENV="production" \
PORT="3000"
# Add custom domain
az containerapp hostname add \
--hostname cms.yourdomain.com \
--name payload-cms \
--resource-group blog-rg
# Bind managed certificate (Azure provisions and renews it)
az containerapp hostname bind \
--hostname cms.yourdomain.com \
--name payload-cms \
--resource-group blog-rg \
--environment blog-env \
--validation-method CNAME
Add the CNAME record in your DNS provider pointing cms to the Container App's default domain (shown in the Azure portal).
# .github/workflows/deploy-cms.yml
name: Deploy CMS
on:
push:
branches: [master]
paths: ['apps/cms/**', 'Dockerfile']
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Build and push image
run: |
az acr build \
--registry blogregistry \
--image payload-cms:${{ github.sha }} \
--file apps/cms/Dockerfile .
- name: Deploy to Container Apps
run: |
az containerapp update \
--name payload-cms \
--resource-group blog-rg \
--image blogregistry.azurecr.io/payload-cms:${{ github.sha }}
- name: Health check
run: |
sleep 30
curl -f https://cms.yourdomain.com/admin || exit 1
SSL mode required. Azure PostgreSQL enforces SSL. Always include ?sslmode=require in your connection string. Without it, Payload won't connect and fails silently in some configurations.
Cold start latency. Container Apps scale to zero by default. Set --min-replicas 1 to keep at least one instance warm. The cost is minimal and the latency difference is significant.
Environment variables not refreshing. Container App env vars are set at deploy time. Changing them requires redeploying the container revision. Use az containerapp update --set-env-vars to update without a new image build.
?sslmode=require in the PostgreSQL connection string for Azure--min-replicas 1 to avoid cold starts on the admin panelaz acr build in CI to build directly in Azure — no Docker daemon needed on the runner