What Docker Compose Actually Is (and Isn’t)
Docker Compose is a way to define and run multi-container applications using a single YAML file. Instead of typing out long docker run commands with a dozen flags, you describe what you want in a file called docker-compose.yml and run docker compose up -d. That’s it.
From the homelab: Docker Compose is the backbone of my homelab. I run 30+ services across multiple hosts, all managed through Compose files. Once you get this, you will never go back to raw docker run commands.
I manage about 40 containers across six hosts. Every single one is defined in a Compose file. Not because I love YAML (nobody loves YAML), but because Compose files are self-documenting. Six months from now, when I need to rebuild a service, the Compose file tells me exactly what ports, volumes, environment variables, and networks it needs. A docker run command in your bash history doesn’t survive a terminal wipe, a new machine, or a memory lapse.
One thing that confuses people: Docker Compose is no longer a separate tool. It used to be a standalone Python binary called docker-compose (with a hyphen). That version is deprecated. Modern Docker Compose is a plugin built into Docker itself. The command is docker compose (with a space). If you installed Docker following our Ubuntu, Debian, or Raspberry Pi guides, you already have it.

Your First Compose File
Let’s start with the simplest useful example: running Nginx to serve a static website.
Create a directory for the project and add a docker-compose.yml file:
mkdir -p ~/nginx-test && cd ~/nginx-test
Create docker-compose.yml:
services:
web:
image: nginx:latest
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
Create a simple HTML page:
mkdir html
echo "<h1>Hello from Docker Compose</h1>" > html/index.html
Now start it:
docker compose up -d
Open http://your-server-ip:8080 in a browser and you should see your page. That’s a working Compose deployment in four lines of YAML.
Let’s break down what each line does:
- services: – The top-level key. Everything inside defines a container.
- web: – The name of this service. You can call it anything. This becomes the container name (prefixed with the directory name).
- image: nginx:latest – Which Docker image to use.
latestgets the newest version. - ports: “8080:80” – Map port 8080 on the host to port 80 inside the container. Format is always
host:container. - volumes: – Mount the local
./htmldirectory into the container.:romeans read-only. - restart: unless-stopped – Restart the container automatically unless you explicitly stop it.
You no longer need a version: key at the top of your Compose files. It was deprecated in Compose v2. If you see tutorials that start with version: "3.8" or similar, they’re outdated. Modern Compose ignores the version key entirely. Just start with services:.
YAML Syntax: The Bits That Trip People Up
YAML is whitespace-sensitive. Indentation matters, and it must be spaces, not tabs. Two spaces per indent level is the convention. Here are the most common mistakes:
- Tabs instead of spaces: YAML doesn’t allow tabs. Your editor might insert them silently. Use an editor that shows whitespace or configure it for spaces.
- Inconsistent indentation: If
image:is indented two spaces underweb:, thenports:must also be two spaces underweb:. - Missing quotes around port mappings: Write
"8080:80"not8080:80. Without quotes, YAML can interpret the colon-separated values as a sexagesimal number. It’s bizarre, but it’s bitten enough people that Docker’s own documentation recommends quoting ports. - Wrong list syntax: Items in a list start with a dash and a space:
- "8080:80". Forget the space after the dash and YAML won’t parse it.
Volumes: Keeping Your Data
Containers are ephemeral. When you run docker compose down, the container is removed and any data inside it is lost. Volumes solve this by mapping data to persistent storage on the host.
There are two types of volumes you’ll use:
Bind mounts (host path)
volumes:
- ./data:/app/data
- /home/teky/configs/nginx.conf:/etc/nginx/nginx.conf:ro
These map a specific directory (or file) on the host into the container. You can see and edit the files directly on the host. Useful for configuration files and data you want to manage yourself.
Named volumes (Docker-managed)
services:
db:
image: mariadb:latest
volumes:
- db_data:/var/lib/mysql
volumes:
db_data:
Named volumes are managed by Docker and stored under /var/lib/docker/volumes/. They’re better for database data and anything where you don’t need to interact with the files directly. Docker handles permissions and lifecycle.
Never store database data in a bind mount to a directory you might accidentally delete. Named volumes are safer for databases because docker compose down doesn’t remove them. Only docker compose down -v (with the -v flag) deletes volumes, and you have to do that deliberately. I’ve seen people lose a database by deleting the wrong directory. Named volumes make that harder to do accidentally.
Networks: How Containers Talk to Each Other
When you run docker compose up, Compose automatically creates a network for that project. All services in the same Compose file can reach each other by service name. No IP addresses, no manual network configuration.
services:
app:
image: wordpress:latest
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
ports:
- "8080:80"
depends_on:
- db
db:
image: mariadb:latest
environment:
MYSQL_DATABASE: wordpress
MYSQL_ROOT_PASSWORD: changethis
In this example, WordPress connects to MariaDB using the hostname db, which is the service name. Docker’s internal DNS resolves it to the correct container IP. You never need to know what that IP is.
If you need containers from different Compose files to communicate, create an external network:
docker network create shared
Then reference it in each Compose file:
networks:
default:
external: true
name: shared
Environment Variables and .env Files
Hardcoding passwords and configuration values in your Compose file is a bad habit. Use environment variables.
Inline environment variables
services:
db:
image: mariadb:latest
environment:
MYSQL_ROOT_PASSWORD: supersecretpassword
MYSQL_DATABASE: myapp
This works, but the password is in plain text in your Compose file. If you commit this to git, your password is in your repository history permanently.
Using a .env file (recommended)
Create a .env file in the same directory as your Compose file:
# .env
MYSQL_ROOT_PASSWORD=supersecretpassword
MYSQL_DATABASE=myapp
WEB_PORT=8080
Reference variables in your Compose file with ${VARIABLE} syntax:
services:
db:
image: mariadb:latest
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
web:
image: wordpress:latest
ports:
- "${WEB_PORT}:80"
Docker Compose reads the .env file automatically. Add .env to your .gitignore so secrets never get committed. Create a .env.example file with placeholder values so others know which variables are needed.
I keep a .env.example in every service directory with comments explaining each variable. When I rebuild a service on a different host, I copy .env.example to .env and fill in the values. It saves me from having to remember what PUID, PGID, and TZ are supposed to be set to every time.
Essential Compose Options
depends_on
Controls startup order. If your app needs the database to be running first:
services:
app:
depends_on:
- db
db:
image: mariadb:latest
Note: depends_on only waits for the container to start, not for the service inside it to be ready. A database container might start but take a few seconds to initialise. If your app crashes on first launch because the database isn’t ready yet, add a retry mechanism in your app or use a health check.
restart policies
restart: unless-stopped # Restarts on crash, not if manually stopped
restart: always # Always restarts, even after manual stop + reboot
restart: on-failure # Only restarts if the container exits with an error
restart: "no" # Never restarts (default)
For homelab services, unless-stopped is usually what you want. It keeps services running through reboots but respects when you deliberately stop something.
resource limits
services:
app:
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
Useful on hosts with limited resources. Prevents one misbehaving container from consuming all available RAM and taking down everything else. I use this on my Pi Docker hosts where memory is tight.
The Commands You’ll Use Daily
All commands are run from the directory containing your docker-compose.yml file.
# Start everything in the background
docker compose up -d
# Stop and remove containers (keeps volumes and images)
docker compose down
# View logs (follow mode)
docker compose logs -f
# View logs for a specific service
docker compose logs -f web
# Pull latest images
docker compose pull
# Restart after config changes
docker compose down && docker compose up -d
# See what's running
docker compose ps
# Execute a command inside a running container
docker compose exec db mysql -u root -p
The real daily workflow is: edit docker-compose.yml, run docker compose down, optionally docker compose pull to get updated images, then docker compose up -d. That’s how you update, reconfigure, and maintain every service. It becomes muscle memory after a week.
Practical Example 1: WordPress with MariaDB
A complete WordPress stack with persistent data and sensible defaults:
services:
wordpress:
image: wordpress:latest
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: ${WP_DB_USER}
WORDPRESS_DB_PASSWORD: ${WP_DB_PASS}
WORDPRESS_DB_NAME: ${WP_DB_NAME}
volumes:
- wp_data:/var/www/html
restart: unless-stopped
depends_on:
- db
db:
image: mariadb:10.11
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASS}
MYSQL_DATABASE: ${WP_DB_NAME}
MYSQL_USER: ${WP_DB_USER}
MYSQL_PASSWORD: ${WP_DB_PASS}
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
volumes:
wp_data:
db_data:
With the corresponding .env file:
WP_DB_USER=wordpress
WP_DB_PASS=changethispassword
WP_DB_NAME=wordpress
MYSQL_ROOT_PASS=anotherstrongpassword
Practical Example 2: Monitoring Stack
Uptime Kuma for uptime monitoring with Grafana for dashboards. Both persist their data through named volumes:
services:
uptime-kuma:
image: louislam/uptime-kuma:latest
ports:
- "3001:3001"
volumes:
- uptime_data:/app/data
restart: unless-stopped
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_USER}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASS}
volumes:
- grafana_data:/var/lib/grafana
restart: unless-stopped
volumes:
uptime_data:
grafana_data:
For a full walkthrough on Uptime Kuma specifically, see our Uptime Kuma setup guide.
Practical Example 3: Nextcloud with MariaDB and Redis
A three-container stack for a self-hosted cloud storage solution. Redis provides caching for better performance:
services:
nextcloud:
image: nextcloud:latest
ports:
- "8080:80"
environment:
MYSQL_HOST: db
MYSQL_DATABASE: ${NC_DB_NAME}
MYSQL_USER: ${NC_DB_USER}
MYSQL_PASSWORD: ${NC_DB_PASS}
REDIS_HOST: redis
volumes:
- nc_data:/var/www/html
restart: unless-stopped
depends_on:
- db
- redis
db:
image: mariadb:10.11
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASS}
MYSQL_DATABASE: ${NC_DB_NAME}
MYSQL_USER: ${NC_DB_USER}
MYSQL_PASSWORD: ${NC_DB_PASS}
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
redis:
image: redis:alpine
restart: unless-stopped
volumes:
nc_data:
db_data:
Notice how Nextcloud references db and redis by their service names. Docker’s internal DNS handles the resolution. No IP addresses anywhere.
Common Mistakes
Using latest for databases
Pin your database image versions. mariadb:latest might jump a major version on your next docker compose pull, and major version upgrades on databases can break things or require migration steps. Use mariadb:10.11 or postgres:16 and upgrade deliberately.
Not using .env files
Passwords in your Compose file will end up in git, in backups, and on any machine you copy the file to. Use .env files and add them to .gitignore.
Forgetting to set restart policies
Without a restart policy, your containers won’t start after a host reboot. Every service you want to survive a reboot needs restart: unless-stopped or restart: always.
Port conflicts
Two containers can’t bind to the same host port. If you get “address already in use”, change the host port (the left number in the port mapping). The container port (right number) stays the same.
Docker Compose appears in nearly every DevOps job description I see. It’s the standard way to define development environments, CI/CD pipelines, and even lightweight production deployments. Being able to read, write, and debug Compose files fluently is a baseline skill for infrastructure roles. The good news is that once you understand the examples in this guide, you understand 90% of what Compose does. The remaining 10% is edge cases you’ll pick up as you need them.
Key Takeaways
- Docker Compose is now a built-in plugin, not a separate tool. Use
docker compose(space) notdocker-compose(hyphen) - You no longer need a
version:key at the top of Compose files. Start withservices: - Use named volumes for database data and bind mounts for config files you want to edit directly
- Containers in the same Compose file reach each other by service name. No IP addresses needed
- Always use
.envfiles for secrets and add.envto.gitignore - Pin database image versions. Use
latestfor applications but never for databases - Set
restart: unless-stoppedon every service you want to survive a host reboot - The daily workflow is: edit the file,
docker compose down,docker compose pull,docker compose up -d
Related Guides
If you found this useful, these guides continue the journey:
- Essential Docker Commands You Need to Know — The core Docker commands every homelab operator should know by heart
- How to Install Docker on Ubuntu 24.04 — Step-by-step Docker installation on Ubuntu 24.04 LTS
- Nextcloud Docker Setup Guide — Deploy your own cloud storage with Nextcloud in Docker
- Grafana and Prometheus Homelab Monitoring — Set up proper monitoring for your homelab with Grafana and Prometheus
- Pi-hole Docker Setup Guide — Block ads network-wide with Pi-hole running in Docker
- How to Build Your First Homelab in 2026 — Everything you need to plan and build a homelab from scratch

ReadTheManual is run, written and curated by Eric Lonsdale.
Eric has over 20 years of professional experience in IT infrastructure, cloud architecture, and cybersecurity, but started with PCs long before that.
He built his first machine from parts bought off tables at the local college campus, hoping they worked. He learned on BBC Micros and Atari units in the early 90s, and has built almost every PC he’s used between 1995 and now.
From helpdesk to infrastructure architect, Eric has worked across enterprise datacentres, Azure environments, and security operations. He’s managed teams, trained engineers, and spent two decades solving the problems this site teaches you to solve.
ReadTheManual exists because Eric believes the best way to learn IT is to build things, break things, and actually read the manual. Every guide on this site runs on infrastructure he owns and maintains.
Enjoyed this guide?
New articles on Linux, homelab, cloud, and automation every 2 days. No spam, unsubscribe anytime.

