The Docker Compose Cheat Sheet for Self-Hosting Everything
At some point every self-hoster hits the same wall: you have six containers running, you cannot remember which flags you used for which one, and restarting the stack after a reboot feels like defusing a bomb. Docker Compose fixes this. One YAML file describes your entire setup, and you stop typing forty-character docker run commands from memory. Here is the cheat sheet I actually use.
The bare minimum Compose file
services:
app:
image: nginx:latest
container_name: my-app
ports:
- "8080:80"
volumes:
- ./data:/usr/share/nginx/html
restart: unless-stoppedThat is genuinely most of what you need for a huge chunk of self-hosted apps. Swap the image, adjust the ports, point the volume somewhere sensible, and you are running something.
The commands you will actually type
- docker compose up -d: start everything in the background
- docker compose down: stop and remove containers (your volumes survive, relax)
- docker compose logs -f app: tail the logs for one specific service
- docker compose restart app: restart a single service without touching the rest
- docker compose pull && docker compose up -d: update to the latest image and recreate the container
- docker compose ps: see what is actually running versus what you think is running
Multiple services, one file
This is where Compose earns its keep: defining several services that need to talk to each other, without manually wiring up networks by hand.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: changeme
volumes:
- db_data:/var/lib/postgresql/data
restart: unless-stopped
app:
image: my-app:latest
depends_on:
- db
ports:
- "3000:3000"
restart: unless-stopped
volumes:
db_data:Both services land on the same default network automatically, so “app” can reach “db” just by using the service name as the hostname. No manual IP juggling required.
One habit worth building
Keep every stack in its own folder with its own compose.yaml, and commit those folders to a private git repo. When your SD card dies (it will) or your server needs a clean reinstall, your entire homelab comes back with a handful of `docker compose up -d` commands instead of a weekend of reconstructing everything from memory.
Environment variables instead of hardcoded values
Hardcoding a database password directly in compose.yaml is fine until you want to commit that file to git, at which point it very much is not. Drop the values in a .env file next to your compose.yaml instead:
# .env
POSTGRES_PASSWORD=changeme
APP_PORT=3000Then reference them in compose.yaml with ${VARIABLE_NAME}:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${APP_PORT}:3000"Add .env to your .gitignore, and now your compose.yaml is safe to commit while the actual secrets stay local. Compose picks up a .env file in the same folder automatically, no extra flag needed.
Healthchecks: telling Docker what “working” means
By default, Docker considers a container “up” the moment the process starts, not when it’s actually ready to serve traffic. For a database, those two moments can be seconds apart, which breaks depends_on ordering more often than people expect. A healthcheck fixes that:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
app:
image: my-app:latest
depends_on:
db:
condition: service_healthyNow “app” genuinely waits for the database to accept connections, not just for the postgres process to start, which is the actual source of most “connection refused” errors on a fresh docker compose up.
When compose up doesn’t behave
- “port is already allocated”: something else on the host (often a previous container you forgot was running) already has that port.
docker psorsudo lsof -i :PORTwill tell you what. - Changes to compose.yaml don’t seem to apply: Compose won’t recreate a container just because the file changed. Run
docker compose up -d --force-recreateto force it. - Volume permissions errors inside the container: usually a UID mismatch between the host folder owner and the user the container runs as. Setting
user: "1000:1000"in the service definition to match your host user fixes most of these.
Related reading
- Running these containers on a Pi? Pi-hole is a great first one to try
- Whatever host runs this stack, make sure SSH on it is locked down


