Docker Compose guide for beginners: from zero to all your services in one file
What Docker Compose is, how to read a docker-compose.yml, everyday commands, volumes, ports and variables, with real examples from my home server.
Every service on my Raspberry Pi lives in one file: docker-compose.yml. With it I start, stop, update or rebuild everything in seconds. If you’re getting started with Docker, Compose is the first thing you should learn after docker run.
What problem it solves
Without Compose, each container starts with a very long command:
docker run -d --name gitea --restart unless-stopped \
-p 3000:3000 -p 2222:22 -v ./gitea:/data \
-e USER_UID=1000 -e USER_GID=1000 docker.gitea.com/gitea:1.25
With five services, you end up forgetting which options you used. Compose keeps all that in a file that’s easy to read and can be versioned with Git.
Anatomy of a docker-compose.yml
services:
gitea: # service name
image: docker.gitea.com/gitea:1.25 # image and version
container_name: gitea # fixed container name
restart: unless-stopped # start automatically after reboot
environment: # configuration variables
USER_UID: "1000"
USER_GID: "1000"
ports: # host:container
- "3000:3000"
- "2222:22"
volumes: # where data is stored
- ./gitea:/data
uptime-kuma:
image: louislam/uptime-kuma:2
container_name: uptime-kuma
restart: unless-stopped
ports: ["3001:3001"]
volumes: [./uptime-kuma:/app/data]
It’s YAML: indentation (spaces, never tabs) matters. Let’s take it piece by piece.
image
The image and its tag (version). My advice: pin the version (:1.25, :2) instead of :latest. With latest, one day you pull and get a major version without warning.
restart
unless-stopped: starts automatically when the machine boots, unless you stopped it. It’s what I use almost always.always: always starts, even if you stopped it.no: never automatically (useful for one-off tasks).
ports
Format host:container. "8080:80" means: “whatever arrives on port 8080 of my machine, send it to port 80 inside the container”.
And a security trick: put an IP in front and it only listens on that interface:
ports: ["127.0.0.1:8222:80"] # only reachable from the machine itself
That’s how my password manager is set up: nobody on the network can reach it directly.
volumes
The most important part. A container is disposable: delete it and everything inside is lost. Volumes keep the data outside:
./gitea:/data→ thegiteafolder next to the compose file is mounted at/datainside. This kind is the easiest to back up: backing up means copying the folder.data:/data(no./) → a named volume managed by Docker. Cleaner, but you need to know where it lives to back it up./path:/path:ro→:romounts it read-only.
environment
Variables the program reads to configure itself. Each image documents its own.
For passwords and secrets, don’t write them in the compose file (they’ll end up in Git). Use an .env file next to it that you don’t commit:
environment:
DB_PASSWORD: ${DB_PASSWORD}
# .env (not committed to Git)
DB_PASSWORD=something-long-and-secret
Everyday commands
Always from the folder containing docker-compose.yml:
| Command | What it does |
|---|---|
docker compose up -d |
Create and start everything in the background |
docker compose up -d gitea |
Just that service |
docker compose ps |
What’s running |
docker compose logs -f gitea |
Follow the logs live (Ctrl+C to exit) |
docker compose stop |
Stop without deleting |
docker compose down |
Stop and delete the containers (volume data stays) |
docker compose pull |
Download new image versions |
docker compose exec gitea sh |
Open a shell inside the container |
Updating a service
docker compose pull uptime-kuma
docker compose up -d uptime-kuma
up -d notices the image changed and recreates the container with the new one. The data, in its volume, is still there. Afterwards, to free space from old images:
docker image prune
Common errors
- “port is already allocated”: another program is using that port. Find it with
sudo ss -ltnp | grep :PORTand change the left-hand port. - Permissions on the data folder: many containers run as a specific user (hence the
PUID/PGIDorUSER_UIDvariables). If it can’t write, check who owns the folder. - Tabs in the YAML: syntax error. Spaces only.
- Forgetting the volume: everything works… until you recreate the container and the data vanishes. Always check where the image stores its data.
How I organise it
~/servicios/
├── docker-compose.yml
├── .env
├── gitea/
├── uptime-kuma/
├── homepage/
└── vaultwarden/
One file, one folder per service. Backing up means copying ~/servicios, and rebuilding the server on another machine means copying the folder and running docker compose up -d.
Did it stick?
Three quick questions. Each right answer is worth 10 XP.