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.