Homelab Haven

Docker Compose Basics: What's Actually Happening Before You Copy Anyone's compose.yaml

January 8, 20266 min read

Every guide on this site — the media stack, Cloudflare Tunnel, Tailscale, the recipe managers — starts with a block that looks like this and just expects you to know what it means:

services:
  app:
    image: someproject/app:latest
    restart: unless-stopped
    environment:
      - PUID=1000
      - PGID=1000
    volumes:
      - /path/to/appdata/app:/config
    ports:
      - "8080:8080"

If that's already obvious, skip this one. If it isn't, this is the primer — what each line actually does, and why this site's guides all repeat the same handful of patterns.

Summary

  • A container is a running process; an image is the read-only template it starts from. Compose's job is describing a group of containers as one file.
  • Volumes are what survive a container being deleted and recreated — put anything you'd be upset to lose in one.
  • restart: unless-stopped means "come back after a reboot," not "restart forever." That distinction matters.
  • PUID/PGID aren't Docker features — they're an convention certain images (LinuxServer.io's, mainly) read to avoid running as root.

Image vs. container, in one sentence

An image is a frozen filesystem plus some metadata — think of it as a program you haven't run yet. A container is that image actually running: its own process, its own writable layer on top of the image, isolated from the host and from other containers unless you explicitly connect them.

This is why docker compose up -d and docker compose restart behave differently. up -d can recreate a container from the image (picking up a new image version, a changed environment variable); restart just stops and starts the existing container's process, changing nothing about its configuration. If you edit a compose file and only run restart, your edits won't apply — this is the single most common "I changed it but nothing happened" moment in Docker.

The compose file is just: which images, connected how

A compose.yaml describes one or more services — each service becomes one container (usually named after it) — and how they relate:

services:
  app:
    image: someproject/app:latest    # which image
    container_name: app              # the name docker ps shows, and how other
                                      # containers on the same network can reach it
    restart: unless-stopped          # what to do if it crashes or the host reboots
    environment:                     # env vars passed into the container
      - PUID=1000
      - PGID=1000
    volumes:                         # host path : container path
      - /path/to/appdata/app:/config
    ports:                           # host port : container port
      - "8080:8080"

Every guide on this site follows this exact shape. Once you can read this block, you can read all of them.

Volumes: the only thing that survives

Delete a container and its writable layer goes with it — anything the app wrote inside the container's own filesystem is gone. A volume (here, a bind mount — a host folder mapped in) is how you keep what matters: point /config at a real folder on your host, and recreating the container changes nothing about the app's data, because the data was never inside the container to begin with.

volumes:
  - /path/to/appdata/app:/config

Read this as host path : container path. The app writes to /config from its own point of view; you see those files at /path/to/appdata/app on the host. This is why every guide on this site puts appdata in a predictable place — the environment table in the media-stack series is exactly this convention applied consistently.

The rule that matters most: if losing a folder would mean losing something real (a database, uploaded photos, a config you tuned by hand), it needs a volume. If it's genuinely disposable (cache, temp files), it doesn't.

Networks: containers finding each other by name

Containers in the same compose file share a network by default, and can reach each other by service name — not localhost, not an IP:

services:
  app:
    depends_on: [db]
    environment:
      - DB_HOST=db     # not "localhost", not an IP — the other service's name
  db:
    image: postgres:16-alpine

This is why every guide on this site says things like "host sonarr, port 8989" — sonarr isn't a hostname you configure anywhere; it's just the other service's name in the same compose file, and Docker's internal DNS resolves it automatically. It only works between containers started from the same compose file (or explicitly joined to the same external network) — a container in a different stack can't reach sonarr by name unless you wire that up deliberately.

restart: unless-stopped — the one policy you actually want

Four options exist, and one of them is right almost all the time:

PolicyBehaviour
no (default)Never restarts automatically, not even after a reboot
on-failureRestarts only if the process exits with an error
unless-stoppedRestarts on crash and on host reboot — unless you stopped it manually
alwaysSame as unless-stopped, but also restarts a container you deliberately stopped, the next time the Docker daemon starts

unless-stopped is what you want for anything you expect to run continuously: it survives a host reboot, but if you docker stop app to work on something, it stays stopped instead of fighting you.

PUID / PGID — not Docker, a convention

Docker itself has no PUID/PGID concept. These are environment variables that certain images (most notably LinuxServer.io's) read at startup and use to switch the process to run as that user/group instead of root, before touching your files.

environment:
  - PUID=1000
  - PGID=1000

Find your own values with id <your username> on the host — 1000 is a common default for the first non-root user on Linux, not a value to copy blindly. Set the same values in every container that shares a volume, or you'll spend an afternoon debugging "permission denied" errors that are really just two containers disagreeing about who owns a file. This exact trap — and the fix — comes up constantly across this site's guides.

Healthchecks: telling Docker what "working" means

By default, Docker only knows if a container's process is running, not whether the app inside it actually works. A healthcheck closes that gap:

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 30s

docker ps then shows (healthy) or (unhealthy) instead of just Up, and other tools (Dockhand, depends_on: condition: service_healthy) can act on it — a database container that's merely "running" isn't necessarily ready to accept connections yet; service_healthy is what actually waits for it.

Checkpoint

You now have the vocabulary every other guide on this site assumes:

  • Image vs. container, and why restart alone won't pick up compose file changes
  • Volumes are what survives; put real data behind one
  • Services reach each other by name, only within the same compose file
  • restart: unless-stopped for anything meant to run continuously
  • PUID/PGID — an image convention, not a Docker feature, and it must match across containers sharing a volume
  • Healthchecks tell Docker (and everything watching it) what "actually working" means

From here, the media stack series is the deepest worked example on this site — seven real compose files building on each other. The Cloudflare Tunnel and Tailscale guides assume everything above and won't re-explain it.

If this saved you some time, a coffee keeps the lights on and the posts coming.

☕ Buy me a coffee