All playbooks
Self-hosting
24 min read

Deploying Next.js to a £6 VPS

The full recipe to take a production Next.js 16 app off Vercel and onto a tiny VPS without giving up ISR, image optimisation, edge-style streaming, or automatic deploys. The bill drops from triple digits to single digits per month.

Why leave Vercel

Vercel is a wonderful product for the first six months of any app. The DX is unbeatable, previews are instant, and you stop thinking about infrastructure. The moment your traffic, bandwidth, or function invocations cross the Hobby line, the bill starts climbing in ways that surprise people. Image optimisation, ISR cache writes, edge functions, and bandwidth all meter independently. Two of my clients hit a £400 month bill before they noticed.

A £6 VPS will happily serve a Next.js app doing tens of thousands of requests per day. The work is in the first afternoon of setup; after that it runs unattended. This playbook is the exact recipe I use, distilled from migrating a handful of apps off Vercel.

Vercel sells you the seam; a VPS sells you the cloth. Both are valid. The cloth is cheaper if you can sew.

The VPS pick

I use Hetzner CX22 in Falkenstein. 2 vCPU, 4 GB RAM, 40 GB NVMe, 20 TB bandwidth, £4.50/month. The bandwidth allowance alone makes it ridiculous compared to most cloud providers. For a UK audience, Hetzner Helsinki is slightly faster on average; for a US audience, Hetzner Hillsboro works.

Alternatives that are also fine: DigitalOcean (£5 Basic Droplet), Vultr (£5 Cloud Compute), Scaleway (DEV1-S). Avoid AWS Lightsail; the egress pricing is a trap. Avoid any "serverless" VPS branded thing; you are paying for predictability, not auto scale.

Pick Ubuntu 24.04 LTS. Add a sudo user, lock down SSH to keys only, and install ufw before installing anything else. That is the cheapest thirty minutes of work in the project.

Standalone Dockerfile

Next.js can output a "standalone" build that includes only the files needed to run, plus a minimal Node server. That makes the Docker image small (around 150 MB) and avoids shipping a full node_modules to production. Add this to your next.config.mjs:

js
// next.config.mjs export default { output: 'standalone', // If you serve images from R2 or a CDN, list those domains: images: { remotePatterns: [ { protocol: 'https', hostname: 'pub-*.r2.dev' }, ], }, }

The Dockerfile uses three stages: deps, build, runner.

dockerfile
# syntax=docker/dockerfile:1.7 FROM node:20-alpine AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN corepack enable && pnpm build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs \ && 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 HOSTNAME=0.0.0.0 CMD ["node", "server.js"]

Caddy in front of Node

Caddy is the smallest possible reverse proxy that does TLS automatically. The whole config is twelve lines.

caddy
# /etc/caddy/Caddyfile www.example.com, example.com { encode zstd gzip reverse_proxy localhost:3000 # Cache static assets aggressively; Next emits hashed filenames. @static path /_next/static/* header @static Cache-Control "public, max-age=31536000, immutable" }

Caddy gets a Let's Encrypt cert on first hit and renews it forever. You do not edit it again. If you want HTTP/3, add protocols h1 h2 h3 to the server block.

Env vars and secrets

I keep an /etc/sarmalinux/env file owned by the app user, mode 600. The systemd unit (or docker compose) reads it. The file is never in git; it is restored on a new box by a one shot script that pulls from a private bucket I control.

bash
# /etc/sarmalinux/env, mode 600 NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=ey... SUPABASE_SERVICE_ROLE_KEY=ey... RESEND_API_KEY=re_... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=...

ISR, image optimisation, sharp

Two things people assume only Vercel can do, but Next handles natively on any Node runtime.

ISR writes its cache to disk under .next/cache. On a single VPS, this just works. If you ever scale to multiple containers, point ISR cache at a shared volume or Redis using the experimental.incrementalCacheHandlerPath option.

Image optimisation needs sharp installed in the runtime image. Add RUN apk add --no-cache vips libc6-compat to the runner stage on Alpine, and ensure pnpm install picked up sharp. Optimised images cache under .next/cache/images; mount that on a volume so it survives restarts.

CI driven zero downtime deploys

GitHub Actions, on push to main: build the image, push to a registry (ghcr.io is free for public repos), then ssh to the VPS and run docker compose pull && docker compose up -d. Compose does a rolling restart; the old container stays up until the new one passes its health check.

yaml
# .github/workflows/deploy.yml name: deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: push: true tags: ghcr.io/sarmakska/site:latest - name: SSH and update uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} script: | cd /srv/site docker compose pull docker compose up -d --remove-orphans docker image prune -f

Monitoring and logs

Use the boring stack: journalctl -u caddy for the reverse proxy, docker logs -f site for the app. For metrics, install node_exporter and point a free Grafana Cloud account at it; the free tier handles a single VPS comfortably. For uptime, Uptime Kuma in another container hits the public URL every 60 seconds and emails you when it stops responding.

What it actually costs

Hetzner CX22 £4.50, a domain £8/year, optional R2 for images £0 on free tier. Round it to £6/month, all in. The Vercel Pro plan starts at $20/month and climbs from there once you push image optimisation or ISR. For an app doing under a million requests a month, the VPS is between five and twenty times cheaper.

Pitfalls

Forgetting sharp on Alpine

next/image silently falls back to the Squoosh JS optimiser, which is ~10x slower. Install vips on the runtime image and verify with `node -e "require('sharp')"` in the container.

ISR cache wiped on every deploy

If you do not mount .next/cache on a docker volume, every deploy throws away your ISR pages and the first hits are slow. Add `- isr_cache:/app/.next/cache` to compose.

No log rotation

Docker JSON logs eat disk silently. Add `logging.driver: json-file` with `max-size: 10m` and `max-file: 3` in compose, or you will wake up to a full disk in three months.

Skipping the firewall

ufw default deny incoming, allow 22, allow 80, allow 443. Without it, your Docker bridge happily exposes whatever port your app binds to.

Wrap up

The first time you do this it takes an afternoon. The second time, an hour. After that it is the same recipe for every app and the bill stops being a worry. The hardest part is admitting that the magic of Vercel was, mostly, just a Dockerfile, a reverse proxy, and a deploy script you can own forever.

Want this done for you?

If you would rather skip the YAK shave and have someone who has done this fifty times set it up properly, that is what I do for a living.

Start a project