Docker · 45 min

Orchestrate Services with Docker Compose

Stop juggling containers by hand. Declare a whole multi-service stack — a web app plus a PostgreSQL database, with a private network and a named volume — in a single docker-compose.yml, then bring it up, operate it and tear it down with the modern `docker compose` v2 commands.

Problem

A real application is never a single container. You have a web app, a database, maybe a cache and a background worker — and they all have to start in the right order, find each other on a network, and keep their data across restarts. Wiring that by hand with `docker run` is painful and error-prone: you repeat long flag strings, invent a network with `docker network create`, remember which container must start first, and reconstruct it all from memory every single time. **Docker Compose** replaces that fragility with one declarative file. A `docker-compose.yml` describes every service, network and volume of your stack. One command — `docker compose up` — creates all of it in the correct order; one command — `docker compose down` — cleans it up. The definition lives in your repo, versioned alongside the code, so a teammate reproduces your entire environment with a single command. In this lab you'll write a real `docker-compose.yml` for a two-service stack (a web server and a PostgreSQL database), bring it up, watch Compose build the network and volumes for you, operate the full lifecycle, and prove the two services talk to each other over Compose's internal DNS — using the modern `docker compose` (v2) syntax throughout.

Objectives

Prerequisites

What you will build

You'll write one file — docker-compose.yml — that declares a small but complete stack: a
web service (nginx) and a db service (PostgreSQL), connected on a private network, with a
named volume so the database keeps its data across restarts. Then you'll drive the whole thing
with a handful of docker compose commands: bring it up in the background, inspect it, read its
logs, open a psql shell in the database, prove the web container can resolve db by name, and
finally tear it down cleanly.

Why Compose exists

In the earlier labs you ran single containers with docker run. That's fine for one thing, but a
real app is several things at once. Compare the two approaches for the exact same stack:

By hand With Compose
docker network create appnet networks: block (auto-created)
docker volume create dbdata volumes: block (auto-created)
one long docker run … per service, in the right order, from memory docker compose up -d
tear down each container, network and volume one by one docker compose down

Compose turns a fragile sequence of imperative commands into a single declarative file you
commit to your repo. You describe the desired end state; Compose figures out how to get there.

The docker compose (v2) command — mind the space

Modern Compose is a plugin built into the Docker CLI, invoked as docker compose (two words,
a space). The old standalone docker-compose (with a hyphen) is v1 and is deprecated. Everything
in this lab uses the v2 form.

One more thing you'll see in old tutorials: a version: "3.8" line at the top of the file. In
Compose v2 that top-level version key is obsolete and ignored — you can (and should) simply
omit it. If you see it in an example, it's a v1-era leftover, not something you need.

The Compose file at a glance

Every Compose file is a map with a few top-level keys:

Inside a service you'll use keys like image, build, ports, environment, env_file,
volumes, depends_on and networks. You'll meet each of them by writing them, not just reading
about them.

How to work through this lab

Do the steps in order — each builds on the file from the last. Run every command yourself and read
its real output. You'll collect the outputs you need for the submission in Step 6.

