Locking Down a DMZ Web Server with Cloudflare Tunnels

You’ve got an edge web server. It’s internet-facing. In my case it’s a Rocky Linux VM sitting on its own VLAN, isolated from the rest of the homelab network. The only reason it’s exposed is Cloudflare Tunnels — traffic enters through Cloudflare’s edge, traverses the tunnel via cloudflared, and hits services on localhost. No open ports on the router for anything but SSH.
That’s the ideal. Here’s what you actually need to do beyond “put it in a DMZ and run a tunnel.”
Network Segmentation First
The machine lives on its own subnet. In my setup that’s 192.168.5.0/24, with the gateway at .5.1. No routes to the internal LAN (192.168.1.0/24). Wi-Fi interface down — no accidental bridge if the box has a wireless NIC. DNS resolves through the DMZ gateway only.
If your router doesn’t already support VLANs, do this before anything else. pfSense, OPNsense, Untangle — pick one and get it between your modem and your LAN. The DMZ VLAN needs its own interface, its own subnet, and no upstream routes to your internal network. Your router’s firewall should only allow the .5.x subnet outbound to the internet. Not .1.x. Not .3.x. Not anywhere else.
Verify it works: ping an internal IP from the DMZ host. If it replies, you missed a rule.
Host Firewall — Only SSH
Every port except 22 goes through Cloudflare. The firewall reflects that. On RHEL-family systems, that’s firewalld:
# Remove everything that shouldn't face the internet
sudo firewall-cmd --remove-service=cockpit --permanent
sudo firewall-cmd --remove-port=8080/tcp --permanent
sudo firewall-cmd --remove-service=dhcpv6-client --permanent
sudo firewall-cmd --reload
# Verify
sudo firewall-cmd --list-all
The output should show only ssh in services. No ports. The default zone is public, policy is reject by default. If you’re on Arch or Debian, use nftables or iptables — same concept, only port 22 inbound.
Cockpit is the danger here. It ships pre-installed on many RHEL distributions and it’s a full web-based admin console. If it’s in the firewall allow list, someone can browse https://your-ip:9090 and get a graphical interface to your server. Remove it from the firewall minimum, disable the service if you don’t use it.
Service Minimization
Audit what’s running. Everything on the box should have a reason to be there.
systemctl list-units --type=service --state=running
On a web server, the normal set is: sshd, cloudflared, the container runtime, firewalld, chronyd, NetworkManager, rsyslog, auditd. Everything else is a candidate for removal. Unnecessary services I’ve seen sitting idle: atd, cockpit, systemd-journal-gatewayd, USB daemons, Bluetooth, and printers. Disable what you don’t need:
sudo systemctl disable --now atd cockpit.socket cockpit.service
SSH Hardening
SSH is the only direct access point. Lock it down before someone tells you they should.
Edit /etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication no
AllowUsers derek wordpress
MaxAuthTries 3
That’s four lines. Most of your SSH exposure disappears with them.
PermitRootLogin no— root can’t SSH at all, keys or not. Admin tasks happen under a regular user withsudo.PasswordAuthentication no— only key-based authentication. Brute force is useless without keys.AllowUsers— explicitly list every user that should authenticate. By default any user in/etc/passwdcan SSH. That includes service accounts you created months ago and forgot about.MaxAuthTries 3— limits key attempts per connection. Matters less without passwords, but it’s noise reduction.
Validate the config before you reload:
sudo sshd -t
sudo systemctl reload sshd
Validate first. sshd -t exits silently on success, prints an error on failure. Don’t skip it — a bad config reloads and you lose your active session.
User Accounts
Check /etc/passwd for interactive shells on service accounts:
grep -v nologin /etc/passwd | grep -v root
Any account that corresponds to an application, a container user, or a backup job shouldn’t have /bin/bash. Fix it:
sudo chsh -s /sbin/nologin <serviceuser>
SELinux
On RHEL-family systems, check. It should be Enforcing:
getenforce
If it’s Permissive or Disabled, something went wrong on install or someone disabled it because “it was blocking things.” You can tune SELinux around whatever was blocked. Disable it, you’re running your own custom firewall with no audit trail and no mandatory access control. Not worth it.
fail2ban
SSH lives on port 22, internet-scanning bots will hit it every few minutes. SSH with password auth off doesn’t mean there’s no risk — malformed packets, protocol fuzzing, or anyone who somehow gets hold of a key still triggers auth attempts. fail2ban adds rate limiting at the connection level:
sudo dnf install -y epel-release fail2ban
Create /etc/fail2ban/jail.local:
[DEFAULT]
banaction = firewallcmd-rich-rules
bantime = 3600
findtime = 600
maxretry = 3
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/secure
maxretry = 3
Start it:
sudo systemctl enable fail2ban
sudo systemctl start fail2ban
fail2ban-client status sshd
If you’re on Debian/Ubuntu, the action is iptables-multiport or nf-iptables-ipset-proto6. If you’re on Arch with nftables, use nftables-multiport. The jail config itself is the same.
On a production box where you SSH from multiple machines, the ignoreip directive matters. Whitelist your workstations or management subnets:
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24 192.168.2.0/24
Read that list before you save the file. If your workstation IP isn’t on it and you misconfigure something, you’ll be locked out of your own machine.
Cloudflare Tunnel as the Only Inbound Path
cloudflared listens on localhost. Its admin port needs to bind to 127.0.0.1 only. Check this with:
ss -tlnp | grep cloudflared
If you see 0.0.0.0:20241 or :::20241, that’s not right. The tunnel config should have url: http://localhost:20241 or equivalent, binding to loopback.
Any service the tunnel proxies should also bind to 127.0.0.1 wherever possible. If a Podman container needs to listen on 0.0.0.0: because it’s rootless and the user namespace maps the port, the host firewall handles the restriction. Both work, but binding to localhost removes one layer of trust from the equation.
The Final State
When you’re done, the box should look like this:
| Check | Expected |
|---|---|
| Host firewall allowed ports | 22/tcp only |
| Services in the tunnel’s path | cloudflared on 127.0.0.1 only |
| SSH config | PasswordAuthentication no, PermitRootLogin no, AllowUsers set |
| fail2ban | Active, jail sshd running |
| SELinux | Enforcing |
| Unnecessary services | Disabled |
| User accounts | Service accounts use /sbin/nologin |
Nothing else faces the internet. No direct access. No IP on a screen scraping bot’s list. The tunnel is the door. Everything else is a wall.
Next
- Why I Use Both GitHub and Forgejo — self-hosted Git server with CI, running behind the same tunnel infrastructure that gets you started.