Mert Özen Further With Every Line
DevOps Domain, DNS and CDN — #06

Putting Bookstore Behind Cloudflare: Proxying, SSL/TLS Modes, Cache Rules and the Real Client IP

Mert Özen Nov 14, 2026 13 min 1 views
Putting Bookstore Behind Cloudflare: Proxying, SSL/TLS Modes, Cache Rules and the Real Client IP

Moving to Cloudflare means moving the nameservers, keeping web records proxied and mail records DNS-only, choosing Full (strict) as the SSL/TLS mode, writing Cache and Redirect Rules instead of Page Rules, and trusting CF-Connecting-IP at the origin only for connections from Cloudflare's IP ranges. In a lab we see the redirect loop Flexible mode produces, tests that reject spoofed headers with KnownIPNetworks in ASP.NET Core, and the Azure Front Door equivalents.

Version note: The examples in this post were run with .NET 10, xUnit 2.9 and Microsoft.AspNetCore.Mvc.Testing 10.0.15, Nginx 1.30.6 and curl 8.17 on Docker 29; the Cloudflare and Azure Front Door steps are taken from the official documentation as of 14 November 2026 and were not run in a Cloudflare or Azure account.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, has moved to Cloudflare. The origin server has no certificate yet, so in the setup wizard Emre picks "Flexible" as the SSL/TLS mode: visitors reach Cloudflare over HTTPS, Cloudflare reaches the origin over HTTP. Meanwhile Bookstore.Api still has the app.UseHttpsRedirection() line that came with the template.

Guess: what happens when a visitor opens https://bookstore.example/api/books? And another one: the records now show Cloudflare's IPs, so the origin's IP is "hidden". Is it really? In this post we set up the move to Cloudflare step by step, reproduce the first question in the lab and answer the second by looking at the origin's logs.

Onboarding: Moving Nameservers and Importing Records

In Cloudflare's standard ("full") setup, DNS itself moves to Cloudflare; the delegation we saw in the first post now points to Cloudflare. Per Cloudflare's documentation the flow is: you add the domain to the account, Cloudflare scans and imports the existing records, you review the list, Cloudflare gives you two nameserver names, and you enter them at the registrar. The rules from the fourth post apply as they are. The automatic scan can't find every record: since zone transfer is disabled, Cloudflare guesses common names; records like asuid.api or s1._domainkey.mail may not make the list. So exporting the zone from the old provider, importing it and then comparing both sides with compare-resolvers.sh is more reliable than trusting the guess. If DNSSEC is on, decide what to do with the DS before the NS change; the SERVFAIL scenario from the fourth post happens just the same when moving to Cloudflare.

If Bookstore's zone lives in a repo with DNSControl, the move is easier: you apply the same dnsconfig.js with the Cloudflare provider and the tool itself shows that both providers have the same records.

Proxied and DNS-only: What Does the Orange Cloud Do?

Every A, AAAA and CNAME record on Cloudflare has a "proxy status". A proxied (orange cloud) record returns Cloudflare's anycast IPs instead of the origin's when queried; traffic goes through Cloudflare's edge, and caching, the WAF and other features come into play. A DNS-only (grey cloud) record is an ordinary DNS record and returns the real IP. To see what comes back for a proxied name, we can check against the IP ranges Cloudflare publishes:

$ curl -s https://api.cloudflare.com/client/v4/ips | jq '{etag: .result.etag, ipv4: (.result.ipv4_cidrs | length), ipv6: (.result.ipv6_cidrs | length), first_v4: .result.ipv4_cidrs[0:3], first_v6: .result.ipv6_cidrs[0:2]}'
{
  "etag": "38f79d050aa027e3be3865e495dcc9bc",
  "ipv4": 15,
  "ipv6": 7,
  "first_v4": [
    "173.245.48.0/20",
    "103.21.244.0/22",
    "103.22.200.0/22"
  ],
  "first_v6": [
    "2400:cb00::/32",
    "2606:4700::/32"
  ]
}
$ dig @1.1.1.1 example.com A +short
104.20.23.154
172.66.147.243
$ python3 -c "
import ipaddress
nets = [ipaddress.ip_network(l.strip()) for l in open('cloudflare-ips.txt') if l.strip()]
for ip in ['104.20.23.154', '172.66.147.243', '173.245.48.10']:
    a = ipaddress.ip_address(ip); print(ip, [str(n) for n in nets if a in n])"
