Boring Docs

Why Ditch Vercel & The Zero-Open-Port VPS Manual (Cloudflare Tunnel + Tailscale)

The complete guide to quitting the $100/mo Vercel tax, setting up a zero-attack-surface VPS with Tailscale and Cloudflare Tunnel, and battle-tested troubleshooting for real-world issues.

Why Ditch Vercel & The Zero-Open-Port VPS Manual

$5/Month Total Cost • Zero Open Public Ports • 0.1ms SQLite Queries • Zero Docker Lock-In


1. Why Ditch Vercel, Serverless & Cloud DBs?

Most modern boilerplates push founders into the "Modern Cloud Stack": Vercel + Neon Postgres + Upstash Redis + Datadog/Sentry + Clerk Auth.

Before you make your first dollar, you are locked into a fragile, multi-vendor subscription trap:

The Real Cost Comparison

Problem The Vercel + Cloud DB Stack The BoringPush VPS Stack
Monthly Hosting Bill $20/seat + bandwidth overages ($50–$200+/mo) $4–$5/mo flat (Hetzner, DigitalOcean, Linode)
Database Cost $29/mo (Neon / Supabase compute add-on) $0 (Embedded SQLite on NVMe SSD)
Query Latency 35ms–100ms per query across cloud network hops 0.1ms (In-memory / local disk read)
Connection Limits Serverless function connection pooling exhaustion (500 errors) Zero connection limits (Direct file descriptor)
Cold Starts 800ms–2.5s cold boots on lambdas 0ms (PM2 persistent Node.js process)
Execution Timeouts Hard 10s–15s function timeouts kill long tasks Unlimited (Long-running workers & exports run fine)
Attack Surface Complex IAM permissions, public endpoints 0 public open ports (Invisible to port scanners)

The Latency Truth

When your Next.js serverless route executes 3 database queries to render a page, each query incurs a TCP roundtrip to AWS us-east-1 or Supabase:
$$3 \text{ queries} \times 35\text{ms} = 105\text{ms of pure network waiting}.$$

On a $5/mo VPS with SQLite:
$$3 \text{ queries} \times 0.1\text{ms} = 0.3\text{ms total}.$$
Your pages render and return before a serverless function even finishes its SSL handshake.


2. The Zero-Attack-Surface Architecture

How do you run on a bare VPS safely without becoming a full-time DevOps engineer?

We combine two battle-tested zero-trust tools:

                            VISITORS (Public Web)
                                      │
                                      ▼ HTTPS request (boringpush.com)
                        ┌───────────────────────────┐
                        │   Cloudflare Edge Network │
                        └─────────────┬─────────────┘
                                      │ Outbound Encrypted Tunnel (Zero inbound ports)
                                      ▼
┌────────────────────────────────────────────────────────────────────────────────┐
│ YOUR VPS (Port 22, 80, 443 are 100% CLOSED on the public internet)            │
│                                                                                │
│   ┌──────────────────────────┐         ┌────────────────────────────────────┐  │
│   │ cloudflared daemon       │────────▶│ Next.js App (127.0.0.1:3000)       │  │
│   │ (Inbound Web Traffic)    │ HTTP    │ Supervised by PM2 (zero-downtime)  │  │
│   └──────────────────────────┘         └─────────────────┬──────────────────┘  │
│                                                          │                     │
│                                                          ▼                     │
│                                        ┌────────────────────────────────────┐  │
│                                        │ SQLite DB (/var/www/app/db/app.db) │  │
│                                        │ 0ms latency, persistent on disk    │  │
│                                        └────────────────────────────────────┘  │
│                                                          ▲                     │
│                                                          │ build & reload      │
│   ┌──────────────────────────┐         ┌─────────────────┴──────────────────┐  │
│   │ Tailscale (WireGuard)    │────────▶│ Bare Git Repo (/var/repo/app.git)  │  │
│   │ (Private Admin & Deploy) │ SSH     │ post-receive hook                  │  │
│   └──────────────────────────┘         └────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────────────────────┘
                                      ▲
                                      │ git push production main
                                      │ Encrypted WireGuard Mesh (100.x.y.z)
                        ┌─────────────┴─────────────┐
                        │ YOUR MAC / LAPTOP         │
                        │ Runs local CI gate        │
                        └───────────────────────────┘
  1. Cloudflare Tunnel (cloudflared):
    • Creates an outbound-only connection from your VPS to Cloudflare's global edge.
    • Visitors reach https://boringpush.com with free automated SSL, DDoS mitigation, and edge caching.
    • No ports 80 or 443 need to be opened on your VPS firewall.
  2. Tailscale (Private WireGuard Mesh):
    • Assigns your VPS a private, permanent 100.x.y.z IP and MagicDNS name.
    • Used exclusively by you for terminal SSH and git push production main.
    • Port 22 is completely closed to the public internet. Port scanners see a brick wall.

