Automation & Tools

Docker Compose for Your Home Lab, Part 2: Your First Useful Stack

Tony Mattke · 2026.09.01 · 12 min read

In Part 1 we got an Nginx container running, both with raw docker run and with a one service compose.yaml. That was the setup. Today we deploy something you’ll use every day… a DNS server with ad and tracker blocking, sitting on your home network and answering queries for every device on it. There are two popular ways to get there, and there are real reasons to choose either, so we’ll compare them before we pick. By the end of the next post it’ll even be a real device on your network, with its own IP your router can hand out as the DNS server, instead of a container hiding behind a port on your Docker host.

Pi-hole vs Technitium

The two best known, self hosted, DNS and adblocking options for a home lab are Pi-hole and Technitium. Both run in Docker, both ship a web UI, and both solve the basic problem of getting your network to stop loading display ads. They have meaningfully different personalities though.

Pi-hole is the older, more popular project. It’s built on dnsmasq (a well trodden, lightweight DNS server) plus a web interface served by its own FTL engine (older versions used PHP and lighttpd, v6 dropped that). Its default mode is to forward all queries to an upstream resolver you configure (Cloudflare’s 1.1.1.1, Quad9, your ISP, whatever) and filter based on a curated list of known ad and tracker hosts. It doesn’t do recursive resolution itself by default. The community is large, the documentation is mature, the blocklist ecosystem is rich, and most home lab forum threads about DNS sinkholing are going to assume Pi-hole.

Technitium is newer and built differently. It’s a full DNS server written in .NET, with the web UI baked in. The default behavior includes recursive resolution (no upstream needed unless you want one), DNSSEC validation, and support for DoH (DNS over HTTPS), DoT (DNS over TLS), and DoQ (DNS over QUIC) as both a client and a server. It’s a more ambitious project. The admin UI exposes a lot more options.

Honest tradeoff, audience-pitched. Here’s how they split.

  • Pi-hole wins on community size, name recognition, blog post coverage, and approachability. If you hit a problem at 11pm, the answer is more likely to be on the first page of search results. The web UI is opinionated and uncluttered.
  • Technitium wins on technical capability, modernity, and “doing more out of the box.” If you care about not depending on an upstream resolver, or want DNSSEC validation without bolting on a separate Unbound container, Technitium gives you that for free.

For this series I’m going with Pi-hole, mostly because the audience is more likely to bump into Pi-hole tutorials when they need help, and because the macvlan story we’ll work through in Part 3 has more battle tested Pi-hole examples to compare against. None of that means Technitium is wrong. If you want to deploy it instead, the Compose patterns we cover here all translate, and the Technitium image is technitium/dns-server:latest instead of pihole/pihole:latest. The networking situation in Part 3 will be identical.

If you’ve used Pi-hole before from a one line install script, the rest of this post is going to feel familiar in shape and different in delivery. The thing we get out of doing it in Compose is that the whole config is in one file we can commit, copy, back up, and bring back to life on another box without redoing the install.

A tour of compose.yaml

Before we deploy Pi-hole, it’s worth taking a slightly longer look at the file format. Part 1’s one service example used five lines. A useful stack needs a few more concepts.

A Compose file has three top level sections you’ll see most often.

yaml
services:
  # The containers you want to run.

volumes:
  # Named storage that persists across container restarts.

networks:
  # How containers can talk to each other.

We’ll touch all three across the series. For today we’ll mostly use services, with one bind-mounted directory for persistent state.

Inside services, each entry describes one container. The keys you’ll meet today.

  • image. Which image to pull from Docker Hub (or another registry). Same as Part 1.
  • container_name. Friendly name. Optional but useful for docker logs pihole and similar.
  • hostname. What the container thinks its hostname is. Some apps care, most don’t.
  • ports. Host to container port mappings. Strings, in quotes, because YAML will try to interpret a bare 53:53 as a sexagesimal number in older specs.
  • environment. Environment variables passed into the container. Most images are configured this way.
  • volumes. Mount points. Either a host path like ./etc-pihole:/etc/pihole (left of the colon is on the host, right is inside the container) or a named volume.
  • restart. What to do if the container exits. unless-stopped is what you want for home lab services. always is fine too. The default (none) means “if it dies, it dies.”
  • cap_add. Linux capabilities to add. Most services don’t need any. Some, like Pi-hole’s DHCP server mode, want NET_ADMIN.
  • healthcheck. A command Docker runs periodically to decide if the service is healthy. Worth adding for things you care about.
  • depends_on. Startup ordering (“don’t start service A until service B is at least running”). We don’t need this today but we will in Part 4.

