Skip to content

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-data

That 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: alpine

with:

services:
  frontend:
    image: alpine

  backend:
    image: alpine

  database:
    image: alpine

The second version communicates much more.

Instead of thinking:

a → b → c

you 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: alpine

you 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  ↔ database

The 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 down

and:

docker compose down -v

The 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    → removed

This 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=development

The important separation is:

compose.yaml
    │
    └── application structure


.env
    │
    └── configuration values

This makes it easier to use the same Compose structure with different configuration values.

For example:

development → APP_MODE=development
testing     → APP_MODE=testing

You 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=8080

Those are ordinary configuration values.

But putting:

DB_PASSWORD=my-password

into .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.txt

The distinction is:

ordinary configuration → environment variables

sensitive configuration → secrets

Warning

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=info

doesn’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/configuration

Choose 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-environment

This is useful because the same configuration genuinely belongs to both services.

But abstraction has a cost.

Compare:

services:
  app:
    image: alpine
    environment:
      APP_MODE: development

with 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
        └── worker

This 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: development

That’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=development

can 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 -d

starts the application in the background.

docker compose down

stops 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
       │
       ▼
repeat

When you are finished with the application:

docker compose down

Use 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 A

you can think:

                 Compose Application
                         │
          ┌──────────────┼──────────────┐
          │              │              │
       services       networks       volumes

The 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 files

That 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 volume

If 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:

QuestionWhat 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 configuration

And 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.

Last updated on