Skip to content
All posts
/3 min read

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.

Cloudflare Blocked My Own Server. A Free WireGuard Tunnel Fixed It.

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.

OptionCostVerdict
Turn off Bot Fight ModeFreeRemoves protection site-wide
WireGuard tunnelFreePrivate, encrypted, nothing public added
Grey cloudFreeBreaks firewall, exposes origin
Open 443 to Server A's IPFreeWidens firewall
Pro planPaidStill 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.

Updated Nov 09, 2026