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 yourdeployuser. - Port 8080 free on the host. Check with
ss -tulpn | grep 8080.
Confirm the plugin is present:
docker compose version
docker --versionIf 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:
mkdir -p /opt/demo/site
cd /opt/demo
echo "<h1>Hello from Compose</h1>" > site/index.html
nano docker-compose.ymlPaste this:
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: bridgeValidate it before starting anything:
docker compose configCompose 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 examplenginx:alpine.build— build from a localDockerfileinstead, usingbuild:.orbuild:./app.container_name— a fixed name instead of the generateddemo-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. Useenv_file:.envto keep secrets out of the YAML.command— override the image's default command, for examplecommand: 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:
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 80Bind 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:rosuffix 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.
docker compose exec web getent hosts dbThat 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:
depends_on:
db:
condition: service_healthyCompose 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:
docker compose up -dUseful commands while it runs:
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 networksAfter 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:
curl -I http://203.0.113.10:8080You 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:
docker compose exec db psql -U deploy -d appdb -c "select version();"
docker compose psBoth services should show running, and db should show healthy. Finally, prove persistence:
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 withss -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 readscompose.yaml,compose.yml,docker-compose.yamlordocker-compose.yml.could not translate host name "db"— the service is not on the same network, or the hostname is misspelled. Check thenetworkslist on both services and rundocker compose configto 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.webstarts before the database is ready —depends_onalone does not wait for readiness. Add ahealthcheckto the database and usecondition: service_healthy.
Next steps
- Docker run flags you will actually use (-d, -p, -v, -e, --restart) — the single-container equivalents of the keys above.
- Docker basics: containers, images and registries explained — what happens between
image:and a running process. - Docker persistent storage for non-root users on a Linux VPS — permissions when your containers run as an unprivileged user.
- Docker networks: bridge, host and custom networks explained — split your stack into frontend and backend networks.
