Behind Cloudflare? Make sure visitors can't skip it and reach your server directly

Cloudflare only protects you when traffic goes through it. If someone finds your server's own address, they can go around it. This guide makes your server answer web requests from Cloudflare only.

Keep SSH open. Everything here is about ports 80 and 443 only. SSH doesn't go through Cloudflare, so it must stay open to you. The script in step 4 never touches the SSH rule, and stops if it can't find one.

Before you start

  • Your site must already work through Cloudflare, with the orange cloud ("Proxied") on in Cloudflare's DNS settings.
  • You need a user with sudo (see our sudo guide) and access to your Cloudflare dashboard.
  • Keep your hosting provider's web console at hand, in case you lock yourself out.
  • We tested on Ubuntu 24.04 (Nginx 1.24, Apache 2.4.58) and Debian 13 (Nginx 1.26, Apache 2.4), both with UFW 0.36. The examples use the site shop.example and the server address 203.0.113.5. Use yours.

1. Why this matters

With Cloudflare in front, visitors talk to Cloudflare, and Cloudflare talks to your server. That's where its firewall, bot checks and rate limits do their work. But your server still answers anyone who connects to it directly. An attacker who knows its address can skip all of that.

The address is often easy to find:

  • Old DNS records. Sites keep the history of DNS. If your domain pointed at the server before you turned on Cloudflare, the address is on record.
  • Other names on the same server. A record such as mail., ftp. or dev. with the grey cloud points straight at it.
  • Emails. If your site sends mail from the server itself, the server's address is in the headers of every message.
  • Certificates. Every certificate is listed in public logs. Scanners connect to every address on the internet and note which one answers with a certificate for your domain.

You can't erase all of this. So instead of hiding the address, make it useless: the server should refuse web visitors that don't come through Cloudflare.

2. Close the easy leaks

  • In Cloudflare's DNS list, look for records with the grey cloud ("DNS only") that point at the web server. Turn on the orange cloud, or remove the ones you don't use.
  • Send your site's email through an email service instead of the server itself. Our guide on emails that don't land in spam shows how.
  • If the address leaked long ago and attacks keep coming, some people also move to a new address from their host after the steps below. The steps below matter more.

3. Set Cloudflare's SSL mode to "Full (strict)"

This setting decides how Cloudflare connects to your server. Find it in the Cloudflare dashboard under SSL/TLS → Overview.

  • Flexible sends traffic from Cloudflare to your server without encryption. Don't use it.
  • Full encrypts it, but accepts any certificate, even a fake one.
  • Full (strict) encrypts it and checks that your server's certificate is valid and matches your domain. This is the one you want.

Your server needs a valid certificate for this. Two good choices:

  • Let's Encrypt, free and renewed on its own. See our HTTPS guide. Renewal should keep working after step 4, because with the orange cloud on, Let's Encrypt's check reaches your server through Cloudflare like any visitor. If you switch the record to "DNS only", renewal stops working: see our renewal guide.
  • A Cloudflare Origin CA certificate. Create it in the dashboard under SSL/TLS → Origin Server. It can last up to 15 years. Only Cloudflare trusts it, so browsers that reach the server directly get a warning. That's fine here, but it means you can't turn the orange cloud off without changing the certificate first.

Switch to Full (strict) before step 6. Cloudflare's client certificate only works when it connects with HTTPS.

4. Allow web traffic only from Cloudflare with UFW

This is the simplest way, and it works for Nginx, Apache and anything else on ports 80 and 443. Direct visitors get no answer at all. If you don't have UFW running yet, set it up first with our UFW firewall guide. You should have SSH, 80 and 443 allowed.

Cloudflare publishes its address ranges at cloudflare.com/ips-v4 and ips-v6. This script reads both lists and turns them into UFW rules. It is safe to run again and again:

  • If a download fails, or a list looks wrong, it stops and changes nothing.
  • It adds new ranges first, then removes old ones, so Cloudflare is never shut out in between.
  • It only touches its own rules (marked cloudflare). It stops if UFW has no rule for SSH.
sudo nano /usr/local/sbin/cloudflare-ufw
#!/bin/bash
# Allow web traffic (ports 80 and 443) only from Cloudflare's addresses.
# Safe to run again: it adds new ranges first, then removes old ones.
set -euo pipefail

SSH_PORT=22    # change this if SSH runs on another port

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

# 1. Download both lists. If a download fails, stop and change nothing.
curl -fsS --max-time 30 https://www.cloudflare.com/ips-v4 -o "$tmp/v4"
curl -fsS --max-time 30 https://www.cloudflare.com/ips-v6 -o "$tmp/v6"

# 2. Check that every line looks like an address range. If not, stop.
check() {
  local all good
  all=$(grep -c . "$1" || true)
  good=$(grep -Ecx "$2" "$1" || true)
  if [ "$all" -lt 3 ] || [ "$all" -ne "$good" ]; then
    echo "The list from Cloudflare looks wrong. Nothing was changed." >&2
    exit 1
  fi
}
check "$tmp/v4" '[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}'
check "$tmp/v6" '[0-9a-f:]+/[0-9]{1,3}'
# (The files don't end with a new line, so add one between them.)
{ cat "$tmp/v4"; echo; cat "$tmp/v6"; echo; } | grep . | sort -u > "$tmp/new"

