I Built a Privacy-First Home Lab on Two Raspberry Pis (Here’s Everything That Went Wrong, and How It Turned Out)

I didn’t set out to build a home lab. I set out to stop seeing quite so many ads. One Raspberry Pi and a weekend later, that had somehow turned into network-wide ad blocking, a DNS resolver that answers to nobody, a private VPN I can use from anywhere in the world, and a personal media server that beats paying for another streaming subscription. This is the whole thing, including the parts that didn’t work the first time.

The starting problem: DNS sees everything

Every time any device on your network visits a website, it first asks a DNS server “where do I find this?” That server sees the destination of every request you make — not the content, but the pattern of your entire online life, one domain lookup at a time. Usually that’s your ISP, or if you’ve ever changed your router’s settings, maybe Google or Cloudflare instead. Either way, someone’s collecting a very detailed diary of where you go.

I wanted to own that diary myself. So I built, in layers:

  • Pi-hole, which blocks ad and tracker domains network-wide before they even load.
  • Unbound, which replaces Pi-hole’s reliance on a third-party DNS provider with a resolver I run myself — one that talks directly to the source and cryptographically verifies the answers.
  • Tailscale, a private VPN that extends both of those protections to my phone and laptop wherever I actually am, and lets me reach my home network from anywhere without opening a single port to the internet.
  • Jellyfin, a self-hosted media server for my own movies and music, also reachable from anywhere over that same private tunnel.

Each one stands alone. You could stop after Pi-hole and already have something genuinely useful. But together, they turned two cheap Raspberry Pis into the most useful thing on my network.

Step one: two Pis, no monitor required

I flashed both boards using Raspberry Pi Imager, which has a genuinely great trick hidden behind its gear icon: you can set a hostname, enable SSH, and set a username and password before you ever boot the thing. Skip the advanced options, and you’re stuck plugging in a monitor and keyboard just to turn SSH on. Use them, and you go straight from “insert SD card” to “SSH in from your laptop” with nothing in between.

I called mine pi-core1 and pi-core2. One would become the ad blocker. The other, a home dashboard and a couple of small self-hosted apps I wanted to tinker with.

Pi-hole: the fast, satisfying part

Pi-hole was the easy win. It works by becoming your network’s DNS server — every device asks it where to find a website, and it just doesn’t answer for anything on its blocklists. A docker-compose file, one docker compose up -d, and pointing my router’s DNS setting at the Pi, and I had a live dashboard with a query counter that ticks up satisfyingly every time it blocks something. There’s a genuine dopamine hit watching that number climb the first time you load a page that used to be covered in ads.

Unbound: where I learned what DNSSEC actually does

Pi-hole alone still forwards every unblocked query to a third-party provider. I wanted to go one step further and stop trusting anyone with that information at all. Unbound does this by resolving domains itself — walking the DNS hierarchy directly, the way the big providers do internally, and validating a cryptographic signature at each hop (DNSSEC) so I can trust the answers weren’t tampered with somewhere in transit.

This is also where I hit my first real bug. I mounted Unbound’s config file read-only, because that felt like the responsible thing to do. It broke everything — queries just hung, no error message anywhere obvious. Turns out the container image does some first-run setup (generating TLS certificates for its internal control tool) that needs to write to that file, and a read-only mount fails that step completely silently. Lesson: not every config file wants to be read-only, and “responsible-looking” isn’t the same as “correct.”

The second thing I got wrong was more subtle. I set everything up, dig’d a couple of test domains, and got answers back that looked fine — except DNSSEC wasn’t actually validating anything. It turns out the DNS resolver image I used doesn’t ship with a trust anchor baked in; you have to explicitly tell it to fetch and verify one on startup. One extra config line and a command override fixed it. The lesson generalizes further than DNS: “it resolves” and “it resolves correctly and securely” are not the same claim, and it’s worth testing for the second one specifically — I used a domain with a deliberately broken signature (dnssec-failed.org) to confirm mine correctly refused to resolve it, which is the actual proof DNSSEC is doing its job.

Tailscale: the part that made this actually useful

Pi-hole and Unbound only protect devices on my home Wi-Fi. Tailscale is what extends that everywhere. It’s a mesh VPN — it links your devices into a private network no matter where they physically are, over an encrypted tunnel, with zero ports opened to the internet. I installed it on the Pi-hole box, pointed the tailnet’s DNS settings at it from the Tailscale admin console, and installed the phone app.

The test that sold me on it: phone on mobile data, Wi-Fi off, Tailscale connected — and ads were still getting blocked. My home network’s protection was now just… wherever I was.

Adding more devices turned out to be exactly as easy, with one asterisk. When I added a second machine — the one running my media server — I authenticated with the wrong account by accident. The login link opened in a browser that was already signed into an old Tailscale account from a completely different project, years back, and the new device silently joined that network instead of mine. Everything worked from the device’s own point of view — it got an IP, commands ran fine — it just never showed up next to my other devices, which was deeply confusing for about twenty minutes until I ran tailscale status on the machine itself and saw a device I didn’t recognize sitting next to mine. Logged out, logged back in, picked the right account deliberately. Worth knowing about before it costs you the same twenty minutes.

Jellyfin and the NAS: the part where I learned Jellyfin can’t see the network

I run Jellyfin — free, open-source, no ads, no algorithm — on a separate machine, joined to the tailnet the same way as everything else. Here’s the thing I didn’t know going in: Jellyfin has no concept of a network share. It only understands local file paths. Your NAS full of movies has to get mounted onto the media server’s own filesystem first, and only then can you point a Jellyfin library at it.

That’s a straightforward mount command and an /etc/fstab entry once you know it’s required — but the first time I tried adding a library, the scan finished in under a second with nothing in it, and I spent a confused few minutes assuming Jellyfin was broken before realizing the mount itself had simply never happened. An empty ls on the mount point was the whole story.

There’s a second wrinkle worth mentioning if your own media collection is anything like mine was: it was all dumped into one folder, movies and music and everything else mixed together, not neatly separated. Pointing one Jellyfin library at the whole thing makes it try to match band folders against a movie database, which is exactly as messy as it sounds. The fix was just patience — creating separate Movies and Music libraries and adding only the relevant subfolders to each one, rather than the whole folder at once.

What I’ve got now

A network that blocks ads before they load, resolves its own DNS without handing that information to anyone else, extends both of those protections to my phone anywhere on Earth, and streams my own media library to any device I own — all running on hardware that cost less than one month of a couple of streaming subscriptions combined.