NestJS Next.js CI/CD GitHub Actions Configuration

Every NestJS Next.js CI/CD GitHub Actions configuration starts simple — a single workflow file, one deploy step, works on your machine. Then you add a second environment, then a database migration you cannot run twice, then a deployment that fails halfway through and leaves production in an inconsistent state. The YAML you need at fifty users is different from what you need at five thousand. But the foundation — the workflow files, the caching strategy, the deployment pattern — is the same either way, and GitHub Actions handles all of it without a third-party CI service.
We maintain our NestJS Next.js CI/CD GitHub Actions configuration across four SaaS projects. This post is the exact workflow files we use, with the caching, parallel jobs, environment-specific deployments, database migration handling, rollback workflow, and Slack notifications we have shipped to production.

Our Pipeline Stages
The pipeline follows four stages: lint, test, build, deploy. Each stage gates the next. Lint fails, the PR does not merge. Tests fail, the build does not run. Build fails, the deploy does not trigger. This is not novel — it is the baseline we expect from any NestJS Next.js CI/CD GitHub Actions configuration worth shipping.
The nuance is in what runs where and how fast. We run lint and test in parallel. Build runs after both pass. Deploy runs after build, but only on specific branches. This keeps the critical path as short as possible without skipping quality gates.
GitHub Actions Workflow for NestJS Backend
This NestJS Next.js CI/CD GitHub Actions configuration handles the NestJS API — install dependencies, run lint and tests in parallel, build the Docker image, run database migrations, deploy to the target environment.
1# .github/workflows/api.yml
2name: API — NestJS Backend
3
4on:
5 push:
6 branches: [main, staging]
7 paths:
8 - "apps/api/**"
9 - "packages/**"
10 - "pnpm-lock.yaml"
11 - ".github/workflows/api.yml"
12 pull_request:
13 branches: [main]
14 paths:
15 - "apps/api/**"
16 - "packages/**"
17 - "pnpm-lock.yaml"
18
19env:
20 NODE_VERSION: "20"
21 PNPM_VERSION: "9"
22 REGISTRY: ghcr.io
23 IMAGE_NAME: ${{ github.repository }}/api
24
25concurrency:
26 group: api-${{ github.ref }}
27 cancel-in-progress: true
28
29jobs:
30 lint:
31 runs-on: ubuntu-latest
32 steps:
33 - uses: actions/checkout@v4
34 - uses: pnpm/action-setup@v4
35 with:
36 version: ${{ env.PNPM_VERSION }}
37 - uses: actions/setup-node@v4
38 with:
39 node-version: ${{ env.NODE_VERSION }}
40 cache: "pnpm"
41 - run: pnpm install --frozen-lockfile
42 - run: pnpm --filter @app/api lint
43
44 test:
45 runs-on: ubuntu-latest
46 steps:
47 - uses: actions/checkout@v4
48 - uses: pnpm/action-setup@v4
49 with:
50 version: ${{ env.PNPM_VERSION }}
51 - uses: actions/setup-node@v4
52 with:
53 node-version: ${{ env.NODE_VERSION }}
54 cache: "pnpm"
55 - run: pnpm install --frozen-lockfile
56 - run: pnpm --filter @app/api test:ci
57
58 build:
59 needs: [lint, test]
60 runs-on: ubuntu-latest
61 outputs:
62 digest: ${{ steps.build-and-push.outputs.digest }}
63 steps:
64 - uses: actions/checkout@v4
65 - uses: pnpm/action-setup@v4
66 with:
67 version: ${{ env.PNPM_VERSION }}
68 - uses: actions/setup-node@v4
69 with:
70 node-version: ${{ env.NODE_VERSION }}
71 cache: "pnpm"
72 - run: pnpm install --frozen-lockfile
73 - run: pnpm --filter @app/api build
74 - name: Build and push Docker image
75 id: build-and-push
76 uses: docker/build-push-action@v6
77 with:
78 context: .
79 file: apps/api/Dockerfile
80 push: ${{ github.ref == 'refs/heads/main' }}
81 tags: |
82 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
83 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
84 cache-from: type=gha
85 cache-to: type=gha,mode=max
86
87 migrate:
88 needs: [build]
89 runs-on: ubuntu-latest
90 environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
91 steps:
92 - uses: actions/checkout@v4
93 - uses: pnpm/action-setup@v4
94 with:
95 version: ${{ env.PNPM_VERSION }}
96 - uses: actions/setup-node@v4
97 with:
98 node-version: ${{ env.NODE_VERSION }}
99 cache: "pnpm"
100 - run: pnpm install --frozen-lockfile
101 - name: Run database migrations
102 run: pnpm --filter @app/api migration:run
103 env:
104 DATABASE_URL: ${{ secrets.DATABASE_URL }}
105
106 deploy:
107 needs: [build, migrate]
108 runs-on: ubuntu-latest
109 environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
110 steps:
111 - name: Deploy to target environment
112 run: |
113 echo "Deploying ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
114 # Your deploy command — kubectl set image, SSH pull-and-restart,
115 # or Cloud Run deploy depending on your infrastructureThe key decisions in this NestJS Next.js CI/CD GitHub Actions configuration: lint and test run in parallel because they have no dependency on each other. Build waits for both. Migrate runs before deploy so the schema is ready when new instances start. The Docker image is always built and pushed on main pushes, but the deploy step only runs on the target environment. For the NestJS application structure this pipeline expects, see our NestJS project structure guide. The concurrency group cancels redundant in-progress runs when a new push arrives — no point running CI on a stale commit.
GitHub Actions Workflow for Next.js Frontend
The frontend workflow is simpler because Next.js has no database migrations and no Docker build for static export deployments (we deploy to Vercel). The pattern is still the same: lint, test, build, deploy — the same structure as the NestJS Next.js CI/CD GitHub Actions configuration for the backend.
1# .github/workflows/web.yml
2name: Web — Next.js Frontend
3
4on:
5 push:
6 branches: [main, staging]
7 paths:
8 - "apps/web/**"
9 - "packages/**"
10 - "pnpm-lock.yaml"
11 - ".github/workflows/web.yml"
12 pull_request:
13 branches: [main]
14 paths:
15 - "apps/web/**"
16 - "packages/**"
17 - "pnpm-lock.yaml"
18
19env:
20 NODE_VERSION: "20"
21 PNPM_VERSION: "9"
22
23concurrency:
24 group: web-${{ github.ref }}
25 cancel-in-progress: true
26
27jobs:
28 lint:
29 runs-on: ubuntu-latest
30 steps:
31 - uses: actions/checkout@v4
32 - uses: pnpm/action-setup@v4
33 with:
34 version: ${{ env.PNPM_VERSION }}
35 - uses: actions/setup-node@v4
36 with:
37 node-version: ${{ env.NODE_VERSION }}
38 cache: "pnpm"
39 - run: pnpm install --frozen-lockfile
40 - run: pnpm --filter @app/web lint
41
42 test:
43 runs-on: ubuntu-latest
44 steps:
45 - uses: actions/checkout@v4
46 - uses: pnpm/action-setup@v4
47 with:
48 version: ${{ env.PNPM_VERSION }}
49 - uses: actions/setup-node@v4
50 with:
51 node-version: ${{ env.NODE_VERSION }}
52 cache: "pnpm"
53 - run: pnpm install --frozen-lockfile
54 - run: pnpm --filter @app/web test:ci
55
56 build:
57 needs: [lint, test]
58 runs-on: ubuntu-latest
59 steps:
60 - uses: actions/checkout@v4
61 - uses: pnpm/action-setup@v4
62 with:
63 version: ${{ env.PNPM_VERSION }}
64 - uses: actions/setup-node@v4
65 with:
66 node-version: ${{ env.NODE_VERSION }}
67 cache: "pnpm"
68 - run: pnpm install --frozen-lockfile
69 - run: pnpm --filter @app/web build
70 - uses: actions/upload-artifact@v4
71 with:
72 name: next-build
73 path: apps/web/.next
74
75 deploy:
76 needs: [build]
77 runs-on: ubuntu-latest
78 environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
79 steps:
80 - uses: actions/download-artifact@v4
81 with:
82 name: next-build
83 - name: Deploy to Vercel
84 run: |
85 npx vercel --token ${{ secrets.VERCEL_TOKEN }} \
86 --prod \
87 --scope ${{ vars.VERCEL_SCOPE }}The frontend workflow is intentionally kept independent from the backend workflow. A frontend-only change deploys in under three minutes without touching the API pipeline. This separation is the main reason we use two workflow files instead of one, even in a monorepo setup like the one described in our SaaS monorepo structure guide. The Next.js deployment documentation covers the static export and server configurations that pair with this pipeline.

