jani@raatti:~ $ cat ~/blog/simplicity-is-a-feature-migrating-to-cloudflare-tunnel-on-red-hat-linux.md
---
title: "Simplicity is a Feature: Migrating to Cloudflare Tunnel on Red Hat Linux"
date: 2026-03-22
author: jani
categories: [Cloudflare, DevOps, Linux, Security, Server]
reading_time: 6 min
---

“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.”
— Antoine de Saint-Exupéry

Unnecessary is the enemy of perfect. This is a principle I keep coming back to when managing server infrastructure. It is not enough to have something that works — if it works through unnecessary complexity, it is already a liability waiting to become a problem.

This post is about removing something that worked perfectly fine, because it did not need to exist.

For the full story of how that setup came to be — including the bandwidth mystery, wp-cron self-hammering, and compiling mod_evasive from source — see From Bandwidth Mystery to Hardened Origin.


The Old Setup

My server at raatti.net runs Red Hat Linux with Apache and php-fpm. Traffic goes through Cloudflare, which handles DDoS protection, caching, and TLS termination.

To prevent anyone from bypassing Cloudflare and hitting the origin directly, I maintained firewall rules that restricted HTTP and HTTPS access to Cloudflare’s published IP ranges:

rule family="ipv4" source ipset="cloudflare-ipv4" port port="80" protocol="tcp" accept
rule family="ipv6" source ipset="cloudflare-ipv6" port port="80" protocol="tcp" accept
rule family="ipv4" source ipset="cloudflare-ipv4" port port="443" protocol="tcp" accept
rule family="ipv6" source ipset="cloudflare-ipv6" port port="443" protocol="tcp" accept
rule family="ipv4" source address="127.0.0.1" port port="80" protocol="tcp" accept

But Cloudflare manages dozens of IP ranges across IPv4 and IPv6, and they update them. Which means either you script the updates, or you do them manually, or you quietly forget about it and hope nothing changes. None of these options are great. All of them are unnecessary.

Five rules. Two IP families. One nagging feeling that Cloudflare updated their ranges last Tuesday.

There is a better way: Cloudflare Tunnel.


What is Cloudflare Tunnel?

Instead of accepting inbound connections from Cloudflare’s IPs, your server initiates an outbound connection to Cloudflare’s network. Cloudflare then routes traffic through that connection to your origin. No inbound ports. No IP allowlists. No firewall rules to maintain for HTTP/HTTPS at all.

The tunnel is persistent, runs as a systemd service, and is free on Cloudflare’s free plan. It is production-ready — not to be confused with TryCloudflare quick tunnels, which are for development only.


Installation on Red Hat Linux

1. Add the Cloudflare repository and install cloudflared

# Add Cloudflare's RPM repository
sudo dnf config-manager --add-repo https://pkg.cloudflare.com/cloudflared.repo

# Install cloudflared
sudo dnf install -y cloudflared

Verify the installation:

cloudflared --version

2. Authenticate with Cloudflare

cloudflared tunnel login

This opens a browser window. Log in to your Cloudflare account and select the domain you want to use. A certificate (cert.pem) is saved to ~/.cloudflared/.

3. Create the tunnel

cloudflared tunnel create raatti-tunnel

Note the tunnel UUID from the output — you will need it shortly. You can also list tunnels at any time:

cloudflared tunnel list

4. Create the configuration file

Create the config directory and file:

sudo mkdir -p /etc/cloudflared
sudo nano /etc/cloudflared/config.yml
tunnel: <YOUR-TUNNEL-UUID>
credentials-file: /root/.cloudflared/<YOUR-TUNNEL-UUID>.json

ingress:
  - hostname: raatti.net
    service: http://localhost:80
  - hostname: www.raatti.net
    service: http://localhost:80
  - service: http_status:404

The catch-all rule at the end is required — requests that do not match any hostname return a 404. cloudflared will refuse to start without it. It has standards.

Note: Traffic between cloudflared and Apache is local (localhost), so no TLS is needed there. Cloudflare handles TLS termination at the edge.

5. Route DNS to the tunnel

Your tunnel UUID is needed here. Find it with:

cloudflared tunnel list

If this is a fresh domain with no existing DNS records, let cloudflared create them automatically:

cloudflared tunnel route dns raatti-tunnel raatti.net
cloudflared tunnel route dns raatti-tunnel www.raatti.net

If you already have A records pointing to your server (like most migrations), cloudflared will error with “A record with that host already exists”. You have two options:

Option A — Delete and recreate (CLI):

  1. Go to Cloudflare Dashboard → your domain → DNS → Records
  2. Delete the existing A records for raatti.net and www.raatti.net
  3. Then run the cloudflared tunnel route dns commands above as normal

Option B — Edit in place (UI):

  1. Go to Cloudflare Dashboard → your domain → DNS → Records
  2. Edit each existing A record:
    • Change type from A to CNAME
    • Set target to <YOUR-TUNNEL-UUID>.cfargotunnel.com
    • Make sure Proxy status is set to Proxied (orange cloud)
  3. Save

Either way, the result is the same: a proxied CNAME pointing at your tunnel.

6. Install and start as a systemd service

sudo cloudflared service install
sudo systemctl enable --now cloudflared

Check that it is running:

sudo systemctl status cloudflared

You should see the tunnel connect and show a Healthy status in the Cloudflare dashboard under Zero Trust → Networks → Tunnels.


Simplifying the Firewall

This is the satisfying part. All those Cloudflare IP rules can go:

sudo firewall-cmd --permanent --remove-rich-rule='rule family="ipv4" source ipset="cloudflare-ipv4" port port="80" protocol="tcp" accept'
sudo firewall-cmd --permanent --remove-rich-rule='rule family="ipv6" source ipset="cloudflare-ipv6" port port="80" protocol="tcp" accept'
sudo firewall-cmd --permanent --remove-rich-rule='rule family="ipv4" source ipset="cloudflare-ipv4" port port="443" protocol="tcp" accept'
sudo firewall-cmd --permanent --remove-rich-rule='rule family="ipv6" source ipset="cloudflare-ipv6" port port="443" protocol="tcp" accept'
sudo firewall-cmd --reload

Verify what remains open:

sudo firewall-cmd --list-all

Note: the localhost rule for port 80 is intentionally kept — both cloudflared (proxying tunnel traffic to Apache) and WordPress cron make local HTTP requests to 127.0.0.1:80.

HTTP and HTTPS no longer need to be reachable from the internet at all. The tunnel uses outbound port 443, which is almost certainly already permitted by default. Your origin is now unreachable except through Cloudflare.


SELinux on Red Hat Linux

Red Hat Linux runs SELinux in enforcing mode by default. cloudflared works out of the box without any additional policy changes — it runs as a system service communicating over standard ports, which SELinux handles without complaint.

You can verify this yourself:

sudo ausearch -m avc -ts recent | grep cloudflared

No output means no denials. Nothing to do here. I was almost disappointed.


The Result

The Apache and php-fpm configuration did not change. The site works exactly as before. What changed is what is no longer there: no IP allowlist, no maintenance burden, no firewall rules for HTTP or HTTPS.

The server now has fewer open ports, no inbound web traffic, and one less thing to think about.

That is the point. Perfection is not adding better firewall rules. It is not needing them. Cloudflare Tunnel makes this possible — for free, I might add. Love Cloudflare ❤️

Leave a Comment

Your email address will not be published. Required fields are marked *