# 3. Make sure SSH has its own rule, so you can't lock yourself out.
if ! ufw show added | grep -Eq "OpenSSH| $SSH_PORT(/tcp)?( |$)"; then
  echo "UFW has no rule for SSH (port $SSH_PORT). Add one first." >&2
  exit 1
fi

# 4. Add every Cloudflare range. Ranges you already have are skipped.
for ip in $(cat "$tmp/new"); do
  ufw allow proto tcp from "$ip" to any port 80,443 comment cloudflare > /dev/null
done

# 5. Remove ranges that Cloudflare no longer lists.
old=$(ufw show added | awk '/cloudflare/ { for (i = 1; i < NF; i++) if ($i == "from") print $(i+1) }')
for ip in $old; do
  if ! grep -qxF "$ip" "$tmp/new"; then
    ufw delete allow proto tcp from "$ip" to any port 80,443 > /dev/null
    echo "Removed old range $ip"
  fi
done

echo "Done: $(wc -l < "$tmp/new") Cloudflare ranges allowed on ports 80 and 443."

If your SSH runs on another port, change SSH_PORT at the top. Then make it runnable and run it:

sudo chmod 755 /usr/local/sbin/cloudflare-ufw
sudo /usr/local/sbin/cloudflare-ufw

It should end with Done: 22 Cloudflare ranges allowed (the number may change over time). On Ubuntu it took about 17 seconds in our test, because UFW is slow to add rules one by one.

Only after you see "Done", remove the rules that let everyone in:

sudo ufw delete allow 80/tcp
sudo ufw delete allow 443/tcp

If you used an app profile instead, such as Nginx Full or Apache Full, delete that one: sudo ufw delete allow 'Nginx Full'. Now check:

sudo ufw status

You should see your SSH rule, then lines like 80,443/tcp ALLOW 173.245.48.0/20 # cloudflare, and no rule that allows 80 or 443 from Anywhere. Open your site in a browser: it should work as before.

In our test, the "Cloudflare" machine still got the site, a direct visitor got no answer on 80 or 443, and SSH stayed open to both. Running the script again changed nothing. When we added two fake old ranges, the next run removed exactly those two. A broken download and a list full of nonsense both stopped the script with no change to the rules.

Run it every week. Cloudflare's ranges don't change often, but they do change. Create /etc/cron.d/cloudflare-ufw with this line:

30 4 * * 1 root /usr/local/sbin/cloudflare-ufw > /var/log/cloudflare-ufw.log 2>&1

If you also use Cloudflare's ranges in Nginx (step 5 below, or the real-address file from our rate limiting guide), refresh those files when the script removes an old range.

Docker: ports published by Docker containers skip UFW. If your site runs in a container, see the Docker note in our UFW guide.

5. Or: refuse direct visitors in Nginx

If you can't use a firewall, Nginx can do a similar job. It's weaker: direct visitors still reach Nginx, which then closes the connection. Use it as a second lock, or when the firewall isn't yours to change.

Make a list of Cloudflare's ranges for Nginx:

{
  echo 'geo $realip_remote_addr $from_cloudflare {'
  echo '    default 0;'
  for ip in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
    echo "    $ip 1;"
  done
  echo '}'
} | sudo tee /etc/nginx/conf.d/cloudflare-only.conf

Check that the file lists about 20 ranges. Then add one line to your site's server block, for both port 80 and 443:

if ($from_cloudflare = 0) { return 444; }

444 is special to Nginx: it closes the connection without an answer.

Why $realip_remote_addr? If you set up the real visitor address from our rate limiting guide, Nginx swaps Cloudflare's address for the visitor's. A check on the normal address then sees the visitor, not Cloudflare. In our test that blocked every real visitor. $realip_remote_addr always holds the address that actually connected, so the check works with or without that file. The same trap applies to Nginx's allow and deny lines, so we don't use them here.

Also stop answering for unknown names. Scanners connect by address, without your domain name. Nginx then answers with your first site's certificate, which tells them which domain lives there. Add a catch-all server block that refuses them:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    ssl_reject_handshake on;
    return 444;
}

Remove default_server from any other site first (Ubuntu and Debian put it in /etc/nginx/sites-enabled/default). Then:

sudo nginx -t && sudo systemctl reload nginx

In our test, a visitor through "Cloudflare" got the site. A direct visitor got no answer, even when they faked the CF-Connecting-IP header. A connection by address only got no certificate at all.

Apache: use the firewall from step 4. It's simpler and works the same for every site.

6. Stronger: Authenticated Origin Pulls

Steps 4 and 5 check where a request comes from. Authenticated Origin Pulls also checks who sends it. Cloudflare shows a certificate of its own when it connects to your server, like an ID card. Your server refuses any HTTPS request that doesn't show one.

