Putting Cloudflare in front of a single VPS is one of the highest-leverage moves available to a small team: free TLS termination at the edge, DDoS absorption, a global cache, and a WAF, all in front of a box you’d otherwise be exposing directly to the internet. It’s also full of small configuration traps that look like Cloudflare being broken when they’re actually the origin and the edge disagreeing about who’s supposed to be doing what. Most of them come down to one root cause: Cloudflare sits in the request path twice as much as people think, once for DNS and once for the actual proxy, and each of those has its own settings.

Proxied vs DNS-Only Records

Every DNS record in Cloudflare has a toggle, the orange cloud vs the grey cloud, and it’s the single most consequential setting in the whole setup.

  • Grey cloud (DNS-only): Cloudflare answers the DNS query with your origin’s real IP. Traffic goes straight to your server. No caching, no WAF, no DDoS protection, but also no added latency and no risk of Cloudflare features interfering with things like raw TCP protocols.
  • Orange cloud (proxied): Cloudflare answers with its own anycast IPs, terminates the connection, and forwards to your origin over a separate connection. This is where caching, WAF, bot management, and IP masking all live.
$ dig +short infra.furybee.org
104.21.32.10          # Cloudflare anycast IP, not your VPS
172.67.128.55

$ dig +short db.furybee.org        # grey-clouded on purpose
62.210.65.94                       # your actual VPS IP

The practical rule: proxy anything that’s HTTP/HTTPS and meant to be public. Leave grey-cloud anything that isn’t HTTP (SSH, a raw Postgres port exposed to a Tailscale IP, SMTP) — Cloudflare’s proxy only understands HTTP(S) and a short list of other protocols on paid plans, and it will otherwise refuse the connection or behave unpredictably.

SSL/TLS Modes and the Redirect Loop

Cloudflare’s SSL/TLS setting controls the encryption between Cloudflare and your origin, not between Cloudflare and the visitor (that’s always HTTPS if you force it). There are four modes, and picking the wrong one produces one specific, very common symptom: an infinite redirect loop.

ModeCloudflare → OriginCommon failure
OffPlaintext HTTP onlyVisitor traffic never encrypted at all — don’t use this
FlexiblePlaintext HTTP, even if visitor used HTTPSRedirect loop if origin redirects HTTP→HTTPS
FullHTTPS, but doesn’t validate the origin certWorks with self-signed certs, but origin impersonation is possible
Full (strict)HTTPS, validates the origin cert against a trusted CABreaks if origin cert expires or doesn’t match hostname

The classic redirect loop: your app (say, an Nginx config or an Astro/Node server) redirects any HTTP request to HTTPS, which is good practice. A visitor requests https://infra.furybee.org. Cloudflare terminates that as HTTPS, then — because the zone is set to Flexible — connects to your origin over plain HTTP. Your origin sees an HTTP request and redirects it to HTTPS. Cloudflare receives that redirect, decides the visitor should be sent to HTTPS again (which they already were), and the cycle repeats until the browser gives up with ERR_TOO_MANY_REDIRECTS.

The fix is almost always the same: switch to Full or Full (strict), and make sure the origin is actually listening on 443 with a certificate — self-signed is enough for Full, but Full (strict) needs a cert Cloudflare’s default trust store recognizes, which is exactly what a free Cloudflare Origin Certificate is for.

# Generate an origin certificate from the Cloudflare dashboard (SSL/TLS → Origin Server),
# or via API, then install it on the origin like any other cert:

server {
    listen 443 ssl;
    server_name infra.furybee.org;

    ssl_certificate     /etc/nginx/certs/cloudflare-origin.pem;
    ssl_certificate_key /etc/nginx/certs/cloudflare-origin.key;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

Cloudflare Origin Certificates are trusted by Cloudflare’s edge but by nothing else — they’re meant to be paired with Full (strict) precisely so a request can never reach the origin over anything but Cloudflare’s own proxy without a certificate error. That’s a feature: it stops someone from hitting your origin’s real IP directly and impersonating your domain with a valid-looking cert.

Origin Cert Expiry: The Silent 526

If Full (strict) is set and the origin certificate expires — whether it’s a Cloudflare Origin Certificate or a Let’s Encrypt cert that failed to renew — Cloudflare can’t validate the origin anymore. The visitor doesn’t get a generic connection error; they get Cloudflare’s own error page: Error 526: Invalid SSL certificate.

This is a client-visible signal that something is wrong with the origin, not with Cloudflare, and it’s worth automating a check for rather than waiting for a user report:

#!/usr/bin/env bash
# check-origin-cert.sh — run from cron, alert if < 14 days from expiry
HOST="infra.furybee.org"
EXPIRY=$(echo | openssl s_client -servername "$HOST" -connect "$HOST":443 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - $(date +%s)) / 86400 ))

if [ "$DAYS_LEFT" -lt 14 ]; then
  echo "WARNING: origin cert for $HOST expires in $DAYS_LEFT days"
  exit 1
fi

Note this checks the cert as served, which through a proxied record is whatever Cloudflare’s edge presents to visitors (its own managed cert) — to actually check the origin’s cert you need to connect directly to the origin IP with -connect <origin-ip>:443, bypassing Cloudflare’s proxy, which is the only way to catch expiry before it becomes a client-facing 526.

