Docker & DevOps

Docker Compose file explained: services, volumes, networks and ports

A line-by-line walk through docker-compose.yml: what services, volumes, networks, ports and depends_on do, with a working example stack you can run on a Linux VPS.

BeginnerLinux server7 min read

A Docker Compose file describes a whole stack — containers, storage, networking and startup order — in one YAML document. This guide explains each top-level key in docker-compose.yml and builds a working two-service example you can run on a Linux VPS.

Compose reads the file, creates the networks and volumes it needs, then starts your services in the order you define. Once you understand the four blocks — services, ports, volumes and networks — most Compose files stop looking like magic.

Before you start

You need:

  • A Linux VPS running Ubuntu 22.04/24.04, Debian 12, AlmaLinux or Rocky 8–9.
  • Docker Engine and the Compose plugin installed. If not, follow Install Docker and Docker Compose on a Linux VPS.
  • A working directory, for example /opt/demo, owned by your deploy user.
  • Port 8080 free on the host. Check with ss -tulpn | grep 8080.

Confirm the plugin is present:

Bash
docker compose version
docker --version

If docker compose (with a space) fails but docker-compose works, you have the old standalone binary. The examples here use the plugin syntax.

What a Compose file is

A Compose file has three top-level keys that matter:

  • services — one entry per container you want to run.
  • volumes — named storage that survives container restarts and removal.
  • networks — the virtual networks your services attach to.

You may also see version: "3.8" at the top of older files. Compose v2 ignores it and prints a warning, so leave it out of new files.

The example docker-compose.yml

Create the file and a folder for static content:

Bash
mkdir -p /opt/demo/site
cd /opt/demo
echo "<h1>Hello from Compose</h1>" > site/index.html
nano docker-compose.yml

Paste this:

YAML
services:
  web:
    image: nginx:alpine
    container_name: demo-web
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./site:/usr/share/nginx/html:ro
    networks:
      - frontend
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: demo-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: deploy
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: appdb
    volumes:
      - db_data:/var/lib/postgresql/data
    networks:
      - frontend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U deploy -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:

networks:
  frontend:
    driver: bridge

Validate it before starting anything:

Bash
docker compose config

Compose prints the fully resolved file. If YAML is malformed, you get the error here rather than halfway through a deploy.

Services: the core of the file

Every key under services is a container. The name you choose (web, db) becomes the DNS hostname other services use to reach it.

Common keys:

  • image — pull a prebuilt image, for example nginx:alpine.
  • build — build from a local Dockerfile instead, using build:. or build:./app.
  • container_name — a fixed name instead of the generated demo-web-1. Handy for logs, but it blocks scaling with --scale.
  • restart: unless-stopped — bring the container back after a reboot or crash, unless you stopped it yourself.
  • environment — variables passed into the container. Use env_file:.env to keep secrets out of the YAML.
  • command — override the image's default command, for example command: nginx -g "daemon off;".
  • depends_on — startup ordering, covered below.

Warning: POSTGRES_PASSWORD: change-me in the example is a placeholder. Set a real password before exposing the database to anything, and keep the file readable only by root — chmod 600 docker-compose.yml.

Ports: publishing container ports

The ports list maps a host port to a container port as "HOST:CONTAINER". In the example, "8080:80" means traffic to port 8080 on the VPS reaches port 80 inside the web container.

Three forms you will use:

YAML
ports:
  - "8080:80"            # host 8080 -> container 80, all interfaces
  - "127.0.0.1:5432:5432" # only reachable from the VPS itself
  - "80"                 # random host port, container port 80

Bind database and admin ports to 127.0.0.1 unless you genuinely need remote access. A published port is open to the internet as soon as the container starts, regardless of your application's own auth. If you later tighten the host firewall, see Check which ports are open and listening on Linux.

Volumes: keeping data after the container stops

Containers are disposable; volumes are not. The example uses both kinds:

  • ./site:/usr/share/nginx/html:ro — a bind mount. A path on the host is mounted into the container. The :ro suffix makes it read-only inside the container.
  • db_data:/var/lib/postgresql/data — a named volume. Docker manages the storage location, and the volume persists until you delete it.

Named volumes must also be declared in the top-level volumes block. An empty value (db_data:) is enough; add options like driver only when you need them.

Warning: docker compose down -v deletes named volumes and everything in them. Use plain docker compose down when you only want to stop and remove containers.

For a deeper comparison, see Docker volumes vs bind mounts: store container data safely.

Networks: how services find each other

Compose creates a default network per project, but declaring your own is clearer and lets you split tiers later. Here both services join frontend, so web can reach the database at the hostname db on port 5432.

Bash
docker compose exec web getent hosts db

That should print the container's IP. You never hardcode that address — it changes on every recreate, which is why the service name is the stable reference.

To isolate a database from a public-facing service, define two networks and attach each service only to the ones it needs. The mechanics are covered in Docker networks: bridge, host and custom networks explained.

depends_on and startup order

depends_on controls order, not readiness. The short form starts db first, but web may still launch before Postgres accepts connections.

The long form used in the example waits for the healthcheck:

YAML
depends_on:
  db:
    condition: service_healthy

Compose holds web until pg_isready succeeds inside db. If you omit the healthcheck, condition: service_started is all you get — and your app needs its own retry logic.

Run and manage the stack

Start everything in the background:

Bash
docker compose up -d

Useful commands while it runs:

Bash
docker compose ps                 # status of each service
docker compose logs -f web        # follow logs for one service
docker compose exec db psql -U deploy -d appdb   # shell inside a container
docker compose restart web        # restart one service
docker compose down               # stop and remove containers and networks

After editing the YAML, apply the change with docker compose up -d again. Compose recreates only the services whose configuration changed.

Check it works

From your workstation or the VPS itself:

Bash
curl -I http://203.0.113.10:8080

You should get HTTP/1.1 200 OK and the "Hello from Compose" page in a browser. Then confirm the database is reachable only from inside the network:

Bash
docker compose exec db psql -U deploy -d appdb -c "select version();"
docker compose ps

Both services should show running, and db should show healthy. Finally, prove persistence:

Bash
docker compose down
docker compose up -d
docker compose exec db psql -U deploy -d appdb -c "\dt"

The volume survived the recreate, so any tables you created are still there.

Troubleshooting

  • Bind for 0.0.0.0:8080 failed: port is already allocated — something else on the host holds port 8080. Find it with ss -tulpn | grep 8080, then either stop that process or change the host side of the mapping.
  • no configuration file provided: not found — you are in the wrong directory, or the file is named something Compose does not look for. It reads compose.yaml, compose.yml, docker-compose.yaml or docker-compose.yml.
  • could not translate host name "db" — the service is not on the same network, or the hostname is misspelled. Check the networks list on both services and run docker compose config to see what was resolved.
  • mapping values are not allowed in this context — YAML does not allow tabs. Re-indent with spaces only; two spaces per level is the common convention.
  • web starts before the database is ready — depends_on alone does not wait for readiness. Add a healthcheck to the database and use condition: service_healthy.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.