There are two kinds:

  • Cloudflare's shared certificate ("Global"). Quick to set up. But every Cloudflare customer's traffic carries the same certificate, so it only proves the request came through Cloudflare, not through your account.
  • Your own certificate ("Zone-level"). You make it and upload it to Cloudflare. Only your zone uses it. This is the stronger choice.

The same gap applies to step 4: anyone with a Cloudflare account sends traffic from Cloudflare's addresses. Your own certificate closes it.

For the shared certificate, download it to the server:

sudo curl -fsS -o /etc/ssl/cloudflare-origin-pull-ca.pem https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem

For your own certificate, make a small certificate authority and one client certificate signed by it:

sudo mkdir -p /root/origin-pull && cd /root/origin-pull
sudo openssl req -x509 -newkey rsa:2048 -nodes -days 1826 -subj "/CN=shop.example origin pull CA" -keyout ca.key -out ca.crt
sudo openssl req -newkey rsa:2048 -nodes -subj "/CN=shop.example origin pull" -keyout client.key -out client.csr
sudo openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 730 -out client.crt
sudo cp ca.crt /etc/ssl/cloudflare-origin-pull-ca.pem

In the Cloudflare dashboard, go to SSL/TLS → Origin Server → Authenticated Origin Pulls, and in the Zone-level part choose Upload certificate. Paste client.crt and client.key. Then keep ca.key somewhere safe, away from the server, and delete client.key from the server. The client certificate lasts two years. Cloudflare emails you before it runs out.

Nginx, in the site's server block for port 443:

ssl_client_certificate /etc/ssl/cloudflare-origin-pull-ca.pem;
ssl_verify_client optional;

Apache, in the site's <VirtualHost *:443>:

SSLCACertificateFile /etc/ssl/cloudflare-origin-pull-ca.pem
SSLVerifyClient optional
SSLVerifyDepth 1

Turn it on in this order, so the site never breaks:

  1. Add the lines above with optional and reload. Nothing changes for visitors yet.
  2. In Cloudflare, under Authenticated Origin Pulls, switch it On (Global, or Zone-level for your own certificate).
  3. Change optional to on in Nginx (ssl_verify_client on;) or to require in Apache (SSLVerifyClient require). Check and reload: sudo nginx -t && sudo systemctl reload nginx, or sudo apache2ctl configtest && sudo systemctl reload apache2.

We couldn't use Cloudflare's own certificate in our lab, so we tested with a client certificate we made ourselves, as above. With the certificate, the site loaded. Without it, Nginx answered 400 No required SSL certificate was sent. A certificate from a different authority got 400 The SSL certificate error. Apache closed the connection in both cases.

This only covers HTTPS. Make port 80 do nothing but redirect to HTTPS, or close it to everyone except Cloudflare with step 4.

7. Keep seeing real visitor addresses

Behind Cloudflare, your logs show Cloudflare's addresses unless you tell the web server to read the visitor's address from Cloudflare's header. For Nginx, step 7 of our rate limiting guide does this with the same address list. For Apache, the module mod_remoteip does the same job. Keep in mind that Fail2ban can't block visitors that arrive through Cloudflare, because the firewall only sees Cloudflare's address.

8. Check from outside

Test from a machine that isn't the server, such as your own computer. First, ask the server directly for your site, skipping Cloudflare:

curl -sv -m 10 --resolve shop.example:443:203.0.113.5 https://shop.example/

With the firewall from step 4, this should end with Connection timed out. With only step 5 or 6, the connection opens but you get no page, or a 400 error. If you see your site, direct access still works.

Then scan the ports, as in our guide to open ports:

nmap -Pn -p 22,80,443 203.0.113.5

In our test, the direct visitor saw 22 open, 80 filtered and 443 filtered. Finally, open your site normally in a browser to check it still works through Cloudflare. If your server has an IPv6 address, repeat the tests with it.

If something goes wrong

  • The site is down for everyone: the orange cloud is off or Cloudflare is paused, so visitors now come directly and are refused. Turn the proxy back on, or add sudo ufw allow 80/tcp and sudo ufw allow 443/tcp again while you fix it.
  • Cloudflare shows error 526: Full (strict) can't accept your server's certificate. It has run out, or doesn't cover the name. Check step 3.
  • Cloudflare shows error 522 or 521: Cloudflare can't reach your server. Check that the script ended with "Done" and that sudo ufw status lists the cloudflare rules.
  • Every visitor gets 400 after step 6: Authenticated Origin Pulls isn't on in Cloudflare, or the certificate you uploaded doesn't match the file on your server. Switch back to optional while you check.
  • The script says "UFW has no rule for SSH": add one with sudo ufw allow OpenSSH (or your port), then run it again.
  • The script stops with "Bad source address": you probably joined the two lists in your own version without a new line in between. Cloudflare's files don't end with one.
  • Locked out of SSH: use your hosting provider's web console, as in our UFW guide.

Official sources

Next steps: protect the one door that stays open to everyone, with SSH keys and no password login, and work through the security checklist for a new PHP server.

ServerNest is in development. One of our goals is safe defaults for each server, such as a firewall that only opens what a site needs. Join early access to try it first.

← Back to the security blog