Docker Compose for Your Home Lab, Part 1: A Container Is Not a Tiny VM
Three months ago you ran a docker run command from a blog post and got Pi-hole working. Three weeks later it died on a reboot and never came back. The blog post is gone, the shell history on your home lab box rolled over, and now there’s a directory called ~/pihole-data on disk that you’re not sure if you can delete or if it has six months of your DNS config in it. You don’t even remember which port it was on. Sound familiar?
If that hits home, you’re the audience for this series… this is the article I should’ve written when I was learning Docker seven plus years ago. The destination is a real home lab stack you can re-deploy in five minutes from a couple of YAML files in a git repo: ad-blocking DNS on your network, reverse proxy in front of services, Tailscale for remote access from anywhere, a single dashboard pointing at all of it, and Paperless-ngx running behind the proxy as your end-user app.
Today we start with the part of Docker most tutorials skip and instead expect you to absorb by osmosis.
A container is not a tiny VM
I know every tutorial says a container is “kind of like a lightweight VM.” They’re wrong, and that misconception gets in the way of every other thing you’ll try to learn.
A container is just a process, the same kind of process that runs when you type python at a shell, on the same kernel and the same hardware as everything else on the box. The thing that makes it a container is that the kernel hands it a deliberately incomplete view of the system. The container sees only the files it’s been given, only the network interfaces it’s been assigned, only the processes inside its own namespace. The rest of the host might as well not exist.
There’s no second kernel, no hypervisor, no 2 GB of memory wasted booting another whole operating system… just a normal Linux process with carefully drawn lines around what it can see.
This matters because the lightweight VM mental model leads you to wrong conclusions. You assume you need to “boot” a container (you don’t, it starts as fast as any other process). You assume containers have init systems (most don’t, and the ones that do are usually wrong for a container). You assume you should ssh into them to administer them (you shouldn’t, treat them as ephemeral). The textbook answer to “how do I edit a config file inside a running container” is “you don’t, you change the file on the host and restart the container.” That answer doesn’t make sense until you stop thinking of containers as little machines.
What containers ship with is an image, which is a tarball of files (plus a bit of metadata) representing the entire filesystem the container will see. Pull an image once, run as many containers from it as you want. The mental shortcut is that images are installable apps and containers are running instances of them. Same image, ten containers, all independent. Delete a container, the image is still there.
Where containers shine, where they don’t
Containers are great at boring stuff. They’re miserable at weird stuff. The line between boring and weird isn’t where most people draw it.
Boring is anything where the state of the world is well-contained in one or two directories on disk: web apps, reverse proxies, dashboards, Pi-hole’s /etc/pihole, Paperless-ngx’s data and media folders, Nginx Proxy Manager’s data and letsencrypt. If you can identify a small set of host paths that need to survive, mount them into the container as volumes, throw the container itself away whenever you want, and recreate it from a Compose file without losing anything that matters. The entire stack we’re building in this series lives in that territory.
Weird is everything that wants to talk to physical hardware in a specific way. USB tuners for HDHomeRun-style TV setups. GPUs for video transcoding. Audio devices for a music server. It’s all technically possible, but the configuration gets fiddly fast: device permissions, driver versions, Linux-only ceilings on what you can pass through. I lost a Saturday to a USB tuner container before admitting defeat and running the thing on the host… learn from my Saturday. Run that stuff on the host.
Worse than weird is anything that wants its own kernel module, and ZFS is the classic case. The container doesn’t run a different kernel, remember? It runs yours. If the app needs a kernel module, it has to talk to your host’s kernel anyway, which means you’re managing kernel modules on the host and a container that wraps a thing that needs them. Run it on the host directly and call it done.
And then there’s the case I think a lot of beginners get backwards: “I want a whole second Linux distro to run an app.” What you’re describing is a VM. Containers run a single application plus its dependencies, and the “second Linux” you keep picturing is mostly an init system and a pile of services you don’t need. Skip the second distro and find the container image for the app you want.
docker run is a trap (eventually)
Plain docker run has two failure modes that show up in every home lab. The first is you write commands you can’t remember an hour later. The second is you write commands you can remember, but the moment two services need to work together, it falls apart.
Here’s a docker run for Pi-hole from a typical tutorial:
docker run -d \
--name pihole \
-p 53:53/tcp -p 53:53/udp \
-p 80:80 \
-e TZ=America/Chicago \
-e FTLCONF_webserver_api_password=changeme \
-v ./etc-pihole:/etc/pihole \
-v ./etc-dnsmasq.d:/etc/dnsmasq.d \
--restart=unless-stopped \
pihole/pihole:latest(Yes, that’s really the password variable in Pi-hole v6. The WEBPASSWORD you’ll find in every older tutorial silently does nothing now, which is its own kind of bad afternoon.)
Nobody types that twice. You type it once, paste it into a setup.sh, lose the script in a folder reorg, and have a bad afternoon six months later trying to remember what -v paths Pi-hole needed. I’ve done this exact thing. I bet you’ve done it. The setup.sh workflow works 60% of the time, every time… they’ve done studies. The script is the only documentation, and it’s one rm -rf away from being gone.
The bigger problem is that home labs run more than one service. Pi-hole alone is one container. A reverse proxy in front of three services, each with its own database, all needing a network where they can find each other, that’s nine containers, and docker run makes you start them in the right order, by hand, every time, while wiring up networks and volumes between them.
Docker Compose fixes both problems by letting you describe what you want in YAML, in one file per stack, and running one command to bring it up. The file is small. It’s text, so it goes in git. And it describes the end state you want, leaving Compose to work out the order of operations. “Tea. Earl Grey. Hot.” Picard declares the outcome and the replicator compiles the steps… a Compose file is the same trick for your lab.
If you’ve used Ansible, the analogy lands cleanly. docker run is to ansible ad-hoc commands what Compose is to playbooks. One is fine for one-offs. The other is what you actually use to run things.
Install Docker (and avoid two common traps)
You need the Docker Engine and the Compose plugin, and the plugin is the half you’re really here for. On modern installs they come together. Pick your platform.
Before the commands, two gotchas that bite people.
First, resist installing docker.io from Ubuntu’s or Debian’s own apt repos. On current Ubuntu it’s not even stale anymore, but it still doesn’t pull in the Compose plugin, and the Compose plugin is the thing you want out of this install. Skip it and you’ll end up an hour from now googling “docker compose command not found”… good luck with that… and finding contradictory advice about a deprecated standalone docker-compose Python tool. Docker’s convenience script pulls from Docker’s official apt repo and includes the Compose plugin automatically.
Second, the usermod step you’ll see below is real. Being in the docker group is effectively root on the host, of course, because you can mount the host’s filesystem into a container and trivially become root from there. On your single-user home lab box that’s fine, you’re already root. On a shared box, think twice.
# Ubuntu / Debian (recommended: Docker's official convenience script)
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
# Log out and back in for the group change to take effect.# macOS / Windows
# Install Docker Desktop from https://www.docker.com/products/docker-desktop/
# On Windows, enable the WSL2 backend in Docker Desktop's settings.Full disclosure… everything in this series runs on Linux at my house. If Docker Desktop on Windows does something weird, the comments are down there, and somebody who knows WSL2 better than I do probably is too.
Verify both pieces work:
$ docker version | grep Client -A1
Client: Docker Engine - Community
Version: 29.7.1
$ docker compose version
Docker Compose version v5.4.0Your numbers will probably be newer than mine. As long as both commands return a version instead of an error, you’re done with setup.
Your first container
Run Nginx in one command.
$ docker run -d -p 8080:80 --name hello nginx:alpine
Unable to find image 'nginx:alpine' locally
alpine: Pulling from library/nginx
...
Status: Downloaded newer image for nginx:alpine
1f3a8c... (some long hash)Three things happened in that one command. Docker noticed you didn’t have the nginx:alpine image, pulled it from Docker Hub, and started a container from it. The long hash at the end is the container’s ID. Open a browser and hit http://localhost:8080. You should see “Welcome to nginx!” That’s a working web server, in a container, on your machine. Total elapsed time including the image pull is maybe twenty seconds.
The flags:
-druns the container detached (in the background). Without it, your terminal would attach to the container’s stdout and block. You don’t want that for anything you intend to leave running.-p 8080:80publishes port 80 inside the container to port 8080 on the host. Nginx listens on port 80 by convention. Mapping it to 8080 on the outside keeps it clear of anything already listening on 80, and host port 80 is going to be spoken for once the reverse proxy shows up later in this series.--name hellogives the container a friendly name. Without it Docker assigns something likecranky_einstein, which is funny once.nginx:alpineis the image. Thealpinetag picks the variant built on Alpine Linux, which is tiny (around 50 MB on disk). I default to:alpinevariants when they exist for exactly this reason. The base image is so much smaller that pulls, restarts, and disk use all feel snappier.
Look at what’s running:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1f3a8c... nginx:alpine "/docker-entrypoint.…" 13 seconds ago Up 12 seconds 0.0.0.0:8080->80/tcp helloStop and remove:
$ docker stop hello
hello
$ docker rm hello
helloTwo commands to clean up. The image is still on your disk (try docker images to see). Next time you start an Nginx container, it boots in under a second because the image is already local. Only the container instance was removed.
Same thing, but reproducible
Time to do the exact same thing with a Compose file. It’s about the same length as the docker run line… the payoff comes in six months, when you stare at the directory with no idea what’s in it, and the file tells you.
Make a directory and drop one file in it:
mkdir -p ~/lab/hello && cd ~/lab/helloWrite compose.yaml:
services:
hello:
image: nginx:alpine
container_name: hello
ports:
- "8080:80"
restart: unless-stoppedFive fields. Same shape as the docker run line. Bring it up:
$ docker compose up -d
[+] Running 2/2
✔ Network hello_default Created
✔ Container hello StartedSame Nginx, same port, same result in the browser. Check the state:
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
hello nginx:alpine "/docker-entrypoint.…" hello 30 seconds ago Up 4 seconds 0.0.0.0:8080->80/tcpTail the logs:
$ docker compose logs -f
hello | 192.168.1.42 - - [05/Aug/2026:01:14:22 +0000] "GET / HTTP/1.1" 200 615 "-" "Mozilla/5.0 ..."Ctrl-C exits the log tail without stopping the container.
Tear it down:
$ docker compose down
[+] Running 2/2
✔ Container hello Removed
✔ Network hello_default RemovedNote the Network hello_default lines in both directions. Compose created a network for the stack (named after the directory) without being asked, and removed it on the way out. You probably glossed over the Created line on the way up… that auto-created network matters a lot more once stacks have multiple services, and we’ll lean on it hard in a couple posts.
One file and a handful of commands is the whole loop, and everything else in this series builds on it.
The daily loop
Tape these to your monitor for a week. After that they’ll be reflex.
docker compose up -d # Start everything in compose.yaml, detached
docker compose ps # What's running in this stack
docker compose logs -f # Tail logs from all services
docker compose logs -f svc # Tail logs from one named service
docker compose down # Stop and remove containers + networks (volumes kept)
docker compose pull && docker compose up -d # Upgrade the stack in place. No `down` needed —
# up -d only recreates containers whose image changed
docker compose restart # Restart everything in the stackAlways run them from the directory holding compose.yaml. Compose auto-discovers the file, which is why everyone keeps each stack in its own folder with its own compose.yaml. The convention is so universal it might as well be a rule.
One housekeeping note, because the upgrade loop has a hidden cost… every pull leaves the previous image layers on disk, and after a few months they add up to real gigabytes. docker image prune deletes the layers nothing references anymore. Run it whenever you remember it exists. Quarterly is plenty for a home lab.
What’s next
You installed Docker, ran your first container two ways, and ended up with a one-service Compose stack on disk. Every post after this one adds something useful on top.
Part 2 puts the first real service on this foundation… ad-blocking DNS for the whole network, deployed the Compose way. By the end of that post your router points at something you built, and your family’s Netflix is at the mercy of your YAML.
For now, go play with that Nginx container… change the port, swap the image to httpd:alpine, stop it and start it until the loop is muscle memory. And if the install fought you anywhere, drop it in the comments. Part 1 only works if zero-to-first-container actually lands, and the comments are how I find out where it didn’t.
New posts in your inbox when they publish. No digest, no marketing.