Stop Managing Multiple Containers Manually. Use Docker Compose.

by Eric Hanson, Backend Developer at Clean Systems Consulting

The shell script that nobody wants to own

Somewhere in your repo or your team's wiki, there's a list of docker run commands. Maybe it's a shell script. Maybe it's a README section titled "Running Locally." It looks something like this:

docker network create app-network

docker run -d \
  --name postgres \
  --network app-network \
  -e POSTGRES_DB=mydb \
  -e POSTGRES_USER=user \
  -e POSTGRES_PASSWORD=secret \
  -v postgres_data:/var/lib/postgresql/data \
  postgres:16

docker run -d \
  --name redis \
  --network app-network \
  redis:7

docker run -d \
  --name app \
  --network app-network \
  -e DATABASE_URL=postgresql://user:secret@postgres:5432/mydb \
  -e REDIS_URL=redis://redis:6379 \
  -p 8080:8080 \
  your-app:latest

This works until it doesn't. Someone runs it twice and gets "network already exists" errors. Someone starts the app before the database is ready. Someone updates an environment variable in one container but not another. The postgres data volume exists on their machine but not the new developer's. The runbook diverges from what's actually running in production.

Docker Compose was built to replace exactly this pattern.

What you get with a single Compose file

The equivalent docker-compose.yml:

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: user
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      retries: 5

  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgresql://user:secret@postgres:5432/mydb
      REDIS_URL: redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

volumes:
  postgres_data:

One command starts everything:

docker compose up

Compose creates a network automatically, starts containers in dependency order (waiting for health checks before starting dependent services), and manages volumes declaratively. Run it twice and nothing errors — it's idempotent. Stop everything with docker compose down. Stop and delete volumes with docker compose down -v.

The networking that's already done for you

Each docker compose up invocation creates a bridge network for the project. Every service is reachable from other services by its service name as the hostname — postgres, redis, app in the example above. No manual network creation, no --network flags, no IP address management.

This is why DATABASE_URL: postgresql://user:secret@postgres:5432/mydb works: within the Compose network, postgres resolves to the postgres container's IP. DNS is handled by Docker's built-in resolver.

The service name must match exactly. If your Compose service is named database but your application connects to postgres, the connection will fail. Keep service names consistent with your application's expectations, or use environment variables to make the hostname configurable.

Dependency ordering done right

The shell script approach has no dependency ordering. Whoever runs it first gets a race condition if the app starts before the database is ready.

Compose's depends_on with condition: service_healthy waits for health checks to pass before starting dependent services:

app:
  depends_on:
    postgres:
      condition: service_healthy

This requires the healthcheck directive on the dependency. Without a healthcheck defined, the only available condition is service_started — which means the container is running, not that the service inside it is ready to accept connections.

For databases, use the database's native readiness tool:

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
  interval: 5s
  timeout: 3s
  retries: 10
  start_period: 10s

pg_isready is included in the Postgres image. It exits 0 when the database is accepting connections, non-zero otherwise. The start_period option prevents failed health checks during initial startup from counting toward retries.

Volumes: named vs bind-mount

Named volumes (the postgres_data: in the example) are managed by Docker and persist between docker compose down and docker compose up. Data is preserved across container restarts.

Bind mounts are useful for development workflows — mounting source code into a container so edits are reflected without rebuilding:

app:
  volumes:
    - ./src:/app/src      # bind mount for live reload
    - node_modules:/app/node_modules  # named volume to avoid overwriting

The named volume for node_modules is the classic workaround: without it, the bind mount of the entire project would shadow the node_modules installed inside the container with whatever (likely absent or mismatched) node_modules exists on the host.

Environment variables: .env files and variable interpolation

Compose reads a .env file in the same directory by default and makes those variables available for interpolation in the Compose file:

# .env
POSTGRES_USER=user
POSTGRES_PASSWORD=secret
APP_PORT=8080
services:
  postgres:
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
  app:
    ports:
      - "${APP_PORT}:8080"

The .env file should be in .gitignore — it's the mechanism for per-developer overrides, not shared config. Shared defaults go in the Compose file directly or in a committed .env.example that developers copy and customize.

Common operations you'll use daily

# Start all services, rebuild if needed
docker compose up --build

# Start in background
docker compose up -d

# View logs across all services
docker compose logs -f

# View logs for one service
docker compose logs -f app

# Execute a command in a running container
docker compose exec app sh
docker compose exec postgres psql -U user mydb

# Restart a single service without affecting others
docker compose restart app

# Check status
docker compose ps

# Stop without removing containers
docker compose stop

# Stop and remove containers (keep volumes)
docker compose down

# Stop and remove containers AND volumes (full reset)
docker compose down -v

The script that should replace your shell script

Delete the docker run shell script. Replace it with a docker-compose.yml. Add the volumes and networks to .gitignore if they're dev-only, or commit them if they're shared config. Add a one-line README: "Run docker compose up to start the development environment."

The next developer who joins your team should be up and running in one command.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

REST API Design in Practice — The Decisions That Determine Developer Experience

REST APIs are built once and integrated against indefinitely. The design decisions made in the first hour — resource modeling, error shapes, versioning, pagination — determine how much friction every integration will carry forever.

Read more

How Much Does a Backend Contractor Actually Cost vs a Full-Time Hire — A Brutally Honest Breakdown

The day rate looks expensive. The full-time salary looks cheaper. Neither comparison is complete until you account for what each model actually costs to produce a shipped feature.

Read more

Using Trello, Notion, or Jira as a Solo Contractor

Project management tools aren’t just for teams. Even as a solo contractor, using Trello, Notion, or Jira can keep your work organized and your brain sane.

Read more

Why Backend Systems Fail at Scale

“It worked perfectly… until we got users.” Scale doesn’t break systems — it reveals what was already fragile.

Read more