Docker Compose: orchestrate local development

Docker Compose gives you a single command to build, start, and wire together the services that make up your local development stack. This page covers the Compose workflow and configuration. For building images and running standalone containers, see Docker: containerize a Python project.


On this page


Key concepts

  • Services: a service is a named container definition in docker-compose.yaml. Each service maps to one image (built or pulled) and one or more running containers.
  • Override files: Compose merges multiple -f files in order. Use an override file (for example docker-compose.hardened.yaml) to layer settings on top of a baseline without editing it.
  • Variable substitution: ${VAR:-default} in a compose file resolves from your shell environment and the project-level .env file. This keeps the compose file stable across environments.
  • env_file vs environment: env_file loads variables into the running container at runtime. environment sets variables directly in the compose file and can override env_file values. Neither is used for build-time args (those come from variable substitution or the shell).

Quick start: local dev with compose

This page assumes Docker and Docker Compose are installed. If you are new to Docker images, start with the Docker page for image building fundamentals and the reference Dockerfile.

Setup steps

  1. Copy the examples into conventional names at the project root:
    • _pages/docs/containerization/docker/Dockerfile.exampleDockerfile
    • _pages/docs/containerization/docker-compose/docker-compose.example.yamldocker-compose.yaml
    • _pages/docs/containerization/docker-compose/docker-compose.hardened.example.yamldocker-compose.hardened.yaml if you want a production-like posture
  2. Create the env files for compose:
    • .env: used by compose for ${...} variable substitution and passed into the container at runtime via env_file (application configuration).
  3. Start the service:

Keep runtime secrets out of version control. Treat .env as local-only inputs and share templates (for example .env.example) when you need a documented baseline.

docker compose up --build

Minimal examples:

# .env (compose variable substitution)
PACKAGE_NAME=myapp
PORT=8080
# .env (runtime container environment)
ENVIRONMENT=dev
LOG_LEVEL=INFO

Hardened posture

Use this when you want to surface hidden write assumptions early while keeping the baseline setup simple.

docker compose -f docker-compose.yaml -f docker-compose.hardened.yaml up --build

The hardened override adds read_only: true, drops all Linux capabilities, prevents privilege escalation, and creates explicit writable tmpfs mounts for /tmp. This can surface hidden assumptions in Python libraries that try to write to ~/.cache or ~/.config. Route those caches to writable locations (commonly under /tmp) via environment variables such as HOME, XDG_CACHE_HOME, and XDG_CONFIG_HOME.

Day-to-day loop

This is a practical loop for local development:

  • Start the stack: follow the setup steps above.
  • Observe logs: use the commands in Logs and debugging.
  • Restart after changes (when you are not bind-mounting code): docker compose up --build --force-recreate
  • Run a one-shot command in the same image (migrations, a backfill, a smoke test):
docker compose run --rm <service-name> /app/.venv/bin/python -m <module>

If your local workflow needs live code reload, prefer making that an explicit compose variant (bind-mount code + dev server command), while keeping the default path “rebuild the image” so you do not accidentally depend on host-only state.


Resources

Use these templates when you want a baseline quickly, then customize for your application.

docker-compose.example.yaml
services:
  myapp:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        PACKAGE_NAME: ${PACKAGE_NAME:-myapp}
        PYTHON_VERSION: ${PYTHON_VERSION:-3.13}
        UID: ${UID:-90001}
    image: myapp:dev
    env_file:
      - .env
    environment:
      ENVIRONMENT: dev
      PORT: ${PORT:-8080}
    ports:
      - "${PORT:-8080}:${PORT:-8080}"
    command: ["/app/.venv/bin/python", "main.py"]
    restart: unless-stopped
    init: true
    healthcheck:
      test:
        [
          "CMD",
          "/app/.venv/bin/python",
          "-c",
          "import os, socket; port=int(os.getenv('PORT','8080')); addrs=[ai[4][0] for ai in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)]; addrs=[a for a in addrs if not a.startswith('127.')]; host=(addrs[0] if addrs else '127.0.0.1'); s=socket.socket(); s.settimeout(2); s.connect((host, port)); s.close()",
        ]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 15s
docker-compose.hardened.example.yaml
services:
  myapp:
    environment:
      HOME: /tmp
      XDG_CACHE_HOME: /tmp/.cache
      XDG_CONFIG_HOME: /tmp/.config
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    tmpfs:
      - /tmp:rw,nosuid,nodev,noexec,size=256m

Compose configuration reference

An example compose file is provided at _pages/docs/containerization/docker-compose/docker-compose.example.yaml as a baseline starting point for running the container locally.