Running Tests in Parallel
The workflows above already run lint and test as parallel jobs. But within the test job itself, you can split the test suite across multiple runners using a matrix strategy. This matters when your NestJS test suite grows past a few hundred tests.
1 test:
2 runs-on: ubuntu-latest
3 strategy:
4 matrix:
5 shard: [1, 2, 3, 4]
6 steps:
7 - uses: actions/checkout@v4
8 - uses: pnpm/action-setup@v4
9 with:
10 version: ${{ env.PNPM_VERSION }}
11 - uses: actions/setup-node@v4
12 with:
13 node-version: ${{ env.NODE_VERSION }}
14 cache: "pnpm"
15 - run: pnpm install --frozen-lockfile
16 - run: pnpm --filter @app/api test:ci -- --shard=${{ matrix.shard }}/4Jest's --shard flag splits the test suite evenly across the specified number of runners. A test suite that takes 12 minutes on one runner completes in roughly 3-4 minutes across four shards. For any NestJS Next.js CI/CD GitHub Actions configuration, the tradeoff is runner minutes — you pay for four runners instead of one — but the wall-clock time improvement is worth it during a hotfix deploy.
Caching Strategy for NestJS Next.js CI/CD GitHub Actions Configuration
The NestJS Next.js CI/CD GitHub Actions configuration is only as fast as its cache hit rate. Every workflow spends most of its time installing dependencies and rebuilding packages. Cache correctly, and a PR that only changes frontend code takes under two minutes. Cache poorly, and every push reinstalls everything.
Two levels of caching matter:
Dependency cache. The actions/setup-node step with cache: "pnpm" handles pnpm's store automatically. The cache key is a hash of pnpm-lock.yaml. When the lockfile changes, the cache misses and dependencies are reinstalled. When it does not, the restore takes seconds.
Turborepo cache. In a monorepo, Turborepo caches build outputs in the .turbo directory. Enable remote caching to share these across CI runs and developer machines:
1 - run: pnpm turbo build --filter @app/api --remote-only
2 env:
3 TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
4 TURBO_TEAM: ${{ vars.TURBO_TEAM }}Remote caching means the first CI run after a PR merges caches the build output. The next CI run — even on a different branch — pulls the cached artifact instead of rebuilding. For shared packages that rarely change, this is nearly free.
Environment-Specific Deployments
We maintain three environments: development (local), staging, and production. The workflow determines the target environment from the branch name:
| Branch | Environment | Deploy trigger |
|---|---|---|
main | Production | On push |
staging | Staging | On push |
| Any PR branch | — | Lint + test only |
The environment field in the deploy job serves two purposes. It controls which secrets are available — production secrets are not accessible from a staging deploy. And it creates a deployment record in GitHub that shows the deployment history and current state.
Environment-specific configuration lives in GitHub Actions environment secrets, not in the workflow file. DATABASE_URL, VERCEL_TOKEN, SLACK_WEBHOOK_URL — each environment has its own values. The workflow references them by name and GitHub resolves the correct value based on the environment.
Database Migration Step
The migration job runs after the build succeeds and before the deploy starts. This ordering is deliberate — you want the schema to be ready before new application instances begin serving traffic. In a complete NestJS Next.js CI/CD GitHub Actions configuration, the migration step is what prevents schema-related downtime.
1 migrate:
2 needs: [build]
3 runs-on: ubuntu-latest
4 environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
5 steps:
6 - uses: actions/checkout@v4
7 - uses: pnpm/action-setup@v4
8 with:
9 version: ${{ env.PNPM_VERSION }}
10 - uses: actions/setup-node@v4
11 with:
12 node-version: ${{ env.NODE_VERSION }}
13 cache: "pnpm"
14 - run: pnpm install --frozen-lockfile
15 - run: pnpm --filter @app/api migration:run
16 env:
17 DATABASE_URL: ${{ secrets.DATABASE_URL }}The migration command must be idempotent — running it twice should produce the same result as running it once. TypeORM's migration:run and Prisma's migrate deploy both handle this out of the box. The NestJS database documentation covers configuring both TypeORM and Prisma for production use. If the migration fails, the deploy job never runs, and the previous version stays live.
Never run migrations as part of the application startup. That pattern couples deployment health to database schema changes — if the migration fails, the application crashes too. A separate migration step in the pipeline isolates the failure to a single job that you can retry without redeploying the application.
Rollback Strategy
Deployments fail. The question is how fast you can undo one. A NestJS Next.js CI/CD GitHub Actions configuration without a rollback plan is not a production pipeline — it is a hope with YAML attached. Our rollback workflow keeps the previous Docker image tagged and deploys it when things go sideways.
1# .github/workflows/rollback-api.yml
2name: Rollback — NestJS API
3
4on:
5 workflow_dispatch:
6 inputs:
7 environment:
8 description: "Environment to rollback"
9 required: true
10 default: "production"
11 type: choice
12 options:
13 - staging
14 - production
15 image_tag:
16 description: "Docker image tag to deploy"
17 required: true
18
19jobs:
20 rollback:
21 runs-on: ubuntu-latest
22 environment: ${{ github.event.inputs.environment }}
23 steps:
24 - name: Deploy previous image
25 run: |
26 echo "Rolling back ${{ github.event.inputs.environment }} to ${{ github.event.inputs.image_tag }}"
27 # Your deploy command pointing at the previous tagThe rollback is a manually triggered workflow — workflow_dispatch with the environment and image tag as inputs. You keep the last N successful image tags in your registry and reference them by SHA. A rollback is just a deploy of the previous tag.
Database rollbacks require more care. Write every migration with a corresponding down migration. When you roll back the application, run the down migration separately as part of the rollback procedure. Test both the up and down paths in your staging environment. We scoped a rollback workflow once and discovered in staging that the down migration took 20 minutes on a large table — we fixed the migration before it ever hit production.
Slack Notifications on Pipeline Failure
A pipeline that fails silently is worse than no pipeline. You find out about the broken deploy when the customer reports it. Adding Slack notifications to your NestJS Next.js CI/CD GitHub Actions configuration closes the loop.
1 notify:
2 needs: [deploy]
3 if: always()
4 runs-on: ubuntu-latest
5 steps:
6 - name: Notify Slack on failure
7 if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
8 uses: slackapi/slack-github-action@v2
9 with:
10 webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
11 webhook-type: incoming-webhook
12 payload: |
13 {
14 "text": "API deploy ${{ job.status }} — ${{ github.ref }} (${{ github.sha }})",
15 "blocks": [
16 {
17 "type": "section",
18 "text": {
19 "type": "mrkdwn",
20 "text": "*API deploy ${{ job.status }}*\nBranch: `${{ github.ref }}`\nCommit: `${{ github.sha }}`\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
21 }
22 }
23 ]
24 }The needs: [deploy] and if: always() combination ensures the notification runs regardless of whether the deploy succeeded or failed. The notification includes the branch, commit hash, and a direct link to the workflow run so the team can investigate immediately.
Slack is a notification channel, not a debugging tool. The message should tell you what broke and where to look — not try to explain why. Include a link to the workflow run, and the team can click through to the logs.

