Profile
Back to NewsBack
Dev.to 18 min
Reader Mode
Scaling Next.js on a $0 VPS: 25ms Cloudflare Edge Shield Guide

Scaling Next.js on a $0 VPS: 25ms Cloudflare Edge Shield Guide

1 day ago

How combining lightweight /api healthchecks, Docker container limits, and Cloudflare edge caching protects dynamic servers from traffic spikes.

The alert triggered at 2:40 PM on a Friday.

A technical post discussing self-hosted infrastructure hit the front page of a popular aggregator. Within six minutes, traffic surged from a comfortable baseline of five requests per minute to more than two hundred requests per second.

On our single-core, 1GB RAM cloud server, the system degraded rapidly.

CPU utilization jumped from 4% to 100%. Node.js event loop latency climbed past 2,500 milliseconds. Within ninety seconds, memory climbed until the Linux kernel Out-Of-Memory (OOM) killer stepped in, terminated the Next.js container, and killed the active SSH daemon. The collapse was total.

Visitors saw blank pages and 502 Bad Gateway errors. Everything froze up.

HTTP/2 502 Bad Gateway
server: cloudflare
cf-ray: 8e5f12a9b8cd9a41-EWR
content-type: text/html

When your web server runs on modest hardware, raw compute capacity is not your friend. Dynamic Next.js Server-Side Rendering (SSR) evaluates React component trees, builds hydration scripts, and executes runtime logic for every single visitor. Under a sudden surge, an unshielded single-core server will fold in seconds.

Here is how we redesigned our deployment pipeline, locked down container resource limits, fixed our monitoring bottlenecks, and built a Cloudflare Edge Shield that slashed Time-to-First-Byte (TTFB) to 25ms while handling traffic spikes without breaking our zero-dollar budget.


The Anatomy of an SSR CPU Collapse

To fix a server under load, you first have to understand why Next.js behaves differently than static file servers like Nginx or Caddy.

When Nginx serves an HTML file, it issues a sendfile() system call. The kernel transfers file bytes directly from the page cache to the network socket. The CPU barely wakes up. Nginx can easily push thousands of static pages per second on modest hardware.

Next.js SSR does not work that way.

Every time a user visits a dynamic route (like / or /blogs/post-slug), Node.js goes to work in user space. It parses route parameters, runs data-fetching functions, executes React code, serializes components into HTML strings, and builds JSON hydration payloads for the browser.

Client Request
      |
      v
+---------------+
| Next.js Node  | ---> Parse Route & Context
| Server Engine | ---> Execute Component Tree
|               | ---> Query Database (SQLite/Postgres)
|               | ---> Render HTML & Hydration Payloads
+---------------+
      |
      v
Output HTML String (Heavy CPU & Memory Cost)

On a desktop machine or an expensive 16-core cloud instance, rendering a React page in 40 milliseconds feels instantaneous.

On a shared 1-vCPU cloud instance, however, that 40ms CPU burst turns deadly. If thirty concurrent visitors request pages at the same instant, they require 1,200ms of raw CPU processing time. Because Node.js runs on a single main thread event loop, those requests queue up behind each other.

Incoming TCP connections stack up in the kernel accept queue. Memory usage climbs as active request buffers sit uncompleted in the V8 heap.

Then comes the garbage collector. Seeing memory pressure, V8 pauses execution to scavenge unused memory. This pushes CPU load to 100%, freezes response handling, and causes upstream proxies to time out.

The server did not crash because of a bug in your code. It crashed because full dynamic SSR is fundamentally too expensive to run on-demand for every untrusted web request. The math is brutal.


Docker Containment: Why Running Without Resource Limits Is Fatal

Most developers deploy their applications using a basic docker-compose.yml file that looks something like this:

services:
  web:
    image: my-nextjs-app:latest
    ports:
      - "3000:3000"
    restart: always

This configuration contains a hidden danger: by default, Docker imposes zero limits on CPU and memory consumption.

If your Next.js process enters a heavy garbage collection loop, runs a slow database query, or gets hit by an aggressive search crawler indexing fifty pages concurrently, the container will consume every byte of RAM and every CPU cycle available on the host.

When host memory runs out, the Linux kernel invokes oom-killer. The kernel looks for processes with high memory usage and badness scores.

All too often, the kernel terminates the container, but occasionally it kills your logging daemon, your reverse proxy, or your SSH daemon (sshd). When sshd dies, you lose console access and cannot log in to troubleshoot the box.

