How to Deploy Next.js to VPS with Docker and Nginx
ID | EN

How to Deploy Next.js to VPS with Docker and Nginx

Thursday, Dec 25, 2025

Tired of paying too much for hosting? Or want full control over your server? Well, deploying Next.js to a VPS with Docker and Nginx could be the right solution. In this tutorial, I’ll explain step-by-step how to go from zero to having your app live with free SSL.

Why Deploy to VPS?

Before we start, you might be wondering: “Why bother with VPS?”

Main reasons:

  • Cheaper - VPS starting from $5/month can handle multiple apps
  • Full control - You can install anything, configure however you want
  • Scalable - Upgrade resources anytime without migration
  • Learning experience - Your DevOps skills will level up

If you’re already using Vercel and happy, no problem. But if you need more than what the free tier offers, or want to learn “real” deployment, let’s go!

Prerequisites

Before starting, make sure you have:

  • VPS with at least 1GB RAM (recommended: 2GB). Can use DigitalOcean, Vultr, Linode, or local providers
  • Domain already pointing to your VPS IP
  • SSH access to VPS
  • Next.js project ready to deploy
  • Basic knowledge of terminal/command line

VPS Setup (Ubuntu 22.04)

First, SSH into your VPS:

ssh root@your_server_ip

Update system and install essential packages:

# Update package list
apt update && apt upgrade -y

# Install essential tools
apt install -y curl wget git nano ufw

Setup Firewall

# Allow SSH, HTTP, and HTTPS
ufw allow OpenSSH
ufw allow 80
ufw allow 443

# Enable firewall
ufw enable

# Check status
ufw status
# Create new user
adduser deploy

# Add to sudo group
usermod -aG sudo deploy

# Switch to new user
su - deploy

Install Docker and Docker Compose

