Self-Hosting a Homelab with Docker Compose

Self-Hosting a Homelab with Docker Compose

Everything in my homelab runs in Docker, and the whole thing is described by a handful of

1docker-compose.yml
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.

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

You can start a container with a long

1docker run
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.

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:

  • 1restart: unless-stopped
    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.
  • The named volume
    1db_data
    is where the actual database lives. Containers are disposable; the volume is not. I can
    1docker compose down
    , pull a new image, and
    1up
    again without losing a row.
  • The healthcheck makes
    1depends_on ... condition: service_healthy
    meaningful. Adminer won't start hammering Postgres until
    1pg_isready
    actually succeeds, which avoids a noisy race on cold boot.

Keep secrets out of the file

Notice

1${DB_PASSWORD}
above. Compose reads variables from a
1.env
file sitting next to the Compose file:

1# .env  — never committed
2DB_PASSWORD=correct-horse-battery-staple
3

I commit the

1docker-compose.yml
and a
1.env.example
with placeholder values, but the real
1.env
is gitignored. That way the repo is safe to clone and nothing sensitive ever lands in history.

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

1pull && up -d
line is my entire update routine. Compose only recreates containers whose image actually changed, so updates are fast and low-drama.

Backups that run themselves

A homelab without backups is just a countdown. For Postgres I run a nightly

1pg_dump
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:

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

1/etc/cron.daily
(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.

What this buys you

The payoff is repeatability. My hosts are essentially cattle: a fresh Debian install, Docker,

1git clone
, drop in the
1.env
, and
1docker compose up -d
. 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.

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.