System-Wide Ad Blocking with Cloudflare Zero Trust: No Apps, No Pi-hole

System-Wide Ad Blocking with Cloudflare Zero Trust: No Apps, No Pi-hole
Photo by Kamilla Isalieva / Unsplash
On this page

The standard advice for blocking ads and tracking telemetry is to install a VPN
app or run a Pi-hole. Both work. Both have problems.

A persistent VPN tunnel on your phone is a battery tax and an occasional routing
grenade. A Pi-hole is solid infrastructure - until you leave your house. The
moment you drop off your home Wi-Fi onto a coffee shop network or LTE, you are
unprotected and you will stay that way until you get back.

There is a cleaner approach. Cloudflare's free Zero Trust tier combined with the
native encrypted DNS protocols already built into Android, Windows 11, and Linux
gives you an enterprise-grade DNS firewall that follows your devices anywhere in
the world. No background apps. No battery penalty. No ongoing maintenance once
it is configured.

This is not a workaround. DNS over TLS and DNS over HTTPS are first-class OS
features. We are just pointing them at something useful.


How This Actually Works

Every device on the internet resolves domain names through DNS. By default that
traffic is unencrypted, unfiltered, and goes wherever your ISP or router points
it. What we are building replaces that with an encrypted DNS tunnel that
terminates at Cloudflare's edge, where a firewall policy decides what resolves
and what gets dropped.

Ad networks, tracking domains, and known malware infrastructure get blocked at
the DNS layer before a single packet leaves your device toward them. The
filtering happens in Cloudflare's infrastructure, not on your device - which is
why there is no app, no local process, and no battery impact.

Three phases to build the foundation: create the firewall policy in Cloudflare,
generate your unique encrypted endpoints, configure each device natively. iOS is
the exception to the native encrypted DNS story - that gets its own honest
treatment below.

Phase 1: Build the DNS Firewall Policy

Cloudflare Zero Trust is free for up to 50 users. Create an account if you do
not have one, then log into the Zero Trust dashboard.

Navigate to Firewall Policies -> DNS and click Add a policy. Name it
something descriptive - "Block Ads and Malware" works fine.

Build the rule with two conditions joined by OR:

Selector Operator Value
Content Categories in Advertisements
Security Categories in All Security Risks

Set Action to Block and save.

The two-row structure matters. Content Categories catches ad networks and
tracking telemetry. Security Categories catches malware, phishing, and
command-and-control infrastructure. They are separate classification trees in
Cloudflare's system - a single row cannot address both. OR logic ensures a
domain matching either condition gets dropped.

Phase 2: Generate Your Unique Encrypted Endpoints

Standard IP-based DNS routing does not work here. Your phone is constantly
moving between home Wi-Fi, cellular, and whatever network you are on at any
given moment - its public IP is always changing. We need endpoints tied to your
account, not your location.

In the Zero Trust dashboard, navigate to Networks -> Resolvers & Proxies ->
Locations
and click Add a location. Name it "Native OS DNS" or whatever
makes sense to you.

Leave the IPv4 and IPv6 source network fields completely blank.
Populating these ties the location to a specific IP range. Lock it to your
home IP and it will reject queries the moment your phone leaves that network.
Leave them empty and Cloudflare authenticates by the endpoint hostname instead.

On the next screen, enable DNS over TLS (DoT) and DNS over HTTPS (DoH).
Leave IPv4/IPv6 DNS off - those are the unencrypted endpoints.

On the final screen, disable both DoT endpoint filtering and DoH endpoint
filtering
. These controls restrict which source IPs can use your endpoints. On
a mobile device that changes towers every few minutes, they will cause
intermittent resolution failures that look like random network issues. Turn them
off.

Your unique endpoints will look like this:

  • DoT: [unique-string].cloudflare-gateway.com
  • DoH: https://[unique-string].cloudflare-gateway.com/dns-query

Keep this page open. You need both strings for the next phases.

Phase 3: Android - DNS over TLS

