Posts

Block ads and trackers without Pi-hole, with a hosts file

Profil Picture

Guillaume Briday

6 minutes

A few years ago I wrote about installing Pi-hole on a headless Raspberry Pi Zero W, and I still think it’s the nicest way to clean up a whole network at once, phones and TVs included.

But it’s also a machine to keep alive. It’s a DNS server the entire house depends on, so when the SD card dies or you unplug it by mistake, nobody can browse anything anymore. And the moment you take your laptop out of the house, you get all the ads back, because you’re not on that network anymore.

There’s a much simpler option for a single machine, and it needs no extra hardware, no daemon and no DNS server: your /etc/hosts file.

Since the file lives on your disk, it works anywhere the laptop goes. A coffee shop, the office network, a hotel, tethered to your phone in a train, a client’s WiFi you have no control over. Nothing to configure when you change network, nothing to turn back on when you come home.

There’s a side effect that is arguably the best part: browsing gets faster. A blocked domain fails instantly at the resolver, so the browser never opens the connection, never downloads the tracker and never runs it. Fewer requests, fewer scripts, less data, and a battery that lasts a bit longer. On a slow WiFi the difference is obvious.

Pi-hole itself is mostly a DNS server plus a big list of domains, and that list is public. StevenBlack/hosts publishes it already formatted as a hosts file, which means you can drop it straight into /etc/hosts and be done.

The lists

The base list merges several reputable sources and blocks adware and malware domains. On top of it you can add fakenews, gambling and social, in any combination.

Every variant is a plain file in the repository, so the URL is the only thing you have to configure. The base list, around 82,500 domains, is at the root: raw.githubusercontent.com/StevenBlack/hosts/master/hosts.

The combinations live in alternates/, in a folder named after the extensions they add, sorted alphabetically. Base plus fake news and gambling, around 91,300 domains: raw.githubusercontent.com/StevenBlack/hosts/master/alternates/fakenews-gambling/hosts.

Swap the folder name for the combination you want. The README lists them all with their domain counts, including -only variants that ship a single extension without the base list. Just know that social blocks Facebook, X and Instagram entirely.

Inside, every blocked domain is one line:

Terminal
0.0.0.0 ad-assets.futurecdn.net
0.0.0.0 eu1.clevertap-prod.com

The list uses 0.0.0.0 and not 127.0.0.1 on purpose. If you point blocked domains at your loopback address, every blocked request lands on whatever local web server you happen to be running, and you get a slow timeout instead of an instant failure.

Try it by hand

Before automating anything, back up the file you already have:

Terminal
$ sudo cp /etc/hosts /etc/hosts.bak

Then append the list to it:

Terminal
$ curl -fsSL https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts | sudo tee -a /etc/hosts

The file you download starts with its own localhost and broadcasthost entries, so you’ll end up with those twice. It’s harmless, the first match wins, but we’ll get rid of them in the script below.

On macOS, the resolver caches aggressively, so flush it:

Terminal
$ sudo dscacheutil -flushcache
$ sudo killall -HUP mDNSResponder

And check that it worked:

Terminal
$ dscacheutil -q host -a name ad-assets.futurecdn.net
name: ad-assets.futurecdn.net
ip_address: 0.0.0.0

That’s it, you’re blocking 82,000 domains with a text file. Open a news site, the one that used to take five seconds to settle down, and enjoy.

The script

Now the interesting part, replacing the copy-paste with something you run in one command.

The tricky bit is that /etc/hosts is not only the blocklist, it’s also your own entries, the 127.0.0.1 myapp.test lines you need for local development. A script that overwrites the file would take them with it.

So we put a marker in the file. Everything above it is yours, everything below is generated and gets replaced on every run:

Terminal
#!/bin/bash
set -euo pipefail

LIST_URL="https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
HOSTS_FILE="/etc/hosts"
MARKER="# ==== BEGIN blocklist, everything below this line is generated ===="

# Domains you want to keep working, even if the list blocks them
export ALLOWED="
"

tmp_list=$(mktemp)
tmp_hosts=$(mktemp)
trap 'rm -f "$tmp_list" "$tmp_hosts"' EXIT

# Keep the blocked domains and the comments, drop the localhost entries, comment out the allowed ones
curl -fsSL --max-time 60 "$LIST_URL" | awk '
  BEGIN { split(ENVIRON["ALLOWED"], domains); for (i in domains) allowed[domains[i]] = 1 }
  /^#/ || NF == 0 { print; next }
  $1 == "0.0.0.0" && $2 != "0.0.0.0" {
    if ($2 in allowed) print "#", $1, $2; else print $1, $2
  }
