Self-Hosting a Homelab with Docker Compose

Everything in my homelab runs in Docker, and the whole thing is described by a handful of
files I keep in a private Git repo. That one decision — treating my infrastructure as code instead of a pile of hand-installed packages — is the single biggest reason the lab has stayed reliable. When a disk dies or I rebuild a host, I'm back online in minutes because the definition of the stack lives in version control, not in my head.1docker-compose.yml
In this post I'll walk through the patterns I actually use: a real Compose file, healthchecks that keep things honest, named volumes for persistence, and a backup approach that runs itself.
Why Compose instead of 1docker run
1docker runYou can start a container with a long
command. The problem is that the command is ephemeral — six months later you won't remember the exact flags, ports, and env vars you used. Compose turns all of that into a declarative file you can read, diff, and roll back.1docker run
A small, real example: Postgres plus a lightweight admin UI.
1services: 2 db: 3 image: postgres:16 4 restart: unless-stopped 5 environment: 6 POSTGRES_USER: app 7 POSTGRES_PASSWORD: ${DB_PASSWORD} 8 POSTGRES_DB: app 9 volumes: 10 - db_data:/var/lib/postgresql/data 11 healthcheck: 12 test: ['CMD-SHELL', 'pg_isready -U app -d app'] 13 interval: 10s 14 timeout: 5s 15 retries: 5 16 17 adminer: 18 image: adminer:4 19 restart: unless-stopped 20 ports: 21 - '8080:8080' 22 depends_on: 23 db: 24 condition: service_healthy 25 26volumes: 27 db_data: 28
A few things I want to call out, because they're the parts people skip:
means the container comes back after a reboot or a crash, but stays down if I deliberately stopped it. It's the sane default for a homelab.1restart: unless-stopped- The named volume
is where the actual database lives. Containers are disposable; the volume is not. I can1db_data
, pull a new image, and1docker compose down
again without losing a row.1up - The healthcheck makes
meaningful. Adminer won't start hammering Postgres until1depends_on ... condition: service_healthy
actually succeeds, which avoids a noisy race on cold boot.1pg_isready
Keep secrets out of the file
Notice
above. Compose reads variables from a1${DB_PASSWORD}
file sitting next to the Compose file:1.env
1# .env — never committed 2DB_PASSWORD=correct-horse-battery-staple 3
I commit the
and a1docker-compose.yml
with placeholder values, but the real1.env.example
is gitignored. That way the repo is safe to clone and nothing sensitive ever lands in history.1.env
Running it
The commands I use day to day are boring, which is exactly what you want from infrastructure:
1# start everything in the background 2docker compose up -d 3 4# see what's running and whether healthchecks pass 5docker compose ps 6 7# tail logs for one service 8docker compose logs -f db 9 10# pull newer images and recreate changed containers 11docker compose pull && docker compose up -d 12
That
line is my entire update routine. Compose only recreates containers whose image actually changed, so updates are fast and low-drama.1pull && up -d
Backups that run themselves
A homelab without backups is just a countdown. For Postgres I run a nightly
straight into the same volume layout, then sync the dump offsite. The trick is to exec into the already running container so you don't need a second Postgres install:1pg_dump
1#!/usr/bin/env bash 2set -euo pipefail 3stamp=$(date -u +%F) 4docker compose exec -T db \ 5 pg_dump -U app -d app | gzip > "/srv/backups/app-${stamp}.sql.gz" 6 7# keep the last 14 days, delete the rest 8find /srv/backups -name 'app-*.sql.gz' -mtime +14 -delete 9
Drop that in
(or a systemd timer) and you have point-in-time recovery you never have to remember to run. I test a restore every few months by piping a dump into a throwaway container — an untested backup isn't a backup.1/etc/cron.daily
What this buys you
The payoff is repeatability. My hosts are essentially cattle: a fresh Debian install, Docker,
, drop in the1git clone
, and1.env
. Everything that makes the machine mine is in those files. Adding a new service is a pull request to myself; removing one is deleting a block of YAML.1docker compose up -d
If you're starting your own lab, resist the urge to install things directly on the host. Put it in Compose, commit it, give it a healthcheck and a named volume, and let your future self thank you the next time hardware fails.