Android has supported DNS over TLS natively since Android 9 under the label
"Private DNS." This is an OS-level setting - it applies to every app on the
device regardless of what those apps do with their own network stack.

Settings -> Network & internet -> Private DNS -> Private DNS provider
hostname

Paste your DoT string: [unique-string].cloudflare-gateway.com

Do not include https:// - DoT operates over port 853. The field expects a
hostname only.

Save. Within a few seconds the label beneath the menu option should update to
show your custom hostname, confirming the encrypted tunnel is active.

If you were previously running the Cloudflare WARP app for DNS filtering,
uninstall it. Private DNS and WARP will conflict. The native OS setting is the
cleaner solution.

Phase 4: Windows 11 - DNS over HTTPS

Windows 11 supports DNS over HTTPS natively, but only through the modern
Settings app. The legacy ncpa.cpl network control panel predates DoH support
entirely - configuring DNS there sets an unencrypted resolver and silently
ignores any template you add.

Settings (Win + I) -> Network & internet -> [Wi-Fi or Ethernet] -> Hardware
properties -> DNS server assignment -> Edit

Switch the dropdown to Manual and enable IPv4.

  • Preferred DNS: 1.1.1.1
  • DNS over HTTPS: On (manual template)
  • DNS over HTTPS template:
    https://[unique-string].cloudflare-gateway.com/dns-query

The bootstrap IP is not optional. Windows needs a plain IP address to establish
its initial network connection before the encrypted tunnel can be negotiated.
1.1.1.1 is Cloudflare's anycast resolver - fast, globally available, and it
hands off cleanly to your DoH template once the tunnel is up.

Save, then flush the DNS cache:

ipconfig /flushdns

Then restart your browser completely. Chrome, Edge, and Brave all maintain their
own DNS cache independent of the OS. A browser running since before you made
this change will continue using cached resolutions until it restarts.

Check your browser's "Secure DNS" or "Use secure DNS" setting. If it
is enabled, the browser is resolving DNS independently using its own DoH
provider - bypassing your Windows configuration entirely. Disable it in the
browser and let the OS handle DNS.

Phase 5: iOS - The Honest Tradeoff

iOS does not have a built-in Private DNS toggle equivalent to Android. Native
system-wide DoT or DoH on iOS requires either a configuration profile or the
Cloudflare One client app. If you want true encrypted DNS without installing
anything, the short answer is: you cannot get there cleanly on iOS without a
profile or an app. That is a platform limitation, not a configuration problem.

The practical fallback is to use the plaintext IPv4 DNS addresses Cloudflare
assigns to your Location. Your filtering policy still applies - Cloudflare still
drops ad and malware domains at the edge. What you lose is transport encryption.
Your DNS queries travel in plaintext from your device to Cloudflare's resolver.
On your home network behind a trusted router that is a minor concern. On a
shared hotel or coffee shop Wi-Fi it is a real one.

Know the tradeoff before you choose it.

To find your Location's IPv4 addresses:

In the Zero Trust dashboard, go to Networks -> Resolvers & Proxies ->
Locations
, open "Native OS DNS", and look for the DNS over IPv4 section. You
will see two anycast IPs in the 172.64.x.x range.

To apply them on iOS:

Settings -> Wi-Fi -> tap the circle-i next to your network -> Configure DNS
-> Manual

Delete any existing entries, add both IPv4 addresses, and save. This only
applies to the current Wi-Fi network - you will need to repeat it for every
network you use. Cellular DNS on iOS is assigned by the carrier and cannot be
overridden without an app or profile.

For full end-to-end encrypted DNS on iOS without the per-network manual
configuration, the Cloudflare One client is the right answer. It is an app,
which this guide is trying to avoid, but it is the correct tool for the job on
this platform.

Phase 6: Linux - systemd-resolved with DNS over TLS

Most modern Linux distributions - Ubuntu, Fedora, Pop!_OS, Debian - use
systemd-resolved, which supports DNS over TLS natively. No additional daemons,
no proxy layers, no third-party software.

Open a terminal and edit the resolver configuration:

sudo nano /etc/systemd/resolved.conf

Replace the contents of the [Resolve] section with the following, substituting
your DoT hostname from Phase 2:

[Resolve]
DNS=1.1.1.1#[unique-string].cloudflare-gateway.com 1.0.0.1#[unique-string].cloudflare-gateway.com
DNSOverTLS=yes
DNSSEC=yes
FallbackDNS=9.9.9.9#dns.quad9.net
Cache=yes

Save and apply:

sudo systemctl restart systemd-resolved
sudo systemctl enable systemd-resolved

On some distributions the stub resolver symlink is not set correctly. Verify it:

sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

This is idempotent - running it when the symlink already exists does nothing.
Running it when it does not exist fixes DNS resolution for anything that reads
/etc/resolv.conf directly, including Docker containers.

Confirm the configuration took:

resolvectl status

You should see your DoT hostname listed as the active DNS server for your
primary interface.

Validation

Visit https://1.1.1.1/help from each configured device and check the debug
output:

  • Android: "Using DNS over TLS (DoT)" should read Yes
  • Windows: "Using DNS over HTTPS (DoH)" should read Yes
  • Linux: Confirm via resolvectl status rather than the web check

To confirm the blocking policy is active, open the Zero Trust dashboard and
navigate to Logs -> Gateway -> DNS. Browse normally for a few minutes and
watch the feed. Blocked domains appear in red. Ad networks, tracking pixels, and
telemetry endpoints will show up immediately on any normal browsing session.

If queries are resolving but nothing is being blocked, verify the firewall policy
from Phase 1 is enabled and that the Location you created in Phase 2 is the one
associated with your endpoints. A new Location with no policy attached resolves
everything without filtering.

Extending the Blocklist with Community-Maintained Domain Lists

The built-in Advertisements and Security Risk categories cover a lot of ground.
If you want the kind of granular blocking that NextDNS users get from
community-maintained lists - domains that Cloudflare's categories have not yet
classified, newly registered tracking domains, regional ad networks - you can
pull those lists in via the Cloudflare API and attach them to your existing
policy.

The script below downloads and parses hosts-format blocklists from GitHub,
deduplicates them, and pushes the result into a Cloudflare Gateway Domain List
that you can reference in your DNS policy.

Cloudflare Gateway Domain Lists are capped at 1,000 entries on
the free tier. The community blocklists referenced below contain far more than
1,000 domains. The script batches uploads in chunks of 1,000 and will stop at
the limit. On the free tier, prioritize the lists that matter most to you and
trim the URL list accordingly. The paid Zero Trust tiers raise this limit
significantly.

Save the following as Update-CloudflareAdBlock.ps1 and run it as
Administrator. Fill in your credentials once - the script handles creates and
updates from there.

# ================== CONFIG ==================
$CF_API_TOKEN  = "your_api_token_here"        # Requires Zero Trust: Edit permission
$ACCOUNT_ID    = "your_account_id_here"        # Found in the Zero Trust dashboard URL
$LIST_NAME     = "GitHub-AdBlock-Combined"     # Name of the list in Cloudflare
$POLICY_NAME   = "Block Ads and Malware"       # Must match your DNS policy name exactly

# Hosts-format blocklists to aggregate - add or remove as needed
$BlocklistURLs = @(
    "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
    "https://raw.githubusercontent.com/PolishFiltersTeam/KADhosts/master/KADhosts.txt",
    "https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews/hosts"
)
# ============================================

Write-Host "Downloading and parsing blocklists..." -ForegroundColor Green

$AllDomains = [System.Collections.Generic.List[string]]::new()