Putting It Together
The NestJS Next.js CI/CD GitHub Actions configuration we use across four production projects follows the same pattern: separate workflows for the backend and frontend, parallel lint and test jobs, dependency and build caching, environment-gated deployments with secrets, database migrations as a separate pipeline stage, a tested rollback workflow, and Slack notifications.
Start with the workflow files in this post. Add the cache configuration. Add the migration step when you need it. Add the rollback when you have shipped to production at least once. You do not need all of this on day one. But the structure — the YAML files, the job dependencies, the environment separation — is easier to put in place before the first deploy than to retrofit after the first production incident.
Most CI/CD over-engineering happens when teams try to build the perfect pipeline before they have shipped anything. Our pipeline started as a single job that ran pnpm test and called docker push. The parallel jobs, the caching, the Slack notifications, the rollback workflow — each one was added when a specific failure proved we needed it. That pattern works better than trying to predict everything upfront.
If you are setting up a NestJS Next.js monorepo and want a second pair of eyes on the workflow boundaries before you commit them, get in touch. It is easier to get the pipeline structure right on day one than to untangle it after a failed deployment at 2am — and we have the pagers to prove it.
Frequently Asked Questions
GitHub Actions with separate workflow files for the NestJS backend and Next.js frontend. Use parallel jobs for linting, testing, and building. Cache node_modules and Turborepo build artifacts to keep pipeline times under 5 minutes. Deploy to staging on every push to main, deploy to production on tagged releases. Add a database migration step before deploying the backend, and a rollback workflow that redeploys the previous Docker image.
Use actions/setup-node with the cache parameter set to pnpm (or your package manager). For Turborepo monorepos, also cache the .turbo directory to skip rebuilding unchanged packages. The cache key includes a hash of pnpm-lock.yaml for node_modules, and a hash of the relevant source files for build artifacts. Restore-keys allow partial cache matches when the lockfile changes.
Use separate workflow files if the backend and frontend have independent deploy cadences and environments. Use a single workflow with matrix jobs if they always deploy together and share infrastructure. For most SaaS projects, separate workflows give more flexibility — you can deploy a backend fix without touching the frontend, and vice versa. Monorepo setups with Turborepo handle shared dependency building automatically.
Add a migration step that runs TypeORM or Prisma migrations as part of the deploy job, but only after the build passes and before the new application instances start serving traffic. Use idempotent migration commands so they can be safely retried. In production, run migrations as a separate step that completes before the deployment rollout begins. Never run migrations as part of the application startup — that couples deployment health to database schema changes.
Keep the previous Docker image tagged and available in your container registry. On deployment failure, redeploy the previous image tag. For database rollbacks, write down migrations that undo each up migration, and run them as part of the rollback workflow. Test the rollback workflow regularly — a rollback you have never tested is a rollback that will fail at 2am on a Saturday.
