Dev Tools & InfraAzureDockerPayload CMSDeployment

Deploying Payload CMS to Azure Container Apps

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

March 31, 2026

deploying payload cms azure

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.

What You'll Set Up

  • Azure Container Registry (ACR) — stores your Docker images
  • Azure PostgreSQL Flexible Server — managed database
  • Azure Blob Storage — media uploads from Payload
  • Azure Container Apps — runs your container with managed SSL
  • GitHub Actions — CI/CD pipeline

Prerequisites

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

Step 1: Create Azure Resources

# 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

Step 2: The Dockerfile

# 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

Step 3: Build and Push the Image

# 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

Step 4: Deploy to Container Apps

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

Step 5: Custom Domain and SSL

# 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).

Step 6: GitHub Actions CI/CD

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

Common Issues

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.

Key Takeaways

  • Azure Container Apps handles SSL, auto-scaling, and ingress — you just deploy a container
  • Always include ?sslmode=require in the PostgreSQL connection string for Azure
  • Set --min-replicas 1 to avoid cold starts on the admin panel
  • Use az acr build in CI to build directly in Azure — no Docker daemon needed on the runner
  • The managed SSL certificate is free and auto-renewed; just add a CNAME and bind the hostname