List of Articles Icon

Knowledge Base

Guides and answers for your VPS, the client area, and billing

Cloudflare best practices for your VPS

What this is

Putting Cloudflare in front of a website on your VPS, and configuring it so it actually delivers what people install it for. The free tier gives you a hidden origin IP, absorbed attack traffic, edge caching, and bot filtering, but only the DNS move happens automatically. The rest is configuration on your side, and a half-done setup quietly delivers none of it: the "hidden" IP still answers to anyone who scans it, logs fill with Cloudflare's IPs instead of your visitors', and the cache never caches the pages that cost you CPU.

This guide is the whole path: the move, then the four practices that separate a proper setup from a decorative one.

The mental model (read this first)

With Cloudflare's proxy enabled, visitors never connect to your VPS. They connect to Cloudflare's edge, and Cloudflare connects to your VPS on their behalf:

visitor ⇄ Cloudflare edge ⇄ your VPS (the "origin")

In the DNS records, the orange cloud means proxied (the name resolves to Cloudflare's IPs, and your real IP stays out of public DNS) and the grey cloud means DNS only (the name resolves straight to your VPS, no Cloudflare involvement). One consequence drives half of this article: the proxy only handles HTTP and HTTPS. Mail, SSH, game servers, and anything else non-web must stay grey-clouded, or it silently stops working the moment you flip the switch.

Moving a site to Cloudflare

The DNS mechanics are covered in Pointing your domain; the Cloudflare-specific care points:

  1. Add the site in the Cloudflare dashboard. It scans and imports your existing DNS records, and this import is the step people regret rushing: it can miss records, subdomains especially. Compare the imported list against your current zone before going further.
  2. Change the nameservers at your registrar to the pair Cloudflare assigns. Propagation takes minutes to hours.
  3. Orange-cloud the web records only (@ and www, plus any subdomain serving a website). Keep grey: the hostname your MX record points at, anything you SSH to by name, and any non-HTTP service. If in doubt, grey is the safe default, it just means "works exactly as before".

One honest caveat about secrecy: proxying hides your IP from now on, but if the domain pointed straight at the VPS before, that old A record lives on in DNS-history databases attackers know to check. The orange cloud alone is not what hides your origin. The firewall is, which brings us to the first best practice.

Best practice 1: allow only Cloudflare to reach your web ports

If ports 80 and 443 answer to the whole internet, anyone who learns your IP (from DNS history, a mail header, a verbose error page) can bypass Cloudflare and hit your server directly, attacks, scrapers, and all. The fix is to accept web traffic only from Cloudflare's published IP ranges (https://www.cloudflare.com/ips/), in your VPS's own firewall. (Our managed edge firewall is a separate layer that never blocks web ports; this lockdown is yours to do, with ufw or nftables inside the VPS.)

With ufw already set up the standard way (default deny incoming, SSH allowed):

for net in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
  ufw allow proto tcp from $net to any port 80,443 comment 'Cloudflare'
done

Then make sure no broad rule undoes it: ufw status numbered should show 80/443 allowed only from the Cloudflare ranges, so delete any general 80/tcp ALLOW Anywhere rule left over from before. Prove it worked from any machine that isn't your VPS:

curl -m 5 http://YOUR.VPS.IP/

A timeout is success: the origin no longer talks to strangers.

Three warnings that keep this safe:

  • Never apply this thinking to SSH. Cloudflare doesn't proxy SSH, so restricting port 22 to Cloudflare ranges locks you out, not attackers. Leave SSH rules alone, and keep your current session open while changing firewall rules (the Console is the recovery path if something goes wrong).
  • The ranges change, rarely but really. A stale list eventually surfaces as intermittent 521 errors (Cloudflare says the origin refused the connection). If 521s appear months after setup, refreshing these rules is the first suspect: delete the Cloudflare-commented rules and re-run the loop.
  • Mind the other leaks. Sending email directly from the VPS stamps its IP into the mail headers, and hosting your mail on the same VPS makes the IP public by necessity (MX can't be proxied). If origin secrecy genuinely matters, send mail through a relay and keep mail off the web VPS; otherwise, know the trade-off you're accepting.

Best practice 2: restore real visitor IPs

Once traffic arrives via the proxy, your web server's peer is Cloudflare, so access logs, rate limits, and applications all see Cloudflare's IPs. The real visitor address travels in the CF-Connecting-IP request header, and each web server has a module to swap it in, trusting the header only when the connection comes from Cloudflare's ranges, so it can't be spoofed.

nginx (the realip module is built in). Generate the config and reload:

(for net in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
  echo "set_real_ip_from $net;"
done
echo "real_ip_header CF-Connecting-IP;") > /etc/nginx/conf.d/cloudflare-realip.conf
nginx -t && systemctl reload nginx

Caddy, in the global options at the top of your Caddyfile (paste the current ranges from cloudflare.com/ips in place of the two shown):

{
    servers {
        trusted_proxies static 173.245.48.0/20 103.21.244.0/22 # ...the rest of the ranges
        trusted_proxies_strict
        client_ip_headers CF-Connecting-IP
    }
}

Apache, with mod_remoteip:

a2enmod remoteip

Then in a config file (one RemoteIPTrustedProxy line per Cloudflare range):

RemoteIPHeader CF-Connecting-IP
RemoteIPTrustedProxy 173.245.48.0/20
RemoteIPTrustedProxy 103.21.244.0/22
# ...the rest of the ranges

One Apache extra: the stock combined log format logs %h (the connection peer, still Cloudflare), so switch it to %a in your LogFormat to log the restored client IP.

Verify by tailing your access log: entries should show real, varied visitor IPs again, not addresses from the Cloudflare ranges.

The fail2ban consequence almost everyone misses: behind Cloudflare, banning a visitor's IP in your local firewall does nothing, because the packets reaching your VPS come from Cloudflare's addresses, not the visitor's. Log-parsing still works (the log now shows real IPs), but the enforcement has to move to Cloudflare: fail2ban ships a cloudflare action that issues the ban through Cloudflare's API instead of iptables, and for simpler cases a WAF custom rule or an IP Access rule in the dashboard does the same by hand.

Best practice 3: the SSL mode that can't loop

Set SSL/TLS → Full (strict) and give the origin a valid certificate. That's the whole rule, and the full reasoning (why Flexible causes the infamous redirect loop, what each mode actually does) lives in Fixing SSL certificate errors. The practical notes for a VPS behind the proxy:

  • The lazy-and-correct option for an always-proxied site is a free Cloudflare Origin CA certificate (dashboard → SSL/TLS → Origin Server): valid up to 15 years, no renewals to babysit, trusted only by Cloudflare, which is exactly who connects to your origin now.
  • Your existing certbot certificate keeps working too: Let's Encrypt's HTTP validation passes through the proxy, so renewals survive the firewall lockdown from practice 1. If you want renewals that don't depend on HTTP at all, the certbot-dns-cloudflare plugin validates via a DNS record using a Cloudflare API token.
  • Error decoder while you're here: 526 means Full (strict) rejected the origin's certificate, 525 means the TLS handshake itself failed, both point at the origin certificate, not at Cloudflare.

Best practice 4: make the cache actually cache

Here's the disappointment this section prevents: you add Cloudflare, re-test your site, and the response times barely move. That's expected, because by default Cloudflare only caches static files, decided by file extension (images, CSS, JS, fonts, and similar). HTML, and therefore everything your PHP or app server renders, is never cached by default. Every page view still hits your VPS.

The diagnostic tool is one header:

curl -sI https://example.com/ | grep -i cf-cache-status
  • DYNAMIC: not eligible for caching (the default verdict for HTML), Cloudflare didn't even consider storing it.
  • MISS: eligible, fetched from origin this time, stored for next time. Run the same command again and a HIT confirms the cache works.
  • BYPASS: eligible by rule, but the response talked Cloudflare out of it, a Set-Cookie, or Cache-Control: private / no-store from the origin.

Caching HTML, the right way

Two pieces, and you want both:

  1. The origin declares cacheability. Send proper Cache-Control headers from the pages you want cached. In PHP:

    header('Cache-Control: public, max-age=0, s-maxage=600');

    s-maxage is for shared caches like Cloudflare (here, 10 minutes at the edge), max-age for browsers (here, always revalidate, so you keep control). A detail that explains a lot of mystery BYPASSes: the moment a PHP script calls session_start(), PHP emits Cache-Control: no-store, no-cache and a session cookie on its own, marking the page uncacheable. That's the correct default for pages with any per-user content, and it means you add caching headers only to pages that are genuinely the same for everyone.

  2. A Cache Rule makes HTML eligible. Dashboard → Caching → Cache Rules (the free plan includes 10; these replaced the old Page Rules): create a rule matching your site or the paths you want cached, with Eligible for cache, leaving Edge TTL on "respect origin headers" so the s-maxage you set above stays in charge.

The safety rule: never cache a logged-in page

A cached page is served identically to everyone, so caching a personalized page is how one user sees another's account. Protect against it explicitly with a bypass rule ordered before the caching rule: match your application's session cookie and bypass cache. For WordPress, the expression http.cookie contains "wordpress_logged_in" (plus bypassing /wp-admin) is the standard pattern; other apps have an equivalent session cookie. Cloudflare's refusal to cache Set-Cookie responses is your backstop, but be deliberate anyway.

Why this is worth the effort here specifically

Every edge cache hit is a request your VPS never sees: no PHP executed, no bytes from your monthly traffic allowance. On a busy or bot-hammered site, good edge caching is the cheapest capacity upgrade available, it's free.

Two operational habits: use Development Mode (dashboard, temporarily disables caching) while actively working on the site instead of fighting the cache, and purge by URL rather than "purge everything" when you can, so you don't dump the whole cache to fix one page.

The free-tier switches worth flipping

  • WAF custom rules (5 on the free plan): put a managed challenge on your login endpoints, e.g. expression http.request.uri.path eq "/wp-login.php", and block /xmlrpc.php outright if nothing uses it. Scanners fail the challenge; you barely notice it.
  • Rate limiting: the free plan includes basic rate limiting rules, useful for an API path or login endpoint that shouldn't see bursts.
  • Bot Fight Mode and the AI-crawler controls: one-toggle filtering for the scraper flood, covered properly in Handling bot traffic.
  • Under Attack mode: the panic switch that challenges every visitor. Flip it during an active attack, not permanently, and see our DDoS policy for how attacks are handled on our side.

Gotchas that generate tickets

  • Uploads over 100 MB fail with a 413. That's the free plan's request-body cap at the edge, not your server. Workarounds: chunked uploads in the app, or a grey-clouded subdomain (uploads.example.com) that skips the proxy for uploads, remembering that subdomain reveals the IP and needs its own certificate thinking.
  • Long requests die at about two minutes with a 524. Cloudflare's proxy read timeout (currently 120 seconds, not raisable on the free plan) cuts off slow admin exports, reports, and one-shot migration scripts. Move long work into background jobs, or run it on a grey-clouded hostname or via SSH.
  • 521 means "origin refused the connection": your web server is down, or the practice-1 firewall is blocking a Cloudflare range it should allow (the stale-list symptom). 522 is a connect timeout, usually the same family of causes.
  • Old tutorials will mislead you. Auto Minify no longer exists (removed August 2024), Page Rules gave way to Cache Rules, and Rocket Loader is a "test it, don't trust it" toggle that can break JavaScript-heavy sites.
  • Non-standard ports aren't proxied. Cloudflare handles 80/443 and a short list of alternates (8080, 8443, and a few others). A panel on port 3000 behind an orange-clouded name won't work; put it behind a reverse proxy on 443 or grey-cloud it. And if what you're protecting is a game server or another non-web service, Cloudflare's proxy isn't the tool at all, the options that are (game proxies, Spectrum, protected tunnels) are in our DDoS policy.

The five-minute verification

After setup, this list proves each layer did its job:

  1. dig +short example.com returns Cloudflare IPs, not your VPS's.
  2. curl -m 5 http://YOUR.VPS.IP/ from another machine times out (origin locked).
  3. curl -sI https://example.com/style.css | grep -i cf-cache-status shows MISS then HIT on the second run.
  4. Your access log shows real visitor IPs (realip working).
  5. SSL/TLS mode reads Full (strict) and the site loads with no redirect loop.
  6. A test email to the domain arrives (MX still grey-clouded and working).

Still need help?

You can open a support ticket. So we can help on the first reply, it's worth mentioning:

  • the domain and the VPS behind it,
  • what the browser or curl -I shows (any cf-cache-status, any 52x error code),
  • whether the record is orange- or grey-clouded, and your SSL/TLS mode.

Bear in mind that Cloudflare's dashboard and plans are theirs, not ours, we can help with everything on the VPS side, and their community covers the rest.

  • "How do I put my website behind Cloudflare?"
  • "How do I hide my VPS's IP address?"
  • "How do I allow only Cloudflare IPs to my web server?"
  • "Why do my logs show Cloudflare IPs instead of visitors?"
  • "How do I get real visitor IPs in nginx / Caddy / Apache behind Cloudflare?"
  • "Does fail2ban work behind Cloudflare?"
  • "Why isn't Cloudflare caching my HTML or PHP pages?"
  • "What does cf-cache-status DYNAMIC mean?"
  • "How do I cache HTML on Cloudflare's free plan?"
  • "Which Cloudflare SSL mode should I use?"
  • "Why do large uploads fail behind Cloudflare?"
  • "What causes Cloudflare error 521 or 524?"
Last reviewed: 2026-07-05