Skip to content

Deployment

Cloudflare

Serve a vukem app behind Cloudflare - trust CF-Connecting-IP only from Cloudflare's published ranges so rate limits and logs see the real visitor IP, and lock the origin so nobody bypasses the proxy.

Last updated:

When Cloudflare proxies your domain, every request reaches your app from a Cloudflare edge server. Without extra setup, c.IP() is that edge address: every visitor shares one rate-limit bucket and your logs show Cloudflare instead of your users. Cloudflare sends the visitor's address in the CF-Connecting-IP header, but any client can send that header too, so you may only believe it when the request really comes from Cloudflare.

The optional contrib/cloudflare module does these checks, and more, with the standard library and vukem core only.

You will learn:

  • how to get the real visitor IP from Cloudflare safely;
  • how to refuse requests that skip Cloudflare and hit your server directly;
  • how to read the visitor's country and bot signals safely;
  • how to cache public pages at the edge and purge them;
  • how to check Cloudflare Access tokens on admin pages;
  • how to keep the list of Cloudflare addresses current.

Install the module#

Terminal
go get github.com/AlexGalhardo/vukem/contrib/cloudflare

Get the real visitor IP#

Pass Cloudflare's address ranges to the app as trusted proxies, and name the header they set:

cmd/app/main.go
app, err := vukem.New(
	vukem.WithTrustedProxies(cloudflare.Ranges()...),
	// your other options
)
.env
CLIENT_IP_HEADER=CF-Connecting-IP

Now c.IP(), ratelimit.ByIP, the request log and sessions see the visitor's address. A request that carries CF-Connecting-IP from any other address keeps its own peer address, so the header cannot be spoofed.

cloudflare.Ranges() returns the lists Cloudflare publishes at ips-v4 and ips-v6 (read 2026-10-29), embedded in the module. See Cloudflare's IP addresses and HTTP headers pages.

Lock the origin#

Your server still has a public address. Someone who finds it can skip Cloudflare's firewall and rate limits. cloudflare.OriginLock answers 403 to any request whose connection does not come from Cloudflare:

routes/web.go
// Railway, Fly and similar platforms run health checks from their own network: allow it.
r.Use(cloudflare.OriginLock(netip.MustParsePrefix("10.0.0.0/8")))

It looks only at the connection's address, never at a header.

Read the visitor's country and bot signals#

Cloudflare can tell your app the visitor's country and, on plans with Bot Management, how likely the request is a bot. cloudflare.Headers collects these headers into the request context:

routes/web.go
r.Use(cloudflare.Headers())

r.Get("/pricing", func(c *web.Ctx) error {
	v, ok := cloudflare.VisitorFrom(c.Context())
	if ok && v.Country == "BR" {
		return c.Render("pricing", pricesIn("BRL"))
	}

	return c.Render("pricing", pricesIn("USD"))
})
FieldHeaderValues
CountryCF-IPCountryISO country code, XX unknown, T1 Tor
RayCF-RayRay ID, to find the request in Cloudflare's logs
BotScorecf-bot-score1 (bot) to 99 (human), 0 when not sent
VerifiedBotcf-verified-bottrue for a verified bot

BotScore and VerifiedBot need the "Add bot protection headers" Managed Transform. The headers are read only when the connection comes from Cloudflare; anyone else could send them, so VisitorFrom then reports false.

Cache pages at the edge#

Cloudflare caches images, scripts and styles by default, but not HTML. To let it cache a public page, tell it how long with a CachePolicy:

routes/web.go
cache, err := cloudflare.CachePolicy{
	Edge:                 time.Hour,      // Cloudflare keeps it an hour
	Browser:              time.Minute,    // browsers a minute
	StaleWhileRevalidate: 5 * time.Minute,
	Tags:                 []string{"games"},
}.Middleware()
if err != nil {
	return err
}

r.Get("/games", listGames).Use(cache)
FieldHeaderMeaning
EdgeCloudflare-CDN-Cache-Controlhow long Cloudflare keeps the page; only Cloudflare reads it
BrowserCache-Controlhow long browsers keep it
StaleWhileRevalidateCloudflare-CDN-Cache-Controlserve the old copy while fetching a new one
TagsCache-Tagnames to purge the page by later; visitors never see them

A page that belongs to one user must never be cached for everyone. The middleware leaves the response alone when the request has a Cookie or Authorization header, when the response sets a cookie or its own Cache-Control, and when the status is not 200. See Cloudflare's CDN-Cache-Control page.

Purge the cache#

When a game changes, remove the cached pages that show it. Create an API token with only the Zone > Cache Purge permission for your zone, then call the purge client from a queued job:

app/jobs/purge_games.go
purger, err := cloudflare.NewPurger(cfg.CloudflareZoneID, cfg.CloudflareToken)
if err != nil {
	return err
}

return purger.Tags(ctx, "games")

URLs, Prefixes, Hosts and Everything purge by URL, URL prefix, hostname or the whole zone. Long lists are sent in batches of 30. Errors carry Cloudflare's code and message (for example 1134 rate limit reached), never the token. See the purge API.

Protect admin pages with Cloudflare Access#

Cloudflare Access can put a login in front of /admin before a request reaches your app. Access then sends a signed token in the Cf-Access-Jwt-Assertion header. Check it in the app too, so a request that reaches the origin some other way is refused:

routes/admin.go
access, err := cloudflare.NewAccess(cloudflare.AccessOptions{
	TeamDomain: "https://example.cloudflareaccess.com",
	Audience:   cfg.AccessAUD, // the application's AUD tag
})
if err != nil {
	return err
}

r.Group("/admin", func(g *router.Group) {
	g.Use(access.Middleware())
	g.Get("", adminHome)
})

The middleware answers 403 unless the token is signed with your team's keys (fetched from /cdn-cgi/access/certs), names your team domain as issuer and your application's AUD tag as audience, and has not expired. If the keys cannot be fetched, every request is refused. In a handler, cloudflare.AccessClaimsFrom(c.Context()) returns the claims; the user's email is in Extra["email"]. See Validate JWTs.

Behind another proxy#

Both features need Cloudflare to connect to your app directly. Some platforms put their own load balancer between Cloudflare and your app; then the connection comes from that load balancer. In that case trust the platform's network with TRUSTED_PROXIES instead, and lock the origin at the platform or with Cloudflare's Authenticated Origin Pulls.

Keep the ranges current#

Cloudflare rarely changes its ranges, and a module upgrade brings the new list. To find out early, run a scheduled job that compares:

app/console/cloudflare_ranges.go
current, err := cloudflare.FetchRanges(ctx, http.DefaultClient)
if err != nil {
	return err
}

if !slices.Equal(current, cloudflare.Ranges()) {
	app.Log.Warn("Cloudflare ranges changed: upgrade contrib/cloudflare")
}

A list that does not parse, is empty or covers every address is an error, never an update.