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 Non-Root User (Recommended)
# 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/ssh-action@v1.0.0
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 addressVPS_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! 🚀