3. How to Set Up Cloudflare Tunnel (In 3 Minutes)

  1. Go to your Cloudflare Zero Trust Dashboard.
  2. Navigate to Networks → Tunnels and click Create a Tunnel.
  3. Select Cloudflared, name it (e.g. vps-tunnel), and save.
  4. Under Install and run a connector, select Debian 64-bit and run the provided command on your VPS terminal:
    sudo cloudflared service install <YOUR_TUNNEL_TOKEN>
    
  5. Click Next to go to Public Hostnames:
    • Subdomain: Leave blank (for apex domain boringpush.com) or enter subdomain (e.g. app).
    • Domain: Select your domain from the dropdown (boringpush.com).
    • Type: Select HTTP.
    • URL: Type localhost:3000 (or 127.0.0.1:3000).
  6. Click Save hostname.

4. How to Set Up Tailscale (In 2 Minutes)

  1. On your VPS, install and enable Tailscale with native SSH:
    curl -fsSL https://tailscale.com/install.sh | sh
    sudo tailscale up --ssh
    
  2. On your Mac, ensure Tailscale is installed and logged into the same account.
  3. Verify your connection:
    tailscale status
    
    You will see your VPS name and its 100.x.y.z IP.
  4. Close public SSH on the server firewall completely:
    sudo ufw delete allow OpenSSH
    sudo ufw allow in on tailscale0
    sudo ufw reload
    

5. Battle-Tested Field Guide: Common Issues & Exact Fixes

Here are the real-world gotchas encountered when provisioning a fresh VPS and how to solve them instantly:

Issue 1: Terminal Prompts: "Country of origin for the keyboard"

  • What Happens: During server-setup.sh, the process stops at an interactive Debian/Ubuntu dialogue asking for keyboard layout numbers (35. English (US)).
  • Why: Ubuntu updates system packages (keyboard-configuration) interactively by default.
  • The Fix:
    • If stuck in the prompt: Type 35 (English US) and press Enter, then 1 for standard layout.
    • To prevent it permanently: Always export non-interactive flags before updating:
      export DEBIAN_FRONTEND=noninteractive
      apt-get update -y && apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade -y
      