foreach ($url in $BlocklistURLs) {
    try {
        $content = (Invoke-WebRequest -Uri $url -UseBasicParsing).Content
        foreach ($line in ($content -split "`n")) {
            $trimmed = $line.Trim()
            # Skip comments and empty lines
            if ($trimmed -like '#*' -or $trimmed -eq '') { continue }
            # Match standard hosts file format: 0.0.0.0 domain or 127.0.0.1 domain
            if ($trimmed -match '^(?:0\.0\.0\.0|127\.0\.0\.1)\s+(\S+)$') {
                $domain = $Matches[1]
                if ($domain -notlike '*localhost*' -and $domain -ne '0.0.0.0') {
                    $AllDomains.Add($domain)
                }
            }
        }
        Write-Host "  Parsed: $url" -ForegroundColor Gray
    }
    catch {
        Write-Warning "Failed to fetch $url - $_"
    }
}

$UniqueDomains = $AllDomains | Sort-Object -Unique
Write-Host "Total unique domains: $($UniqueDomains.Count)" -ForegroundColor Cyan

$headers = @{
    "Authorization" = "Bearer $CF_API_TOKEN"
    "Content-Type"  = "application/json"
}

# Find or create the domain list
$listResponse = Invoke-RestMethod `
    -Uri "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/gateway/lists" `
    -Method Get `
    -Headers $headers

$existingList = $listResponse.result | Where-Object { $_.name -eq $LIST_NAME }

if ($existingList) {
    $listId = $existingList.id
    Write-Host "Found existing list: $listId" -ForegroundColor Yellow
}
else {
    $body = @{
        name        = $LIST_NAME
        type        = "DOMAIN"
        description = "Aggregated GitHub ad and tracker blocklist"
    } | ConvertTo-Json

    $create = Invoke-RestMethod `
        -Uri "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/gateway/lists" `
        -Method Post `
        -Headers $headers `
        -Body $body

    $listId = $create.result.id
    Write-Host "Created new list: $listId" -ForegroundColor Green
}

# Upload in chunks of 1,000
# Free tier hard limit is 1,000 entries per list - script will stop at that boundary
$chunkSize  = 1000
$totalChunks = [Math]::Ceiling($UniqueDomains.Count / $chunkSize)

for ($i = 0; $i -lt $UniqueDomains.Count; $i += $chunkSize) {
    $end   = [Math]::Min($i + $chunkSize - 1, $UniqueDomains.Count - 1)
    $chunk = $UniqueDomains[$i..$end] | ForEach-Object { @{ value = $_ } }

    $payload = @{ items = $chunk } | ConvertTo-Json -Depth 10

    Invoke-RestMethod `
        -Uri "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/gateway/lists/$listId/items" `
        -Method Put `
        -Headers $headers `
        -Body $payload | Out-Null

    $chunkNum = [Math]::Floor($i / $chunkSize) + 1
    Write-Host "  Uploaded chunk $chunkNum of $totalChunks" -ForegroundColor Gray
}

Write-Host ""
Write-Host "Blocklist updated successfully." -ForegroundColor Green
Write-Host ""
Write-Host "Next step: add the list to your DNS policy '$POLICY_NAME'." -ForegroundColor White
Write-Host "In the policy editor, add a new OR condition:" -ForegroundColor White
Write-Host "  Selector: Domain List  |  Operator: in  |  Value: $LIST_NAME" -ForegroundColor Cyan
Write-Host ""
Write-Host "List ID if you need it for manual API work: $listId" -ForegroundColor Gray

Once the script runs successfully, open your DNS policy in the Zero Trust
dashboard and add one additional OR condition:

Selector Operator Value
Domain List in GitHub-AdBlock-Combined

Save the policy. The community list is now active alongside the built-in
categories.

To keep the list current, schedule the script to run weekly via Windows Task
Scheduler. The script handles updates to an existing list automatically - no
manual cleanup required.

What This Does Not Cover

This configuration secures DNS. It does not encrypt your general browsing
traffic, hide your IP address, or function as a privacy VPN. Cloudflare can see
your DNS queries - that is the tradeoff for using their infrastructure as your
firewall. Their privacy commitments on the 1.1.1.1 resolver are documented and
have held up, but you should know what you are handing them.

For most use cases - blocking ads, killing tracking telemetry, dropping known
malware domains - this is the right tool. For threat models that include
Cloudflare itself as an adversary, it is not.

Know what you are building and why.

Hold on... there’s more