How to Self-Host Next.js on a VPS with Docker and Nginx (2026 Guide)
Konrad Bachowski
Tech lead, HeyNeuron
How to Deploy Next.js on a VPS with Docker and Nginx (2026 Guide)
Self-hosting Next.js costs roughly $30/month versus $305/month on Vercel Pro once you cross 250,000 monthly visitors. That 10× gap is why more teams are moving off Vercel — but the migration has real complexity that most tutorials skip: Redis-coordinated ISR caches, version skew failures, and production observability. This guide covers all of it.
You'll walk away with a reproducible Docker + Nginx + GitHub Actions setup, a security hardening checklist, and a clear-eyed view of when Vercel's pricing is actually worth paying.
What this guide assumes: You have a Next.js 14/15 app in a GitHub repo, basic Linux comfort, and a VPS budget. No Kubernetes required.
Vercel vs Self-Hosting: The Real Cost Math
Most cost comparisons stop at compute. The real numbers include bandwidth, seat pricing, and the hidden metering lines Vercel introduced in 2023–2025.
A typical B2B SaaS with 250,000 monthly visitors and 3 developer seats:
| Cost Line | Vercel Pro | Self-Hosted (Hetzner + Cloudflare) |
|---|---|---|
| Compute / hosting | $40/month | $4.50–$18/month |
| Bandwidth (1 TB) | $150/month | Free (Cloudflare CDN) |
| Team seats (3) | $60/month | $0 |
| Edge Middleware | $35/month | $0 |
| Image Optimization | $20/month | $0 (sharp, self-managed) |
| Total | ~$305/month | ~$25–35/month |
According to MakerKit's 2026 Vercel pricing analysis, bandwidth alone becomes uneconomical above 1 TB/month on Vercel. For traffic-heavy apps, self-hosting pays for itself within weeks.
The flip side: your first self-hosted deployment takes 20–25 hours to set up properly. Ongoing maintenance runs 1–2 hours/month. Factor that into your cost model — a $150/hour developer's time is worth quantifying.
When to Stay on Vercel (Read This First)
Self-hosting isn't always the right call. Stay on Vercel when:
- Your team has fewer than 3 developers. Operational overhead per person increases as the team shrinks.
- You rely heavily on Vercel Edge Functions or Edge Middleware. These run in Cloudflare's global PoP network — reproducing that latency profile on a single VPS is impossible without additional CDN investment.
- Time-to-market is the priority. Vercel eliminates a real category of work. For pre-revenue startups, the $20/month Hobby plan is the correct choice.
- Your compliance requirements are unclear. EU data residency on Vercel is easier to certify than a self-managed stack.
For everything else — B2B SaaS, established products, traffic-heavy apps — self-hosting is the better long-term call.
Architecture Overview
A production-ready self-hosted Next.js stack has four layers:
- VPS (Hetzner / DigitalOcean / Vultr) — your compute, $4.50–$18/month
- Docker + Next.js standalone output — portable, reproducible builds (~150 MB image)
- Nginx reverse proxy — SSL termination, HTTP/2, request buffering
- GitHub Actions — CI/CD pipeline for zero-downtime deployments
Optional (recommended for ISR-heavy apps): Redis for distributed caching across multiple instances.
Step 1: Configure Next.js Standalone Output
Next.js 15's standalone output mode produces a self-contained build that includes only the Node.js server code your app actually uses — no node_modules on the production image.
Add this to next.config.ts:
const nextConfig = {
output: 'standalone',
// Version skew protection (prevents stale client chunks after deploys)
generateBuildId: async () => process.env.GIT_COMMIT_SHA || 'local',
experimental: {
instrumentationHook: true,
},
}
export default nextConfig
The generateBuildId config ties your build ID to the Git commit SHA. This powers Next.js 15's version skew protection — when a client requests a chunk from an old build after you've deployed a new one, Next.js detects the mismatch via X-Deployment-Id headers and triggers a hard reload instead of serving corrupted state.
Without this, you'll see hydration errors on clients that were open during a deployment window.
Step 2: Dockerfile for Production
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
fi
# Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG GIT_COMMIT_SHA
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
RUN npm run build
# Production image — only the standalone output
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
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"]
This produces a ~150 MB image (down from 1 GB+ with a naive node:22 base). The multi-stage build keeps credentials, source maps, and dev dependencies out of the final image.
Step 3: Docker Compose Setup
# docker-compose.yml
services:
app:
image: ghcr.io/${GITHUB_REPOSITORY}:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- REDIS_URL=${REDIS_URL}
ports:
- "127.0.0.1:3000:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
command: redis-server --appendonly yes
volumes:
redis_data:
Note: 127.0.0.1:3000:3000 binds the app port to localhost only. Nginx handles the public-facing connection — the app is never directly reachable from the internet.
Step 4: Nginx Configuration
Install Nginx on your VPS and create /etc/nginx/sites-available/yourapp:
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$host$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_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Proxy to Next.js
location / {
proxy_pass http://127.0.0.1:3000;
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;
# Required for Server-Sent Events (streaming AI responses)
proxy_buffering off;
proxy_read_timeout 300s;
}
# Cache Next.js static assets
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Then enable: sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/ and sudo nginx -t && sudo systemctl reload nginx.
Get SSL certificates: sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Step 5: GitHub Actions CI/CD Pipeline
# .github/workflows/deploy.yml
name: Deploy to VPS
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
build-args: |
GIT_COMMIT_SHA=${{ github.sha }}
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
deploy:
needs: build-and-push
runs-on: ubuntu-latest
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
export IMAGE_TAG=${{ github.sha }}
docker compose pull
docker compose up -d --no-deps app
docker compose exec app wget -qO- http://localhost:3000/api/health || exit 1
docker image prune -f
The health check (/api/health) gate prevents bad deploys from going live silently. If it fails, the old container keeps running.
Step 6: Redis Caching for ISR and Session State
Next.js ISR (Incremental Static Regeneration) uses a file-system cache by default. On a single VPS that's fine — on multiple instances, each node builds its own cache and you get inconsistent responses.
The fix: a custom cache handler backed by Redis.
Install the package: npm install @neshca/cache-handler ioredis
Create cache-handler.mjs:
import { CacheHandler } from '@neshca/cache-handler'
import createRedisHandler from '@neshca/cache-handler/redis-stack'
import { createClient } from 'redis'
CacheHandler.onCreation(async () => {
const client = createClient({ url: process.env.REDIS_URL })
await client.connect()
const handler = await createRedisHandler({ client, keyPrefix: 'myapp:' })
return { handlers: [handler] }
})
export default CacheHandler
Reference it in next.config.ts:
const nextConfig = {
output: 'standalone',
cacheHandler: process.env.NODE_ENV === 'production'
? require.resolve('./cache-handler.mjs')
: undefined,
}
Now all ISR pages share the same Redis cache across instances. A revalidation on one node immediately invalidates the cached response on all others.
Production Security Hardening Checklist
Before you make the app public:
- [ ] Bind app to localhost —
127.0.0.1:3000:3000in Docker Compose, not0.0.0.0 - [ ] Rotate secrets regularly — use GitHub Environments to inject
DATABASE_URL,NEXTAUTH_SECRETat deploy time, not baked into images - [ ] Enable UFW firewall — allow only ports 22, 80, 443; block everything else:
sudo ufw allow 22 && sudo ufw allow 80 && sudo ufw allow 443 && sudo ufw enable - [ ] Install fail2ban — rate-limit SSH and Nginx failed requests:
sudo apt install fail2ban && sudo systemctl enable fail2ban - [ ] Disable root SSH — set
PermitRootLogin noin/etc/ssh/sshd_config; use key-based auth only - [ ] Add Nginx rate limiting —
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;prevents API abuse - [ ] Rotate SSL certificates automatically —
sudo certbot renew --dry-runcron job (Certbot auto-renews by default, but verify) - [ ] Scan your Docker image — run
docker scout quickviewortrivy imagebefore pushing to production
Monitoring and Observability
A self-hosted app without monitoring is a liability. The minimum production observability stack:
Application errors: Add Sentry (free tier covers 5,000 events/month). It integrates with the Next.js instrumentation hook — one import catches both Server Component errors and client-side exceptions.
Uptime monitoring: BetterUptime or UptimeRobot (both free tiers) pings your /api/health endpoint every minute and sends alerts via Slack or email.
Server metrics: Netdata (free, self-hosted) gives real-time CPU, memory, and disk I/O dashboards. Install with one command: curl https://my-netdata.io/kickstart.sh | bash
Nginx access logs: Parse them with GoAccess for human-readable traffic reports. sudo goaccess /var/log/nginx/access.log -o /var/www/report.html --log-format=COMBINED
Version Skew Protection
Next.js 15 introduced built-in version skew protection, but it only works if you configure it. The problem: when you deploy a new build, users who have the old client JavaScript cached will request chunks from the new build ID. Without protection, they get 404s on their next interaction.
The fix is already in the next.config.ts above — tying generateBuildId to your Git SHA. When skew is detected, Next.js returns a Cache-Control: no-store response and the client re-fetches the full page.
You can verify skew protection is active by checking response headers:
curl -I https://yourdomain.com | grep x-deployment-id
If the header appears, version skew protection is running.
Cost Breakdown by Implementation Route
Choosing how to set up your self-hosted stack depends on your team's time budget:
| Route | Setup Cost | Monthly Cost | Best For |
|---|---|---|---|
| DIY (this guide) | 20–25 hrs | $10–35 | Teams comfortable with Linux and Docker |
| Coolify (self-hosted PaaS) | 4–6 hrs | $15–40 | Teams wanting a Vercel-like UI on their own VPS |
| Railway / Render | 1–2 hrs | $20–80 | Teams that want managed infra without Vercel pricing |
| Hiring DevOps freelancer | $800–2,000 one-time | $25–40 VPS | Teams with no ops experience internally |
Coolify is worth highlighting for teams without Linux expertise — it's a self-hosted PaaS that gives you a Vercel-like dashboard (auto-SSL, environment variables UI, deploy previews) while running on your Hetzner VPS. Setup takes a weekend afternoon instead of 20 hours.
When NOT to Self-Host (4 Scenarios)
1. Your team has no one who owns infrastructure. Self-hosting creates an ongoing operational responsibility. If the person who set it up leaves, the team is stuck. Vercel's simplicity has real value when ops ownership is unclear.
2. You need multi-region global edge. A single Hetzner VPS in Frankfurt doesn't help users in Singapore or São Paulo. Vercel's Edge Network runs in 100+ locations — reproducing that without Cloudflare Workers or a CDN partner means real complexity.
3. Your app has < 50,000 monthly visitors. The cost savings are marginal at low traffic. Vercel Pro's $20/month Hobby plan or $20/month Pro (1 seat) is often cheaper than the engineering time spent maintaining a VPS.
4. You're pre-product-market-fit. Moving fast matters more than saving $275/month. Use Vercel's free tier until you have paying customers, then revisit.
FAQ
How much does it cost to self-host Next.js?
A Hetzner CX22 VPS ($4.50/month) handles up to ~100,000 monthly visitors for a typical Next.js app. With Cloudflare's free CDN for static assets and a $0 SSL certificate from Let's Encrypt, total monthly cost is under $10. Compared to Vercel Pro at ~$305/month for similar traffic, self-hosting saves over $3,500/year.
Can I self-host Next.js without Docker?
Yes — you can run Next.js directly with Node.js using npm start after npm run build. Docker is recommended for reproducible deployments, easy rollbacks, and consistent environments between CI and production. Without Docker, environment drift between servers becomes a real operational risk.
Does self-hosting support all Next.js features?
All App Router features work in self-hosted mode, including Server Components, Server Actions, ISR, and streaming. The main exception: Vercel's proprietary Edge Runtime functions (runtime: "edge") require a compatible edge network. For ISR, you need to configure a custom cache handler (Redis is recommended for multi-instance deployments).
How do I handle zero-downtime deployments?
The GitHub Actions workflow above uses docker compose up -d --no-deps app, which starts the new container before stopping the old one (with Nginx continuing to proxy until the new container passes its health check). For true blue-green deployments with zero traffic interruption, use Docker Swarm's --update-order start-first flag.
What VPS provider should I use for Next.js?
Hetzner is the community favorite for European teams — the CX22 ($4.50/month, 2 vCPU, 4 GB RAM) handles most production Next.js apps. DigitalOcean Droplets ($12/month for comparable specs) are a good choice for US teams. Vultr and Linode offer similar pricing. Avoid AWS EC2 or GCP Compute for small teams — the complexity-to-cost ratio is poor compared to simpler VPS providers.
How do I update my Next.js app without downtime?
Push to your main branch. The GitHub Actions pipeline builds a new Docker image, pushes it to GitHub Container Registry, SSH-connects to your VPS, pulls the new image, starts the new container, runs a health check, and removes the old container — automatically. The entire process takes 3–5 minutes for a typical Next.js app.
Can I use this setup for GDPR compliance?
Yes. Self-hosting on EU-based infrastructure (Hetzner's Falkenstein datacenter is in Germany) gives you full control over data residency — no third-party SaaS, no transatlantic data transfers for your application data. You'll need to document the data flows in your Article 30 Record of Processing Activities and ensure Redis data (session state, cache keys) doesn't contain PII that exceeds your retention policy.
Is Next.js self-hosting suitable for high-traffic sites?
A single CX22 Hetzner VPS handles approximately 100,000–250,000 monthly visitors for content-heavy Next.js apps. For higher traffic, scale horizontally — run 2–3 VPS instances behind a load balancer (Nginx upstream or Cloudflare Load Balancing). The Redis cache handler in Step 6 ensures ISR consistency across all nodes. Industry experience suggests vertical scaling (larger VPS) is simpler until you cross ~1M monthly visitors.
Conclusion
Self-hosting Next.js on a VPS with Docker and Nginx cuts your hosting bill by 80–90% versus Vercel Pro once you're past 100,000 monthly visitors. The tradeoff is real: 20–25 hours of initial setup, ongoing ops responsibility, and the need to own your monitoring stack.
The setup in this guide — standalone output, Nginx with HTTP/2, Redis-backed ISR caching, GitHub Actions CI/CD, and fail2ban security hardening — gives you a production-grade stack that scales to several hundred thousand monthly visitors on a $10–18/month VPS.
Ready to migrate? HeyNeuron's team builds and deploys Next.js applications on cost-efficient infrastructure — talk to us about your project.
Related guides: - Next.js App Router best practices for production (2026) - How to build a PWA with Next.js - How to choose the right tech stack for your web app - How much does it cost to build a SaaS platform - React Native CI/CD pipeline with GitHub Actions - How much does custom software maintenance cost - Next.js development agency Poland - Web application services - Contact HeyNeuron
Stay up to date with AI and automation
Subscribe to our newsletter to receive specific tips and tools once a week. Join over 2,000 subscribers.