Why Bare Metal for Next.js?
Next.js was designed to run on Vercel, but that doesn't mean it should. Vercel's pricing punishes growth: $20/user/month, bandwidth caps at 1TB, and serverless functions that cold-start at 250ms+. For a 5-person team with moderate traffic, you're looking at $200-500/mo before you even hit scaling issues.
On bare metal, your Next.js app runs as a persistent Node.js process. No cold starts. No function timeouts. No bandwidth surprises. The server is always warm, always yours, and costs a fraction of what Vercel charges.
| Metric | Bare Metal (RAW) | Vercel Pro |
|---|---|---|
| Cold start | 0ms (always running) | 250-800ms |
| Bandwidth | 20 TB included | 1 TB ($40/100GB after) |
| Build minutes | Unlimited | 6,000/mo |
| Team pricing | $6/mo flat | $20/user/mo |
| Root access | Full SSH | None |
| Database | Self-host ($0) | Vercel Postgres ($20+/mo) |
RAW raw-2 ARM plan: 2 vCPU, 4 GB RAM, 40 GB NVMe. Enough for most Next.js apps.
Prerequisites
- A bare metal server (we'll use RAW —
npx rawhq deploy) - A domain name pointed to your server's IP
- A Next.js app ready to deploy
- Basic SSH knowledge
Step 1: Provision and Prepare the Server
Spin up a server and connect via SSH:
# Deploy a RAW server (Ubuntu 24.04)
npx rawhq deploy
# SSH into your new server
ssh root@your-server-ip
# Update packages
apt update && apt upgrade -yStep 2: Install Node.js
Install Node.js 22 LTS using the official NodeSource repository:
# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
# Verify installation
node --version # v22.x.x
npm --version # 10.x.x
# Install PM2 globally for process management
npm install -g pm2Step 3: Deploy Your Next.js App
Clone your repo, install dependencies, and build:
# Clone your project
git clone https://github.com/youruser/yourapp.git /var/www/app
cd /var/www/app
# Install dependencies
npm ci --production=false
# Build for production
npm run build
# Verify the build works
npm start
# Visit http://your-server-ip:3000 — you should see your appStep 4: Run with PM2
PM2 keeps your app running, restarts it on crashes, and auto-starts on server reboot. Create an ecosystem file for reproducible deployments:
# /var/www/app/ecosystem.config.js
module.exports = {
apps: [{
name: 'nextjs-app',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/var/www/app',
instances: 'max', // Use all available CPU cores
exec_mode: 'cluster', // Cluster mode for load balancing
env: {
NODE_ENV: 'production',
PORT: 3000
},
max_memory_restart: '500M',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
error_file: '/var/log/pm2/nextjs-error.log',
out_file: '/var/log/pm2/nextjs-out.log',
merge_logs: true
}]
}# Create log directory
mkdir -p /var/log/pm2
# Start with PM2
pm2 start ecosystem.config.js
# Save the process list (survives reboots)
pm2 save
# Set PM2 to start on boot
pm2 startup systemd
# Run the command it outputs
# Check status
pm2 status
pm2 logs nextjs-app --lines 20Step 5: Nginx Reverse Proxy
Nginx sits in front of your Node.js process — handling SSL termination, static file caching, gzip compression, and HTTP/2. This is critical for performance.
# Install Nginx
apt install -y nginx
# Remove default config
rm /etc/nginx/sites-enabled/defaultCreate the Nginx config:
# /etc/nginx/sites-available/nextjs
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 (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 60s;
proxy_send_timeout 60s;
}
# Cache Next.js static assets aggressively
location /_next/static/ {
proxy_pass http://nextjs_upstream;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Cache public directory assets
location /public/ {
proxy_pass http://nextjs_upstream;
add_header Cache-Control "public, max-age=86400";
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 256;
}# Enable the site
ln -s /etc/nginx/sites-available/nextjs /etc/nginx/sites-enabled/
# Test config
nginx -t
# Reload Nginx
systemctl reload nginxStep 6: SSL with Certbot (Free HTTPS)
Let's Encrypt gives you free SSL certificates, auto-renewed. This takes 30 seconds:
# Install Certbot
apt install -y certbot python3-certbot-nginx
# Get certificate (replace with your domain)
certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Certbot automatically:
# 1. Obtains the certificate
# 2. Modifies your Nginx config to use HTTPS
# 3. Sets up auto-renewal via systemd timer
# Verify auto-renewal
certbot renew --dry-runAfter Certbot runs, uncomment the return 301 line in your Nginx config to force HTTPS.
Alternative: Deploy with Docker
If you prefer containers, here's a production-ready Dockerfile for Next.js:
# Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]This requires output: 'standalone' in your next.config.js:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig# Build and run
docker build -t nextjs-app .
docker run -d --name nextjs-app -p 3000:3000 --restart unless-stopped nextjs-appCI/CD with GitHub Actions
Automate deployments on every push to main. This workflow builds your app, SSHs into your server, pulls the latest code, and restarts PM2:
# .github/workflows/deploy.yml
name: Deploy to Bare Metal
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: root
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/app
git pull origin main
npm ci --production=false
npm run build
pm2 reload ecosystem.config.js --update-env
echo "Deployed at $(date)"Add your server IP and SSH key as GitHub repository secrets (SERVER_HOST and SSH_PRIVATE_KEY).
For the Docker variant, replace the deploy script with:
script: |
cd /var/www/app
git pull origin main
docker build -t nextjs-app .
docker stop nextjs-app || true
docker rm nextjs-app || true
docker run -d --name nextjs-app -p 3000:3000 --restart unless-stopped nextjs-app
docker image prune -fPerformance: Bare Metal vs Vercel
We benchmarked a standard Next.js 15 app (App Router, 20 routes, PostgreSQL backend) across both platforms:
| Metric | Bare Metal (RAW raw-4) | Vercel Pro |
|---|---|---|
| TTFB (p50) | 42ms | 110ms |
| TTFB (p99) | 89ms | 680ms (cold start) |
| API route latency | 8ms | 45ms |
| ISR revalidation | Instant (local) | ~300ms (edge prop) |
| Concurrent users | 5,000+ | Varies (throttled) |
| Cost (5 devs) | $11/mo | $100/mo + overages |
Tested with k6, 30-second sustained load, Frankfurt datacenter. RAW raw-4 ARM: 4 vCPU, 8 GB RAM.
The biggest win is eliminating cold starts. On Vercel, serverless functions spin down after inactivity. On bare metal, your Node.js process is always running. API routes respond in single-digit milliseconds.
Production Checklist
Before going live, verify these essentials:
- SSL working:
curl -I https://yourdomain.comreturns 200 - PM2 persistence: Reboot the server, verify your app comes back up
- Firewall:
ufw allow 22,80,443/tcp && ufw enable - Monitoring: Set up uptime checks (UptimeRobot, BetterStack, or a simple cron curl)
- Backups: Snapshot your server weekly via RAW dashboard
- Logs:
pm2 logsfor app logs,/var/log/nginx/for access/error
When to Stay on Vercel
Bare metal isn't for everyone. Stay on Vercel if:
- You're a solo developer and $20/mo is worth the time savings
- You need edge functions in 30+ regions globally
- Your app is a static site or mostly ISR (Vercel's CDN is genuinely great for this)
- You have zero interest in server management
For everyone else — teams, API-heavy apps, apps with databases, budget-conscious startups — bare metal gives you 10x the resources at a fraction of the cost.
Deploy Next.js on RAW
Get a bare metal server in 13 seconds. Pre-installed with Ubuntu 24.04, Node.js ready. Full root access, 20 TB bandwidth, no surprises on your bill.
$ npx rawhq deployDeploy Free Server →