
A boot drive fails and the data survives, because the data lives on separate disks. What does not survive is the shape of the machine. Nine containers, each started months earlier with a long docker run command somebody typed once, tweaked twice and never wrote down. Which port went to Jellyfin? What exact path was bind-mounted for Pi-hole’s configuration? Rebuilding from shell history is archaeology, and it is the default position for anyone who set their home server up interactively. That is the real argument for Compose, and it has nothing to do with elegance. A Compose file is a description of the whole stack in one text file you can keep in a Git repo, so a rebuild becomes a clone followed by a single command. The failure does not get less likely. It gets cheap. Here is how I would set that file up today.
why one file beats a folder of docker run commands

A docker run command is an instruction. A Compose file is a description. That sounds like a philosophy-class distinction until the day you need to rebuild, and then it is the only thing that matters. An instruction happens once and evaporates. A description sits on disk, can be read six months later, can be diffed, reviewed and copied to a second machine.
The practical wins stack up fast. Every container’s ports, volumes and restart behaviour are visible in one place, so you answer “what is using port 8080” by reading rather than guessing. Related services get their own private network, so your database stops being exposed to the rest of the flat. Updating everything is two commands instead of nine.
The unexpected benefit is that the file becomes documentation for future you. Run a handful of services on the kind of machine described in my old laptop home server guide and six months later you will not remember what half of them need. You do not have to. The file remembers.
anatomy of a compose file


Modern Docker expects the file to be called compose.yaml, though docker-compose.yml still works and you will see it everywhere in older guides. The top-level keys you care about are services, volumes and networks. Everything else is detail hanging off those three. Here is the smallest useful thing:
services:
uptime:
image: louislam/uptime-kuma:2
container_name: uptime
restart: unless-stopped
environment:
- TZ=Asia/Kolkata
volumes:
- uptime-data:/app/data
ports:
- "3001:3001"
volumes:
uptime-data:
services and image tags
Each key under services is a container. The image line is the one people get careless about. Using :latest means an update can arrive the moment you pull, including a major version that changes its own database format. Pinning to a major version like :2 or an exact version like :2.5.5 gives you a choice about when to take that risk. Pin anything that holds a database and let cosmetic tools float.
Pins age, so check each project’s releases page before copying a tag from any article, this one included. A major-version jump on a service that holds a database (Jellyfin 10 to 12, Uptime Kuma 1 to 2) changes its database format, so take a backup of its data first.
volumes are where the data survives
Anything written inside a container that is not on a volume dies with the container. This is the single most important idea in the whole system, and it is worth being slightly paranoid about. Before deleting a container, ask one question: if this thing vanished right now, what would I lose? If the answer is not “nothing”, something is not on a volume yet.
networks let you stop publishing ports
Compose puts every service in a file on a shared private network and lets them reach each other by service name. A web app can talk to db:5432 without db publishing a port to your LAN at all. Only publish what a human or another machine needs to reach; a database almost never qualifies. Published ports also bypass ufw and firewalld rules on the host, as Docker’s install guide warns, so don’t count on the host firewall to hide one; bind LAN-only services to a specific address instead, for example "192.168.1.50:3001:3001".
restart policies, healthchecks and depends_on
On a home server in India, restart: unless-stopped is not optional. Power drops, the box comes back, services return without a keyboard. It also means a container you deliberately stopped stays stopped across a reboot, which always does not respect.
A healthcheck teaches Docker the difference between “the process is running” and “the thing works”. Add a condition on depends_on and a dependent service waits for real readiness instead of racing a database that is still starting.
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdata
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 6
app:
image: ghcr.io/example/app:2.4
restart: unless-stopped
depends_on:
db:
condition: service_healthy
named volumes, bind mounts, and the permission tax