' > "$tmp_list"

count=$(grep -c '^0\.0\.0\.0 ' "$tmp_list" || true)
if [ "$count" -lt 1000 ]; then
  echo "Got only $count entries, that does not look like the blocklist. Leaving $HOSTS_FILE alone." >&2
  exit 1
fi

{
  # Everything you wrote yourself, up to the marker
  awk -v marker="$MARKER" '$0 == marker { exit } { print }' "$HOSTS_FILE"
  echo "$MARKER"
  echo "# Source: $LIST_URL"
  cat "$tmp_list"
} > "$tmp_hosts"

cp "$tmp_hosts" "$HOSTS_FILE"
chmod 644 "$HOSTS_FILE"

dscacheutil -flushcache
killall -HUP mDNSResponder

echo "$count domains blocked."

A few things in there matter more than they look.

LIST_URL is the base list. Point it at one of the alternates/ URLs from earlier if you want the extensions, nothing else changes.

The script refuses to touch /etc/hosts unless it got a plausible list. Without that check, a captive portal answering your curl with a login page would wipe your blocklist, or worse, your own entries.

The comments from the list are kept on purpose, they’re what makes the file readable when you go looking for a domain later.

On Linux, replace the two macOS cache commands with whatever your distribution needs, often sudo resolvectl flush-caches.

Run it

Save the script as /usr/local/sbin/update-hosts and make it executable:

Terminal
$ sudo chmod +x /usr/local/sbin/update-hosts

If you already pasted a list into /etc/hosts by hand, remove it before the first run, otherwise those thousands of lines sit above the marker and the script will keep them there forever. Restoring the /etc/hosts.bak from earlier, or trimming the file down to your own handful of lines, is enough.

Then, one command:

Terminal
$ sudo update-hosts
82489 domains blocked.

Run it twice, the file stays exactly the same size. That’s the marker doing its job.

That’s the whole maintenance story. I run it when I think about it, roughly once a month, and it takes two seconds. From now on, add your own entries above the marker line, anything you write below it disappears on the next run.

Allowing a domain the list blocks

It happens. A newsletter link goes through a tracking domain and the click does nothing, an analytics script your own app depends on is blocked in development, a domain you actually need shares a host with something the list blocks.

First, confirm the list is what’s blocking it:

Terminal
$ grep segment.io /etc/hosts
# [segment.io]
0.0.0.0 api.segment.io
0.0.0.0 cdn.segment.io

A hosts file has no way to say “this one is fine”, so the line has to go, or at least be commented out. Doing it by hand works until the next run, which is why the script has the ALLOWED list.

Add the domains to it, one per line:

Terminal
export ALLOWED="
api.segment.io
cdn.segment.io
"

Then run the script again:

Terminal
$ sudo update-hosts
82487 domains blocked.

Two domains fewer, and they stay allowed on every future run. Their lines are still in the file, right where they were, commented out:

Terminal
$ grep segment.io /etc/hosts
# [segment.io]
# 0.0.0.0 api.segment.io
# 0.0.0.0 cdn.segment.io

The match is exact, so segment.io on its own does not unblock api.segment.io. Copy the domain names straight out of the grep output above and you can’t get it wrong.

The whole file is a text file, so grep is also how you answer “why is this site behaving weirdly” in general. That’s more or less the query log, without the dashboard.

What you lose compared to Pi-hole

This is not a Pi-hole replacement, it’s a different trade-off, and it’s worth being clear about what you’re giving up.

There are no wildcards in a hosts file. You block ads.example.com, not *.example.com. The list is long precisely because every subdomain has to be spelled out.

It’s also one machine at a time. Your phone, your TV and your guests keep their ads, and there’s no dashboard, no query log and no per-device rule.

If a blocked domain still loads in your browser, check its DNS-over-HTTPS setting. A browser resolving names on its own doesn’t necessarily go through your system resolver.

What you get in exchange: nothing to maintain, nothing that can break the internet for the whole family, and a machine that blocks ads wherever it happens to be.

The two also stack. Pi-hole at home for every device, a hosts file on the laptop for everywhere else.

Conclusion

82,000 domains, a 30-line shell script and one command to run when you think about it. Hard to beat for the effort.

That’s it for this blog post, hope you found it helpful! Happy coding! 🚀

Simplify your time tracking with Slog-app

Slog-app is a time tracking app that brings simplicity in your day to day life.

Slog-app projects