104.20.23.154 ['104.16.0.0/13']
172.66.147.243 ['172.64.0.0/13']
173.245.48.10 ['173.245.48.0/20']
$ curl -sI https://example.com | grep -iE "^(HTTP|server|cf-cache-status|age)"
HTTP/2 200
server: cloudflare
age: 3029
cf-cache-status: HIT

example.com's addresses are in Cloudflare's ranges, and the answer comes from Cloudflare's cache (cf-cache-status: HIT). Where the origin is can't be seen from DNS.

The proxy only carries HTTP and HTTPS. According to Cloudflare's documentation, the ports proxied by default are 80, 8080, 8880, 2052, 2082, 2086 and 2095 for HTTP, and 443, 2053, 2083, 2087, 2096 and 8443 for HTTPS. Caching is disabled on some of them. SSH, SMTP or a database port does not go through the proxy. So in Bookstore these must stay DNS-only: the mail servers MX points to, and names used for SFTP or SSH. TXT, MX and CAA can't be proxied anyway; the orange cloud exists only for A, AAAA and CNAME. The decision for Bookstore: the apex, www, api and the staging names are proxied; everything mail-related is DNS-only.

SSL/TLS Modes and the Flexible Trap

A proxied site has two separate TLS connections: between the visitor and Cloudflare (Cloudflare manages the edge certificate) and between Cloudflare and the origin. The mode decides how the second one is made. Per Cloudflare's documentation:

ModeCloudflare to originIs the origin certificate validated?
OffHTTP (the visitor side is HTTP too)No
FlexibleAlways HTTPNo
FullSame protocol as the visitorNo (self-signed is accepted)
Full (strict)Same protocol as the visitorYes (public CA or Cloudflare Origin CA)
Strict (SSL-Only Origin Pull)Always HTTPSYes

The default for new zones is Automatic SSL/TLS: Cloudflare scans the origin and picks the most secure mode that works on its own, and never downgrades the mode automatically.

Flexible's first danger is obvious: the visitor sees the padlock in the address bar, but their data travels unencrypted from Cloudflare to the origin. The second is the situation from Guess First. I built the same setup in the lab: an Nginx edge answers the visitor over HTTPS and goes to the origin over HTTP. Like Cloudflare, it also sends the origin the CF-Connecting-IP and X-Forwarded-Proto headers:

# Cloudflare "Flexible" in the lab: HTTPS to the visitor, plain HTTP to the origin
server {
    listen 8443 ssl;
    server_name bookstore.example;
    ssl_certificate     /etc/nginx/certs/edge.crt;
    ssl_certificate_key /etc/nginx/certs/edge.key;

    location / {
        proxy_pass http://origin;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header CF-Connecting-IP $remote_addr;
    }
}
$ docker run --rm curlimages/curl:8.17.0 -sk --connect-to bookstore.example:443:host.docker.internal:8443 \
    -L --max-redirs 5 -o /dev/null -w "final: %{http_code} after %{num_redirects} redirects\n" https://bookstore.example/api/books
final: 307 after 5 redirects
$ echo $?
47
$ docker run --rm curlimages/curl:8.17.0 -sk --connect-to bookstore.example:443:host.docker.internal:8443 \
    -D - -o /dev/null https://bookstore.example/api/books | grep -iE "^(HTTP|location)"
HTTP/1.1 307 Temporary Redirect
Location: https://bookstore.example/api/books

The origin sees the request as HTTP, and UseHttpsRedirection sends the visitor to the same HTTPS address. The visitor comes back to Cloudflare, Cloudflare goes to the origin over HTTP again; in the browser this is a "too many redirects" (ERR_TOO_MANY_REDIRECTS) error. curl gave up after five redirects with code 47. You could break the loop by having the origin trust X-Forwarded-Proto, but that only removes the symptom; the origin leg stays unencrypted.

