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.

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.