You have two ways to keep data. A named volume is managed by Docker and lives under /var/lib/docker/volumes. A bind mount points at a directory you chose, like ./jellyfin/config or /srv/media. Both work. They fail differently.
My working rule, and the one that survives contact with a migration: named volumes for opaque application state such as databases and caches, bind mounts for anything you want to read, edit or back up with ordinary tools. A config file you might fix by hand at 11 PM belongs somewhere a text editor can reach. Media libraries are always bind mounts, because the files existed before the container and will outlive it.
PUID, PGID and the folder nobody can write to
Here is the problem that costs every newcomer an evening. A process inside a container runs as some user ID. Your files on the host are owned by some other user ID. The container does not care about names, only numbers. So it happily tries to write as UID 911 into a directory owned by UID 1000, gets refused, and reports something unhelpful about a read-only database.
Images from the LinuxServer project solve this with PUID and PGID environment variables that tell the container which numeric identity to adopt. Find your own numbers first, then use them consistently across every service that shares a folder:
id -u anurag
id -g anurag
# Fix ownership on a shared media tree once, properly
sudo chown -R 1000:1000 /srv/media
sudo find /srv/media -type d -exec chmod 775 {} +
# Inside a service block
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Kolkata
Resist the temptation to solve this with chmod 777. It does make the error go away, and it also means every container on the box can rewrite your photo library. Two minutes of matching UIDs is the better trade.
the .env file keeps secrets out of your repo

Compose reads a file called .env sitting next to compose.yaml and substitutes ${VARIABLE} references from it. That gives you one obvious place for passwords, paths and the handful of values that differ from one machine to the next.
# .env — never commit this file
TZ=Asia/Kolkata
PUID=1000
PGID=1000
# host render group id, from: getent group render | cut -d: -f3 (replace 122 with yours)
RENDER_GID=122
MEDIA_ROOT=/srv/media
CONFIG_ROOT=/srv/appdata
DB_PASSWORD=change-me-to-something-long
# Then, in .gitignore
echo ".env" >> .gitignore
Commit a .env.example with the keys present and values blanked, so a future rebuild tells you what it needs. Then run docker compose config, which prints the fully resolved file and catches empty values and indentation mistakes before they become a container that restarts in a loop.
the cycle you will actually run

Day to day, five commands cover almost everything. Learn these and the rest can wait until you need it.
| Command | What it does | When to use it |
|---|---|---|
| docker compose up -d | Creates or updates containers to match the file, in the background | After every edit |
| docker compose logs -f app | Follows one service’s output live | First thing when something misbehaves |
| docker compose pull | Fetches newer images without starting them | Monthly, or before a known release |
| docker compose ps | Shows status, health and published ports | Sanity check after a reboot |
| docker compose down | Stops and removes containers, keeps named volumes | Before a disruptive change |
Upgrading is pull then up -d. Compose notices which images changed and recreates only those containers. Nothing else is touched, which is a relief the first time you watch it skip eight services and restart one.
cd /srv/stack
docker compose pull
docker compose up -d
docker compose ps
# Reclaim space from the images you just replaced
docker image prune -f
One warning. Plain down keeps your named volumes; adding -v deletes them. That one flag is the quickest way to lose a home lab database.
a worked stack for a small home server

Here is the shape a small home server usually settles into, trimmed to three services so the structure stays readable: a reverse proxy, a media server and a monitoring dashboard. Everything shares one network, and only the proxy publishes ports to the LAN.
services:
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
networks:
- edge
jellyfin:
image: jellyfin/jellyfin:12.2
container_name: jellyfin
restart: unless-stopped
user: "${PUID}:${PGID}"
group_add:
- "${RENDER_GID}"
environment:
- TZ=${TZ}
volumes:
- ${CONFIG_ROOT}/jellyfin:/config
- ${MEDIA_ROOT}:/media:ro
devices:
- /dev/dri:/dev/dri
networks:
- edge
uptime:
image: louislam/uptime-kuma:2
container_name: uptime
restart: unless-stopped
volumes:
- uptime-data:/app/data
networks:
- edge
volumes:
caddy-data:
caddy-config:
uptime-data:
networks:
edge:
Three details are doing quiet work. The media mount ends in :ro, so a compromised or confused media server cannot delete your files. The devices line hands the integrated GPU to Jellyfin for hardware transcoding, and group_add gives the non-root container user permission to use it, which I unpack properly in the Jellyfin media server guide. And the Caddyfile is mounted read-only from a path in the repo, so the proxy configuration is version-controlled alongside everything else.
Where does the storage under MEDIA_ROOT come from? Ideally a pair of drives with a filesystem chosen on purpose rather than by accident — a decision I unpack in building your own NAS at home.
put the file in version control, today

