Docker Compose Best Practices
A Compose file can start small and still become difficult to maintain once more services, networks, volumes, and configuration are added.
The goal of best practices isn’t to make every Compose file look complicated. It is to keep the file clear, predictable, safe, and easy to change as the application grows.
You’ve now seen the major Compose building blocks. This lesson brings those ideas together and focuses on the decisions that make a Compose project easier to work with.
Keep The Compose File Easy To Read
A Compose file is configuration, but it is also a description of your application’s architecture.
For example:
services:
frontend:
image: alpine
networks:
- frontend-network
backend:
image: alpine
networks:
- frontend-network
- backend-network
database:
image: alpine
networks:
- backend-network
volumes:
- database-data:/data
networks:
frontend-network:
backend-network:
volumes:
database-data:Even without running it, you can understand the major relationships:
frontend
│
│ frontend-network
▼
backend
│
│ backend-network
▼
database
│
▼
database-dataThat is a useful property of a Compose file.
Good Compose configuration should make the application’s structure easier to understand, not harder.
Avoid adding configuration merely because Compose supports it.
Give Services Clear Names
Service names are important because they appear throughout your Compose configuration and become useful when services communicate with one another.
Compare:
services:
a:
image: alpine
b:
image: alpine
c:
image: alpinewith:
services:
frontend:
image: alpine
backend:
image: alpine
database:
image: alpineThe second version communicates much more.
Instead of thinking:
a → b → cyou can immediately understand:
frontend → backend → database
Use names that describe the role of the service.
## Don't Create Networks Just Because You Can
Compose automatically provides a default network for a project.
For a simple application:
```yaml
services:
app:
image: alpine
database:
image: alpineyou don’t necessarily need to define a custom network.
Both services can use the default Compose network.
Add explicit networks when they represent a real communication boundary.
For example:
services:
frontend:
image: alpine
networks:
- frontend-network
backend:
image: alpine
networks:
- frontend-network
- backend-network
database:
image: alpine
networks:
- backend-network
networks:
frontend-network:
backend-network:Here the networks communicate something meaningful:
frontend ↔ backend
backend ↔ databaseThe network structure is part of the architecture.
Tip
Start with the default network. Introduce explicit networks when you have a reason to separate communication paths.
Don’t Add Volumes To Stateless Services
A volume exists to preserve data beyond the lifecycle of a container.
If a service does not need persistent data, don’t attach a volume simply because another service uses one.
For example:
services:
app:
image: alpine
database:
image: alpine
volumes:
- database-data:/data
volumes:
database-data:The database needs persistent storage.
The application may not.
Think about the question:
What data must survive if this container is replaced?
That question should drive your volume design.
Treat Persistent Data Carefully
You already saw the difference between:
docker compose downand:
docker compose down -vThe first normally removes the containers while keeping Compose-managed named volumes.
The second also removes those volumes.
So:
docker compose down
│
├── containers → removed
└── volumes → kept
docker compose down -v
│
├── containers → removed
└── volumes → removedThis matters especially for databases.
Warning
Be careful with docker compose down -v. It can remove persistent application data stored in Compose-managed named volumes.
For development, deleting volumes may be convenient when you want a completely clean environment.
For important data, it is a destructive operation and should be treated accordingly.
Keep Configuration Separate From The Compose Structure
You learned that environment variables can keep changing values outside the main Compose structure.
For example:
services:
app:
image: alpine
environment:
APP_MODE: ${APP_MODE}Then .env can provide:
APP_MODE=developmentThe important separation is:
compose.yaml
│
└── application structure
.env
│
└── configuration valuesThis makes it easier to use the same Compose structure with different configuration values.
For example:
development → APP_MODE=development
testing → APP_MODE=testingYou don’t have to duplicate the entire Compose file just because a configuration value changes.
Don’t Treat .env As A Secret Store
This deserves special attention.
A .env file can contain:
APP_MODE=development
APP_PORT=8080Those are ordinary configuration values.
But putting:
DB_PASSWORD=my-passwordinto .env doesn’t make the password secure.
The value is still sitting in a normal file.
For sensitive configuration, you’ve already seen Compose secrets:
services:
app:
image: alpine
secrets:
- app_password
secrets:
app_password:
file: ./app_password.txtThe distinction is:
ordinary configuration → environment variables
sensitive configuration → secretsWarning
Don’t commit real passwords, API credentials, or other sensitive values to a project repository just because they are inside a .env or secret source file.
For the tutorial examples, the values are intentionally fake. In a real project, secret handling needs additional care.
Use Secrets Only When You Actually Have Sensitive Data
The opposite mistake is also possible.
Not every configuration value needs to become a secret.
For example:
APP_MODE=development
LOG_LEVEL=infodoesn’t normally need secret handling.
Turning every ordinary setting into a secret can make the configuration harder to understand.
A simple rule is:
Is it sensitive?
│
┌─┴─┐
yes no
│ │
secret environment/configurationChoose the mechanism based on the value’s purpose.
Use x- Reuse Carefully
You’ve seen how x- extension fields and YAML anchors can reduce repetition.
For example:
x-common-environment: &common-environment
APP_MODE: development
LOG_LEVEL: info
services:
frontend:
image: alpine
environment:
<<: *common-environment
backend:
image: alpine
environment:
<<: *common-environmentThis is useful because the same configuration genuinely belongs to both services.
But abstraction has a cost.
Compare:
services:
app:
image: alpine
environment:
APP_MODE: developmentwith a file containing several layers of reusable anchors just to avoid repeating one line.
The second may be technically clever but harder to understand.
Don’t optimize for the fewest lines. Optimize for the clearest configuration.
Use x- elements when they make shared configuration easier to maintain.
Prefer One Source Of Truth
If several services should use the same configuration, try to define that configuration in one logical place.
For example:
shared configuration
│
├── frontend
├── backend
└── workerThis reduces the chance of accidental differences.
The same principle applies to application structure.
If a network is meant to represent one communication boundary, define it once and attach the appropriate services to it.
If a volume represents persistent database storage, define that volume once and mount it where needed.
The goal is consistency.
Avoid Hard-Coding Values That Should Change
Suppose you write:
services:
app:
image: alpine
environment:
APP_MODE: developmentThat’s fine if the service should always use development.
But if the value changes between environments, consider externalizing it:
services:
app:
image: alpine
environment:
APP_MODE: ${APP_MODE}Then:
APP_MODE=developmentcan be supplied through the environment or .env.
The rule isn’t:
“Never hard-code values.”
The better rule is:
Hard-code stable configuration. Externalize configuration that genuinely needs to vary.
Keep Development Configuration Understandable
Compose is often used to make development environments reproducible.
That doesn’t mean the Compose file should become a giant configuration system.
A developer should be able to look at it and understand:
What services run?
↓
How do they communicate?
↓
What data persists?
↓
What configuration do they need?If those answers are obvious from the file, you’ve already achieved something valuable.
Use The Lifecycle Commands Consistently
You have learned the Compose lifecycle:
docker compose up -dstarts the application in the background.
docker compose downstops and removes the application’s containers and Compose-managed resources according to their lifecycle rules.
For example, a common development cycle is:
change configuration
│
▼
docker compose up -d
│
▼
test application
│
▼
make another change
│
▼
repeatWhen you are finished with the application:
docker compose downUse the commands according to their purpose rather than manually managing every container individually.
That is one of the main reasons you introduced Compose in the first place.
Keep The Application As A Unit
One of the biggest advantages of Compose is that the application becomes a unit.
Instead of thinking:
container A
container B
container C
network A
network B
volume Ayou can think:
Compose Application
│
┌──────────────┼──────────────┐
│ │ │
services networks volumesThe resources still exist individually inside Docker.
Compose simply gives you a higher-level way to describe and manage their relationships.
This is the mental shift worth keeping:
Docker manages individual resources. Compose lets you describe an application made from those resources.
Don’t Make The Production Simulation Unrealistically Complex
Your final Compose lesson will build a larger production-style example.
There is an important temptation to add every possible feature:
many services
many networks
many volumes
many environment variables
many anchors
many configuration filesThat can make an example look impressive while teaching very little.
A good example should have a reason for each component.
For example:
frontend
│
▼
backend
│
▼
database
│
▼
persistent volumeIf a second network is introduced, there should be a reason.
If a secret is introduced, there should be a sensitive value that needs it.
If shared configuration is introduced, multiple services should genuinely share it.
The goal is not to demonstrate every Compose feature at once.
The goal is to make the reader make decisions.
A Practical Checklist
Before considering a Compose file finished, ask:
| Question | What You Are Checking |
|---|---|
| What services exist? | The application structure is clear |
| Which services communicate? | Network design is intentional |
| What data must survive? | Volumes are intentional |
| Which values change between environments? | Configuration is externalized where useful |
| Which values are sensitive? | Secrets are handled separately |
| Is configuration repeated? | x- reuse may be appropriate |
| Is the file easy to understand? | Abstraction hasn’t gone too far |
What happens during down? | Resource lifecycle is understood |
Could down -v destroy needed data? | Persistent storage is protected |
This checklist is more useful than memorizing a list of rules.
A Good Compose File Has A Clear Story
When you read a well-structured Compose file, you should be able to follow a story:
These are my services.
│
▼
These services need to communicate.
│
▼
These networks provide those boundaries.
│
▼
This service owns persistent data.
│
▼
This volume keeps that data outside
the container lifecycle.
│
▼
These values configure the services.
│
▼
These values are sensitive,
so they are handled as secrets.
│
▼
This repeated configuration is shared
because several services need it.That is the real purpose of Compose configuration.
The syntax is important, but the architecture behind the syntax is more important.
What You Should Remember
The best Compose file isn’t the one with the most features.
It’s the one where every piece of configuration has a clear reason to exist.
Keep these principles in mind:
Clear service names
+
Intentional networks
+
Intentional volumes
+
Externalized changing configuration
+
Separate handling for secrets
+
Careful reuse
=
Maintainable Compose configurationAnd keep the larger mental model:
Compose describes an application as a group of services, their communication, their persistent data, and their configuration. Good practices keep those relationships explicit and understandable.
You’ve now covered the main Compose building blocks.
The final lesson puts them together in one Zero-to-Hero Example. Instead of simply repeating the previous examples with more services, you’ll build a realistic application step by step and make decisions about its services, networks, volumes, configuration, secrets, and reusable settings.