Cloudflare Blocked My Own Server. A Free WireGuard Tunnel Fixed It.
How a bot filter turned a working feature into a 502, and the small private network that brought it back.

TL;DR: My production backend called a second server of mine through Cloudflare. Bot Fight Mode saw a datacenter IP and served a JS challenge, so the call got a 403 and users got a 502. I fixed it with a free WireGuard tunnel between the two VPS machines. One env var changed. Zero code changes.
I recently moved a project to two fresh hardened VPS servers:
* Server A: the main app (frontend, backend, database)
* Server B: a second machine hosting an internal service that Server A calls
Both sit behind Cloudflare (orange cloud). UFW only allows Cloudflare IPs on 80/443.
When a user triggers a certain action, the backend on A calls the internal service on B using its public hostname, so the call goes through Cloudflare.
Browser:
POST /api/some-feature -> 502
Cloudflare error code: origin_bad_gateway
Backend log:
[Backend] internal service responded (143ms, status=403)
Manual test from Server A:
$ curl -sD - -o /dev/null https://internal.<YOUR_DOMAIN>
HTTP/2 403
cf-mitigated: challenge
server: cloudflare
cf-mitigated: challenge means Cloudflare stopped the request and asked it to solve a JS puzzle. Node can't. Yet in my browser the same URL loaded fine. Same server, same name, two different results.
Cloudflare scores requests on:
* IP reputation: datacenter IPs start with a worse score
* TLS fingerprint: Chrome and Node look different
* Headers: a bare fetch sends very few
* JavaScript: only browsers can get the cf_clearance cookie
My backend is a bot, technically. The filter was right.
* A custom rule to skip checks for my own IPs. It matched traffic, the challenge kept coming. On the Free plan, Bot Fight Mode can't be skipped by custom rules. Only paid Super Bot Fight Mode can.
* Grey-clouding the hostname. Server A would then hit B from its own IP, and my UFW only allows Cloudflare. Timeout, plus the origin gets exposed.
* North-south traffic: outside world to your servers. Bot filters belong here.
* East-west traffic: your servers to each other. You trust both ends.
My bug: I sent east-west traffic out the front door. The fix isn't a weaker guard, it's a private road.
| Option | Cost | Verdict |
|---|---|---|
| Turn off Bot Fight Mode | Free | Removes protection site-wide |
| WireGuard tunnel | Free | Private, encrypted, nothing public added |
| Grey cloud | Free | Breaks firewall, exposes origin |
| Open 443 to Server A's IP | Free | Widens firewall |
| Pro plan | Paid | Still a public path |
* Private IP: 10.8.0.x only means something inside your own network, so it never clashes with anyone else's.
* Port: the door number on an IP. 443 HTTPS, 22 SSH, 9000 my internal service.
* TCP vs UDP: TCP is a phone call, UDP is a postcard. WireGuard uses UDP.
* Keys: public key is a padlock you share, private key never leaves its server.
* Envelope in an envelope: your TCP request is encrypted and sealed inside a UDP packet on port 51820.
sudo apt update && sudo apt install -y wireguard
cd /etc/wireguard
sudo sh -c 'umask 077; wg genkey | tee private.key | wg pubkey > public.key'
sudo cat public.key
Swap public keys between servers. Never share a private key.
Server A /etc/wireguard/wg0.conf:
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <SERVER_A_PRIVATE_KEY>
[Peer]
PublicKey = <SERVER_B_PUBLIC_KEY>
Endpoint = <SERVER_B_PUBLIC_IP>:51820
AllowedIPs = 10.8.0.2/32
Server B is the mirror (Address = 10.8.0.2/24, Endpoint = A's public IP, AllowedIPs = 10.8.0.1/32).
Then:
```bash
sudo chmod 600 /etc/wireguard/wg0.conf
sudo ufw allow from <OTHER_SERVER_PUBLIC_IP> to any port 51820 proto udp
sudo systemctl enable --now wg-quick@wg0
sudo wg show # look for "latest handshake"
Server B docker-compose.yml:
ports:
- "127.0.0.1:9000:8080"
- "10.8.0.2:9000:8080" # tunnel only
Server A env file, the actual fix:
INTERNAL_SERVICE_URL=http://10.8.0.2:9000
Then restart the backend. It's http because the tunnel already encrypts the hop.
1.
failed to bind host port 10.8.0.2:9000/tcp: cannot assign requested address
WireGuard wasn't up, so 10.8.0.2 didn't exist yet.
2.
Key is not the correct length or format: YOUR_PRIVATE_KEY_HERE
Configuration parsing error
I'd left a placeholder in the config. Fixed with sed and $(sudo cat private.key) so the key never printed.
Reboot-proofing: make Docker start after WireGuard.
[Unit]
After=wg-quick@wg0.service
Wants=wg-quick@wg0.service
```
1. If two of your servers talk, don't route them through the public internet.
2. The UFW rule is UDP 51820, not TCP.
3. Wrong AllowedIPs fails silently.
4. Always put an address in Docker port mappings, or you can bypass UFW.
5. Rotate any secret you ever pasted into chat.
Public site still has full Cloudflare protection. Everything works again. Cost: $0, about 30 minutes.