Building a CI/CD pipeline for building, type-checking, and deploying both Next.js and Payload from a pnpm monorepo.
March 31, 2026

A CI/CD pipeline for a TypeScript monorepo has a few requirements that generic tutorials don't address: you only want to build changed packages, the type checker needs to run before deploy, and two apps (Next.js and Payload CMS) have completely different deployment targets. Here's the pipeline this project uses.
Three jobs, one workflow:
lint + type-check → build → deploy
Lint and type-check run in parallel across all packages. Build runs only on changed packages. Deploy runs the CMS and frontend deploys concurrently if both changed.
# .github/workflows/deploy-cms.yml
name: CI/CD
on:
push:
branches: [master]
pull_request:
branches: [master]
env:
NODE_VERSION: '20'
PNPM_VERSION: '9'
jobs:
check:
name: Type Check & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check
run: pnpm turbo type-check
- name: Lint
run: pnpm turbo lint
--frozen-lockfile ensures the CI install matches the committed lockfile exactly. Any dependency drift fails fast here rather than producing a subtly different build.
build-cms:
name: Build & Push CMS Image
runs-on: ubuntu-latest
needs: check
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- name: Log in to Azure
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Build and push to ACR
id: build
run: |
IMAGE_TAG=${{ github.sha }}
az acr build \
--registry ${{ vars.ACR_NAME }} \
--image payload-cms:${IMAGE_TAG} \
--file apps/cms/Dockerfile \
.
echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT
- name: Deploy to Container Apps
run: |
az containerapp update \
--name payload-cms \
--resource-group ${{ vars.RESOURCE_GROUP }} \
--image ${{ vars.ACR_NAME }}.azurecr.io/payload-cms:${{ steps.build.outputs.image_tag }}
- name: Health check
run: |
echo "Waiting for deployment..."
sleep 45
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://cms.arunabh.me/admin)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS"
exit 1
fi
echo "✅ CMS healthy"
deploy-frontend:
name: Deploy Frontend
runs-on: ubuntu-latest
needs: check
if: github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Deploy to Vercel
run: |
pnpm dlx vercel \
--token=${{ secrets.VERCEL_TOKEN }} \
--prod \
--cwd apps/supercoder-blog \
--yes
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID_BLOG }}
For a monorepo with many packages, rebuild only what changed. Turborepo handles this with the --filter flag:
- name: Build changed packages only
run: pnpm turbo build --filter=[HEAD^1]
[HEAD^1] means "packages that changed since the previous commit." Combined with Turborepo's remote caching, CI builds drop from minutes to seconds for unchanged packages.
Set these in your repository settings:
Secrets (sensitive, never logged):
AZURE_CREDENTIALS # Service principal JSON from: az ad sp create-for-rbac
VERCEL_TOKEN # From Vercel account settings
VERCEL_ORG_ID # From Vercel project settings
VERCEL_PROJECT_ID_BLOG # From Vercel project settings
Variables (non-sensitive, visible in logs):
ACR_NAME # e.g., blogregistry
RESOURCE_GROUP # e.g., blog-rg
Create the Azure service principal:
az ad sp create-for-rbac \
--name "github-actions-blog" \
--role contributor \
--scopes /subscriptions/{subscription-id}/resourceGroups/blog-rg \
--sdk-auth
Paste the entire JSON output as the AZURE_CREDENTIALS secret.
Only trigger the CMS deploy when CMS files change:
on:
push:
branches: [master]
paths:
- 'apps/cms/**'
- 'Dockerfile'
- 'docker-compose.yml'
This prevents a frontend change from triggering a CMS redeploy. For the frontend, use paths: ['apps/supercoder-blog/**'].
--frozen-lockfile in CI to catch dependency drift earlyaz acr build to build Docker images directly in Azure — no Docker daemon needed on the runner