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.

MetricBare Metal (RAW)Vercel Pro
Cold start0ms (always running)250-800ms
Bandwidth20 TB included1 TB ($40/100GB after)
Build minutesUnlimited6,000/mo
Team pricing$6/mo flat$20/user/mo
Root accessFull SSHNone
DatabaseSelf-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 -y

Step 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 pm2

Step 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 app

Step 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 20

Step 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/default

Create 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 nginx

Step 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-run

After 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-app

CI/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 -f

Performance: Bare Metal vs Vercel

We benchmarked a standard Next.js 15 app (App Router, 20 routes, PostgreSQL backend) across both platforms:

MetricBare Metal (RAW raw-4)Vercel Pro
TTFB (p50)42ms110ms
TTFB (p99)89ms680ms (cold start)
API route latency8ms45ms
ISR revalidationInstant (local)~300ms (edge prop)
Concurrent users5,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.com returns 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 logs for 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 →