DevBox

Docker Compose Guide: Ports, Volumes, Env Vars and depends_on

DevBox Team · Last updated 2026-07-10

Want to generate directly? Try Docker Compose Template Generator — fill in the form and get results instantly. Open tool →

A minimal compose file

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./site:/usr/share/nginx/html:ro
    restart: unless-stopped

Save it as docker-compose.yml, then:

docker compose up -d      # start in the background
docker compose ps         # check status
docker compose logs -f    # follow logs
docker compose down       # stop and remove containers

Two things changed in recent years: the top-level version: field is gone (Compose V2 ignores it and the docs mark it obsolete), and docker-compose became the docker compose subcommand.

ports: do not reverse the direction

ports:
  - "8080:80"               # host 8080 → container 80
  - "127.0.0.1:5432:5432"   # bound to loopback only, unreachable from outside
  - "80"                    # random host port

The order is always host:container, outside to inside.

A security note: - "5432:5432" exposes your database on every network interface of the server. Combined with a weak password, that is an internet-reachable database. If the port is only used by other services in the same compose network, do not declare ports at all — containers on one network reach each other by service name:

services:
  api:
    image: myapi
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/app   # service name "db"
  db:
    image: postgres:16
    # no ports, so nothing outside can reach it

Only when you genuinely need to connect from the host for debugging should you write "127.0.0.1:5432:5432", pinning it to loopback.

volumes: named volume or bind mount

The two forms look alike and behave completely differently:

volumes:
  - ./site:/usr/share/nginx/html    # bind mount: a directory on your host
  - pgdata:/var/lib/postgresql/data # named volume: storage Docker manages

volumes:
  pgdata:      # named volumes must be declared at the top level
Bind mount ./x:/yNamed volume name:/y
Where data livesA host directory you chooseAn area Docker manages
Can you edit it directlyYes, changes appear instantlyNot conveniently
Cross-platform behaviorSlow I/O on Windows and macOSConsistent and fast
Good forSource code, config filesDatabase data

Rule of thumb: bind-mount code and config, use named volumes for database data. Database files on a macOS bind mount are noticeably slower and invite file-permission trouble.

Append :ro for read-only. Config files should almost always have it, so a container cannot corrupt them:

- ./nginx.conf:/etc/nginx/nginx.conf:ro

Environment variables: three distinct things

This is where Compose confuses people most, because three separate mechanisms share a name.

1. environment — injected straight into the container

environment:
  NODE_ENV: production
  DATABASE_URL: postgres://user:pass@db:5432/app
  API_KEY:              # no value = pass through from the current shell

2. env_file — a file of variables injected into the container

env_file:
  - .env.production

3. .env — interpolation for the compose file itself

A .env next to your compose file substitutes ${VAR} placeholders inside the compose file. It does not end up inside containers:

# .env contains: TAG=1.2.3
services:
  api:
    image: myapi:${TAG}     # becomes myapi:1.2.3

Precedence: values under environment beat values from env_file. .env is on a different axis altogether — it governs how the compose file is rendered, not what the container sees.

Keep secrets out of git. Put .env in .gitignore and commit a .env.example documenting which variables are needed.

depends_on does not wait for readiness

The single most common beginner trap:

services:
  api:
    depends_on:
      - db        # only guarantees the db container was started
  db:
    image: postgres:16

depends_on controls start order and nothing else. It does not care whether the program inside is ready. Postgres needs a few more seconds before it accepts connections, by which time api has already tried and failed with Connection refused.

Give the dependency a healthcheck and use the long form to wait for it:

services:
  api:
    image: myapi
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s

start_period is a grace window: failures during it do not count toward retries, which is exactly what slow-starting services need.

Even so, your application should still retry its connections. Orchestration can order startup; it cannot stop a database from restarting later while everything is running.

restart: four policies

restart: unless-stopped
ValueBehavior
noDefault. Never restarted.
on-failureRestarted only on a non-zero exit code.
alwaysAlways restarted, even after you stopped it manually and the daemon restarts.
unless-stoppedAlways restarted, except containers you stopped manually.

Use unless-stopped in production. Its only difference from always shows up after you run docker compose stop on a service: when the machine or Docker daemon restarts, always brings it back anyway while unless-stopped respects your decision. For run-once tasks like migrations or backups, use no or on-failure.

Next steps

FAQ

Do I still need the version field at the top of docker-compose.yml?

No. Compose V2 ignores the top-level version field and the official docs mark it obsolete; recent versions may even warn about it. Start straight from services. The command changed too, from docker-compose with a hyphen to docker compose as a subcommand.

In ports, which side is the host?

The first number is the host and the second is the container, always in host:container order. So 8080:80 forwards host port 8080 to container port 80. Giving a single number instead assigns a random host port. Mnemonic: outside to inside, outside goes first.

Why did depends_on not wait for my database?

Because plain depends_on only guarantees the container was started, never that the program inside is ready to serve. A Postgres container takes a few more seconds before it accepts connections. To wait for real readiness, add a healthcheck to the dependency and use the long form of depends_on with condition set to service_healthy.

If environment and env_file both set a variable, which wins?

Values written directly under environment take precedence and override the same key from env_file. The .env file is a separate mechanism entirely: it only interpolates ${VAR} placeholders inside the compose file itself and is not automatically injected into containers.

More about Docker Compose