An optional hardened override is provided at _pages/docs/containerization/docker-compose/docker-compose.hardened.example.yaml. It is designed to layer on top of the baseline file so you can switch between a low-friction dev posture and a production-like posture with a single additional -f flag.

When to use each compose field

  • build: use when you want the image rebuilt from local sources as part of up. Avoid it when you want to pin to a prebuilt image digest.
    • build.args: use when the Dockerfile requires build-time arguments (the reference Dockerfile requires PACKAGE_NAME to match your top-level Python package directory; UV_IMAGE is optional if you want to pin the installer image).
      • Compose variable substitution for build args uses your shell environment and the project-level .env file. It does not use env_file (which only sets runtime container environment variables).
  • env_file: use when you want a single place for environment-specific defaults (and to keep docker-compose.yaml stable across environments).
  • environment: use for values you want to be explicit at the compose layer (and to override env_file selectively).
  • ports: use when the service must be reachable from the host (local testing). Avoid publishing ports you do not need.
    • If you want a configurable port mapping, use compose variable substitution (for example "${PORT:-8080}:${PORT:-8080}") and set PORT at runtime so the app and healthcheck agree.
  • volumes: use when the container needs persistent data (named volumes) or when you want to mount local files into the container for dev (bind mounts). Prefer explicit mounts over relying on writes to the container filesystem.
  • restart: controls how Docker restarts containers.
    • restart: "no": use for one-shot tasks and batch jobs where failure should surface immediately (and exit codes should be visible to CI/operators).
    • restart: on-failure[:N]: use for transient failures where retrying is reasonable; consider adding a max retry count.
    • restart: always: use for long-running services that should be kept up regardless of manual stops (commonly used outside local dev).
    • restart: unless-stopped: use for long-running services that should restart on failure and daemon restart, but stay stopped if an operator intentionally stops them (common for local dev stacks).
  • command: use when the runtime command differs between contexts (dev server vs worker vs batch).
  • container_name: use only when you have a strong reason to force a fixed container name. Avoid it if you want to scale services (for example docker compose up --scale ...) or if you run multiple copies of the same stack (to prevent name collisions).
  • init, read_only, cap_drop, security_opt, tmpfs: use when you want runtime hardening defaults (read-only filesystem, least privilege, and explicit writable paths such as /tmp).
    • init: true enables a minimal init process as PID 1 inside the container. It improves signal handling (shutdown behavior) and reaps zombie processes when your app spawns subprocesses.
    • read_only: true makes the container filesystem read-only. This is a practical way to detect unexpected writes early and to reduce the amount of mutable state a container can accumulate at runtime.
    • cap_drop: [ALL] removes Linux capabilities from the container to enforce least privilege. Add back only what you can justify for the workload.
    • security_opt: ["no-new-privileges:true"] prevents privilege escalation through setuid/setcap binaries, even if they exist in the image.
    • tmpfs: [...] creates explicit writable mounts in memory (for example /tmp). Combined with read_only: true, this makes writable paths intentional and easy to audit.
    • In practice, keep these settings in a separate override file (for example docker-compose.hardened.yaml) so the baseline setup stays easy to run.
    • A hardened setup can surface hidden assumptions in Python libraries that try to write to ~/.cache or ~/.config. If you use read_only: true and a non-root user, route caches/config to writable locations (commonly under /tmp) via environment variables such as HOME, XDG_CACHE_HOME, and XDG_CONFIG_HOME.
  • healthcheck: use when you want an explicit readiness signal for local orchestration and troubleshooting. Prefer checks that do not require extra OS packages (the example uses Python to probe the listening port).

If you do not want to copy files, you can also adapt the compose file to reference the example Dockerfile path directly (set dockerfile: _pages/docs/containerization/docker/Dockerfile.example).


Logs and debugging

With Compose, use docker compose logs -f when you want a unified stream across all services in a stack.

Follow logs

Stream logs from all services:

docker compose logs -f

Stream logs from a single service:

docker compose logs -f <service-name>

For single-container log streaming, see the Docker page: Logs and debugging.

Debug a running service

Shell into a running service to inspect the filesystem, check environment variables, or run one-off commands:

docker compose exec <service-name> /bin/sh

Run a one-shot command without starting the full stack:

docker compose run --rm <service-name> <command>

Inspect from outside

  • Compose status: docker compose ps
  • Stop a compose stack: docker compose down (add -v if you intentionally want to remove named volumes)
  • Rebuild and restart: docker compose up --build --force-recreate

For standalone Docker commands (docker ps, docker images, docker stats, etc.), see the Docker page: Logs and debugging.

results matching ""

    No results matching ""