Steps

  1. The problem Compose solves and the file's anatomy

    Before writing anything, get clear on why Compose exists and what a Compose file is made of.

    Confirm you have Compose v2

    docker compose version
    

    You should see a Docker Compose version v2.x.y line. If instead docker compose is "not a
    docker command," you're on an old setup — install/enable the Compose v2 plugin (it's bundled
    with current Docker Desktop). Note the space in docker compose: that's the modern v2 CLI. The
    hyphenated docker-compose is the deprecated v1 standalone.

    Why not just docker run everything?

    Imagine standing up a web app plus a database by hand. You'd have to:

    1. docker network create appnet so they can talk,
    2. docker volume create dbdata so the database survives restarts,
    3. docker run the database with the network, the volume and a pile of -e env flags,
    4. docker run the web app with the same network — after the database exists,
    5. and later stop and remove each container, the network and the volume, one by one.

    That's a lot of imperative steps to remember and re-type, and nothing records the intent. Miss
    one flag and you've got a subtly broken environment.

    What Compose gives you instead

    A single docker-compose.yml declares the whole stack. Its top-level shape is:

    services:      # the containers of your app
      web:
        image: nginx:1.27-alpine
        ports:
          - "8080:80"
        depends_on:
          - db
      db:
        image: postgres:16-alpine
        environment:
          POSTGRES_PASSWORD: secret
    
    networks:      # private networks (optional; a default is created)
      # ...
    
    volumes:       # named volumes for persistent data
      # ...
    

    The keys you'll use most inside a service:

    Key What it does
    image the image to run (from Docker Hub or a registry)
    build build an image from a local Dockerfile instead of pulling one
    ports publish container ports to the host, "HOST:CONTAINER"
    environment set environment variables inside the container
    env_file load variables from an external file (e.g. .env)
    volumes mount a named volume or a bind mount for persistent/shared data
    depends_on start this service only after the ones it lists
    networks which private network(s) the service joins

    Checkpoint

    • What three things can a Compose file declare at the top level? (services, networks, volumes.)
    • Which command replaces "create network + create volume + several docker runs in order"? (docker compose up.)
    • Is the top-level version: key required in Compose v2? (No — it's obsolete and ignored; omit it.)
    • What's the difference between docker compose and docker-compose? (The former is v2, a CLI plugin; the latter is the deprecated v1 standalone.)
  2. Write a real docker-compose.yml: web + db

    Now write the file. Create a fresh folder and a docker-compose.yml inside it.

    mkdir compose-lab
    cd compose-lab
    

    The file

    Put this in docker-compose.yml. Read the comments — every block earns its place.

    # No top-level `version:` — it's obsolete in Compose v2 and safely omitted.
    
    services:
      web:                          # first service: a web server
        image: nginx:1.27-alpine    # pinned tag, small Alpine base
        ports:
          - "8080:80"               # HOST:CONTAINER — reach nginx at http://localhost:8080
        depends_on:
          - db                      # start db before web
        networks:
          - appnet                  # join the private network
    
      db:                           # second service: PostgreSQL
        image: postgres:16-alpine
        environment:
          POSTGRES_PASSWORD: secret # required by the postgres image
          POSTGRES_USER: appuser
          POSTGRES_DB: appdb
        volumes:
          - dbdata:/var/lib/postgresql/data  # named volume -> data survives restarts
        networks:
          - appnet
    
    networks:
      appnet:                       # a private bridge network for the stack
        driver: bridge
    
    volumes:
      dbdata:                       # named volume, managed by Docker
    

    Read it block by block

    • services: holds two services, web and db. Each name becomes the container's
      hostname on the network — that matters in Step 4.
    • web pulls nginx:1.27-alpine, publishes 8080:80, and depends_on: [db] so Compose
      starts db first. It joins appnet.
    • db pulls postgres:16-alpine. The postgres image requires POSTGRES_PASSWORD;
      POSTGRES_USER and POSTGRES_DB are optional and seed a user and database on first boot.
    • dbdata:/var/lib/postgresql/data mounts a named volume at Postgres's data directory, so
      the database's files live in a Docker-managed volume, not the container's throwaway layer.
    • networks: appnet defines an isolated bridge network. Both services join it, so they can
      reach each other — and nothing outside the stack can.
    • volumes: dbdata declares the named volume Compose will create.

    Note on depends_on: it controls start order, not readiness. Compose starts db before
    web, but doesn't wait for Postgres to finish booting. For true "wait until the DB accepts
    connections," you'd add a healthcheck on db and depends_on: { db: { condition: service_healthy } }. That's beyond this lab, but good to know the distinction.

    Checkpoint

    • Why does db need a named volume and web doesn't? (The database has state to keep; nginx serving a default page doesn't.)
    • What does depends_on: [db] guarantee — and what does it not? (Start order, not that Postgres is ready to accept connections.)
  3. Bring the stack up with docker compose up -d

    With the file written, bringing the whole stack to life is a single command.

    Up, in the background

    From inside compose-lab (the folder holding docker-compose.yml):

    docker compose up -d
    
    • up reads docker-compose.yml and creates everything it declares.
    • -d (detached) runs it in the background and returns your prompt.

    Watch the output. Compose does a lot for you, in order:

    1. Creates the network compose-lab_appnet (Compose prefixes names with the project, which
      defaults to the folder name).
    2. Creates the volume compose-lab_dbdata.
    3. Pulls postgres:16-alpine and nginx:1.27-alpine if you don't already have them.
    4. Starts db first, then web, honoring depends_on.

    That ordering — network and volumes before containers, dependencies before dependents — is
    exactly the fragile hand-sequence from Step 1, now done for you every time.

    See what's running

    docker compose ps
    

    You'll see both services — web and db — with their state (running) and web's port
    mapping (0.0.0.0:8080->80/tcp). Unlike plain docker ps, docker compose ps is scoped to
    this project, so you see only your stack.

    Open http://localhost:8080 — the "Welcome to nginx!" page confirms web is up.

    Confirm the resources Compose created

    docker network ls | grep appnet     # compose-lab_appnet exists
    docker volume ls  | grep dbdata     # compose-lab_dbdata exists
    

    (On PowerShell, use docker network ls | Select-String appnet.)

    These are the network and volume you never had to create by hand — Compose made them from your
    file, and docker compose down will know how to remove them.

    Checkpoint

    • In what order did Compose start the two containers, and why? (db before web, because of depends_on.)
    • Where did the network compose-lab_appnet come from? (Compose created it from the networks: block; the prefix is the project name.)

    Deliverable for this step: the docker compose ps output showing both services up.

  4. Operate the lifecycle and prove web talks to db

    A running stack isn't a black box. These commands are how you observe it, get inside it, and
    prove the services actually reach each other.

    Follow the logs

    docker compose logs aggregates the output of every service, each line prefixed with the
    service name:

    docker compose logs
    

    You'll see db lines (Postgres reporting "database system is ready to accept connections") and
    web lines together. To follow live, add -f:

    docker compose logs -f
    

    Ctrl+C stops following — it doesn't stop the containers. To watch just one service:

    docker compose logs -f db
    

    Open a psql shell in the database

    docker compose exec runs a command inside a running service (by service name, not
    container id):

    docker compose exec db psql -U appuser -d appdb
    

    That opens the PostgreSQL client inside the db container, connected to the appdb database
    as appuser. Try a query, then quit:

    SELECT version();
    \q
    

    You just talked to the database with no Postgres client installed on your host — it's all
    inside the container.

    Prove web reaches db by service name

    This is the heart of Compose networking. On the appnet network, each service is reachable
    by its service name
    as a DNS hostname. So from inside web, the database is simply db.

    Exec into web and resolve db:

    docker compose exec web sh
    # now inside the web container:
    getent hosts db      # prints db's internal IP — DNS resolved the service name
    nc -z -v db 5432      # (if nc exists) TCP connect to Postgres on the internal network
    exit
    

    Even just getent hosts db returning an IP proves it: web resolved the name db to the
    database container over Compose's internal DNS, with no IP addresses hard-coded anywhere. In a
    real app, your web service's DATABASE_URL would use db as the hostname — e.g.
    postgres://appuser:secret@db:5432/appdb.

    Stop and start without losing data

    docker compose stop      # stop both services, keep containers, network, volume
    docker compose ps        # both show 'exited'
    docker compose start     # bring them back
    

    Because the database's data lives in the dbdata volume, stopping and starting — or even
    down then up — keeps every row. The volume outlives the container.

    Checkpoint

    • Inside web, what hostname reaches the database, and what makes that work? (db — Compose's internal DNS resolves service names on the shared network.)
    • Does docker compose stop delete your database data? (No — the named volume persists; only down -v would remove it.)

    Deliverable for this step: the docker compose logs output showing both services, and the
    getent hosts db (or psql) output proving web reaches db.

  5. Configuration: .env, validate, scale, best practices

    Hard-coding POSTGRES_PASSWORD: secret in the file is fine for a demo, but real stacks
    externalize configuration. Compose has first-class support for this.

    Move variables into a .env file

    Compose automatically reads a file named .env in the project folder and substitutes
    ${VARIABLE} references in docker-compose.yml. Create .env:

    # .env  — do NOT commit this file
    POSTGRES_USER=appuser
    POSTGRES_PASSWORD=secret
    POSTGRES_DB=appdb
    WEB_PORT=8080
    

    Then reference the variables in docker-compose.yml:

    services:
      web:
        image: nginx:1.27-alpine
        ports:
          - "${WEB_PORT}:80"
        depends_on:
          - db
        networks:
          - appnet
      db:
        image: postgres:16-alpine
        environment:
          POSTGRES_USER: ${POSTGRES_USER}
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
          POSTGRES_DB: ${POSTGRES_DB}
        volumes:
          - dbdata:/var/lib/postgresql/data
        networks:
          - appnet
    
    networks:
      appnet:
        driver: bridge
    
    volumes:
      dbdata:
    

    Now the same file works in dev, staging and prod — only the .env changes. There's also a
    per-service env_file: key if you want to point a service at a specific file.

    Validate before you run

    docker compose config parses your file, applies variable substitution, and prints the final,
    fully-resolved configuration — or a clear error if something's wrong:

    docker compose config
    

    Read the output: ${WEB_PORT} is now 8080, the env vars are filled in. This is the fastest
    way to catch a typo or a missing variable before up. (Notice, too, that config doesn't
    emit a version: key — confirming it's obsolete in v2.)

    Scale a service

    Compose can run multiple replicas of a stateless service. Because a fixed host port can't
    be shared by two containers, drop the fixed ports mapping (or use a range) before scaling,
    then:

    docker compose up -d --scale web=3
    docker compose ps        # three web replicas, one db
    

    You now have three web containers behind the one project — the basis of horizontal scaling.
    (Don't scale db this way: databases are stateful and need real replication, not naive copies.)

    Best practices from the chapter

    • Use environment variables and .env for anything that changes between environments — keep the compose file itself stable.
    • Never commit secrets. Add .env to .gitignore; commit a .env.example with blank or dummy values so teammates know what's needed.
    • Use named volumes for stateful data (databases especially) so data survives container churn.
    • Isolate with private networks — only expose to the host the ports that truly need publishing; internal services talk over the Compose network, not localhost.
    • Give services clear names — the service name is the DNS hostname other services use, so name for the role (db, web, cache), not the implementation.
    • Keep the file readable — comment non-obvious blocks; validate with docker compose config in CI.

    Checkpoint

    • How does Compose know to substitute ${WEB_PORT}? (It auto-reads the project's .env file.)
    • What does docker compose config do, and why run it? (Prints the fully-resolved config; catches errors/typos before up.)
    • Why shouldn't .env be committed? (It holds secrets; commit a .env.example template instead.)
  6. Submit: document your orchestrated stack

    Prove you brought a real multi-service stack up, operated it, and showed the services talking —
    by capturing the evidence in a small repository.

    Tear down cleanly first (so your outputs are honest)

    When you're done experimenting:

    docker compose down          # removes containers + the network, keeps the named volume
    # docker compose down -v     # add -v ONLY if you also want to delete the dbdata volume
    

    down removes the containers and the appnet network. It keeps named volumes unless you
    pass -v — that's the safety that stops you from wiping a database by accident.

    Build your submission

    Create a git repo containing your docker-compose.yml, your .env.example (dummy
    values, safe to commit), a .gitignore that ignores .env, and a SUBMISSION.md with your
    real outputs. Paste actual command output — not invented text.

    # Docker Lab 3 — Orchestrate Services with Docker Compose
    
    ## 1. docker compose version
    <paste it — must show v2.x>
    
    ## 2. My docker-compose.yml
    <paste the final file: web + db, network, named volume, no `version:` key>
    
    ## 3. docker compose up -d
    <paste the output showing Compose create the network, the volume, and start db then web>
    
    ## 4. docker compose ps
    <paste it — both web and db running, web mapping HOST:80>
    
    ## 5. Logs from both services
    <paste docker compose logs lines showing BOTH `db` and `web` prefixes>
    
    ## 6. Proof web reaches db
    Output of `getent hosts db` (or a successful psql) run from inside the web container:
    <paste it — an internal IP for `db` proves service-name DNS works>
    
    ## 7. Configuration
    Output of `docker compose config` with my `.env` values substituted (note: no `version:` key):
    <paste it>
    
    ## Reflection (2-3 sentences)
    In my own words: what Compose declares that `docker run` makes me do by hand, and how `web`
    finds `db` without any hard-coded IP.
    

    Submit

    git init
    echo ".env" > .gitignore
    git add docker-compose.yml .env.example .gitignore SUBMISSION.md
    git commit -m "Docker lab 3 — orchestrate services with Compose"
    # push to a public repo or create a gist, then submit that URL
    

    Submit the repository or gist URL as your lab deliverable.

    Submission criteria (self-check) — no stubs

    • docker-compose.yml has two services (web + db), a named volume, and a private network
    • The file has no top-level version: key (you're on Compose v2)
    • docker compose up -d output shows Compose creating the network and volume and starting db before web
    • docker compose ps shows both services running, with web published on a host port
    • docker compose logs output includes lines from both db and web
    • You proved web reaches db by name (a real getent hosts db IP or a successful psql connection) — not just claimed it
    • .env is git-ignored and a .env.example with dummy values is committed instead — no secrets in the repo
    • Your reflection correctly explains, in your words, what Compose declares and how service-name DNS works

    Anything pasted must be real output from your machine. Placeholder or invented text fails
    the check — the whole point is that you actually ran the stack.

    What's next

    You've orchestrated services and seen them find each other by name over a private network — but
    that networking was mostly automatic. In the next Docker lab you'll go deeper on Docker
    networking
    : bridge vs host vs custom networks, how container DNS really works, and how to
    control exactly which services can talk to which.