SoftwareCrafting Logo

Docker Compose: A Practical Guide to Multi-Container Development and Deployment

DDeepak RajputDevOps18 min read21 Aug 2026
Docker Compose blueprint connected to web, API, and database containers with a docker compose up terminal command

TL;DR: Docker Compose lets you define a multi-container application in one declarative compose.yaml file. Use it to make local development reproducible, isolate service dependencies, test realistic integrations, and create a clear handoff to production orchestration. Treat local Compose configuration and production deployment configuration as related but separate concerns.

What Docker Compose solves

A modern application rarely consists of one process. A Node.js API may need PostgreSQL, Redis, an object-storage emulator, a worker, and a reverse proxy. Running each dependency manually creates machine-specific setup instructions, port conflicts, inconsistent versions, and difficult onboarding.

Docker packages a process and its runtime dependencies into an image. Compose defines how several containers run together: images, builds, ports, environment variables, networks, volumes, health checks, and dependencies. Docker describes Compose as a way to define and run multi-container applications from a YAML file.

Compose is not Kubernetes. It is excellent for local development, integration tests, demonstrations, and single-host deployments with an explicit operational boundary. A production platform may use ECS, Kubernetes, managed services, or another orchestrator instead.

A small application stack

Create a compose.yaml file at the repository root:

services:
  api:
    build:
      context: .
      target: development
    command: npm run dev
    ports:
      - '3000:3000'
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app
      REDIS_URL: redis://cache:6379
    volumes:
      - .:/workspace
      - node_modules:/workspace/node_modules
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U app -d app']
      interval: 5s
      timeout: 5s
      retries: 10

  cache:
    image: redis:7-alpine

volumes:
  postgres_data:
  node_modules:

The API connects to db, not localhost, because services communicate through the Compose network. From the host, localhost:3000 reaches the API through the published port. This distinction is one of the first sources of confusion for new teams.

Services, images, and builds

Use image when a trusted prebuilt image is sufficient. Use build when the service is part of your repository and needs a Dockerfile. A multi-stage Dockerfile can keep development dependencies and production runtime images separate.

FROM node:22-alpine AS base
WORKDIR /workspace
COPY package*.json ./
RUN npm ci
COPY . .

FROM base AS development
CMD ["npm", "run", "dev"]

FROM base AS production
RUN npm run build && npm prune --omit=dev
CMD ["node", "dist/server.js"]

Keep the build context small with .dockerignore. Do not copy .env, Git history, local credentials, or build artifacts into an image. Pin major versions and review image updates as dependency changes.

Networking and service discovery

Compose creates a project network by default. Service names resolve through Docker's internal DNS. Expose a port to the host only when a host process or developer needs it. Internal databases and caches usually do not need published ports.

Separate public and private networks when a reverse proxy should be the only path to an API. A container on a shared network can reach other services unless network boundaries are designed intentionally. Network isolation is not a substitute for database authentication or application authorization.

Volumes and data safety

A named volume makes database data survive container recreation. docker compose down removes containers and networks but normally keeps named volumes. docker compose down --volumes removes the stored data, so use it deliberately.

Local development databases should be disposable or backed up when they contain valuable fixtures. Do not assume a Compose volume is a production backup. Production databases need automated snapshots, point-in-time recovery, restore tests, retention policies, and access controls.

For source code, bind mounts support fast feedback but can create permission and performance issues across operating systems. Named volumes for dependency directories can prevent host node_modules from conflicting with container packages.

Environment variables and secrets

Use .env.example to document required variable names without real values. Local Compose can load a developer .env file, but production secrets should come from a managed secret store or orchestrator secret mechanism.

Never commit passwords, cloud credentials, private keys, or provider tokens. Avoid printing the full environment from a running container. A container image should be safe to share with the team without revealing environment-specific secrets.

Startup order is not readiness

depends_on controls a startup relationship, but a process may still be warming up. Health checks express readiness for dependencies such as PostgreSQL. The application should still retry connections with a bounded backoff and fail clearly if the dependency remains unavailable.

Do not use a fixed sleep as a readiness strategy. It makes fast machines wait and slow machines fail. The application should be resilient to restarts because orchestration systems can restart a dependency at any time.

Useful commands

docker compose config
docker compose up --build
docker compose ps
docker compose logs -f api
docker compose exec db psql -U app -d app
docker compose run --rm api npm test
docker compose down

docker compose config renders and validates the resolved configuration. Run it in CI to catch missing variables and YAML mistakes. Use --profile for optional services such as mail testing, tracing, or local object storage.

Profiles and multiple environments

Profiles let a team keep optional tools in one file without starting them every time. You can also use multiple Compose files for base, development, CI, and production-like settings:

docker compose -f compose.yaml -f compose.ci.yaml up --abort-on-container-exit --exit-code-from api

Keep differences visible. A CI database may use an ephemeral volume, while development needs persistent data. Avoid making one huge file with hidden overrides that nobody can reason about.

Testing with Compose

Compose is valuable for integration tests because the test process can run against real PostgreSQL, Redis, or a local dependency emulator. Isolate each test run with a project name or unique database. Wait for health checks, run migrations, execute tests, and tear down the stack even when tests fail.

Use deterministic fixtures and avoid tests that depend on the developer's existing volume. For parallel CI, allocate separate ports or keep all services inside the Compose network and expose only the test runner.

Production boundaries

Compose can run a small application on a single host, but production needs more than a YAML file. You need image promotion, resource limits, restart behavior, health-based routing, TLS, backups, logs, metrics, secrets, patching, and a rollback process. A single host is a single failure domain unless the business explicitly accepts that risk.

For larger systems, translate the service contracts into ECS, Kubernetes, or another orchestrator. Keep the application image and environment contract stable while changing the runtime. Our Docker and Kubernetes learning path explains how the concepts evolve.

Common mistakes

  • Using localhost for container-to-container connections.
  • Publishing database ports unnecessarily.
  • Treating depends_on as a readiness check.
  • Committing .env files or secrets into images.
  • Deleting volumes without confirming the data is disposable.
  • Mounting the whole repository in a production image.
  • Assuming local Compose provides high availability.

Production-ready checklist

  • Images are pinned, scanned, and built reproducibly.
  • .dockerignore excludes secrets and unnecessary files.
  • Internal services use private networks and authenticated connections.
  • Health checks and application retries are configured.
  • Volumes, backups, and restore behavior are understood.
  • Secrets come from the correct environment mechanism.
  • CI runs integration tests against a clean stack.
  • Production deployment has an explicit orchestrator and rollback plan.

Conclusion

Docker Compose turns a multi-service setup into a versioned, reviewable contract. It is one of the fastest ways to give a team a consistent local environment, but it should be connected to a deliberate production strategy. SoftwareCrafting’s DevOps and cloud deployment service can help convert a local Compose stack into a secure, observable deployment workflow.

For the container fundamentals, read What is Docker?.

About the author

Deepak Rajput

This article was published by SoftwareCrafting engineers for founders, product teams, and developers working on real production delivery. We focus on practical tradeoffs, maintainable architecture, and implementation details that hold up outside demos.

View author profile

Last updated: 2026-08-21