Docker makes deployment consistent and reproducible. No more worrying about “works on my machine”.

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Add user to docker group (so you don't need sudo)
sudo usermod -aG docker $USER

# Apply group changes
newgrp docker

# Verify installation
docker --version

Docker Compose is already included in new Docker versions, but if not:

# Install Docker Compose plugin
sudo apt install docker-compose-plugin

# Verify
docker compose version

Dockerfile for Next.js

This is the important part. We’ll use multi-stage build to keep image size small and secure.

Create a Dockerfile in your Next.js project root:

# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Copy package files
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 . .

# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production

# Build the application
RUN npm run build

# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Copy necessary files
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# Set correct permissions
RUN chown -R nextjs:nodejs /app

USER nextjs

EXPOSE 3000

ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

CMD ["node", "server.js"]

Important! Add this to your next.config.js:

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

module.exports = nextConfig

Docker Compose Configuration

Create a docker-compose.yml file:

version: '3.8'

services:
  nextjs:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: nextjs-app
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    networks:
      - webnet
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

networks:
  webnet:
    driver: bridge

Build and Run

# Build image
docker compose build

# Run container
docker compose up -d

# Check logs
docker compose logs -f nextjs

Now your app should be running at http://your_server_ip:3000.

Setup Nginx as Reverse Proxy

Nginx will handle incoming requests and forward them to the Docker container. Plus, it will also manage SSL.

# Install Nginx
sudo apt install nginx -y

# Start and enable
sudo systemctl start nginx
sudo systemctl enable nginx

Nginx Configuration

Create a new config file:

sudo nano /etc/nginx/sites-available/nextjs

Paste this configuration:

upstream nextjs_upstream {
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    # Redirect HTTP to HTTPS (uncomment after SSL setup)
    # return 301 https://$server_name$request_uri;

    location / {
        proxy_pass http://nextjs_upstream;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
        proxy_read_timeout 86400;
    }

    # Static files caching
    location /_next/static {
        proxy_pass http://nextjs_upstream;
        proxy_cache_valid 60m;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
    gzip_min_length 1000;
}

Enable site and test config:

# Create symlink
sudo ln -s /etc/nginx/sites-available/nextjs /etc/nginx/sites-enabled/

# Remove default site
sudo rm /etc/nginx/sites-enabled/default

# Test configuration
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

Now access http://yourdomain.com - it should be working!

SSL with Let’s Encrypt

HTTPS is mandatory in 2024. Luckily, Let’s Encrypt makes this free and easy.

# Install Certbot
sudo apt install certbot python3-certbot-nginx -y

# Generate SSL certificate
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot will automatically modify your Nginx config. After it’s done, update the config to redirect HTTP to HTTPS:

server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com www.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
    
    # SSL configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;

    # ... rest of your config
}

Auto-Renewal

Certbot already sets up auto-renewal, but you can test it:

# Test renewal
sudo certbot renew --dry-run

CI/CD with GitHub Actions

Now let’s set up auto-deployment. Every push to main will automatically deploy.

Create .github/workflows/deploy.yml:

name: Deploy to VPS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Deploy to VPS
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /home/deploy/your-app
            git pull origin main
            docker compose build --no-cache
            docker compose up -d
            docker system prune -f

Setup GitHub Secrets

In your GitHub repository, go to Settings > Secrets and variables > Actions, add:

  • VPS_HOST - Your VPS IP address
  • VPS_USER - Username (e.g., deploy)
  • VPS_SSH_KEY - Private SSH key

Generate SSH key locally:

ssh-keygen -t ed25519 -C "github-actions"

Copy public key to VPS:

ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@your_server_ip

Add the private key (~/.ssh/id_ed25519) to GitHub Secrets.

Monitoring and Logging

Deployment without monitoring is like driving at night without lights. Here’s a basic setup:

Docker Logs

# View logs
docker compose logs -f

# View specific service
docker compose logs -f nextjs

# Last 100 lines
docker compose logs --tail 100 nextjs

System Monitoring with htop

sudo apt install htop -y
htop

Basic Health Check Script

Create health-check.sh:

#!/bin/bash

HEALTH_URL="http://localhost:3000"
DISCORD_WEBHOOK="your_discord_webhook_url"

response=$(curl -s -o /dev/null -w "%{http_code}" $HEALTH_URL)

if [ $response != "200" ]; then
    # Restart container
    docker compose restart nextjs
    
    # Send notification (optional)
    curl -H "Content-Type: application/json" \
         -d '{"content":"⚠️ NextJS app was down and has been restarted!"}' \
         $DISCORD_WEBHOOK
fi

Setup cron job:

# Edit crontab
crontab -e

# Add this line (check every 5 minutes)
*/5 * * * * /home/deploy/health-check.sh

Optimization Tips

1. Enable Docker BuildKit

export DOCKER_BUILDKIT=1
docker compose build

2. Use Docker Layer Caching

Proper Dockerfile structure makes builds faster:

# Dependencies first (rarely changes)
COPY package*.json ./
RUN npm ci

# Source code last (frequently changes)
COPY . .

3. Resource Limits

Add to docker-compose.yml:

services:
  nextjs:
    # ... other config
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 1G
        reservations:
          cpus: '0.5'
          memory: 512M

4. Nginx Caching

# Add proxy cache
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=nextjs_cache:10m max_size=1g inactive=60m;

location / {
    proxy_cache nextjs_cache;
    proxy_cache_valid 200 60m;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    # ... rest of config
}

5. Cleanup Script

Create cleanup.sh to clean unused Docker resources:

#!/bin/bash
docker system prune -af --volumes
docker image prune -af

Troubleshooting

Container won’t start:

docker compose logs nextjs
docker compose ps

Port already in use:

sudo lsof -i :3000
sudo kill -9 <PID>

Nginx 502 Bad Gateway:

  • Check if container is running: docker ps
  • Check container logs: docker compose logs nextjs
  • Verify port mapping in docker-compose.yml

SSL certificate issues:

sudo certbot certificates
sudo certbot renew --force-renewal

Conclusion

Congratulations! You’ve successfully deployed Next.js to VPS with a production-ready setup:

  • ✅ Docker for containerization
  • ✅ Nginx as reverse proxy
  • ✅ Free SSL from Let’s Encrypt
  • ✅ CI/CD with GitHub Actions
  • ✅ Basic monitoring

This setup can handle fairly large traffic and is easy to scale. If traffic increases, just upgrade the VPS or set up a load balancer.

Next steps? You might explore:

  • Docker Swarm or Kubernetes for orchestration
  • Prometheus + Grafana for advanced monitoring
  • Cloudflare for CDN and DDoS protection

Have questions? Drop a comment or reach out to me on Twitter. Happy deploying! 🚀