Cache Rules and Purging

By default, Cloudflare’s free tier caches static file extensions (images, CSS, JS, fonts) based on file extension, and passes everything else — including HTML — straight through to the origin. For a static Astro site, that default under-caches: every page load is a full trip to your origin.

Cache Rules (the modern replacement for the legacy Page Rules) let you cache more aggressively:

# Cloudflare dashboard → Caching → Cache Rules

Rule name: cache-html
When incoming requests match:
  Hostname equals "infra.furybee.org"
Then:
  Cache eligibility: Eligible for cache
  Edge TTL: 4 hours
  Browser TTL: 30 minutes

For anything cached this way, purging is essential whenever content changes — otherwise a stale copy serves for the full TTL. Purge via API in a deploy pipeline, either everything or a specific set of URLs:

# Blunt instrument — fine for a low-traffic static site redeployed a few times a day
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything": true}'

# The scalpel — purge specific URLs instead of the whole zone
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"files": ["https://infra.furybee.org/", "https://infra.furybee.org/sitemap.xml"]}'

Purging everything is wasteful for anything with meaningful cache-hit-ratio at scale, but for a full-purge-per-deploy setup on a small static site it’s a perfectly reasonable default — the alternative is a stampede of cache-miss requests hitting the origin simultaneously right after a deploy, which is worse than an occasional unnecessary full purge. Cache tags (targeted purging by tag rather than by exact URL) require setting a Cache-Tag response header at the origin and are only available on paid plans.

Real Client IPs

Once traffic is proxied, every request arriving at your origin comes from a Cloudflare IP, not the visitor’s. If your app logs req.socket.remoteAddress or relies on the connecting IP for rate limiting or geolocation, it’s now logging Cloudflare’s IP for every single request — useless for abuse detection for and often actively wrong for anything IP-based.

Cloudflare adds the real client IP as a header on every proxied request: CF-Connecting-IP: 203.0.113.42.

In Nginx, restoring the real IP for logging and for anything downstream that reads X-Forwarded-For means trusting Cloudflare’s published IP ranges and using set_real_ip_from:

# /etc/nginx/conf.d/cloudflare.conf
# IP ranges from https://www.cloudflare.com/ips/ — refresh periodically
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 104.16.0.0/13;
# ... full list from Cloudflare's IPv4 + IPv6 ranges

real_ip_header CF-Connecting-IP;

Without this, any app-level rate limiter keyed on source IP effectively rate-limits “everyone behind Cloudflare” as a single bucket the moment traffic is proxied — a subtle bug that looks like intermittent 429s for unrelated users.

WebSockets Through Cloudflare

WebSocket support is on by default on proxied records for all plans, but two things trip people up. First, the free plan enforces a 100-second idle timeout on connections with no traffic — a WebSocket that goes quiet (no ping/pong) for that long gets dropped by the edge even though both ends think it’s alive. Second, sticky routing for a service that keeps connection state in-process (like a signaling server holding WebRTC room state) isn’t something Cloudflare’s proxy does for you; if the origin is a single instance this is moot, but behind a load balancer it needs its own session-affinity story, because Cloudflare doesn’t guarantee the same visitor’s reconnect lands on the same origin server.

// Client-side: send a lightweight ping to keep the connection active
// through Cloudflare's idle timeout window
setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: "ping" }));
  }
}, 60_000); // well under the 100s idle timeout

Rate Limiting

Cloudflare’s own rate limiting rules operate at the edge, before traffic ever reaches the origin — useful for absorbing brute-force login attempts or scraper traffic without spending a single origin CPU cycle on them.

# Dashboard → Security → WAF → Rate limiting rules

When incoming requests match:
  URI Path equals "/api/login"
Rate:
  10 requests per 10 seconds, per IP (using CF-Connecting-IP)
Then:
  Block for 60 seconds, respond with 429

This is complementary to, not a replacement for, application-level rate limiting — the edge rule stops obvious abuse cheaply, but any request that does reach the origin should still be validated there, since the edge rule only sees what it’s told to match and a single misconfigured path exception bypasses it entirely.

Key Takeaways

  • Proxy (orange cloud) only HTTP(S) traffic meant to be public; leave anything else (SSH, admin DB ports) DNS-only or off Cloudflare’s DNS entirely.
  • Never use Flexible SSL if your origin redirects HTTP to HTTPS — that combination is the textbook cause of an infinite redirect loop.
  • Full (strict) with a Cloudflare Origin Certificate is the right default for a self-hosted origin: it rejects any direct connection to the origin’s real IP without a trusted cert.
  • A 526 error is Cloudflare telling you the origin’s SSL is broken, usually an expired cert — monitor origin cert expiry directly against the origin, not through the proxy.
  • Cache Rules need a purge step wired into your deploy pipeline, or stale content serves for the full TTL after every release.
  • Restore the real client IP with set_real_ip_from + CF-Connecting-IP, or every proxied request looks like it came from Cloudflare for logging and rate-limiting purposes.
  • Edge rate limiting absorbs abuse before it reaches the origin, but application-level checks still need to exist for anything that does get through.