The right fix is to put a certificate on the origin and use Full (strict). There are two options. Let's Encrypt: a normal public certificate on the origin; as in the SSL/TLS Certificates and HTTPS series post on Let's Encrypt, remember that HTTP-01 can be a problem behind a proxy and use DNS-01. Cloudflare Origin CA: a certificate valid for up to 15 years, generated in the Cloudflare panel. Renewal is easy, but only Cloudflare trusts it. If you ever switch the record to grey cloud or leave Cloudflare, browsers reject that certificate. Those who want portability pick Let's Encrypt, those who want simplicity pick Origin CA. Bookstore uses Let's Encrypt, since letsencrypt.org is already in the CAA record from the second post. Cloudflare's edge certificates also need CAA permission; according to Cloudflare's documentation, with Universal SSL on and CAA records in the zone, Cloudflare adds the CAA records its own CAs need automatically, and they don't show in the panel, only with dig. For certificate details see the SSL/TLS Certificates and HTTPS series post on certificates on managed platforms.

From Page Rules to Cache Rules, Configuration Rules and Redirect Rules

Most older guides describe Page Rules. Cloudflare has announced that it considers Page Rules legacy and will migrate existing rules to the new products automatically; there is no reason to use Page Rules in a new setup. The equivalents: Cache Rules for caching settings, Configuration Rules for changing zone settings such as the SSL mode per path, Single Redirects in Redirect Rules for redirects, and Origin Rules for settings such as the host header sent to the origin. The new rules take precedence over Page Rules for the same path. Bookstore's needs and their equivalents:

NeedRuleMatch expressionAction
www to the apexSingle Redirecthttp.host eq "www.bookstore.example"Dynamic target concat("https://bookstore.example", http.request.uri.path), 301, query string preserved
HTTP to HTTPSAlways Use HTTPS or a Single RedirectAll requests301
Personalised API answers are never cachedCache Rulestarts_with(http.request.uri.path, "/api/") and not starts_with(http.request.uri.path, "/api/books")Bypass cache
The catalog is cachedCache Rulestarts_with(http.request.uri.path, "/api/books")Eligible for cache; Edge TTL: "Use cache-control header if present, bypass cache if not"

The fourth rule matters. By default Cloudflare caches by file extension; JS, CSS and images are cached, HTML and JSON are not. /api/books has no extension, so without a rule Cloudflare never stores this answer and the CDN-Cache-Control we wrote in the fifth post goes to waste. "Eligible for cache" tells Cloudflare to follow the origin's headers, and bypass if there are none. That way the caching decision stays in the origin's code. Hashed Angular files are cached by default thanks to their extensions and keep their immutable header. I did not run these expressions in a Cloudflare account; they are written with the fields and functions in Cloudflare's Rules language documentation. Before going live, try the rule in the panel's expression preview and on a staging host.

The Real Client IP at the Origin

Behind the proxy, the connection the origin sees comes from Cloudflare's edge. Rate limiting, audit logs and fraud checks need the real visitor's IP. Cloudflare sends it in two headers: CF-Connecting-IP (a single value, the visitor's IP) and X-Forwarded-For (it appends the visitor's IP to the end of the chain). We covered the mechanics of the Forwarded Headers middleware, the middleware order and KnownIPNetworks in .NET 10 in the Web Servers and Hosting series post on ASP.NET Core behind a reverse proxy. What is specific to Cloudflare is that the list of proxies to trust is the set of ranges Cloudflare publishes. A script that writes those ranges to a file:

