Skip to content
shellhacks.dev
Docker

How to Use Docker Compose for Local Development Step by Step

A Docker Compose tutorial for local development: write a compose file for an app and a database, start it, read logs, run commands inside and tear it down.

6 min read
Summarize in AI
Illustration for "How to Use Docker Compose for Local Development Step by Step": a terminal session, a scrolling log, environment variables.

Starting an app, a database and a cache by hand takes three long docker run lines and a network, and remembering them takes a README. Compose puts the whole arrangement in a file you can commit. This tutorial builds one for a small web app, as an extension of the Docker basics. Commands use Docker Engine 27 and Compose 2.30.

The file

compose.yamlYAML
services:
  web:
    build: .
    ports:
      - "127.0.0.1:8000:8000"
    environment:
      DATABASE_URL: postgres://app:dev@db:5432/app
    volumes:
      - ./src:/app/src
    depends_on:
      - db

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Each key under services is a container; its name is its DNS name, so the web app connects to host db. Ports are bound to loopback so the development database and app are not exposed to the network; see publishing ports safely. The named volume keeps the data when the containers are removed. No version: key is needed any more; Compose ignores it.

Run it

Shell
docker compose up -d --build
docker compose ps
docker compose logs -f web
Text
NAME            IMAGE        COMMAND                  SERVICE   STATUS         PORTS
app-db-1        postgres:17  "docker-entrypoint.s…"   db        Up 8 seconds   5432/tcp
app-web-1       app-web      "python manage.py ru…"   web       Up 7 seconds   127.0.0.1:8000->8000/tcp

The project name comes from the directory name, and containers are named project-service-N. --build rebuilds the image if the Dockerfile or its context changed. logs -f web follows one service; leave out the name for all of them, interleaved and color-coded.

Work inside the stack

Shell
docker compose exec db psql -U app -c '\dt'
docker compose run --rm web python manage.py migrate
docker compose restart web

exec runs a command in a service that is already up. run --rm starts a new one-off container from the service definition, which is the right tool for migrations and test runs. Both use the same environment and network as the service.

Tear down

Shell
docker compose down
docker compose down -v

down stops and removes the containers and the network but keeps named volumes, so the database survives. -v deletes the volumes too, which is a clean reset and also the way to lose data you wanted. Think before adding it. docker compose stop pauses without removing anything.

Check what you wrote

Shell
docker compose config
docker compose config --services

The first prints the file after interpolation and merging, which catches misplaced indentation and unset variables. Values like ${PORT:-8000} take a default from the shell or a .env file in the project directory.

Where this does not apply

Compose is for a single host. For several hosts use an orchestrator; for one production server, Compose works but needs restart policies, health checks and a plan for secrets, covered in healthchecks and depends_on. Startup order with a bare depends_on does not wait for the database to be ready. For per-environment differences, read profiles and overrides.

What Compose adds to plain containers

Compose reads a YAML file that describes a set of services, their networks and their volumes, and runs them as one unit under a project name. Everything you can do with a long docker run command line becomes a few lines in a file that lives in version control, so a new teammate starts the same stack with a single command. Compose creates a network for the project, names containers after the project and service, and attaches every service to the network, which is why services reach each other by service name. It does not replace an orchestrator. It manages one host, with no scheduling across machines, so treat it as a tool for development, testing and small deployments, and move to something else when you need more.

The current implementation is the docker compose plugin, and the file format follows the Compose specification. The top-level version key is obsolete and ignored, so new files leave it out. The default file names are compose.yaml and docker-compose.yml, and the first is preferred.

Configuration and secrets in development

Pass settings through the environment key or an env_file, and keep real secrets out of the file you commit. Compose reads a .env file next to the project for variable substitution inside the YAML, as in ${DB_PASSWORD}, and that file belongs in the ignore list with an example copy committed. Use the default syntax ${VAR:-default} for values that have a safe development default, and ${VAR:?message} to stop with an error when a required variable is missing, which is better than starting with an empty password. Remember that variables set in the shell override the .env file, so a stale export in your terminal can silently change what the stack uses. Print the final result with docker compose config, which shows the file after substitution and merging, and use it whenever behavior is unclear.

Day-to-day commands that save time

docker compose up -d starts in the background and docker compose logs -f service follows one service. docker compose ps lists status, docker compose exec runs a command in a running container, and docker compose run --rm starts a one-off container for tasks like migrations or a test run, with the dependencies started for it. After changing a Dockerfile, up --build rebuilds, and build --no-cache forces a fresh build when you suspect a stale layer. docker compose down stops and removes containers and networks while keeping named volumes, and adding -v removes volumes as well, which deletes data. Name the project with -p or the name key so two checkouts of the same repository do not collide on container and network names.

Healthy habits for a development stack include pinning image tags to a version and not latest, so a teammate gets what you got, and reading the logs of a service that restarts in a loop before changing anything else.

A development workflow that holds up

A pattern that works for most teams is a compose file that starts the application and its dependencies with one command, an override that mounts source code and publishes ports for local use, and seed data that loads on first start. Keep the dependencies at the versions production runs, so a bug caused by a database difference shows up before release. Document the three commands a newcomer needs, up, test and reset, in the readme, and keep them working by running them in CI on a clean runner. A compose file that nobody has started in six months usually does not start any more, because an image tag moved or a variable was renamed.

Healthchecks, resource limits and restart policies matter less in development and a great deal in production. Add a healthcheck for each dependency so the application waits for it, and set a restart policy such as unless-stopped for services you expect to survive a reboot. Do not carry development conveniences, such as published database ports and weak passwords, into the file you deploy. Separate files make the difference visible in review.

Finally, learn to read failures. A container that exits at once has a reason in its logs, shown by docker compose logs service, and a service stuck in a restart loop usually has a configuration error or a missing dependency.

See the Compose getting started guide and the Compose file reference.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.