Docker Compose Basics: From docker run to Declarative Stacks

Docker Compose Basics: From docker run to Declarative Stacks

Every self-hosted app README eventually shows you a docker run command spanning six lines of -p, -v, and -e flags. It works exactly once, and then you need to change a port and cannot remember what you originally ran. Docker Compose solves that by moving the whole description into a file, and it is the standard way self-hosted stacks are deployed today.

From docker run to a compose file

Take a typical run command:

docker run -d --name nextcloud \
  -p 8080:80 \
  -v nextcloud_data:/var/www/html \
  -e MYSQL_HOST=db \
  --restart unless-stopped \
  nextcloud:latest

The same thing as compose.yaml:

services:
  nextcloud:
    image: nextcloud:latest
    ports:
      - "8080:80"
    volumes:
      - nextcloud_data:/var/www/html
    environment:
      - MYSQL_HOST=db
    restart: unless-stopped

volumes:
  nextcloud_data:

Every docker run flag has a YAML equivalent in the same vocabulary: ports is -p, volumes is -v, environment is -e. Nothing new to learn conceptually; the knowledge transfers directly.

Two housekeeping notes: the modern command is docker compose (a Docker plugin) rather than the legacy standalone docker-compose, and the version: line you see atop older files is obsolete; the current spec ignores it, so start with services:.

Multi-service stacks: where compose earns its keep

The real payoff arrives with the second container. Apps need databases, and compose describes both plus their relationship:

services:
  nextcloud:
    image: nextcloud:latest
    ports:
      - "8080:80"
    volumes:
      - nextcloud_data:/var/www/html
    environment:
      - MYSQL_HOST=db
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_PASSWORD=change-me
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: mariadb:11
    volumes:
      - db_data:/var/lib/mysql
    environment:
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_PASSWORD=change-me
      - MYSQL_ROOT_PASSWORD=also-change-me
    restart: unless-stopped

volumes:
  nextcloud_data:
  db_data:

Notice what is absent: no IP addresses. Compose creates a private network for the stack where each service resolves by name, so MYSQL_HOST=db just works. Notice also that the database publishes no ports; it is reachable from the nextcloud container over the internal network but not from your LAN, which is exactly the exposure you want.

The command loop

docker compose up -d        # create/start everything (detached)
docker compose ps           # status of this stack
docker compose logs -f app  # follow one service's logs
docker compose pull         # fetch newer images
docker compose up -d        # recreate only what changed
docker compose down         # stop and remove containers + network

The pull then up -d pair is the standard update procedure: compose diffs desired state against running state and recreates only containers whose image or configuration changed. Named volumes survive down; adding -v deletes them, which is the difference between redeploying an app and erasing its data. Treat down -v with the same respect as rm -rf.

Bind mounts vs named volumes

Both appear under volumes: and behave differently. ./config:/config (a path) is a bind mount into a host directory you can browse and back up directly; db_data:/var/lib/mysql (a name) is a Docker-managed volume, better for database internals you never touch by hand. The homelab convention that has emerged: bind mounts for configuration you edit, named volumes for application data, and either way the compose file plus those directories IS your backup surface.

Environment files and secrets

Inlining passwords into compose.yaml works until the file lands in a git repo. The .env file convention keeps them out:

    environment:
      - MYSQL_PASSWORD=${DB_PASSWORD}
# .env (same directory, gitignored)
DB_PASSWORD=actually-secret

Compose substitutes ${DB_PASSWORD} at up time. Commit the compose file, gitignore .env, and your stack definition becomes shareable without sharing credentials.

Compose and Podman

Podman ships podman compose supporting the same file format, and most compose files run unchanged. Rootless Podman adds wrinkles around low ports and volume ownership, but the file you learn to write for Docker is the file you will use there too, which makes compose YAML the closest thing self-hosting has to a lingua franca: nearly every app page in our self-hosted directory ultimately deploys this way.

Frequently Asked Questions

What is Docker Compose?

Compose is a tool that reads a YAML file describing one or more containers, their volumes, networks, ports, and environment, and creates all of it with a single command. It replaces long docker run invocations with a declarative, version-controllable file.

Is docker-compose different from docker compose?

docker-compose with a hyphen is the old standalone Python tool. docker compose with a space is the current plugin built into Docker. They read the same files; the plugin is what you should use today.

Does my compose file need a version key?

No. The top-level version field is obsolete and ignored by the current spec. Start the file directly with services.

How do containers in a compose file talk to each other?

Compose puts all services in the file on a shared private network where each service is reachable by its service name as a hostname. A web service connects to db:5432, no IP addresses needed.

What is the difference between docker compose down and stop?

stop halts containers but leaves them and their network in place. down stops and removes containers and the network. Named volumes survive down unless you add -v, which deletes them and their data.

How do I update a service to a newer image?

Run docker compose pull followed by docker compose up -d. Compose recreates only the containers whose image changed, keeping volumes and data intact.