Deploying Next.js on a standalone Linux server (AWS EC2, DigitalOcean Droplet, Hetzner, etc.) using next build && next start. Everything Vercel handles for you automatically -- CDN, SSL, scaling, preview deploys, environment variables -- is now your responsibility. This guide walks you through every piece.
# On your server (Ubuntu 22.04+)# 1. Build the production bundleNODE_ENV=production npm ciNODE_ENV=production npx next build# 2. Start with PM2 (process manager)npm install -g pm2pm2 start npm --name "myapp" -- startpm2 savepm2 startup# 3. Nginx reverse proxy (after installing nginx)sudo apt install nginx certbot python3-certbot-nginx -ysudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/sudo nginx -t && sudo systemctl reload nginx# 4. SSL certificatesudo certbot --nginx -d myapp.com -d www.myapp.com# 5. Verifycurl -I https://myapp.com
When to reach for this: Your company requires self-hosting, you need to control the server environment, you are deploying to a VPC with no public internet access, or you want predictable monthly costs instead of usage-based billing.
# Clone your repositorygit clone https://github.com/your-org/your-app.git /home/nextjs/appcd /home/nextjs/app# Install production dependenciesnpm ci# Build for productionNODE_ENV=production npx next build
After the build completes, the .next/ directory contains:
# .env.production# Server-side only (not exposed to the browser)DATABASE_URL="postgresql://user:pass@db-host:5432/mydb"NEXTAUTH_SECRET="your-secret-key-here"NEXTAUTH_URL="https://myapp.com"# Client-side (embedded in the JS bundle at BUILD time)NEXT_PUBLIC_API_URL="https://api.myapp.com"NEXT_PUBLIC_POSTHOG_KEY="phc_xxxxxxxxxxxx"
The NEXT_PUBLIC_ prefix is critical to understand:
Prefix
Available Where
When Resolved
Change Requires
NEXT_PUBLIC_
Server + Client (browser)
Build time (baked into JS bundle)
Rebuild
No prefix
Server only
Runtime (read from process.env)
Restart
For PM2-managed environment variables, use an ecosystem.config.js:
# Create logs directorymkdir -p /home/nextjs/logs# Start the applicationpm2 start ecosystem.config.js# Save the process list (so PM2 knows what to restart after reboot)pm2 save# Generate the startup script (run the command it outputs as root)pm2 startup# Copy-paste the generated command, e.g.:# sudo env PATH=$PATH:/home/nextjs/.nvm/versions/node/v20.x.x/bin pm2 startup systemd -u nextjs --hp /home/nextjs# Verify processes are runningpm2 ls
Create a deploy script that pulls the latest code, rebuilds, and gracefully reloads:
#!/bin/bash# /home/nextjs/deploy.shset -euo pipefailAPP_DIR="/home/nextjs/app"LOG_FILE="/home/nextjs/logs/deploy-$(date +%Y%m%d-%H%M%S).log"echo "=== Deploy started at $(date) ===" | tee "$LOG_FILE"cd "$APP_DIR"# Pull latest codeecho "Pulling latest code..." | tee -a "$LOG_FILE"git pull origin main 2>&1 | tee -a "$LOG_FILE"# Install dependencies (ci for clean installs)echo "Installing dependencies..." | tee -a "$LOG_FILE"npm ci 2>&1 | tee -a "$LOG_FILE"# Build the applicationecho "Building..." | tee -a "$LOG_FILE"NODE_ENV=production npx next build 2>&1 | tee -a "$LOG_FILE"# Gracefully reload PM2 processes (zero-downtime)echo "Reloading PM2 processes..." | tee -a "$LOG_FILE"pm2 reload myapp 2>&1 | tee -a "$LOG_FILE"echo "=== Deploy completed at $(date) ===" | tee -a "$LOG_FILE"
Why pm2 reload instead of pm2 restart:
reload -- Starts new worker processes first, waits for them to accept connections, then gracefully shuts down old workers. Zero downtime.
restart -- Kills all workers immediately, then starts new ones. Brief downtime while new processes boot.
Configure your load balancer or monitoring service to poll https://myapp.com/api/health every 30 seconds. A 200 response with "status": "ok" means the server is healthy.
Incremental Static Regeneration works out of the box on a single server because the regenerated pages are written to .next/cache/ on local disk. When a page is revalidated, Next.js:
Serves the stale page immediately
Regenerates the page in the background
Writes the new page to .next/cache/
Serves the new page on the next request
The problem with multiple servers: If you scale to 2+ servers behind a load balancer, each server has its own .next/cache/. Server A might have a fresh page while Server B still serves a stale one. Users see inconsistent content.
Solutions:
Sticky sessions -- Route users to the same server via ALB session affinity. Simplest but reduces load balancing effectiveness.
Shared NFS mount -- Mount .next/cache/ from an EFS volume. All servers share the same cache. Adds latency but ensures consistency.
Custom cache handler -- Use incrementalCacheHandlerPath in next.config.ts to point to a Redis or S3-backed cache:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { cacheHandler: "./cache-handler.ts", cacheMaxMemorySize: 0, // Disable in-memory caching, use external store only};export default nextConfig;
// cache-handler.tsimport { CacheHandler } from "next/dist/server/lib/incremental-cache";import { createClient } from "redis";const client = createClient({ url: process.env.REDIS_URL });client.connect();export default class RedisCacheHandler extends CacheHandler { async get(key: string) { const data = await client.get(key); return data ? JSON.parse(data) : null; } async set(key: string, data: unknown, ctx: { revalidate?: number }) { const ttl = ctx.revalidate ?? 60; await client.set(key, JSON.stringify(data), { EX: ttl }); } async revalidateTag(tag: string) { // Scan for keys with this tag and delete them const keys = await client.keys(`*:${tag}:*`); if (keys.length > 0) { await client.del(keys); } }}
Setting output: "standalone" in next.config.ts tells the build process to trace your application's imports and bundle only the required node_modules into .next/standalone/:
// next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = { output: "standalone",};export default nextConfig;
After building, the .next/standalone/ directory contains:
.next/standalone/├── node_modules/ # Only the dependencies your app actually uses (~50MB)├── server.js # Minimal Node.js server entry point├── package.json└── .next/ └── server/ # Compiled server bundles
You must manually copy static assets:
# After building with output: "standalone"cp -r public .next/standalone/publiccp -r .next/static .next/standalone/.next/static
Then start with:
cd .next/standaloneNODE_ENV=production node server.js
Forgetting to copy public/ and .next/static/ with standalone output. The output: "standalone" mode bundles only the server code. Static assets (public/, .next/static/) must be copied into .next/standalone/ manually, or Nginx will serve 404s for every CSS, JS, and image file.
Running next start as root. If the Node.js process is compromised, the attacker has root access. Always create a dedicated nextjs user with minimal permissions. PM2 runs as that user, and Nginx (which does need port 80/443) runs as its own www-data user.
Not setting NODE_ENV=production. Next.js skips critical optimizations in development mode: no minification, no dead code elimination, verbose error pages with source maps exposed. Always set NODE_ENV=production in your PM2 config or shell environment before building and starting.
Exposing port 3000 directly to the internet. Never let users hit the Node.js process directly. Nginx provides TLS termination, rate limiting, security headers, gzip compression, and protection against slowloris attacks. Your security group should only allow port 3000 from 127.0.0.1.
ISR cache growing unbounded. On high-traffic sites with many dynamic pages (e.g., /product/[id] with 100k products), .next/cache/fetch-cache/ and .next/cache/images/ can fill the disk. Monitor disk usage and set up a cron job to prune old cache entries.
Missing Upgrade headers in Nginx. Without proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade", WebSocket connections fail silently. This affects Server Actions streaming responses, React Server Components streaming, and dev-mode HMR. The connection appears to work but data never arrives.
Certbot renewal not automated. Let's Encrypt certificates expire every 90 days. While certbot sets up a systemd timer by default, verify it is active: sudo systemctl list-timers | grep certbot. If the timer is missing, add 0 0 1 * * certbot renew --quiet to root's crontab.
NEXT_PUBLIC_ vars baked at build time. Developers migrating from Vercel are used to changing environment variables in a dashboard and having them take effect on the next request. On a standalone server, NEXT_PUBLIC_ variables are embedded in the JavaScript bundle during next build. Changing them requires a full rebuild and redeploy, not just a PM2 restart. Server-side-only variables (without the NEXT_PUBLIC_ prefix) do take effect after a restart.
Build failing with OOM on small instances.next build can consume 2+ GB of RAM on large apps. If you are building on a t3.micro (1 GB RAM), add swap space or build on a larger instance and copy the .next/ directory over.
Forgetting to persist .next/cache/ across deploys. If your deploy script runs rm -rf .next before building, you lose the build cache and ISR cache. Builds take longer, and all ISR pages must regenerate. Instead, only remove .next/server/ and .next/static/ if needed, preserving .next/cache/.
Does ISR (Incremental Static Regeneration) still work on a standalone server?
Yes. ISR works out of the box on a single server because regenerated pages are written to .next/cache/ on local disk. The only complication is multi-server setups where each server has its own cache. In that case, use sticky sessions, a shared NFS mount (AWS EFS), or a custom cache handler backed by Redis or S3.
How do I do preview deployments without Vercel?
You have several options: (1) Run a separate PM2 process per branch on a different port, with Nginx routing by subdomain (pr-123.preview.myapp.com). (2) Use Coolify or Dokku, which provide automatic preview deploys. (3) Skip preview deploys and rely on staging environments. Most teams choose option 3 unless they have a dedicated DevOps engineer.
What about image optimization? Does next/image still work?
Yes, next/image works on a standalone server. The difference is that image optimization (resizing, format conversion to WebP/AVIF) runs on your server's CPU instead of Vercel's edge network. For high-traffic sites, this can be CPU-intensive. Mitigation: put a CDN (CloudFront, Cloudflare) in front of your server to cache optimized images, or use loader prop to offload to a service like Cloudinary or Imgix.
How do I roll back a bad deployment?
Since you are deploying via git pull, roll back by checking out the previous commit and rebuilding:
cd /home/nextjs/appgit log --oneline -5 # Find the last good commitgit checkout <commit-hash> # Check out that commitnpm ci && NODE_ENV=production npx next buildpm2 reload myapp
For faster rollbacks, keep the previous .next/ build directory as a backup before each deploy.
Can I use Server Actions on a standalone server?
Yes. Server Actions work identically on a standalone server. They execute as POST requests to the same Node.js server. The only difference from Vercel is that they run in a long-lived Node.js process instead of a serverless function, so be mindful of memory leaks in long-running processes.
Do I need a load balancer for a single server?
No. A single EC2 instance with Nginx as a reverse proxy is sufficient. You only need a load balancer (AWS ALB/NLB) when scaling to multiple servers. However, even with one server, placing it behind an ALB gives you health checks, easy SSL termination via ACM (no certbot needed), and a simpler migration path when you scale later.
How much does this cost compared to Vercel?
A t3.medium EC2 instance (2 vCPU, 4 GB RAM) costs approximately $30/month with a reserved instance or $34/month on-demand. This can handle moderate traffic that would cost $100+ on Vercel Pro. However, you are paying with your time for ops, monitoring, and security patches. For small teams, Vercel's managed service is often cheaper when you factor in engineering time.
Should I use the standalone output mode or the default?
Use output: "standalone" when deploying in Docker containers or when you want the smallest possible deployment artifact (~50 MB). Use the default output when you deploy with the full node_modules/ directory and want simpler deploys (just git pull && npm ci && next build && pm2 reload). Standalone adds a manual step of copying public/ and .next/static/.
How do I handle multiple environments (staging, production)?
Use separate .env.staging and .env.production files. Next.js loads .env.production automatically when NODE_ENV=production. For staging, either set NODE_ENV=staging with a custom env loading strategy, or use PM2 ecosystem files with different env blocks per environment:
The API is identical, but the runtime is different. On Vercel, Middleware runs in V8 edge isolates (limited Web API). On a standalone server, Middleware runs in the full Node.js runtime, which means you have access to more Node.js APIs but lose the edge-location benefit. If your Middleware is latency-sensitive (e.g., geolocation redirects), consider placing a CDN in front of your server.
How do I set up a CI/CD pipeline for this?
Use GitHub Actions (or your CI tool) to SSH into the server and run the deploy script:
What if my build takes too long and causes downtime?
The build runs while the old version is still serving traffic (PM2 keeps the old processes alive until pm2 reload). There is no downtime during the build itself. The only risk is if the build consumes so much CPU/RAM that it degrades the running app. Solutions: (1) Build on a separate CI server and rsync the .next/ directory over. (2) Use a larger instance during builds. (3) Add swap space.