The argument for Compose collapses if the file only exists on the machine it describes. Make a repo, commit compose.yaml, the proxy config, your .env.example and a short README. Push it somewhere private. Now the dead-boot-drive scenario at the top of this article is a twenty-minute job instead of a lost evening.
Git history is also the best debugging tool in the set. When something that worked last month stops working, git log -p compose.yaml shows exactly what you changed and when.
The repo holds the configuration. It does not hold your data. Volumes and bind mounts still need a real backup, on a schedule, with a restore you have tested at least once.
If you are weighing containers against full virtual machines for a bigger lab, the trade-offs are different enough that I would read my Proxmox beginner guide before committing to either. Plenty of people end up running Compose stacks inside a Proxmox VM and are happy with both.
common mistakes

- Editing configuration inside a running container with
docker exec. The nextup -dthrows it away. Change the file, then apply the file. - Running
docker compose down -vto “clean up”. That removes named volumes, which is where your databases live. - Publishing ports for every service out of habit. If only the reverse proxy needs to be reachable, only the reverse proxy should publish.
- Tabs in YAML. The parser rejects them and the error message rarely says so plainly. Two spaces, consistently, and let your editor show whitespace.
- Leaving every image on
:latestand then running an unattended pull. One morning a service comes back with a migrated database and no way back. - Committing
.env. Check before your first push, because removing a secret from Git history is far more annoying than never adding it.
FAQ
one big compose file or several small ones?
Keep one file per group of things that genuinely belong together, each in its own folder. Media services in one, network services in another, experiments in a third. That way restarting the stack you are tinkering with does not take down DNS for the whole flat, which matters when other people in the house are watching something.
docker compose or docker-compose with a hyphen?
Use docker compose, two words. That is the plugin built into current Docker releases. The hyphenated docker-compose is the original Python tool (Compose v1), which Docker no longer supports; the supported releases, Compose v2 and v5, are invoked as docker compose. Old tutorials use the hyphen, so mentally translate as you read.
how much RAM do I need for a stack like this?
Less than you would expect for most services. Small tools such as a DNS blocker or an uptime dashboard are happy in a few hundred megabytes; databases and software transcoding are the real consumers. An old dual-core laptop with 8 GB will generally carry six to eight modest containers without complaint. Watch docker stats on your own box for a week before buying anything.
what happens during a power cut?
With restart: unless-stopped and Docker enabled at boot, everything comes back on its own. The risk is the abrupt write, not the containers. A database killed mid-transaction can return needing repair, so anything holding real data deserves a battery behind it.
Spend one evening converting your existing containers into a single file, then delete your shell-history notes with some satisfaction. The next time a disk dies you will find out how good that trade was.
Update history: switched the Jellyfin service to the official image’s user and render-group settings, updated the Jellyfin and Uptime Kuma image tags, added Caddy’s config volume and a warning that published ports bypass the host firewall, and corrected the Compose v1 note.
Sources
- Docker Compose documentation — the official overview, command reference and project structure conventions.
- Compose file reference — authoritative definitions for services, volumes, networks, healthcheck and depends_on.
- Understanding PUID and PGID — why container images expose numeric user and group IDs, and how to set them.
- Jellyfin container installation — the official image’s
usersetting, used in the worked stack. - Docker packet filtering and firewalls — how published ports interact with ufw and firewalld.