nginx, Let’s Encrypt and a Cloudflare proxy
Two layers that each work perfectly alone become confusing together. Cloudflare terminates TLS at its edge; Let’s Encrypt wants to prove you control the origin; nginx sits behind both and stops being able to tell you who is visiting. This site runs exactly that stack. Here is what actually bit, in the order it bit.
Yes, HTTP-01 still works behind the orange cloud
The internet will tell you that proxied domains require the DNS-01 challenge and a Cloudflare API token. For a simple site, they do not. This certificate was issued straight through the proxy:
certbot --nginx -d fablier.dev
It works because Cloudflare proxies port 80 to your origin rather than swallowing it. Let’s Encrypt requests http://fablier.dev/.well-known/acme-challenge/<token>, Cloudflare forwards it, nginx serves the token, validation passes. Renewal is the same path, unattended, via certbot.timer.
Two conditions have to hold. Your Cloudflare SSL mode must be Full or Full (strict) — on Flexible, Cloudflare talks to your origin in plaintext and any redirect nginx makes to HTTPS turns into a loop. And you must not have an Always Use HTTPS rule that 301s the ACME path before it reaches you.
DNS-01 earns its complexity when you need a wildcard, or when port 80 genuinely cannot reach the origin. Otherwise it is an API token you now have to store and rotate for no benefit.
The hour I lost, so you do not have to: right after pointing the DNS record, the server’s own resolver kept returning NXDOMAIN for the domain — a cached negative answer, with a TTL measured in generations. The record was live everywhere else in the world. Do not debug your nginx config in that state; check from outside your own resolver first:
curl -s 'https://cloudflare-dns.com/dns-query?name=fablier.dev&type=A' \
-H 'accept: application/dns-json'
If the public answer is correct and yours is not, the problem is your cache, not your setup.
The failure with no error message: your logs are all lies
Everything worked. The site served, the certificate was valid, and the access log looked like this:
172.69.108.138 - - [07/Aug/2026:17:58:02 +0200] "GET / HTTP/1.1" 200 1461
172.71.151.191 - - [07/Aug/2026:17:58:44 +0200] "GET / HTTP/1.1" 200 1461
Those are Cloudflare’s machines, not visitors. Nothing errors, nothing warns — you just quietly lose every visitor IP. Analytics become meaningless, and worse, any rate limiting or fail2ban jail reading these logs is now aiming at Cloudflare’s edge. Ban a scraper and you ban a datacentre for everyone routed through it.
Cloudflare sends the real address in a CF-Connecting-IP header. nginx’s realip module can substitute it into $remote_addr — but only from senders you explicitly trust, which is the whole security property. Trusting the header unconditionally would let anyone reaching your origin directly claim any IP they like, poisoning exactly the logs and bans you are trying to fix.
# /etc/nginx/conf.d/cloudflare-realip.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... one line per published Cloudflare range, v4 and v6
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Same request after reloading:
2a03:4000:17:f94:64e5:38ff:febf:5f19 - - [07/Aug/2026:18:12:18 +0200] "GET / HTTP/1.1" 200 712
The part most guides leave out
Those ranges change. Paste them in by hand and you have written a bug with a delay fuse: months later Cloudflare adds a range, requests arrive from an untrusted address, and your logs silently revert to proxy IPs. No error, again.
So fetch them on a schedule. The interesting content of such a script is not the fetch, it is the refusal to trust it:
count=$(grep -c '^set_real_ip_from' "$TMP")
if [ "$count" -lt 10 ]; then
echo "ABORT: only $count ranges retrieved, expected >= 10." >&2
exit 1
fi
[ -f "$CONF" ] && cp -a "$CONF" "$BACKUP"
install -m 644 "$TMP" "$CONF"
if ! nginx -t 2>/dev/null; then
echo "ABORT: nginx -t fails, restoring." >&2
install -m 644 "$BACKUP" "$CONF"
exit 1
fi
systemctl reload nginx
Three guards, each for a specific way an unattended job ruins a weekend. A captive portal or an error page returns HTTP 200 with a body that is not a list of CIDRs — the count check catches a plausible-looking file that would have silently disabled real_ip. The config is only written after that check passes, and only kept if nginx -t accepts it. And if the file is byte-identical, the script exits before reloading: a scheduled job that reloads a service every week for no reason is noise that trains you to ignore it.
Then a timer rather than a cron line, mostly for Persistent=true — a missed run while the machine was down executes on next boot instead of being skipped:
[Timer]
OnCalendar=weekly
RandomizedDelaySec=6h
Persistent=true
RandomizedDelaySec is politeness: every server on earth running this script at exactly 00:00 Monday makes a small stampede against Cloudflare.
Optional: close the origin entirely
Once nginx trusts only Cloudflare’s ranges, the logical next step is for the firewall to accept web traffic only from them too — otherwise anyone who discovers your origin IP can bypass the proxy completely, along with whatever protection you were paying it for.
It is a real improvement, and I have not done it here. The reason is honest: it couples my site’s availability to a script that rewrites firewall rules from a remote list. The failure mode of a stale real_ip config is bad logs. The failure mode of a stale firewall is a site that is down and an SSH session that still works but cannot tell me why. If you do it, keep port 80 and 443 rules generated by the same verified list, and never let a fetch failure result in an empty allow-list.
The pattern underneath
Every problem here shares a shape: the system reports success while being wrong. The certificate issues fine and the logs are fiction. The jail is enabled and bans nobody. The config file contains the right words and the daemon loaded something else.
Which is why the only verification I trust is asking the running system what it currently believes — nginx -T, fail2ban-client status, an actual request followed by an actual look at the log line it produced. A file is a statement of intent. Behaviour is evidence.
This procedure — HTTPS behind a proxy, with the real_ip fix and the verification steps — is one of five server-admin skills I wrote for Claude Code, all of them run against this machine before being written down.
server-doctor, the read-only diagnosis skill, is free on GitHub. The full pack is on Gumroad. Previously: hardening a Debian 13 VPS. Next: the errors in your journal probably don't matter.