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 notesputs both containers on the same network, where they reach each other by name. That’s why the API’s URL uses the host namedb.-v pgdata:/var/lib/postgresql/datastores 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:
build: .builds the API from your Dockerfile, sodocker compose up --buildpicks up code changes.image: notes-api:devnames the result.- A health check for Postgres, plus
depends_onwithcondition: service_healthy. A started container is not a ready database: without this, the API can start first and fail its first connection.pg_isreadyreports whether Postgres accepts connections.$${…}escapes the$so the variable is expanded inside the container, not by Compose. - Credentials from
${…}variables instead of literal values, so they don’t sit in a file you commit. - No
container_nameand 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), notlocalhost. Inside the API container,localhostis the API container itself. - Changed
POSTGRES_PASSWORD, but the old one still works: the official image only reads it when it creates the database. Rundocker compose down -vto 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.
DockerDockerfile 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