To prevent this, you must define strict cgroups limits directly in your compose file:

services:
  web:
    image: my-nextjs-app:latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - NODE_ENV=production
      - NODE_OPTIONS=--max-old-space-size=384
    deploy:
      resources:
        limits:
          cpus: '0.85'
          memory: 512M
        reservations:
          cpus: '0.20'
          memory: 256M

Two key defenses exist in this configuration:

First, limits.cpus: '0.85' guarantees that the Next.js container can never consume more than 85% of the host CPU time within any scheduling period. The Linux CFS (Completely Fair Scheduler) throttles container execution if it exceeds this threshold, leaving at least 15% of compute capacity reserved for the host operating system, SSH sessions, and background maintenance tasks.

Second, limits.memory: 512M combined with NODE_OPTIONS=--max-old-space-size=384 establishes a memory ceiling.

By telling Node.js that its V8 heap limit is 384MB, Node.js triggers aggressive garbage collection when heap usage approaches that boundary, well before the container reaches its 512MB Docker limit.

If a catastrophic memory leak occurs, Docker terminates only that specific container rather than destabilizing the host server.

You can inspect whether your limits are taking effect in real time with docker stats:

docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.BlockIO}}"
NAME                CPU %     MEM USAGE / LIMIT   MEM %     BLOCK I/O
production-web-1    2.15%     184.2MiB / 512MiB   35.98%    14.2MB / 1.1MB

The Healthcheck CPU Death Spiral

Once we placed resource boundaries around our container, we encountered a second, subtler issue: an internal denial of service caused by healthchecks.

To ensure high availability, uptime monitors and Docker healthchecks inspect containers on recurring intervals. A common pattern is writing a healthcheck like this:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:3000/"]
  interval: 30s
  timeout: 5s
  retries: 3

Notice what that check targets: the root URL /.

As established earlier, / is an SSR page. Every 30 seconds, Docker fires up a subshell, runs curl, and hits the root page. Node.js renders the full React tree, queries the database, generates HTML, and returns a 200 OK status.

Now add external monitoring: an Uptime Kuma instance, a Better Stack probe, and a status check from an external dashboard, each pinging your root URL every 30 to 60 seconds.

Suddenly, your server is rendering 10 to 15 full SSR pages every single minute, even when zero real human visitors are browsing your website. Your baseline CPU utilization sits permanently around 25%.

Monitoring Probes (Uptime Kuma, Better Stack, Docker Engine)
                          |
                          v
             Hitting http://localhost:3000/
                          |
                          v
           Runs Full React SSR + DB Queries
                          |
                          v
    Baseline CPU Consumed Permanently: 25% to 35%

Even worse: if a legitimate traffic surge arrives and pushes CPU to 80%, the SSR page takes four seconds to render. The Docker healthcheck hits its 5-second timeout, logs a failure, and after three retries marks the container as unhealthy and restarts it.

Your monitoring system just turned a temporary latency spike into an unrecoverable crash loop.

The solution is simple: eliminate React execution from your healthchecks. Create a dedicated, ultra-lean API route that returns a static JSON status in sub-millisecond time.

In Next.js App Router, create app/api/health/route.ts:

import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

export async function GET() {
  return NextResponse.json(
    {
      status: 'healthy',
      timestamp: Date.now(),
      uptime: Math.floor(process.uptime()),
    },
    {
      status: 200,
      headers: {
        'Cache-Control': 'no-store, max-age=0',
      },
    }
  );
}

Then update your Docker healthcheck:

healthcheck:
  test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health | grep -q 'healthy' || exit 1"]
  interval: 30s
  timeout: 3s
  start_period: 15s
  retries: 3

This endpoint requires zero React rendering, executes zero database queries, and uses built-in wget rather than heavy external tools. Response time drops to less than 2 milliseconds. Monitoring overhead drops to zero. That solved the problem.


Building the Edge Shield: Cloudflare Cache Rules

Restricting container resources and streamlining healthchecks ensures our server remains stable, but it does not solve the root problem: dynamic rendering cannot handle thousands of concurrent users on a 1-core VPS.

To solve that, we must prevent traffic from reaching our server IP in the first place.

Here is the common misconception about Cloudflare: many developers point their domain names to Cloudflare, enable the orange cloud proxy, and assume their entire website is automatically cached.

It is not.

By default, Cloudflare operates as a reverse proxy that caches only static asset extensions: .css, .js, .jpg, .png, .svg, .ico, and .woff2.