That covers maybe 90% of the keys you’ll touch as a home labber. There are dozens more in the spec. Don’t worry about the rest until you have a reason to look them up.

Deploy Pi-hole

Make a directory for the stack and step into it:

bash
mkdir -p ~/lab/pihole && cd ~/lab/pihole

Write compose.yaml:

yaml
services:
  pihole:
    image: pihole/pihole:latest
    container_name: pihole
    hostname: pihole
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8081:80"
    environment:
      TZ: "America/Chicago"
      FTLCONF_webserver_api_password: "changeme"
      FTLCONF_dns_listeningMode: "all"
      FTLCONF_dns_upstreams: "1.1.1.1;9.9.9.9"
    volumes:
      - ./etc-pihole:/etc/pihole
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    healthcheck:
      test: ["CMD", "dig", "+short", "+norecurse", "+retry=0", "@127.0.0.1", "pi.hole"]
      interval: 30s
      timeout: 5s
      retries: 3

A few things worth pointing at before we start it.

The two port lines for 53 cover TCP and UDP DNS separately. DNS uses both, depending on the query. The 8081:80 line maps the web admin UI to port 8081 on the host, deliberately not port 80, so Pi-hole’s web UI doesn’t clash with something else later.

There’s a decent chance your host is already squatting on port 53. On Ubuntu (17.10 and later) and Fedora (33 and later), systemd-resolved runs a stub resolver there out of the box, and Pi-hole’s own docs are blunt that it “will prevent pi-hole from listening on port 53.” If docker compose up dies with an address already in use error, turn the stub listener off and point the host’s resolv.conf at the real upstream file, exactly the way the Pi-hole docs prescribe…

bash
sudo sh -c 'mkdir -p /etc/systemd/resolved.conf.d && printf "[Resolve]\nDNSStubListener=no\n" | tee /etc/systemd/resolved.conf.d/pihole.conf'
sudo sh -c 'rm -f /etc/resolv.conf && ln -s /run/systemd/resolve/resolv.conf /etc/resolv.conf'
sudo systemctl restart systemd-resolved

That’s the port 53 fight in a nutshell, and it exists only because the container is borrowing the host’s IP. Part 3 makes it go away for good.

The FTLCONF_* environment variables are how modern Pi-hole (v6 and later) gets configured. The naming is a touch awkward, but the pattern is FTLCONF_<section>_<key>. We’re setting the web UI password, telling Pi-hole to listen on all interfaces, and giving it two upstream resolvers (Cloudflare and Quad9, separated by a semicolon because that’s the format Pi-hole expects).

The volume line creates a directory called etc-pihole next to the compose.yaml and mounts it at /etc/pihole inside the container. That directory holds Pi-hole’s database, its gravity blocklist, and its configuration. As long as you keep that folder, you keep your Pi-hole state. Stop the container, delete the container, recreate it, and your settings come back.

cap_add: NET_ADMIN lets Pi-hole modify network interfaces inside the container. You don’t strictly need it for pure DNS, but you do need it if you ever turn on Pi-hole’s optional DHCP server later, so it’s a habit to leave it on.

The healthcheck section is new. It tells Docker to run dig pi.hole against the container’s own DNS service every 30 seconds. If the query succeeds, the container is healthy. If it fails three times in a row, Docker marks the container unhealthy. That status shows up in docker compose ps and is useful when something’s wrong.

One caveat before you trust it too much… a healthcheck only proves what it tests. dig pi.hole is answered from Pi-hole’s own local records, so the container can stand in your docker compose ps output declaring ’tis but a scratch with both upstreams down and every real lookup in the house failing. Good for catching a dead process, not a substitute for noticing your phone can’t resolve anything.

Bring it up:

$ docker compose up -d
[+] Running 2/2
 ✔ Network pihole_default  Created
 ✔ Container pihole        Started

Two things happened. Compose created a network for this stack (named pihole_default after the directory) and started the container.

Status:

$ docker compose ps
NAME    IMAGE                  COMMAND   SERVICE  STATUS                  PORTS
pihole  pihole/pihole:latest   "start"   pihole   Up 45 seconds (healthy)  0.0.0.0:53->53/tcp, 0.0.0.0:53->53/udp, 0.0.0.0:8081->80/tcp

