Docker Compose for Beginners: A Practical Guide

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.

Terminal showing docker compose up command deploying a multi-container stack

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. latest gets 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 ./html directory into the container. :ro means 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 under web:, then ports: must also be two spaces under web:.
  • Missing quotes around port mappings: Write "8080:80" not 8080: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.

Practitioner tip: A gotcha I hit early on: if you are using bind mounts and your host user ID does not match the container’s expected UID, you will get silent permission failures. Check your volume permissions if something is writing empty files.

Key Takeaways

  • Docker Compose is now a built-in plugin, not a separate tool. Use docker compose (space) not docker-compose (hyphen)
  • You no longer need a version: key at the top of Compose files. Start with services:
  • 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 .env files for secrets and add .env to .gitignore
  • Pin database image versions. Use latest for applications but never for databases
  • Set restart: unless-stopped on 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:

The RTM Essential Stack - Gear I Actually Use

Enjoyed this guide?

New articles on Linux, homelab, cloud, and automation every 2 days. No spam, unsubscribe anytime.

Scroll to Top