DevOps Docker CI/CD Next.js

DevOps Essentials — Docker, CI/CD, and Deploying Next.js Apps

From Dockerfile to production: a practical guide to containerizing Next.js apps, setting up GitHub Actions CI/CD, and deploying with zero downtime.

All articles

DevOps used to mean a separate team with arcane knowledge. In 2026, it's a skill every full-stack developer needs. This guide covers the essentials: Docker, GitHub Actions, and deploying a Next.js app to a VPS — all from first principles.


1. Why Docker?

Docker packages your application and its runtime into an immutable image. That image runs identically on your laptop, in CI, and on the production server. No more "works on my machine."

Key concepts

  • Image — a read-only snapshot (layers of file system changes)
  • Container — a running instance of an image
  • Dockerfile — the recipe that builds an image
  • Registry — where images live (Docker Hub, GitHub Container Registry)

2. Dockerfile for Next.js

Next.js's standalone output mode produces a minimal Node server — perfect for Docker.

# ── Stage 1: deps ───────────────────────────────────────────────────
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
# ── Stage 2: builder ─────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
# ── Stage 3: runner (production) ────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
 
# Only copy what Next.js standalone needs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
 
EXPOSE 3000
CMD ["node", "server.js"]

Enable standalone in next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
};
module.exports = nextConfig;

Build and run locally:

docker build -t my-nextjs-app .
docker run -p 3000:3000 my-nextjs-app

3. Docker Compose for Local Development

Use Compose to spin up your app alongside a database:

# docker-compose.yml
version: "3.9"
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=mongodb://mongo:27017/mydb
    depends_on:
      - mongo
 
  mongo:
    image: mongo:7
    volumes:
      - mongo_data:/data/db
    ports:
      - "27017:27017"
 
volumes:
  mongo_data:
docker compose up -d   # start in background
docker compose logs -f # stream logs
docker compose down    # stop and remove containers

4. CI/CD with GitHub Actions

A well-structured pipeline has three jobs: test → build → deploy.

# .github/workflows/deploy.yml
name: Deploy
 
on:
  push:
    branches: [main]
 
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 lint
      - run: npm run build  # type-check + build
 
  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Build & push Docker image
        run: |
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker build -t ghcr.io/${{ github.repository }}:latest .
          docker push ghcr.io/${{ github.repository }}:latest
 
      - 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: |
            docker pull ghcr.io/${{ github.repository }}:latest
            docker stop app || true
            docker rm app || true
            docker run -d --name app -p 3000:3000 \
              --env-file /etc/myapp/.env \
              ghcr.io/${{ github.repository }}:latest

Add secrets in GitHub → Settings → Secrets and variables → Actions.


5. Zero-Downtime Deploys with Nginx

On your VPS, use Nginx as a reverse proxy so users never see downtime:

# /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name sabirsoft.com;
 
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Enable HTTPS with Certbot (Let's Encrypt):

sudo certbot --nginx -d sabirsoft.com

For true zero-downtime, use Docker's --stop-timeout and health checks:

HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/api/health || exit 1

6. Environment Variables & Secrets

Never commit .env to git. Use:

  • Vercel → Project Settings → Environment Variables
  • VPS/etc/myapp/.env (only root-readable: chmod 600)
  • GitHub Actions → Repository Secrets

In Next.js, prefix public vars with NEXT_PUBLIC_:

# Server-only (safe)
DATABASE_URL=mongodb+srv://...
RESEND_API_KEY=re_...
 
# Exposed to browser (public)
NEXT_PUBLIC_SITE_URL=https://sabirsoft.com

Checklist Before Going to Production

  • Multi-stage Dockerfile with standalone output
  • .dockerignore excludes node_modules, .next, .git
  • CI runs lint + build before deploy
  • Nginx with HTTPS (Certbot)
  • Health check endpoint at /api/health
  • Secrets managed via environment files, not hardcoded

This stack — Docker + GitHub Actions + Nginx — is battle-tested and scales from a $5 VPS to a Kubernetes cluster without changing your application code.