Note the (healthy) annotation. The healthcheck is passing. For the first 30 seconds you’ll see (health: starting) instead, that’s just the interval before Docker runs its first check, not a problem. Logs:

$ docker compose logs -f pihole
pihole  | [✓] Starting pihole-FTL (DNS, DHCP, ...)
pihole  | [✓] FTL started

Ctrl-C to exit the log tail. The container keeps running.

Verify it works

Open http://localhost:8081/admin in a browser. Log in with the password from your Compose file (changeme, if you didn’t customize it). You should see the Pi-hole dashboard. Change that password now, either through the UI or by editing the env var and running docker compose up -d again.

From a terminal, ask Pi-hole to resolve a name:

$ dig @127.0.0.1 -p 53 example.com +short
172.66.147.243

You’ll get back an A record. The exact IP changes over time as the site moves hosts, that’s fine, the point is you got an answer so the resolver works. (On macOS or Windows the host you query is the Docker host’s IP, but for local testing 127.0.0.1 works because we published port 53 to all interfaces.)

Ask it to resolve a known blocked domain. Most ad network domains are in the default blocklist.

$ dig @127.0.0.1 doubleclick.net +short
0.0.0.0

A blocked query returns 0.0.0.0 (or :: for v6), which is how Pi-hole sinks ads. The client tries to connect and quickly gives up.

If both queries returned what we expected, you have a working DNS sinkhole on your machine. The next step is to point devices at it, which you do by changing your router’s DHCP options to hand out the Pi-hole host’s IP as the DNS server. The mechanics of that are router specific, so we won’t cover them here in detail.

What’s still wrong (and why we’re not fixing it yet)

Point a device at Pi-hole, browse a few sites, then come back to the admin UI and look at the Query Log. It works. Ads vanish, queries pile up. So what’s left to fix?

Pi-hole is a container hiding behind a port on your Docker host. Docker’s default bridge networking puts every container on a private subnet inside the host and forwards traffic in and out through the host’s own IP. As far as your LAN is concerned there’s no Pi-hole, there’s a Docker host that happens to answer on port 53. And you can run it that way… point your router’s DHCP at the host’s IP and every device on the network resolves through Pi-hole, plenty of people stop right there. You give up two things. Pi-hole’s DHCP server is off the table entirely, because a DHCP DISCOVER is a broadcast and broadcasts don’t cross a NAT into a private container subnet. And port 53 on the host stays contested ground… you refereed the systemd-resolved fight by hand earlier, and you’ll referee it again for anything else on that box that ever wants the port.

You’ll also read that a bridged Pi-hole logs every query as coming from one client, the Docker gateway. That was true on older Docker. On current Docker the port forwarding path keeps the real client address most of the time, so don’t be surprised if your phone and your laptop already show up as separate clients, despite what you read online… The host’s own queries still show up as the gateway, and I’ll show you the bench test in Part 3, but the “one giant client” scare is mostly history.

The fix for the real problems is to take Pi-hole off the default bridge network and put it on a macvlan network instead, which gives the container its own IP on your home LAN as a device in its own right. Part 3 covers exactly that.

For now, you have a working DNS server with adblocking, in one Compose file, that you can stop and start with one command. Run it, get used to it. Part 3 gives Pi-hole its own address on the LAN and takes both of those problems off the board.

Cheat sheet

Same as Part 1, scoped to this stack:

bash
cd ~/lab/pihole

docker compose up -d           # Start the Pi-hole stack
docker compose ps              # Show status (look for "healthy")
docker compose logs -f         # Tail Pi-hole logs
docker compose down            # Stop and remove the container

# The upgrade workflow
docker compose pull            # Pull a newer pihole image
docker compose up -d           # Recreate with the newer image

Note that down is only for when you actually want the stack stopped and gone… day to day you’ll rarely touch it. When you edit compose.yaml or an env var, just run up -d again. Compose works out that only the pihole container needs to be torn down and rebuilt with the new config, and your volume (and therefore your settings and history) survives. That recreation behavior is the second best feature of Compose after “everything’s in a file.”

About that container hiding behind a port

Part 3 is where we get into container networking properly. The “Pi-hole isn’t a device on your network” problem we just looked at is the springboard. We’ll walk through what docker0 is actually doing under the hood, what bridge mode costs you and what it doesn’t (bench test included), what macvlan does differently, and how to redeploy Pi-hole onto the home LAN as a first class citizen.

That’s where the home lab starts to feel like a real network with real visibility, instead of a black box with a couple containers in it. See you there.

More in Automation & Tools

Related Posts