DNS Ad Blocker — A free Pi-hole alternative on Cloudflare Zero Trust
A free Pi-hole and NextDNS alternative: block ads and trackers network-wide by deploying the 185K-domain AdGuard DNS Filter to Cloudflare Zero Trust Gateway, refreshed weekly by GitHub Actions.
One Gateway policy blocks roughly 185,000 ad and tracker domains for every device on my network — no Pi-hole, no new hardware, nothing on-prem to maintain. A pair of Python scripts turns the AdGuard DNS Filter into Cloudflare Zero Trust Gateway lists and keeps them fresh.
View on GitHubThe problem
My network already resolves every DNS query through Cloudflare Gateway — at home via the DoH on UniFi setup, and anywhere else via the WARP client, which doubles as a personal switch: toggle it on and the connection is ad-free, toggle it off when a site insists on its trackers. Either way the queries land in the same policy engine — so that's where ad blocking belongs, not on a new box beside the router. Gateway will happily block anything on a list you bring it. The catch is bringing the list.
The good public blocklists are maintained in AdGuard's filter format, and the one this project uses — the AdGuard DNS Filter — is roughly 185,000 domains and 70 IPs. Cloudflare's Standard plan caps Gateway lists at 1,000 entries each. Nobody is pasting 185 CSV files into a dashboard by hand, and nobody is doing it again every week when the filter updates.
Working the limits
So the project is really a format-and-limits bridge. generate-lists.py downloads the filter, separates domains from IPs, and splits them into plan-sized CSVs — about 185 domain lists and one IP list. upload_to_cloudflare.py pushes every list through the API and builds a single DNS policy that ORs across them: domains match with any(dns.domains[*] in $list), IPs with any(dns.resolved_ips[*] in $list).
The alternative was regex policies — Gateway accepts them, but at a 6,500-character limit per rule the same filter takes around 500 separate policies. One list-backed policy versus 500 regex rules isn't a close call: it's one thing to update, one thing to disable, one thing to reason about when a site breaks.
Using it
Two commands to deploy. The upload script prompts before replacing anything from a previous run; --auto-approve makes it unattended. For refreshes, the repo ships a GitHub Actions workflow that regenerates and re-uploads every Sunday — fork it, add the account ID and API token as repository secrets, and there's no server left to keep a cron job on. The token only needs one permission: Account, Zero Trust, Edit, scoped to a single account.
# fetch the AdGuard filter, split into plan-sized CSVs
python3 generate-lists.py
# create the Gateway lists + one blocking policy, via API
python3 upload_to_cloudflare.py
# or hand weekly refreshes to GitHub Actions on your fork
gh secret set CLOUDFLARE_ACCOUNT_ID
gh secret set CLOUDFLARE_API_TOKEN
gh workflow run "Update blocklist"Refreshes replace rather than edit, and that has a sharp edge: Gateway won't delete a list while a policy still references it. The first version tripped over exactly that — every re-run failed to delete the old lists, then collided with them creating the new ones. The script now removes the policy first, then the lists, uploads fresh ones, and recreates the policy. The cost is a few minutes per run where nothing is blocked, so the schedule sits in a quiet hour.
One honest warning, straight from the README: 185K domains is a comprehensive filter, and comprehensive filters occasionally break things you wanted. Test before pointing the whole household at it.
Status
MIT-licensed and running against my own network — the same pipe the DoH setup feeds. Works on the free Zero Trust plan; Enterprise plans can raise the per-list cap to 5,000 and cut the list count to under 40.