When an incoming request asks for https://example.com or https://example.com/blogs/edge-caching, Cloudflare inspects the content type. Because the response is text/html, Cloudflare bypasses its cache and sends the request straight to your origin server.

Every visitor, bot, and crawler still hits your VPS directly.

Default Cloudflare Setup:
User Request ---> Cloudflare (HTML Detected) ---> Origin VPS (Renders SSR) ---> Latency: 350ms

Cloudflare Edge Shield Setup:
User Request ---> Cloudflare Edge (HTML Cached in RAM) ---> Serves User ---> Latency: 25ms
                          |
                     (98% of requests stopped at the edge)

To turn Cloudflare into a true edge shield, we use Cloudflare Cache Rules (the modern replacement for legacy Page Rules).

Here is the exact rule structure we configure in the Cloudflare dashboard under Caching -> Cache Rules:

Rule Name: Edge Shield - Public Pages

Filter Expression:

(http.request.uri.path eq "/" or http.request.uri.path starts_with "/blogs") 
and not (http.request.uri.path starts_with "/api/") 
and not (http.request.uri.path starts_with "/admin")

Cache Settings:

  • Cache Eligibility: Eligible for cache
  • Edge TTL: Override origin -> 4 hours (or 24 hours depending on update frequency)
  • Browser TTL: Override origin -> 10 minutes
  • Serve Stale Content: Enabled (Serve stale content while revalidating)

What happens with this rule in place?

When the first visitor requests /, Cloudflare forwards the request to your origin VPS. Your Next.js container renders the page in 280ms and sends the HTML back.

Cloudflare stores that HTML response in the memory of the nearest data center.

When the next 5,000 visitors arrive from that region over the next four hours, Cloudflare serves the cached HTML directly from its global Edge network.

The request never touches your server. It consumes 0% CPU on your VPS, zero megabytes of host RAM, and zero bytes of origin bandwidth.

And because Cloudflare delivers the response from an edge node located geographically close to the user, the Time-to-First-Byte (TTFB) drops from 300ms down to 25ms.


Verifying Cache Hits with HTTP Headers

You should never guess whether your edge cache is working. You can verify it directly with curl:

curl -Iv https://example.com/ 2>&1 | grep -iE '^(< HTTP/|< cf-|< age:|< cache-control:)'

On your first request, you will see a cache miss:

< HTTP/2 200
< cache-control: s-maxage=14400, stale-while-revalidate=86400
< age: 0
< cf-cache-status: MISS
< cf-ray: 8e5f210a4c9b2e10-EWR

Run the exact same command two seconds later:

curl -Iv https://example.com/ 2>&1 | grep -iE '^(< HTTP/|< cf-|< age:|< cache-control:)'

The output changes completely:

< HTTP/2 200
< cache-control: s-maxage=14400, stale-while-revalidate=86400
< age: 2
< cf-cache-status: HIT
< cf-ray: 8e5f212f0a8d2e10-EWR

Look at cf-cache-status: HIT and age: 2. It served instantly.

That status confirms the page was served directly out of Cloudflare edge memory. The response was delivered in 22 milliseconds. Your origin VPS did not execute a single line of JavaScript.


Managing Dynamic vs Static Content: The Isolation Matrix

Caching HTML at the edge is powerful, but caching the wrong content is disastrous.

If you cache an API endpoint that handles contact form submissions, dynamic authentication tokens, or an administrative dashboard, one user might receive another user's private data or see a stale form submission token.

You must maintain strict isolation between edge-cached public pages and dynamic routes.

Here is how we partition our routes:

Public Pages (/, /blogs/*, /projects):

  • Cache Status: cf-cache-status: HIT
  • Edge TTL: 4 to 24 hours
  • Behavior: Fully cached at edge, background revalidation via stale-while-revalidate.

API Endpoints (/api/*):

  • Cache Status: cf-cache-status: DYNAMIC
  • Edge TTL: Bypassed
  • Behavior: Always forwarded to origin Next.js container.

Admin & Auth Routes (/admin/*, /dashboard/*):

  • Cache Status: cf-cache-status: BYPASS
  • Edge TTL: Bypassed
  • Behavior: Never cached under any circumstances.

To guarantee that Next.js itself enforces this boundary, configure your route handlers with explicit cache-control headers.

For dynamic API routes like /api/contact/route.ts:

import { NextResponse } from 'next/server';

export const dynamic = 'force-dynamic';

export async function POST(request: Request) {
  const body = await request.json();

  // Process contact form...

  return NextResponse.json(
    { success: true, message: 'Message received' },
    {
      status: 200,
      headers: {
        'Cache-Control': 'private, no-cache, no-store, must-revalidate',
        'Pragma': 'no-cache',
      },
    }
  );
}

In your Cloudflare dashboard, add a second Cache Rule placed with higher priority (Rule 1):

Rule Name: Bypass Cache for APIs and Admin

Filter Expression:

(http.request.uri.path starts_with "/api/") 
or (http.request.uri.path starts_with "/admin") 
or (http.cookie contains "admin_session")

Cache Settings:

  • Cache Eligibility: Bypass cache

Notice the cookie check: http.cookie contains "admin_session".

If an administrator logs in and holds an active session cookie, Cloudflare bypasses edge caching even when they browse public pages. They always see real-time, live content, while standard anonymous visitors receive fast edge-cached responses.


Zero-Downtime Deployments with SQLite Data Protection

Many full-stack Next.js applications running on small instances use SQLite (via Prisma, Drizzle, or better-sqlite3) because it requires no separate database process, consumes minimal RAM, and performs reads in microseconds.

However, running SQLite inside Docker creates a major deployment hazard: how do you deploy new application code without overwriting your live database or corrupting active transactions?

If you run docker compose down and docker compose up -d, you introduce downtime.

If your database file lives inside the container filesystem rather than an external volume, pulling a new Docker image permanently deletes all your production data.

Here is our battle-tested deployment setup that combines persistent host volume mounts, SQLite Write-Ahead Logging (WAL) mode, and safe CI/CD updates.

First, enable WAL mode on your SQLite database during initialization:

PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;

WAL mode allows concurrent readers to query the database while a writer is writing, preventing read locks during traffic spikes.

Second, mount the database directory from the host filesystem in docker-compose.yml:

services:
  web:
    image: ghcr.io/username/portfolio:latest
    volumes:
      - /opt/portfolio/data:/app/data
      - /opt/portfolio/uploads:/app/public/uploads

Third, automate deployments through a GitHub Actions workflow that creates an atomic SQLite backup before updating the container.

Here is the exact deploy script executed on the server over SSH:

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/opt/portfolio"
BACKUP_DIR="${APP_DIR}/backups"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")

echo "Starting deployment at ${TIMESTAMP}..."
cd "${APP_DIR}"

# 1. Create atomic SQLite backup using the official sqlite3 CLI tool
mkdir -p "${BACKUP_DIR}"
if [ -f "${APP_DIR}/data/production.db" ]; then
  echo "Backing up SQLite database..."
  sqlite3 "${APP_DIR}/data/production.db" ".backup '${BACKUP_DIR}/db_backup_${TIMESTAMP}.db'"
  # Keep only the last 7 daily backups
  ls -tp "${BACKUP_DIR}"/db_backup_*.db | grep -v '/$' | tail -n +8 | xargs -I {} rm -- {} || true
fi

# 2. Pull the latest Docker image built by GitHub Actions
echo "Pulling new image..."
docker compose pull web

# 3. Recreate the container with zero downtime for sister services
echo "Recreating web container..."
docker compose up -d --no-deps web

# 4. Wait for healthcheck validation
echo "Waiting for container health..."
for i in {1..15}; do
  STATUS=$(docker inspect --format='{{json .State.Health.Status}}' portfolio-web-1 2>/dev/null || echo '"unknown"')
  if [ "${STATUS}" == '"healthy"' ]; then
    echo "Deployment successful: container is healthy."
    break
  fi
  echo "Container health status: ${STATUS}... waiting 2s"
  sleep 2
done

# 5. Clean up stale Docker images to save precious disk space
echo "Pruning dangling images..."
docker image prune -f

echo "Deployment complete."

Notice step 1: sqlite3 ... ".backup".

Never use cp production.db backup.db while an application is running. Standard cp reads bytes in chunks. If Next.js writes a transaction while cp is midway through the file, your backup file becomes corrupt.

The sqlite3 .backup command uses the SQLite online backup API, holding an internal shared lock to produce a consistent point-in-time snapshot.


Load Testing: Proving the Difference with Autocannon

To evaluate the effectiveness of this edge setup, we ran stepped HTTP benchmarks using autocannon, comparing direct origin access against the Cloudflare Edge Shield.

We pointed the load tester at our Next.js blog index page (/blogs/post-slug).

Test 1: Direct Origin Access (No Edge Caching)

  • Target: Origin IP directly
  • Concurrency: 80 connections
  • Duration: 20 seconds
Running 20s test @ http://origin-ip:3000/blogs/post-slug
80 connections

Stat         Avg       Stdev     Max
Latency      784ms     312ms     2140ms
Req/Sec      101.4     24.1      142
Bytes/Sec    1.42MB    340KB     2.01MB

2028 requests in 20.05s, 28.4MB read
Errors: 142 connection timeouts, 68 non-2xx responses

During this direct test, the server core ran at 100% CPU. Event loops froze, and 210 requests either timed out or returned 502 errors.

Test 2: Cloudflare Edge Shield Enabled

  • Target: Public domain via Cloudflare with Cache Rule active
  • Concurrency: 250 connections (over 3x higher load)
  • Duration: 20 seconds
Running 20s test @ https://example.com/blogs/post-slug
250 connections

Stat         Avg       Stdev     Max
Latency      24.6ms    8.2ms     82.4ms
Req/Sec      2,410     180       2,720
Bytes/Sec    33.8MB    2.5MB     38.1MB

48,200 requests in 20.02s, 676MB read
Errors: 0 timeouts, 0 non-2xx responses

The difference is night and day. Zero dropped packets.

Throughput jumped from 101 requests per second to 2,410 requests per second. Average latency plummeted from 784ms down to 24.6ms. Most importantly, errors dropped to zero.

And what was the origin server doing during those 48,200 requests?

Running top on the host revealed CPU utilization sitting calmly at 1.8%. The Next.js container served exactly one request to populate the edge cache. Cloudflare handled the remaining 48,199 requests from memory. It held up perfectly.


An Interesting Fact About Edge Caching

When Akamai invented commercial content delivery networks in 1998, edge caching was exclusively used for static images and media files.

For nearly two decades, caching dynamic HTML pages at the edge was considered impractical because content management systems lacked reliable cache invalidation mechanisms.

Until recently, Cloudflare reserved granular edge HTML caching for Enterprise-tier customers paying thousands of dollars per month under custom Page Rules.

With the introduction of modern Cloudflare Cache Rules and HTTP standard stale-while-revalidate directives, individual developers running single-core cloud instances can now deploy caching configurations that rival Fortune 500 media networks for zero dollars.


My Default Rule of Thumb for Small Next.js Deployments

When running production Next.js apps on small cloud servers:

  • Put a hard ceiling on containers: Always define limits.cpus (e.g. 0.85) and limits.memory in docker-compose, and configure NODE_OPTIONS=--max-old-space-size to prevent OOM panics.
  • Never monitor full SSR pages: Keep uptime bots and Docker healthchecks pointed at a lightweight /api/health route that returns in under 2ms.
  • Cache HTML at the edge: Use Cloudflare Cache Rules to cache public pages (/, /blogs/*) for hours while serving stale content during background revalidation.
  • Strictly bypass APIs: Explicitly exclude /api/* and admin routes from edge caching with Cache-Control: private, no-store.
  • Protect your SQLite database: Run SQLite in WAL mode, mount data to external Docker host volumes, and use sqlite3 .backup before every automated container restart.
  • Verify with headers: Run curl -Iv and confirm you see cf-cache-status: HIT and single-digit millisecond TTFB before launching to the public.

Have you ever had an unshielded server crash under unexpected viral traffic or crawler bots? What was the bottleneck that took it down?

If this breakdown saved you hours of debugging or gave you something practical to use in production, consider buying me a coffee. Your support directly fuels independent, zero-fluff Linux and DevOps technical guides.


About the Author

Asep Sayyad is a Linux and DevOps engineer passionate about Linux administration, automation, cloud technologies, containers, and open-source software. He enjoys solving real-world infrastructure challenges and sharing practical knowledge through in-depth technical articles, tutorials, and hands-on guides.

His goal is to help aspiring and experienced engineers build stronger Linux and DevOps skills with content focused on real production scenarios rather than theory alone.

Connect with Me

Enjoyed this article?

If this guide saved you hours of debugging or gave you something practical for production, consider:

  • Buying me a coffee: Your support directly fuels independent, zero-fluff Linux and DevOps engineering breakdowns.
  • Starring my open-source projects on GitHub.
  • Sharing this article with fellow Linux and DevOps engineers.

You can also follow me for more practical content on Linux, DevOps, Cloud, Containers, Automation, and Open Source. Thanks for reading, and enjoy your learning!

© 2026 Asep Sayyad

Chat with me