Shipping a full-stack Next.js application without a CI/CD pipeline is like driving without a seatbelt — it might work for a while, but eventually you'll regret it. A well-configured continuous integration and continuous deployment pipeline catches bugs early, enforces code quality, and gives your team the confidence to deploy multiple times per day.
In this guide, we'll build a production-grade CI/CD pipeline from absolute scratch for a full-stack Next.js app that includes API routes, a database layer (Prisma + PostgreSQL), automated testing, Docker containerization, and deployment to a cloud provider. By the end, every push to your main branch will trigger a fully automated build, test, and deploy workflow.
Prerequisites
- A Next.js 14+ application with App Router
- A GitHub repository
- Node.js 20+ installed locally
- Basic familiarity with Docker and YAML syntax
- A deployment target (we'll use Vercel and also cover a self-hosted Docker approach)
Step 1: Structuring Your Next.js Project for CI/CD
Before writing pipeline configuration, your project structure needs to support automated workflows. Ensure your package.json has clearly defined scripts:
// package.json
{
"name": "my-nextjs-app",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"type-check": "tsc --noEmit",
"test": "jest --coverage",
"test:e2e": "playwright test",
"db:migrate": "prisma migrate deploy",
"db:generate": "prisma generate",
"postinstall": "prisma generate"
}
}Each script serves a specific role in the pipeline: lint enforces code style, type-check catches TypeScript errors, test runs unit and integration tests, test:e2e runs end-to-end tests, and db:migrate handles database schema changes during deployment.
Step 2: Setting Up Jest for Unit and Integration Testing
CI is only as valuable as your test suite. Install Jest with the Next.js-specific configuration:
// jest.config.js
const nextJest = require('next/jest');
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
setupFilesAfterSetup: ['/jest.setup.js'],
testEnvironment: 'jsdom',
moduleNameMapper: {
'^@/(.*)Write a sample test for an API route handler to validate server-side logic:
// __tests__/api/users.test.ts
import { GET } from '@/app/api/users/route';
import { NextRequest } from 'next/server';
import { prismaMock } from '@/lib/__mocks__/prisma';
jest.mock('@/lib/prisma');
describe('GET /api/users', () => {
it('should return a list of users', async () => {
const mockUsers = [
{ id: '1', name: 'Alice', email: 'alice@test.com' },
{ id: '2', name: 'Bob', email: 'bob@test.com' },
];
prismaMock.user.findMany.mockResolvedValue(mockUsers);
const request = new NextRequest('http://localhost:3000/api/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.users).toHaveLength(2);
expect(data.users[0].name).toBe('Alice');
});
it('should return 500 on database error', async () => {
prismaMock.user.findMany.mockRejectedValue(new Error('DB connection failed'));
const request = new NextRequest('http://localhost:3000/api/users');
const response = await GET(request);
expect(response.status).toBe(500);
});
});
Step 3: Adding Playwright for End-to-End Tests
End-to-end tests validate the entire stack working together. Configure Playwright to run against a built Next.js application:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
webServer: {
command: 'npm run build && npm run start',
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
Step 4: Creating the GitHub Actions CI Pipeline
Now we bring everything together in a GitHub Actions workflow. Create the file .github/workflows/ci.yml:
// .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
jobs:
quality-checks:
name: Lint, Type Check & Unit Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Run database migrations
run: npx prisma migrate deploy
- name: Run ESLint
run: npm run lint
- name: Run TypeScript type checking
run: npm run type-check
- name: Run unit and integration tests
run: npm run test -- --ci --reporters=default --reporters=jest-junit
env:
JEST_JUNIT_OUTPUT_DIR: ./reports
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: ./reports/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
e2e-tests:
name: End-to-End Tests
runs-on: ubuntu-latest
needs: quality-checks
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: Seed test database
run: npx prisma db seed
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: Run Playwright E2E tests
run: npm run test:e2e
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
build:
name: Build Application
runs-on: ubuntu-latest
needs: [quality-checks, e2e-tests]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Next.js application
run: npm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: nextjs-build
path: .next/
This pipeline runs three jobs in sequence: quality checks (lint, type-check, unit tests), E2E tests, and finally a production build. The PostgreSQL service container ensures your database-dependent tests run in an isolated environment.
Step 5: Creating the CD Pipeline for Deployment
Create a separate deployment workflow at .github/workflows/deploy.yml that triggers only when CI passes on the main branch:
// .github/workflows/deploy.yml
name: Deploy to Production
on:
workflow_run:
workflows: ["CI Pipeline"]
branches: [main]
types: [completed]
jobs:
deploy-vercel:
name: Deploy to Vercel
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Vercel CLI
run: npm install -g vercel@latest
- name: Pull Vercel environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build with Vercel
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}
- name: Deploy to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
echo "deployment_url=$URL" >> $GITHUB_OUTPUT
- name: Verify deployment health
run: |
sleep 10
STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ steps.deploy.outputs.deployment_url }}/api/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS"
exit 1
fi
echo "Deployment healthy!"
- name: Notify team on success
if: success()
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Deployed to production: ${{ steps.deploy.outputs.deployment_url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify team on failure
if: failure()
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Production deployment FAILED for commit ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Step 6: Dockerizing for Self-Hosted Deployments
If you're deploying to AWS ECS, Google Cloud Run, or a VPS instead of Vercel, you'll need a Docker setup. Create a multi-stage Dockerfile:
// Dockerfile
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY package.json package-lock.json ./
COPY prisma ./prisma/
RUN npm ci --omit=dev
RUN npx prisma generate
# Build the application
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
RUN npm run build
# Production runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
Enable standalone output in your Next.js config to optimize the Docker image size:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
instrumentationHook: true,
},
};
module.exports = nextConfig;
Add a Docker build-and-push job to your deployment workflow for container-based hosting:
// Additional job for .github/workflows/deploy.yml
deploy-docker:
name: Build and Push Docker Image
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
DATABASE_URL=${{ secrets.DATABASE_URL }}
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
Step 7: Adding a Health Check API Route
Your deployment pipeline needs a health check endpoint to verify the app is running correctly after deployment:
// src/app/api/health/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
// Verify database connectivity
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json(
{
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || 'unknown',
uptime: process.uptime(),
database: 'connected',
},
{ status: 200 }
);
} catch (error) {
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
database: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 503 }
);
}
}
Step 8: Environment Variables and Secrets Management
Managing secrets correctly is critical for CI/CD security. In your GitHub repository, navigate to Settings → Secrets and Variables → Actions and add the following secrets:
- DATABASE_URL — Your production database connection string
- PRODUCTION_DATABASE_URL — Explicit production DB URL for migrations
- VERCEL_TOKEN — API token from your Vercel dashboard
- VERCEL_ORG_ID — Your Vercel organization ID
- VERCEL_PROJECT_ID — Your Vercel project ID
- CODECOV_TOKEN — Token for coverage reporting
- SLACK_WEBHOOK_URL — Webhook for deployment notifications
Never hard-code secrets in your workflow files. Use GitHub's encrypted secrets feature, and consider using environment-specific variables for staging versus production deployments.
Step 9: Branch Protection and Pull Request Workflow
To enforce CI before merging, configure branch protection rules on your main branch. Go to Settings → Branches → Add Branch Protection Rule and enable:
- Require status checks to pass before merging — Select your CI jobs
- Require branches to be up to date before merging
- Require pull request reviews before merging — At least one approval
- Require conversation resolution before merging
This ensures that no code reaches production without passing through the full CI pipeline and code review.
Step 10: Optimizing Pipeline Performance
CI pipelines that take too long will slow your team down. Here are key optimizations:
- Dependency caching: The
actions/setup-node cache option saves minutes on every run by caching node_modules. - Parallel jobs: Run lint, type-check, and tests in parallel rather than sequentially when they don't depend on each other.
- Docker layer caching: The
cache-from: type=gha directive in the Docker build action reuses previous layers. - Selective triggers: Use path filters to skip CI when only documentation changes.
- Turbo/Nx caching: For monorepos, use build system caching to skip unchanged packages.
// Path filter example in workflow trigger
on:
push:
branches: [main]
paths-ignore:
- '*.md'
- 'docs/**'
- '.vscode/**'
- 'LICENSE'
The Complete Pipeline in Action
With everything configured, your workflow looks like this:
- Developer pushes code → GitHub triggers CI Pipeline
- Quality checks run → ESLint, TypeScript, Jest tests with coverage
- E2E tests run → Playwright tests against a built app with a real PostgreSQL database
- Build verification → Next.js production build succeeds
- Deployment triggers → Vercel deployment or Docker image push
- Health check → Automated verification that the deployment is healthy
- Team notification → Slack message confirms successful deployment
Conclusion
Setting up CI/CD for a full-stack Next.js application requires thoughtful configuration across testing, building, containerization, and deployment. The pipeline we've built catches issues at every level — from linting errors to database migration failures — before they reach production. Start with the basic CI workflow, then layer in E2E tests and automated deployment as your confidence grows. The upfront investment in CI/CD pays dividends every single day your team ships code with confidence.
The complete source code for this pipeline configuration is available as a template you can fork and adapt to your own Next.js projects. Remember: the best CI/CD pipeline is one your team trusts enough to deploy on Friday afternoon.
: '/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
'!src/**/*.stories.{ts,tsx}',
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
module.exports = createJestConfig(customJestConfig);Write a sample test for an API route handler to validate server-side logic:
// __tests__/api/users.test.ts
import { GET } from '@/app/api/users/route';
import { NextRequest } from 'next/server';
import { prismaMock } from '@/lib/__mocks__/prisma';
jest.mock('@/lib/prisma');
describe('GET /api/users', () => {
it('should return a list of users', async () => {
const mockUsers = [
{ id: '1', name: 'Alice', email: 'alice@test.com' },
{ id: '2', name: 'Bob', email: 'bob@test.com' },
];
prismaMock.user.findMany.mockResolvedValue(mockUsers);
const request = new NextRequest('http://localhost:3000/api/users');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.users).toHaveLength(2);
expect(data.users[0].name).toBe('Alice');
});
it('should return 500 on database error', async () => {
prismaMock.user.findMany.mockRejectedValue(new Error('DB connection failed'));
const request = new NextRequest('http://localhost:3000/api/users');
const response = await GET(request);
expect(response.status).toBe(500);
});
});Step 3: Adding Playwright for End-to-End Tests
End-to-end tests validate the entire stack working together. Configure Playwright to run against a built Next.js application:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
webServer: {
command: 'npm run build && npm run start',
port: 3000,
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});Step 4: Creating the GitHub Actions CI Pipeline
Now we bring everything together in a GitHub Actions workflow. Create the file .github/workflows/ci.yml:
// .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
jobs:
quality-checks:
name: Lint, Type Check & Unit Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Run database migrations
run: npx prisma migrate deploy
- name: Run ESLint
run: npm run lint
- name: Run TypeScript type checking
run: npm run type-check
- name: Run unit and integration tests
run: npm run test -- --ci --reporters=default --reporters=jest-junit
env:
JEST_JUNIT_OUTPUT_DIR: ./reports
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: ./reports/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
e2e-tests:
name: End-to-End Tests
runs-on: ubuntu-latest
needs: quality-checks
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium firefox
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: Seed test database
run: npx prisma db seed
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: Run Playwright E2E tests
run: npm run test:e2e
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
build:
name: Build Application
runs-on: ubuntu-latest
needs: [quality-checks, e2e-tests]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Next.js application
run: npm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: nextjs-build
path: .next/This pipeline runs three jobs in sequence: quality checks (lint, type-check, unit tests), E2E tests, and finally a production build. The PostgreSQL service container ensures your database-dependent tests run in an isolated environment.
Step 5: Creating the CD Pipeline for Deployment
Create a separate deployment workflow at .github/workflows/deploy.yml that triggers only when CI passes on the main branch:
// .github/workflows/deploy.yml
name: Deploy to Production
on:
workflow_run:
workflows: ["CI Pipeline"]
branches: [main]
types: [completed]
jobs:
deploy-vercel:
name: Deploy to Vercel
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Vercel CLI
run: npm install -g vercel@latest
- name: Pull Vercel environment
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build with Vercel
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}
- name: Deploy to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})
echo "deployment_url=$URL" >> $GITHUB_OUTPUT
- name: Verify deployment health
run: |
sleep 10
STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ steps.deploy.outputs.deployment_url }}/api/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS"
exit 1
fi
echo "Deployment healthy!"
- name: Notify team on success
if: success()
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Deployed to production: ${{ steps.deploy.outputs.deployment_url }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Notify team on failure
if: failure()
uses: slackapi/slack-github-action@v1.25.0
with:
payload: |
{
"text": "Production deployment FAILED for commit ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Step 6: Dockerizing for Self-Hosted Deployments
If you're deploying to AWS ECS, Google Cloud Run, or a VPS instead of Vercel, you'll need a Docker setup. Create a multi-stage Dockerfile:
// Dockerfile
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies
FROM base AS deps
COPY package.json package-lock.json ./
COPY prisma ./prisma/
RUN npm ci --omit=dev
RUN npx prisma generate
# Build the application
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
RUN npm run build
# Production runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]Enable standalone output in your Next.js config to optimize the Docker image size:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
instrumentationHook: true,
},
};
module.exports = nextConfig;Add a Docker build-and-push job to your deployment workflow for container-based hosting:
// Additional job for .github/workflows/deploy.yml
deploy-docker:
name: Build and Push Docker Image
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
DATABASE_URL=${{ secrets.DATABASE_URL }}
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}Step 7: Adding a Health Check API Route
Your deployment pipeline needs a health check endpoint to verify the app is running correctly after deployment:
// src/app/api/health/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
// Verify database connectivity
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json(
{
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || 'unknown',
uptime: process.uptime(),
database: 'connected',
},
{ status: 200 }
);
} catch (error) {
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
database: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 503 }
);
}
}Step 8: Environment Variables and Secrets Management
Managing secrets correctly is critical for CI/CD security. In your GitHub repository, navigate to Settings → Secrets and Variables → Actions and add the following secrets:
- DATABASE_URL — Your production database connection string
- PRODUCTION_DATABASE_URL — Explicit production DB URL for migrations
- VERCEL_TOKEN — API token from your Vercel dashboard
- VERCEL_ORG_ID — Your Vercel organization ID
- VERCEL_PROJECT_ID — Your Vercel project ID
- CODECOV_TOKEN — Token for coverage reporting
- SLACK_WEBHOOK_URL — Webhook for deployment notifications
Never hard-code secrets in your workflow files. Use GitHub's encrypted secrets feature, and consider using environment-specific variables for staging versus production deployments.
Step 9: Branch Protection and Pull Request Workflow
To enforce CI before merging, configure branch protection rules on your main branch. Go to Settings → Branches → Add Branch Protection Rule and enable:
- Require status checks to pass before merging — Select your CI jobs
- Require branches to be up to date before merging
- Require pull request reviews before merging — At least one approval
- Require conversation resolution before merging
This ensures that no code reaches production without passing through the full CI pipeline and code review.
Step 10: Optimizing Pipeline Performance
CI pipelines that take too long will slow your team down. Here are key optimizations:
- Dependency caching: The
actions/setup-nodecache option saves minutes on every run by cachingnode_modules. - Parallel jobs: Run lint, type-check, and tests in parallel rather than sequentially when they don't depend on each other.
- Docker layer caching: The
cache-from: type=ghadirective in the Docker build action reuses previous layers. - Selective triggers: Use path filters to skip CI when only documentation changes.
- Turbo/Nx caching: For monorepos, use build system caching to skip unchanged packages.
// Path filter example in workflow trigger
on:
push:
branches: [main]
paths-ignore:
- '*.md'
- 'docs/**'
- '.vscode/**'
- 'LICENSE'The Complete Pipeline in Action
With everything configured, your workflow looks like this:
- Developer pushes code → GitHub triggers CI Pipeline
- Quality checks run → ESLint, TypeScript, Jest tests with coverage
- E2E tests run → Playwright tests against a built app with a real PostgreSQL database
- Build verification → Next.js production build succeeds
- Deployment triggers → Vercel deployment or Docker image push
- Health check → Automated verification that the deployment is healthy
- Team notification → Slack message confirms successful deployment
Conclusion
Setting up CI/CD for a full-stack Next.js application requires thoughtful configuration across testing, building, containerization, and deployment. The pipeline we've built catches issues at every level — from linting errors to database migration failures — before they reach production. Start with the basic CI workflow, then layer in E2E tests and automated deployment as your confidence grows. The upfront investment in CI/CD pays dividends every single day your team ships code with confidence.
The complete source code for this pipeline configuration is available as a template you can fork and adapt to your own Next.js projects. Remember: the best CI/CD pipeline is one your team trusts enough to deploy on Friday afternoon.