My Fly.io app worked differently depending on one Cloudflare toggle. With the proxy on, it returned Error 525 (SSL handshake failed). With the proxy off, the browser showed a certificate mismatch. The problem was stale DNS preventing Fly.io from issuing the right origin certificate.
Two TLS Hops, One Failure Point
When Cloudflare proxying is enabled (orange cloud), two TLS connections exist: browser to Cloudflare edge, then Cloudflare edge to Fly.io origin. Fly.io must present a valid certificate for the custom domain. A 525 means the origin TLS handshake failed. It does not identify the cause by itself; 526 separately covers invalid origin certificates under strict validation.
The symptom: 525 SSL handshake failed only when Cloudflare proxy was enabled. With proxy off (gray cloud / DNS only), the site showed certificate mismatch errors instead.
Check the origin and DNS Root cause: DNS records at Cloudflare were inconsistent (stale A/AAAA or CNAME remnants). Fly.io manages cert issuance itself through ACME (Let’s Encrypt). Dirty DNS prevented ACME validation, which meant no valid origin cert, which meant Cloudflare’s handshake failed.
If Cloudflare cannot finish that second TLS hop, you get 525.
The Fix
Clean your DNS. Review only the conflicting web-routing A, AAAA, and CNAME records for the app hostname. Preserve unrelated records such as MX, TXT, and other service records. ACME validation and routing depend on unambiguous records.
Add correct AAAA records. Fly.io provides an IPv6 that routes to the app:
| Name | Type | Content | Proxy status |
|---|---|---|---|
example.com | AAAA | <fly-io-ipv6-address> | Proxied |
www.example.com | AAAA | <fly-io-ipv6-address> | Proxied |
No A record needed if Fly.io only gave IPv6. Cloudflare terminates at edge (dual stack) then connects over IPv6 downstream.
Preserve ACME records. Leave existing _acme-challenge CNAMEs intact. These allow Let’s Encrypt to validate via DNS-01.
Wait for propagation. TTL and resolver caches need a few minutes. Verify with dig from several public resolvers.
Use strict origin validation Let Fly.io re-issue certificates: Once DNS points correctly, Fly.io’s dashboard shows domain status as Verified with RSA + ECDSA issued. Set Cloudflare SSL Mode to Full (Strict), not Flexible. Strict ensures Cloudflare validates the origin cert chain instead of ignoring it.
Three Things to Check
I only needed to confirm three things:
| Layer | Healthy state | Failure signal |
|---|---|---|
| DNS | Web-routing records point to the intended Fly.io app | Stale A, AAAA, or CNAME records point elsewhere |
| Fly.io | Domain is verified and RSA + ECDSA certificates are issued | Certificate remains pending or the hostname mismatches |
| Cloudflare | Proxy is on and SSL mode is Full (Strict) | Flexible mode hides the origin problem |
Manual probing and deep TLS inspection were noise once those were correct.
Why custom cert upload failed: Fly.io issues and renews its own certs via ACME. Cloudflare Origin CA certificates can be valid for Cloudflare-to-origin trust, but browsers do not trust them directly. I kept Fly.io’s managed public certificates rather than maintaining a separate certificate workflow.
Common pitfalls: Stale A/AAAA/CNAME records lingering. Mixing previous host IPs with Fly.io AAAA. Using “Flexible” SSL (masks the real problem). Trying to import certificates instead of letting automation run.
Minimal checklist:
- Add domain in Fly.io
- Publish the Fly.io web-routing records; preserve other services and validation records
- Leave
_acme-challengeCNAMEs untouched - Set SSL/TLS = Full (Strict)
- Wait a few minutes; confirm Fly.io shows “Verified / Issued”
Once those three checks were green, the 525 disappeared. Fly.io could issue the certificate and Cloudflare could validate it in Full (Strict) mode.
Was this useful?
Thanks for the feedback.