Back to Blog
devops9 min read

Docker and CI/CD Pipeline for Next.js Applications

A complete guide to containerising Next.js with Docker multi-stage builds and setting up a fully automated CI/CD pipeline with GitHub Actions for zero-downtime deployments.

V
By Ventra Rocket
·Published on 20 February 2026
#Docker#CI/CD#GitHub Actions#Next.js#DevOps

Containerisation and CI/CD are the two pillars of modern DevOps. Combining Docker with GitHub Actions fully automates the journey from code commit to production deployment, eliminating human error and compressing release cycles to minutes.

1. Optimised Dockerfile for Next.js

Multi-stage builds reduce image size from ~1 GB down to ~150 MB.

# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# Stage 3: Runner (production image)
FROM node:20-alpine 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 --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 next.config.ts:

const nextConfig = { output: 'standalone' };
export default nextConfig;

2. Docker Compose for Local Development

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: runner
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - app

volumes:
  postgres_data:

3. GitHub Actions CI/CD Pipeline

# .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to VPS via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/app
            docker compose pull
            docker compose up -d --remove-orphans
            docker image prune -f
            echo "Deploy completed at $(date)"

4. Secrets Management

Never hardcode secrets into Dockerfiles or docker-compose files. Use GitHub Secrets for CI/CD and .env files locally (already gitignored).

gh secret set DATABASE_URL --body "postgresql://..."
gh secret set VPS_SSH_KEY < ~/.ssh/id_rsa
gh secret set NEXTAUTH_SECRET --body "$(openssl rand -base64 32)"

5. Health Check Endpoint and Auto-rollback

// app/api/health/route.ts
export async function GET() {
  return Response.json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    version: process.env.npm_package_version,
  });
}
#!/bin/bash
# deploy.sh — deploy with automatic rollback on health check failure
set -e
PREVIOUS_IMAGE=$(docker inspect app --format='{{.Image}}' 2>/dev/null || echo "")

docker compose pull
docker compose up -d --remove-orphans

sleep 30
if ! curl -sf http://localhost:3000/api/health; then
  echo "Health check failed, rolling back..."
  docker compose down
  docker tag $PREVIOUS_IMAGE ghcr.io/org/app:latest
  docker compose up -d
  exit 1
fi
echo "Deploy successful"

Conclusion

A CI/CD pipeline with Docker and GitHub Actions gives your team the confidence to deploy multiple times per day. With the configuration above, every pull request automatically runs tests, builds a Docker image, and deploys to production in 3–5 minutes. Ventra Rocket applies this pipeline to all client production projects.

Related Articles

Docker and CI/CD Pipeline for Next.js Applications | Ventra Rocket