Issue 2: ./scripts/deploy/setup.sh Hangs at Connecting to ... as coder...

  • What Happens: The script prints Connecting to <IP>... and halts with no output.
  • Why:
    1. Tailscale SSH prompts for browser approval on the first connection (https://login.tailscale.com/a/...), which scp hides.
    2. Or scp is waiting silently for an SSH password/key passphrase.
  • The Fix:
    1. Cancel the stuck command (Ctrl + C).
    2. Run a direct interactive SSH connection once:
      ssh coder@<TAILSCALE_IP>
      
    3. If Tailscale gives you an approval URL, click it in your browser. If it asks for a password, enter it.
    4. Ensure your public SSH key is copied to the server:
      ssh-copy-id coder@<TAILSCALE_IP>
      

Issue 3: Next.js Shows "Internal Server Error" (Static Logo Loads Fine)

  • What Happens: Visiting your domain loads the page logo or styles, but renders a generic Next.js "Internal Server Error".
  • Checking the Logs:
    sudo -u deploy pm2 logs boringpush --lines 20
    
    Shows: NextNodeServer.getMiddlewareManifest ... MODULE_NOT_FOUND.
  • Why: PM2 was started, but the production build has not run yet. The .next/ directory or its middleware-manifest.json file is missing.
  • The Fix: Run a production build on the server:
    # From your Mac:
    git push production main
    
    # Or directly on the server:
    cd /var/www/boringpush
    sudo -u deploy npm run build
    sudo -u deploy pm2 reload boringpush
    

Issue 4: Out-Of-Memory (OOM) Build Failure (Killed / SIGKILL)

  • What Happens: npm run build abruptly aborts with Killed on a 1GB or 2GB VPS.
  • Why: Next.js webpack/SWC compilation can spike memory usage to 1.5GB+ for a few seconds.
  • The Fix: Allocate a 2GB Linux swapfile on the VPS (included in BoringPush setup):
    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
    


Issue 5: Google OAuth Error 400: redirect_uri_mismatch

  • What Happens: Clicking "Continue with Google" opens Google's sign-in page with Error 400: redirect_uri_mismatch.
  • Why:
    1. Next.js in production (next start) loads .env.production with higher priority than .env. If .env.production has a stale NEXTAUTH_URL, it overrides .env.
    2. PM2 retains its initial environment variables across reloads unless invoked with --update-env.
    3. Google Cloud Console does not have the exact production redirect URI registered.
  • The Fix:
    1. In Google Cloud Console (APIs & Services → Credentials → OAuth 2.0 Client IDs), under Authorised redirect URIs, add:
      https://yourdomain.com/api/auth/callback/google
      
      (Make sure it uses https://, has no trailing slash, and includes /api/auth/callback/google).
    2. In your local .env.production, ensure:
      NEXTAUTH_URL=https://yourdomain.com
      
    3. Run the one-command sync from your Mac:
      npm run env:sync
      
      This pushes your .env.production to both .env and .env.production on the server and reloads PM2 with --update-env.

Issue 6: Segmentation fault (core dumped) on SQLite / NextAuth Callback

  • What Happens: The server logs show Segmentation fault (core dumped) immediately when a user attempts to log in or query SQLite.
  • Why: On modern Linux distros (e.g. Ubuntu 24.04 LTS), Node 20 packages can have an ABI symbol registration mismatch with native C++ modules (better-sqlite3).
  • The Fix: Upgrade to Node.js 22 LTS on the VPS:
    # 1. Upgrade to Node 22 LTS on the server:
    curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
    sudo apt-get install -y nodejs
    
    # 2. Rebuild native modules from source:
    cd /var/www/boringpush
    sudo -u deploy npm rebuild better-sqlite3 --build-from-source
    
    # 3. Reload PM2:
    sudo -u deploy pm2 restart boringpush --update-env
    

6. How Mac-to-Linux Deployment Works (Architecture Deep Dive)

Founders often ask: "If I am on a Mac (Apple Silicon ARM64), how does pushing to an Ubuntu x86_64 server work without breaking? Do I build on the server every time?"

Here is the exact lifecycle:

The Golden Rule: Never Ship Machine Binaries Over Git

  • Your Code is 100% Portable: All JavaScript, React JSX, CSS, Tailwind tokens, and Markdown are plain text. They run identically on macOS, Linux, and Windows.
  • Native C++ Modules Are Architecture-Specific: Packages with compiled C++ code (like better-sqlite3, sharp, bcrypt) compile binary .node machine code. A binary compiled on Apple Silicon (darwin-arm64) will instantly crash on Linux (linux-x64).
  • Why We Build on the Linux Server: By pushing pure git source code, the Linux server compiles its own native .next bundles and links directly against its host glibc. You never have to deal with Docker multi-arch builds or cross-compilation errors.

Do We Rebuild better-sqlite3 Every Time?

No. Only the very first time!

  • When better-sqlite3 is compiled for Node 22 during initial setup, the native binary lives inside /var/www/boringpush/node_modules/.
  • When you run git push production main:
    • Git updates only your changed source files (app/, components/, config/).
    • node_modules/ is never touched or wiped.
    • The server only runs next build (compiling your JavaScript and styles in ~15–20 seconds).
    • PM2 performs a zero-downtime graceful reload (pm2 reload --update-env).

The Double-Gate Safety Net

  1. Gate 1 (Local Pre-Push on Mac): The local Git hook runs next lint and next build on your Mac. If you have a syntax error, broken import, or type failure, the push is blocked on your machine. Bad code never touches production.
  2. Gate 2 (Remote Post-Receive on Linux): The server checks out the clean commit, builds the production bundle natively for Linux x86_64, generates live production sitemaps, and seamlessly switches incoming traffic.

7. Daily Deployment Cheat Sheet

Task Command Where to Run
Deploy code updates git push production main Local Mac Terminal
Sync environment variables npm run env:sync Local Mac Terminal
Check server status sudo -u deploy pm2 status VPS Terminal
Inspect live logs sudo -u deploy pm2 logs boringpush VPS Terminal
Instant SQLite database snapshot npm run backup:mjs Local or VPS
One-second rollback to previous commit git checkout HEAD~1 && pm2 reload boringpush --update-env VPS Terminal