Dev Tools & InfraGitHub ActionsCI/CDMonorepoTypeScript

GitHub Actions CI/CD for Full-Stack TypeScript Monorepos

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

March 31, 2026

github actions typescript monorepo

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.

The Pipeline Shape

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.

The Base Workflow

# .github/workflows/deploy-cms.yml
name: CI/CD

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

env:
  NODE_VERSION: '20'
  PNPM_VERSION: '9'

Job 1: Type Check and Lint

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.

Job 2: Build CMS

  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"

Job 3: Deploy Frontend

  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 }}

Only Building Changed Packages

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.

Required Secrets and Variables

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.

Path-Based Triggers

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/**'].

Key Takeaways

  • Run type-check and lint first as a gate; only proceed to build/deploy on pass
  • Use --frozen-lockfile in CI to catch dependency drift early
  • Use az acr build to build Docker images directly in Azure — no Docker daemon needed on the runner
  • Path-based triggers prevent unrelated changes from triggering expensive deploys
  • Use GitHub Variables (not Secrets) for non-sensitive config like registry names — they're visible in logs and easier to audit