Skip to content
OpsAtlas

A Local Dev Stack with Docker Compose

By OpsAtlas · · 3 min read

Table of contents

Running one container with docker run is easy. Running an API, its database and the network between them means remembering three long commands and the order to start them in. Docker Compose describes the whole stack in one compose.yaml and starts it with one command.

In this tutorial you take the notes-api image from Write Your First Dockerfile, add Postgres, and end with a stack that builds itself, waits for the database to be ready and keeps its data between runs.

You need: Docker with the Compose plugin (docker compose version prints a version) and the notes-api folder from Write Your First Dockerfile.

1. Start with the docker run commands

This is how you would run the stack by hand:

docker network create notes

docker run -d --name notes-api --network notes -p 3000:3000 \
  -e DATABASE_URL=postgres://notes:notes@db:5432/notes \
  notes-api:1.0.0

docker run -d --name db --network notes \
  -e POSTGRES_USER=notes -e POSTGRES_PASSWORD=notes -e POSTGRES_DB=notes \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16
  • --network notes puts both containers on the same network, where they reach each other by name. That’s why the API’s URL uses the host name db.
  • -v pgdata:/var/lib/postgresql/data stores the database in a named volume, so the data survives when the container is removed.

It works, but you have to run the commands in this order, and every flag lives only in your shell history.

2. Convert the commands to Compose

Paste the two docker run commands into the docker run to Compose converter. It produces:

services:
  notes-api:
    image: notes-api:1.0.0
    container_name: notes-api
    networks:
    - notes
    ports:
    - "3000:3000"
    environment:
    - DATABASE_URL=postgres://notes:notes@db:5432/notes
  db:
    image: postgres:16
    container_name: db
    networks:
    - notes
    environment:
    - POSTGRES_USER=notes
    - POSTGRES_PASSWORD=notes
    - POSTGRES_DB=notes
    volumes:
    - pgdata:/var/lib/postgresql/data
volumes:
  pgdata: {}
networks:
  notes: {}

Each flag became a key: -p → ports, -e → environment, -v → volumes, --network → networks. The named volume and the network are declared at the bottom so Compose creates them.

The converter also warns that Compose will create the notes network itself. Remove the one from step 1 so the names don’t clash:

docker rm -f notes-api db && docker network rm notes

Save the output as compose.yaml in the notes-api folder.

3. Make it a development stack

The converted file reproduces what you had. Four changes make it better for day-to-day work:

services:
  notes-api:
    build: .
    image: notes-api:dev
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/notes
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: notes
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d notes"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  pgdata:

What changed:

  1. build: . builds the API from your Dockerfile, so docker compose up --build picks up code changes. image: notes-api:dev names the result.
  2. A health check for Postgres, plus depends_on with condition: service_healthy. A started container is not a ready database: without this, the API can start first and fail its first connection. pg_isready reports whether Postgres accepts connections. $${…} escapes the $ so the variable is expanded inside the container, not by Compose.
  3. Credentials from ${…} variables instead of literal values, so they don’t sit in a file you commit.
  4. No container_name and no custom network. Compose creates a default network for the project and names containers after it. Fixed names would stop two copies of the project from running side by side.

Create a .env file next to compose.yaml. Compose reads it automatically:

POSTGRES_USER=notes
POSTGRES_PASSWORD=change-me-locally

Add .env to .gitignore (and keep it in the .dockerignore from the Dockerfile guide), so the password never reaches Git or the image.

4. Run the stack

docker compose up -d --build    # build the API, start both, wait for db to be healthy
docker compose ps               # db shows "(healthy)"; the API starts after it
docker compose logs -f notes-api
curl http://localhost:3000      # {"notes":[]}

Day-to-day commands:

docker compose up -d --build    # after changing code
docker compose restart notes-api
docker compose down             # stop and remove the containers; the pgdata volume stays
docker compose down -v          # also delete the volume, i.e. the database

Run docker compose down and then up -d again: the data in Postgres is still there, because it lives in the pgdata volume, not in the container.

Common problems

  • port is already allocated: something else uses port 3000. Change the left side of the mapping, for example "3001:3000".
  • The API can’t resolve db: use the service name (db), not localhost. Inside the API container, localhost is the API container itself.
  • Changed POSTGRES_PASSWORD, but the old one still works: the official image only reads it when it creates the database. Run docker compose down -v to start fresh (this deletes the data).

Next steps

You now have a stack that starts with one command and keeps its data. To make the image smaller and faster to rebuild, read Multi-stage Docker builds. To run the same image on Kubernetes, continue with Your First Kubernetes Deployment and Service.

Try it yourself

  • docker run to Docker Compose converter

    Convert docker run commands into a clean compose.yaml: ports, volumes, env, networks, healthchecks and limits. Runs in your browser.

    Docker
  • Dockerfile linter and fixer

    Lint a Dockerfile in your browser: find root users, unpinned images, leaked secrets and bloated layers, then apply safe fixes in one click.

    DockerBeta