#!/usr/bin/env bash
# Writes Cloudflare's published edge ranges to src/Bookstore.Api/cloudflare-ips.txt.
# Run it in CI (daily) and on deploy; fail instead of writing an empty list.
set -euo pipefail
out=src/Bookstore.Api/cloudflare-ips.txt
json=$(curl -sf https://api.cloudflare.com/client/v4/ips)
jq -e '.success' <<<"$json" >/dev/null
jq -r '.result.ipv4_cidrs[], .result.ipv6_cidrs[]' <<<"$json" > "$out.new"
[[ $(wc -l < "$out.new") -gt 10 ]] || { echo "suspiciously short list" >&2; exit 1; }
mv "$out.new" "$out"
echo "etag $(jq -r '.result.etag' <<<"$json"), $(wc -l < "$out" | tr -d ' ') ranges"
$ ./scripts/update-cloudflare-ips.sh
etag 38f79d050aa027e3be3865e495dcc9bc, 22 ranges

On the Bookstore.Api side, an extension method that reads this file:

using Microsoft.AspNetCore.HttpOverrides;

namespace Bookstore.Api;

public static class CloudflareForwarding
{
    // Trust CF-Connecting-IP and X-Forwarded-Proto only when the connection itself comes from Cloudflare.
    public static IServiceCollection AddCloudflareForwarding(this IServiceCollection services, string rangesFile)
    {
        var ranges = File.ReadAllLines(rangesFile)
            .Where(line => line.Length > 0)
            .Select(System.Net.IPNetwork.Parse)
            .ToList();

        return services.Configure<ForwardedHeadersOptions>(options =>
        {
            options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
            options.ForwardedForHeaderName = "CF-Connecting-IP";
            options.ForwardLimit = 1;
            options.KnownProxies.Clear();
            options.KnownIPNetworks.Clear();
            ranges.ForEach(options.KnownIPNetworks.Add);
        });
    }
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCloudflareForwarding(Path.Combine(builder.Environment.ContentRootPath, "cloudflare-ips.txt"));
builder.Services.AddHttpsRedirection(options => options.HttpsPort = 443);
var app = builder.Build();

app.UseForwardedHeaders();
app.UseHttpsRedirection();

// ...

api.MapGet("/whoami", (HttpContext http) =>
    new { ClientIp = http.Connection.RemoteIpAddress?.ToString(), http.Request.Scheme });

Setting ForwardedForHeaderName to CF-Connecting-IP is simpler than parsing the X-Forwarded-For chain: a single value, written by Cloudflare. Clearing KnownProxies and KnownIPNetworks matters; the default list contains loopback. To test the behaviour without a real Cloudflare connection I set the connection's source IP by hand in TestServer. One request comes from Cloudflare's range (173.245.48.10), the other from outside; both send the same spoofed headers:

using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;

namespace Bookstore.Api.Tests;

public class ForwardedHeadersTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    private record WhoAmI(string ClientIp, string Scheme);

    private Task<HttpContext> SendFrom(string peer) =>
        factory.Server.SendAsync(c =>
        {
            c.Request.Method = "GET";
            c.Request.Scheme = "http";
            c.Request.Host = new HostString("bookstore.example");
            c.Request.Path = "/api/whoami";
            c.Connection.RemoteIpAddress = IPAddress.Parse(peer);
            c.Request.Headers["CF-Connecting-IP"] = "198.51.100.7";
            c.Request.Headers["X-Forwarded-Proto"] = "https";
        });

    [Fact]
    public async Task A_Cloudflare_edge_is_trusted()
    {
        var context = await SendFrom("173.245.48.10");

        var who = await JsonSerializer.DeserializeAsync<WhoAmI>(context.Response.Body, JsonSerializerOptions.Web);
        Assert.Equal("198.51.100.7", who!.ClientIp);
        Assert.Equal("https", who.Scheme);
    }

    [Fact]
    public async Task Anyone_else_is_not_trusted()
    {
        var context = await SendFrom("203.0.113.50");

        // the spoofed headers are ignored: the request is still plain HTTP, so the origin redirects it
        Assert.Equal(StatusCodes.Status307TemporaryRedirect, context.Response.StatusCode);
        Assert.Equal("https://bookstore.example/api/whoami", context.Response.Headers.Location.ToString());
    }
}
$ dotnet test --logger "console;verbosity=normal"
  Passed Bookstore.Api.Tests.ForwardedHeadersTests.A_Cloudflare_edge_is_trusted [168 ms]
  Passed Bookstore.Api.Tests.ForwardedHeadersTests.Anyone_else_is_not_trusted [2 ms]
Total tests: 2
     Passed: 2
 Total time: 0.4751 Seconds

/api/whoami is a small endpoint returning Connection.RemoteIpAddress and Request.Scheme. For the request from Cloudflare both were corrected and UseHttpsRedirection didn't kick in. For the request from outside that imitated the headers, both were ignored. This test also shows why the loop from Guess First doesn't happen behind Cloudflare: with Full (strict) the request reaches the origin over HTTPS anyway, and even with Flexible a trusted X-Forwarded-Proto prevents the redirect. Don't forget to keep the ranges current: when Cloudflare adds a new block, an origin running with the old list sees every user coming through that block as Cloudflare's IP. Running the script in CI once a day and opening a PR when it changes is enough.

The Alternative: Azure Front Door Standard/Premium

On Azure, the equivalent setup is Azure Front Door Standard or Premium. The mapping: instead of moving nameservers as with Cloudflare, Front Door gives you an endpoint name (*.azurefd.net) and you point your domain at it with a CNAME; for the apex you need the Azure DNS alias record from the second post. Domain validation uses the _dnsauth TXT. Caching, redirects and header manipulation are defined with Front Door's rules engine; on routes you also pick the query string behaviour (ignore, use, specific parameters). In the request to the origin, the visitor's IP arrives in the X-Forwarded-For and X-Azure-ClientIP headers. The source ranges to trust are published in Azure's AzureFrontDoor.Backend service tag.

If you use one of the older Azure products, watch the calendar. According to Microsoft Learn, Azure Front Door (classic) retires on 31 March 2027 and Azure CDN Standard from Microsoft (classic) on 30 September 2027. New resources can't be created on classic Front Door since 1 April 2025 and on classic CDN since 15 August 2025, and managed certificates are no longer supported. The recommended target for both is Front Door Standard/Premium, and Microsoft offers a zero-downtime migration tool. The Azure-side setup in detail is in the Cloud Fundamentals series.

In Production

Guard the origin's IP like a secret. The protection a proxy offers rests on the assumption that an attacker can't reach the origin directly. The origin IP usually leaks through: databases that keep historical DNS records (the A record from before the move to Cloudflare is still in there), a name pointing at the same server and left DNS-only (mail, ftp, direct), ip4: entries in the SPF record, the Certificate Transparency logs of certificates the origin obtained under its own name, and the headers of mails the application sends. Changing the origin's IP after moving to Cloudflare leaves the old IP in the past. In the next post we close this problem completely by opening the origin only to traffic coming from Cloudflare.

Read the header only from a trusted source. On an origin that reads CF-Connecting-IP without a KnownIPNetworks check, anyone who can reach the origin directly can write this header and impersonate any IP. If that IP is the rate limiter's partition key, an attacker writes a different IP on every request and gets past the limit.

Try rules on staging. Cache Rules and Redirect Rules are zone-wide and reach the whole world within seconds. Testing on a separate Cloudflare zone for staging, or at least on a separate hostname, beats finding a redirect loop in production.

Manage cache and SSL settings as code. Like DNS records, Cloudflare's rules can be kept in a repo with Terraform or DNSControl; a change made in the panel has no history.

Common Pitfalls

Turning on Flexible "temporarily": putting a certificate on the origin is an hour's work; Flexible turns into a security hole that stays for months and into the redirect loop from Guess First. Start with Full (strict).

Proxying mail records: putting the A record an MX points to behind the orange cloud breaks mail; the proxy doesn't carry SMTP. Mail-related names must stay DNS-only and, ideally, point at a server other than the origin.

Copying Cloudflare's IP list and forgetting it: an old list hardcoded on the origin misidentifies visitors coming through new blocks. Update the list automatically from the API.

Assuming JSON endpoints get cached on their own: Cloudflare doesn't cache extensionless paths by default. Even with the right headers, the catalog needs a Cache Rule.

Switching to grey cloud with an Origin CA certificate: in an outage where you want to take Cloudflare out of the path, an origin with an Origin CA certificate shows browsers an untrusted certificate. Think about this scenario in your emergency plan.

A Small Experiment

★☆☆ Query the apex addresses of three sites you know well and check with the Python line from this post whether the IPs are in Cloudflare's ranges. Which ones are proxied? Look at the server and cf-cache-status headers with curl -sI.

★★☆ Switch the lab's Flexible edge to Full: start the origin listening on HTTPS in Kestrel with a self-signed certificate and change the edge's proxy_pass to https://. Does the loop go away? What happens when you add proxy_ssl_verify on, and which Cloudflare mode does that correspond to?

★★★ Turn update-cloudflare-ips.sh into a CI/CD job: it runs once a day, opens a pull request that updates cloudflare-ips.txt if the list changed, and runs the tests. Verify that the job fails when the list comes back empty or too short.

Bookstore is now behind Cloudflare and the origin reads the real client IP correctly. But the origin is still open to the internet; anyone who knows its IP can bypass Cloudflare. In the next and final post of the series we cover security at the edge: the WAF, DDoS protection, rate limiting for the login and order endpoints, bot management and opening the origin only to the CDN.