Docker Compose: Run Your Whole Home Server From One File

Docker Compose: Run Your Whole Home Server From One File

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

Comparison: docker run commands are instructions that evaporate after use; a compose.yaml file is a description that documents every port and volume and rebuilds with one command.
When the boot drive dies, the file is what lets you rebuild in minutes.

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

Compose file for Uptime Kuma with the pinned image tag and the named data volume highlighted, plus restart policy, timezone and port 3001.
services, volumes and networks are the three top-level keys. Everything else hangs off them.
Four compose ideas: pin image tags, keep data on volumes, use the private network instead of publishing ports, and set restart policies with healthchecks.
Before deleting a container, ask what would be lost if it vanished right now.

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

Comparison: named volumes are managed by Docker and suit databases and caches; bind mounts point at chosen directories and suit config and data you edit or back up.
A config file you might fix by hand at 11 PM belongs on a bind mount.
Flow: a container writing as UID 911 is denied on a folder owned by UID 1000; setting PUID and PGID to 1000 fixes it.
Two minutes of matching UIDs beats opening the folder to everything.

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

An .env file with timezone, PUID, PGID, media and config paths and a database password, then adding .env to .gitignore and running docker compose config.
Commit a .env.example with the keys and blank values so a rebuild knows what it needs.

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

Table of five docker compose commands: up -d, logs -f, pull, ps and down, with what each does and when to use it.
Upgrading is pull then up -d. Compose recreates only the containers whose images changed.

Day to day, five commands cover almost everything. Learn these and the rest can wait until you need it.

CommandWhat it doesWhen to use it
docker compose up -dCreates or updates containers to match the file, in the backgroundAfter every edit
docker compose logs -f appFollows one service’s output liveFirst thing when something misbehaves
docker compose pullFetches newer images without starting themMonthly, or before a known release
docker compose psShows status, health and published portsSanity check after a reboot
docker compose downStops and removes containers, keeps named volumesBefore 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

Flow: LAN traffic on 80 and 443 reaches Caddy, the only service publishing ports, which proxies to Jellyfin with read-only media and to the Uptime Kuma dashboard.
The :ro media mount means a confused media server cannot delete your files.

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

Checklist: commit compose.yaml, proxy config, README and .env.example; never commit .env; data still needs its own backup.
git log -p compose.yaml shows exactly what changed when something stops working.

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

Four compose mistakes: editing config inside a running container, running down -v, tabs in YAML, and :latest images with unattended pulls.
Change the file, then apply the file.
  • Editing configuration inside a running container with docker exec. The next up -d throws it away. Change the file, then apply the file.
  • Running docker compose down -v to “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 :latest and 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