Ad and tracker blocking is usually presented as a browser problem.
Cloudflare Gateway makes it possible to enforce the same idea at DNS policy level for enrolled devices, but the block action itself is straightforward; the interesting engineering problem is how the domain data gets into policy safely and repeatably.
I built a small Terraform project that consumes remote domain feeds, normalizes them, creates Cloudflare Zero Trust lists and uses those lists in a Gateway DNS block policy.
The architecture
The flow is deliberately simple:
Public domain feeds
↓
Terraform HTTP data sources
↓
Parse / normalize / de-duplicate
↓
Cloudflare Zero Trust domain lists
↓
Gateway DNS policy
↓
Enrolled devices
The result is not a new DNS resolver or filtering engine. Cloudflare Gateway remains the enforcement point; Terraform manages the policy inputs.
Treat the feed as input data
Terraform’s HTTP data source can retrieve a plain-text domain list during planning/apply.
A simplified pattern is:
data "http" "domain_sources" {
for_each = local.enabled_sources
url = each.value.url
request_headers = {
Accept = "text/plain"
User-Agent = "Terraform-Cloudflare-AdBlock/1.0"
}
request_timeout_ms = 30000
}
The project also checks the returned status code rather than silently processing an error page as though it were domain data.
That matters because external feeds fail in ordinary ways: redirects, rate limits, provider outages and unexpected responses.
Normalize before creating Cloudflare lists
Public block lists are not all formatted identically.
Some contain bare domains:
ads.example.com
tracker.example.net
Others resemble hosts files:
0.0.0.0 ads.example.com
127.0.0.1 tracker.example.net
Comments and empty lines also need removing.
Before turning the data into a Cloudflare resource, I normalize it into one canonical list of domain strings.
The useful processing stages are:
split into lines
↓
trim whitespace
↓
ignore comments / blanks
↓
extract domain from supported formats
↓
normalize case
↓
de-duplicate
↓
validate
The enforcement resource should never need to understand how each upstream feed was formatted.
Split very large sets into manageable lists
One giant list is convenient conceptually but awkward operationally.
The project chunks the normalized domain set into multiple Cloudflare lists and gives each list a predictable name.
That gives Terraform smaller resource payloads and makes it easier to reason about updates.
Each Cloudflare list contains domain objects such as:
items = [
for domain in each.value : {
value = domain
description = "Blocked advertising domain"
}
]
The key design point is that the chunking is derived from data. I do not manually maintain list-1, list-2, list-3 contents.
Build policy from the generated lists
Once the lists exist, the Gateway rule can reference the resulting list identifiers.
The rule is a normal DNS policy:
resource "cloudflare_zero_trust_gateway_policy" "block_ads" {
account_id = var.cloudflare_account_id
name = var.policy_name
enabled = true
precedence = var.policy_precedence
filters = ["dns"]
action = "block"
traffic = local.traffic_filter
}
The traffic expression is generated from the lists created by Terraform.
That is the part I like most about this approach: the policy and the data objects it depends on are reviewed in the same infrastructure change.
Keep the control optional
A reusable Terraform project should not force the policy to exist simply because the module or repository is present.
The implementation has an enable/disable variable so the resources can be conditionally created.
That makes testing and staged adoption easier:
enable_ad_blocking = false → no enforcement resources
enable_ad_blocking = true → lists + policy
The same pattern is useful for any security control that may need environment-specific rollout.
Use lifecycle behaviour deliberately
Replacing a list or rule can briefly affect enforcement if Terraform destroys the old object before creating its replacement.
Where the API/resource semantics support it, create_before_destroy can reduce that gap.
The project applies lifecycle settings to list and policy resources for that reason.
Lifecycle configuration should not be cargo-culted into every resource, but it is worth considering when the object is directly referenced by an enforcement policy.
External feeds become a supply dependency
Automating a block list does not remove operational risk. It changes the risk.
A public feed can:
- disappear;
- change format;
- add an unexpectedly large number of domains;
- accidentally include legitimate domains;
- be compromised;
- become unmaintained.
That means feed selection is part of the security design.
I document the enabled sources separately and would review changes to that source list with the same care as changes to the Gateway rule itself.
For a production implementation I also want guardrails around unusually large deltas rather than blindly trusting every upstream update.
Terraform makes the change visible
One benefit of this approach is that the intended change appears in normal infrastructure review.
A typical workflow is:
terraform fmt -check
terraform validate
terraform plan
The plan provides an opportunity to spot unexpected list or policy changes before applying them.
That is not the same as reviewing every domain individually, but it is a better operational boundary than an opaque scheduled script that mutates policy with no review stage.
Keep credentials outside the repository
The Terraform configuration needs Cloudflare account context and an API token with the minimum permissions required for the managed resources.
The token should come from the execution environment or secret store, not from terraform.tfvars committed to Git.
Likewise, remote state deserves the same protection as other infrastructure state because it can contain identifiers and values that are sensitive even when they are not credentials.
I manage the Terraform backend separately for that reason.
This is a useful pattern beyond advertising
The same pipeline applies to many externally maintained domain sets:
Threat feed
SaaS category list
Approved partner domains
Known command-and-control domains
Temporary incident block list
The pattern is:
acquire → normalize → validate → materialize as policy data → enforce
That is more reusable than thinking of the project as an ad blocker.
Related implementation
The Terraform project is documented in Cloudflare Gateway Ad Blocking with Terraform.
For list synchronization from cloud-provider IP feeds, see Dynamic Cloudflare Lists from Provider IPs.