I still use Docker Compose constantly, even in environments that are much more serious than the phrase “just use compose” tends to imply. It is not Kubernetes, and I do not pretend it is. But for single-host deployments, integration stacks, internal tools, CI test environments, and a surprising number of production systems, Compose is exactly the right level of complexity.
The mistake I see most often is treating the compose.yaml file as a dumping ground: copy a few examples from a blog post, jam secrets into environment variables, publish every port because it is convenient, and assume depends_on means the database is ready. That approach works until the first restart race, permission issue, or accidental exposure.
What follows is the way I actually use Compose on real servers: small, readable files, explicit networks and volumes, health checks where they matter, and a clear difference between what should be reachable from the host and what should stay private.
Compose today: use docker compose, not docker-compose
The old standalone docker-compose binary is what many older posts still show. On modern Docker installations, I prefer the Compose plugin integrated into the Docker CLI.
Check the version:
docker compose version
Bring a stack up:
docker compose up -d
The old command may still work if the standalone binary is installed:
docker-compose version
But for new documentation and automation, I use docker compose. I also omit the old version: key in the YAML unless there is a very specific compatibility reason. The modern Compose specification does not require it, and newer examples are cleaner without it.
A small but real starting file
Here is a practical baseline for an app, a database, and a reverse proxy:
services:
app:
build:
context: .
dockerfile: Dockerfile
env_file:
- .env
environment:
APP_ENV: production
DB_HOST: db
depends_on:
db:
condition: service_healthy
restart: unless-stopped
networks:
- backend
db:
image: postgres:16
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
secrets:
- postgres_password
networks:
- backend
nginx:
image: nginx:1.28-alpine
depends_on:
app:
condition: service_started
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
restart: unless-stopped
networks:
- backend
- frontend
volumes:
postgres_data:
networks:
backend:
frontend:
secrets:
postgres_password:
file: ./secrets/postgres_password.txt
That is already enough to illustrate most of the decisions that matter: a named volume for persistent database storage, separate networks, a secret instead of a plaintext database password in the environment, and a health check that Compose can use for startup ordering.
Services, networks, and volumes are the core model
Once you understand those three objects, most Compose files stop feeling mysterious.
Services
A service is a container definition with runtime settings. It can be built from a Dockerfile or pulled from an image.
Examples:
services:
redis:
image: redis:7-alpine
worker:
build: .
I usually give each service one clear responsibility: app, worker, proxy, database, queue, or scheduler. If a Compose file grows into dozens of services with heavily shared lifecycle requirements, I take that as a sign that I may need to break the stack apart or reconsider the platform.
Networks
Compose creates a default network automatically, and service names resolve via Docker’s embedded DNS. That means a service called db can be reached by other services as db.
Inside the stack, the app should connect to PostgreSQL like this:
postgresql://appuser@db:5432/appdb
Not localhost. localhost inside a container means the container itself.
For a deeper walkthrough on bridge behavior and service-to-service connectivity, the Docker networking guide is useful background.
Volumes
Volumes hold persistent data outside the container lifecycle. If you delete and recreate a container, named volumes survive.
List them:
docker volume ls
Inspect one:
docker volume inspect postgres_data
If the data matters, I prefer a named volume or a carefully chosen bind mount. Throwaway application state can stay ephemeral, but databases should be explicit.
environment versus env_file
This gets sloppy fast if you are not disciplined.
Inline environment is useful for a few obvious variables:
environment:
APP_ENV: production
LOG_LEVEL: info
env_file is convenient when you already have a managed file with many non-secret settings:
env_file:
- .env
I use them differently:
environmentfor a small set of important, visible valuesenv_filefor larger bundles of non-secret configuration- Compose
secretsor external secret management for credentials
A common mistake is putting secrets in .env and then casually checking the file into version control. Another is assuming env_file encrypts anything. It does not. It just loads environment variables.
If you need to confirm the rendered configuration, use:
docker compose config
That command is one of my favorites because it resolves merged files, interpolated variables, and defaults into the final effective configuration.
depends_on is not readiness unless you add health checks
This is one of the most persistent Compose misunderstandings.
Basic depends_on only guarantees start order, not that the dependency is ready to accept traffic. The database container may be started while PostgreSQL is still initializing.
Current Compose can use health-based conditions when the service defines a healthcheck:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
interval: 10s
timeout: 5s
retries: 5
app:
depends_on:
db:
condition: service_healthy
Even then, I still prefer application-level retry logic. Compose startup ordering helps, but it is not a substitute for a robust client that can survive a restart or a transient dependency failure.
Restart policies that match reality
I use three restart policies most often:
unless-stoppedalwayson-failure
Examples:
restart: unless-stopped
My rule of thumb:
unless-stoppedfor long-running services I want back after reboot and after crashesalwaysfor infrastructure-style services where manual stop should not survive daemon restartson-failurefor jobs or processes that should not restart forever on clean exit
In practice, unless-stopped is what I use most on single-host app stacks. It behaves sanely during routine operations.
Named volumes versus bind mounts
Both are useful, but they are not interchangeable.
A named volume is managed by Docker:
volumes:
- postgres_data:/var/lib/postgresql/data
A bind mount points to a host path:
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
I prefer named volumes for container-managed persistent application data, especially databases. They decouple the stack from a specific host path and reduce path-related mistakes.
I prefer bind mounts for:
- local source code during development
- configuration files I want to edit on the host
- certificates or assets already managed on the host
The most common bind-mount problem is permissions. A container process running as UID 1000 cannot magically write to a host directory owned by root with restrictive mode bits.
When I troubleshoot that, I check both sides:
ls -ld ./data
id
docker compose exec app id
Service discovery and internal networking
Within a Compose project, services on the same network can resolve each other by service name. That is one of the best reasons not to publish unnecessary ports.
If app and db share the backend network, the app can connect to db:5432 without the database port being reachable from the host at all.
Validate from inside the app container:
docker compose exec app getent hosts db
Or test the connection if the needed client tools exist in the image:
docker compose exec app sh -lc 'nc -vz db 5432'
That kind of check tells me quickly whether the problem is DNS, networking, or the application itself.
expose versus ports: the security difference matters
This is a subtle but important distinction.
ports publishes container ports to the host:
ports:
- "8080:8080"
That means the service is reachable from outside the container network, subject to host firewalling and bind address behavior.
expose documents or exposes ports only to linked services on Docker networks; it does not publish them to the host:
expose:
- "8080"
A lot of articles make expose sound more important than it is. Containers on the same user-defined network can usually communicate on open ports regardless. But the conceptual difference still matters: ports creates host exposure, expose does not.
When I review a Compose file for security, I start by asking which published ports are actually necessary. That question alone often removes half the attack surface.
If the service needs TLS termination or stricter edge handling, I usually place a reverse proxy in front and keep the rest internal. The TLS in Docker guide and the Nginx reverse proxy SSL guide are the two references I return to most.
Profiles for conditional services
Profiles are handy when one Compose file serves multiple use cases.
Example:
services:
mailhog:
image: mailhog/mailhog
profiles:
- dev
adminer:
image: adminer
profiles:
- debug
Start only the default services:
docker compose up -d
Start with a specific profile:
docker compose --profile dev up -d
I use this for debugging helpers, local mail capture, one-off admin UIs, and development-only extras. It is cleaner than maintaining entirely separate files for tiny differences.
Multiple files and the -f override pattern
For environment-specific changes, I often use a base file plus one override.
Example:
docker compose -f compose.yaml -f compose.prod.yaml up -d
Common pattern:
compose.yaml— shared baselinecompose.dev.yaml— development bind mounts and debug settingscompose.prod.yaml— production image tags, restart policies, resource settings
Render the merged result before deployment:
docker compose -f compose.yaml -f compose.prod.yaml config
That catches a lot of mistakes early, especially when arrays and environment settings merge in ways you did not expect.
Secrets in Compose: better than plain environment variables, but know the limits
Compose supports secrets mounted as files, which I prefer to putting passwords straight into environment variables.
Example:
secrets:
postgres_password:
file: ./secrets/postgres_password.txt
services:
db:
image: postgres:16
secrets:
- postgres_password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
This is better operational hygiene, but it is not a full enterprise secret-management platform by itself. The secret still exists on disk at the source path, so filesystem permissions and deployment practices matter.
For bigger environments, I prefer integrating with a dedicated secret manager or CI/CD secret injection rather than turning Compose into a vault.
Updating services without surprising yourself
Compose makes redeploys easy, but it also makes it easy to assume too much. If the service uses a prebuilt image tag from a registry, I normally refresh images explicitly before restarting:
docker compose pull
docker compose up -d
If the service builds locally from a Dockerfile, I rebuild on purpose:
docker compose up -d --build
I do not trust vague memory here because Compose will happily reuse an existing image when nothing told it to rebuild or pull. That is one of the most common reasons people think a fix has been deployed when it has not.
On production hosts I also like to inspect the effective image and command after a change:
docker compose ps
docker inspect $(docker compose ps -q app) --format '{{.Config.Image}} {{.Path}} {{join .Args }}'
That last check has saved me from tag mistakes, stale images, and more than one bad override file.
The operational commands I use most
Bring the stack up:
docker compose up -d
Follow logs from all services:
docker compose logs -f
Follow logs from one service:
docker compose logs -f app
See status:
docker compose ps
Run a shell for debugging:
docker compose exec app sh
Rebuild after Dockerfile or code changes:
docker compose up -d --build
This last one catches people constantly. They change the application code or the Dockerfile, run docker compose up -d, and assume the image changed automatically. It did not unless the container uses a bind mount for that code path or you explicitly rebuilt.
For image construction strategy, the Dockerfile best practices guide is the next article I would read.
down versus stop
These are not the same.
Stop running containers but keep them and the networks around:
docker compose stop
Remove containers and networks created by the project:
docker compose down
Remove containers, networks, and anonymous volumes:
docker compose down -v
I am careful with -v. It is easy to delete data you meant to keep, especially if you have not fully mapped which volumes are named and which are anonymous.
Default network versus named networks
Compose gives you a default project network automatically. For many small stacks, that is fine. I still create named networks when I want clearer separation between tiers or when multiple projects intentionally share a network.
Example:
networks:
backend:
frontend:
Then attach services only where needed. That small bit of design usually makes the stack easier to reason about six months later.
Troubleshooting patterns I keep using
A few recurring checks save a lot of time.
The container is up but the app is not reachable
docker compose ps
ss -tulpn
docker compose logs app --tail 100
Maybe the process is listening only on 127.0.0.1 inside the container. Maybe the port mapping is wrong. Maybe the health check is failing and the app never finished booting.
The app cannot reach the database
docker compose exec app getent hosts db
docker compose exec app sh -lc 'nc -vz db 5432'
docker compose logs db --tail 100
I verify DNS, TCP reachability, and the actual database startup state before staring at application code.
File permission problems on mounted paths
ls -ln ./uploads
docker compose exec app id
UID and GID mismatches are common, especially when images run as non-root users.
Changes do not seem to apply
docker compose config
docker compose up -d --build
Most of the time this is a rebuild or merge-expectation issue.
Conclusion
Docker Compose is at its best when I keep it boring: explicit services, minimal published ports, health checks where startup ordering matters, named volumes for real data, and separate handling for secrets. That is enough for a large share of real deployments.
I do not expect Compose to solve orchestration at cluster scale. I do expect it to give me a clean, repeatable way to run multi-container applications on one host without hidden magic. Used that way, it remains one of the most practical tools in the Docker ecosystem.