A Self-Hosting Lab for Next.js
What does it really take to self-host a modern Next.js app on a VPS? Flood is an open, reproducible lab for finding out: a monorepo containing a Next.js 16 blog platform, infrastructure-as-code to deploy it to a VPS, and k6 load tests to understand how it behaves under real traffic.
This is not a benchmark and not a framework comparison. Some apps run perfectly well on a $5 VPS with basic tuning. The goal is to give you the tools to measure and understand your own setup — whether you stay self-hosted or decide a managed platform makes more sense for your use case.
The app itself only needs Node.js and pnpm. The CLI tools for provisioning, VMs, and load testing require:
- Node.js 22+ and pnpm 10+ —
corepack enable && corepack install - Ansible —
brew install ansible— provisions and deploys to servers - One of:
- k6 —
brew install k6— load testing (optional, only forflood load)
pnpm install
pnpm build
pnpm dev # starts the Next.js app locally (SQLite, no external deps)
# Seed 1000 articles
curl -X POST http://localhost:3000/api/seed \
-H "Content-Type: application/json" -d '{"count": 1000}'Link the CLI for global access:
cd apps/cli && pnpm link --global
flood --helpAll project-wide commands run through turbo:
pnpm build # build all packages
pnpm dev # start dev servers (in-memory cache)
pnpm dev:redis # start dev servers with Redis cache handler
pnpm test # run all tests
pnpm typecheck # type-check all packages
pnpm clean # clean build outputsBy default, "use cache" directives use Next.js's built-in in-memory LRU cache. Set FLOOD_CACHE_HANDLER=redis to use a Redis-backed cache handler instead — useful for multi-instance deployments where cache needs to be shared.
# Start Redis + dev server in one command
pnpm dev:redis
# Or manually
docker compose -f docker-compose.dev.yaml up -d # starts Redis on :6379
FLOOD_CACHE_HANDLER=redis REDIS_URL=redis://localhost:6379 pnpm devThe Redis handler lives at apps/web/src/lib/cache-handlers/redis.js and implements the Next.js 16 cacheHandlers interface (get, set, refreshTags, getExpiration, updateTags).
| Package | Description |
|---|---|
apps/web |
Next.js 16 multi-author blog (the demo app being load tested) |
apps/cli |
CLI for provisioning, deploying, and running load tests |
packages/config |
Shared Zod schemas and env var definitions |
packages/tsconfig |
Shared TypeScript configs |
infra/ansible |
Ansible roles and playbooks for server provisioning |
infra/multipass |
Cloud-init config for local Ubuntu VMs |
k6/ |
k6 load test scripts (general + 7 dedicated scenarios) |
A multi-author blog with 10K+ generated articles. Exercises every Next.js self-hosting feature:
- ISR with
use cache— cached article, author, and tag pages - Custom cache handler — Redis-backed shared cache for multi-instance deployments
- Image optimization —
next/imagewith external sources (picsum.photos) - OG image generation —
next/ogfor dynamic social images - Streaming — React Suspense on the feed page
- Server Actions — newsletter signup form
- Search — full-text search, fully dynamic
- API routes — health check, metrics, seeding
flood load <target> # realistic traffic (50 VUs, 5 minutes)
flood load <target> --stress # ramp to 500 VUs
flood load <target> --scenario cache-exhaustion # target a specific failure modeThere are 7 dedicated scenario scripts in k6/scenarios/, each targeting a specific self-hosting behavior. See Scenarios for full details.
flood vm create my-vm # create Ubuntu VM with cloud-init
IP=$(flood vm ip my-vm) # get the VM's IP address
flood provision $IP # install Node, PostgreSQL, Caddy, Beszel
flood build # build the app locally
flood deploy $IP # rsync build to VM, start with systemd
flood seed $IP # seed the databaseFor local VMs, HTTPS is automatic — the CLI detects the .flood.local domain from /etc/hosts and Caddy provisions a self-signed cert. Access the app at https://my-vm.flood.local.
For public domains, pass --domain explicitly:
flood provision $IP -d flood.example.com # Let's Encrypt cert (public domains)flood vps create my-vps --tier tier1 # create a DO droplet
IP=$(flood vps ip my-vps) # get the IP
flood provision $IP # provision the server
flood build # build the app locally
flood deploy $IP # deploy to the droplet
flood seed $IP # seed the database
flood load $IP # run load tests
flood vps destroy my-vps # tear down when done (stops billing)Requires doctl authenticated with your DigitalOcean account. Tiers: tier1 ($6/mo, 1 vCPU/1GB), tier2 ($12/mo, 1 vCPU/2GB), tier3 ($24/mo, 2 vCPU/4GB). Use flood vps destroy-all --force to clean up all flood droplets.
Same workflow as above, just use your server's IP directly. The default SSH user is root; override with -u if needed.
flood provision 203.0.113.10 # plain HTTP on :80
flood provision 203.0.113.10 -d flood.example.com # automatic HTTPS via Let's Encrypt
flood provision -u ubuntu 203.0.113.10 # different SSH user
flood build
flood deploy 203.0.113.10All commands use Ansible, which connects over SSH. Ansible uses your default SSH key (~/.ssh/id_ed25519, ~/.ssh/id_rsa, etc.) or whichever key is loaded in your SSH agent — no extra config needed.
For a remote VPS, make sure your public key is on the server first (most providers let you add it at creation time). For Multipass VMs, SSH access is handled automatically.
Beszel provides lightweight system metrics (CPU, memory, disk, network) via a web dashboard. It's pre-configured during provisioning — no manual setup needed.
Open http://<target>:8090 and log in with admin@flood.local (password from FLOOD_BESZEL_PASSWORD or default flood_beszel_dev). The local system is already registered and collecting metrics.
Open Beszel at http://<target>:8090 before starting the test so you can watch metrics in real time.
flood load <target> # realistic traffic (50 VUs, 5 min)
flood load <target> --stress # ramp to 500 VUs
flood load <target> --vus 200 --duration 10m # custom
flood load <target> --scenario cache-exhaustion # target a specific failure mode
flood load <target> --scenario isr-revalidation # target a specific behaviork6 prints live stats as the test runs and a summary at the end. Dedicated scenarios have their own defaults for VUs and duration but accept --vus and --duration overrides.
| Metric | Where | Warning sign |
|---|---|---|
| http_req_duration (p95) | k6 output | > 500ms means the server is struggling |
| http_req_failed | k6 output | Any non-zero failure rate |
| CPU usage | Beszel / htop | Sustained > 90% = bottleneck |
| Memory usage | Beszel | Node process > 80% of available RAM |
| PostgreSQL connections | journalctl -u flood-web |
"too many connections" errors |
| Reverse proxy queue | Beszel network graph | Flat throughput + rising latency = saturation |
- CPU-bound: p95 latency climbs linearly with VUs. OG image generation and uncached SSR are the most expensive operations.
- Memory pressure: Node process gets OOM-killed by the OS. Check
dmesg | grep -i oomafter a crash. - Connection pool exhaustion: Database errors in app logs. Increase
FLOOD_DB_POOL_SIZEin the.env. - Response buffering: If streaming responses (Suspense) feel slow, verify
flush_interval -1is set in the Caddyfile.