# Deploy Next.js on Hetzner $5 by Dinner Tonight

> CX22 to production: server setup, Tailscale mesh, Cloudflare Tunnel, PM2, swapfile, and the six gotchas with exact fixes.

Source: https://boringpush.com/blog/deploy-nextjs-hetzner-vps

## "just get a VPS" hides forty small decisions

Every "ditch Vercel" thread ends with "just get a Hetzner box" and skips the part where founders burn a weekend: which image, what firewall, how does HTTPS work with no open ports, why does the build die with `Killed`, why does Google OAuth 400, why does SQLite segfault. The [exit threads](https://www.reddit.com/r/vercel/comments/1sxczcl/we_left_vercel_at_1800month_heres_what_we) that succeed all share one trait — someone wrote down the boring parts.

This is that write-down, matching BoringPush's VPS guide and the Next.js stack that expects it.

## The setup: box to HTTPS in an afternoon

**1. The box.** Hetzner CX22 (2 vCPU, 4GB RAM — verify current specs/pricing), Ubuntu LTS, SSH key at create time. Add a 2GB swapfile immediately — Next.js builds spike past small RAM and die with `Killed` otherwise:

```bash
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```

**2. Zero open ports.** Tailscale up with `--ssh`, then close public SSH and web ports entirely — Cloudflare Tunnel takes visitors through an outbound-only connection, Tailscale carries your admin traffic:

```bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh
sudo ufw delete allow OpenSSH
sudo ufw allow in on tailscale0
```

**3. HTTPS without certbot pain.** `cloudflared` tunnel → `localhost:3000`, hostname in the Zero Trust dashboard, free SSL + DDoS at the edge. PM2 keeps Next alive (`pm2 reload` = zero-downtime deploys), and `git push production main` becomes the entire CI: local gate runs, server builds natively for Linux, traffic swaps. Ship from anywhere on this Next.js stack — laptop, phone, cafe wifi, same command.

## The six gotchas (all hit in production, all fixed)

| Gotcha | Symptom | Exact fix |
|---|---|---|
| Keyboard-layout prompt hangs setup | `server-setup.sh` stops asking country | Type `35` (English US); export `DEBIAN_FRONTEND=noninteractive` forever |
| Deploy hangs at "Connecting…" | First Tailscale SSH needs browser approval | `ssh coder@<TAILSCALE_IP>` once interactively, approve, `ssh-copy-id` |
| Next.js "Internal Server Error", logo loads | `MODULE_NOT_FOUND` middleware manifest | Build never ran: `npm run build` then `pm2 reload` |
| OOM `Killed` on build | 1–2GB box dies compiling | 2GB swapfile above (already done if you followed step 1) |
| Google OAuth 400 redirect mismatch | Stale site address in the production settings plus a cached environment | Register the exact callback URI, then `bp secret sync` (reloads the app) |
| SQLite segfault on login | Node 20 + Ubuntu 24.04 ABI clash | Node 22 LTS + `npm rebuild better-sqlite3 --build-from-source` |

## FAQ

### Hetzner vs DigitalOcean vs Linode?

Interchangeable for this shape — pick by region latency to your users and current promo pricing (all hover $4–6 for 2vCPU/4GB; verify). The setup is provider-agnostic by design.

### Do I need Docker for this?

No. Native PM2 + git-push deploys are simpler and use less RAM.

Docker enters when you need reproducible multi-service topologies — most SaaS never does.

### What about staging?

Second $5 box, or same box with a second PM2 process on another port behind a `staging.` tunnel hostname. Cheap enough that there's no excuse for cowboy deploys.

### How do backups work without a managed DB?

Daily consistent database snapshots plus hourly log trimming, both built in, plus volume snapshots at the provider for disasters. Single-file restores: copy back, restart.

### IPv6, firewalls, fail2ban — needed?

Tailscale makes most of it moot: no public SSH means nothing to brute-force. Keep UFW default-deny, Cloudflare in front, and spend the saved hours on the product.
