DEV Community

Cover image for Docker Compose: Speed Up Your Workflow with Profiles, Extends, and Depends_on
Altair Lage
Altair Lage

Posted on

Docker Compose: Speed Up Your Workflow with Profiles, Extends, and Depends_on

If you are reading this article, probably you have already found yourself wrestling with a docker-compose.yml file.

Whether you are a backend developer, DevOps engineer, cloud engineer, or just getting started with Docker, it is common for Docker Compose to become more challenging in day-to-day work as a project grows. Maybe you have services that are only needed occasionally, like debugging or testing tools, repeated configuration across different services, or errors caused by one container starting before another one is actually ready.

Fortunately, Docker Compose provides advanced features to help with these scenarios and make your development environment more organized and flexible. Three of the main features are profiles, extends, and depends_on. That is what we will explore in this article.

In this article, we will look at each of these features with explanations and practical examples. You will learn how to use profiles to enable or disable optional services when needed, extends to prevent redundancy and repeated code by following the DRY principle (Don’t Repeat Yourself) and depends_on to manage the container startup sequence.

We will also cover a few best practices and how these features can simplify the workflow of development teams.

Profiles: Controlling Optional Services

In many projects, besides the main services, such as your web application and database, there are auxiliary services used only in specific situations, like an integration testing container, a monitoring tool, or a debugging utility. Including all these services in your Docker Compose file and starting them every time can waste resources and time. This is where Docker Compose profiles come in.

The profiles feature allows you to enable services selectively. We can assign one or more profiles to specific services, making them start only when the corresponding profile is enabled. Services without a defined profile always start by default, while services assigned to a profile only start when you explicitly request that profile. This is excellent for keeping both essential services and optional debugging, testing, or tooling services in the same Docker Compose file, without having them running all the time.

Imagine you have a main application and a database that should always run in development, but you also have a container for running integration tests and another one for a fake SMTP server, used to capture emails in development. These extra services are not essential. We can mark them as optional:

version: "3.9"  
services:  
  app:  
    image: my-application:1.0  
    # ... (web application settings, ports, volumes, etc.) Example:  
    # ports:  
    #   - "8000:8000"  
    # depends_on:  
    #   - db  

  db:  
    image: postgres:15  
    environment:  
      - POSTGRES_DB=mydatabase  
      - POSTGRES_USER=user  
      - POSTGRES_PASSWORD=password  
    # Database service always required during development  

  tests_runner:  
    image: my-tests:latest  
    profiles: ["test"]  
    # Non-essential service for running integration tests on demand  

  mailhog:  
    image: mailhog/mailhog  
    profiles: ["devtools"]  
    # Optional service for capturing and viewing emails sent by the app in development
Enter fullscreen mode Exit fullscreen mode

In the YAML above, app and db do not have a profile, so they will always be started. tests_runner, on the other hand, is associated with the "test" profile, and mailhog with the "devtools" profile. This means that, by default, these two services are not executed unless you explicitly specify them.

In practice, you can control this through the Docker Compose command line:

  • Running docker compose up without profiles would start only the default services: app and db.
  • Running docker compose --profile test up would start app, db, and also the tests_runner service, because the test profile was enabled.
  • Running docker compose --profile devtools up would start app, db, and mailhog. You can also enable multiple profiles at once, for example: docker compose --profile test --profile devtools up to include both, or use docker compose --profile "*" to start all services from all profiles.

This way, profiles allow you to keep a single Docker Compose file with everything your project may need, while turning services on or off depending on the situation. This makes your development environment lighter and faster for everyday work. For example, one team member may run docker compose up and focus only on the essentials, while another, debugging a specific problem, may enable the devtools profile to inspect detailed logs or capture emails.

Important best practices:

Do not put your main containers inside a profile. Leave them without a profile so they always start by default. Reserve profiles for optional components or scenario-specific services, such as testing, debugging, monitoring, and so on.

Name profiles clearly: Choose names that make their purpose obvious, such as test, dev, debug, monitoring, or ci. Avoid generic terms so everyone on the team understands when to use them. Document the available profiles and how to enable them in the project README.

Reusing Configuration with extends and Removing Repetition

As your Docker Compose configuration grows, you may notice that several services share common settings. For example, imagine we have two services inside a web application: a web service and a background processing worker service. Both use the same base image, mount the same volume, and need the same environment variables, such as database connection strings, credentials, and so on. Repeating identical configuration blocks for each service makes the Compose file longer, more repetitive, and harder to maintain. Every change requires editing multiple places, which goes against the DRY (Don’t Repeat Yourself) principle.

To avoid duplication, Docker Compose allows you to extend common configuration across services. The extends feature works like inheritance: you define a base service, which may even live in another file, with the shared options, and then other services “inherit” that base while overriding or adding parameters as needed. In YAML, we can also use anchors and aliases to achieve a similar effect in a simple way inside the same file.

Example

We have two services, web and worker, which share a large part of their configuration. Let’s create a reusable base configuration and apply it to both.

# We define a YAML anchor with the shared settings  
x-base-service: &common_config    
  image: my-application:1.0  
  volumes:  
    - .:/app  # mounts the current directory into the container (useful for development)  
  environment:  
    - DATABASE_URL=postgres://user:password@db:5432/mydatabase  
  restart: "always"  
  # ... (any other shared option, e.g., network, log settings, etc.)  

services:  
  web:  
    <<: *common_config      # Imports all settings defined in common_config  
    command: npm start     # Specific command to run the web application  
    depends_on:  
      - db                 # (example: web depends on db being running)  

  worker:  
    <<: *common_config      # Reuses the same base configuration  
    command: npm run worker  # Specific command to run the background worker  

  db:  
    image: postgres:15  
    # ... (database settings)
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, we use a special key, x-base-service, at the top of the YAML file to define a shared configuration block identified by &common_config.

Then, in the web and worker services, we use <<: *common_config to merge those shared settings into each service.

As a result, both services get identical image, volumes, environment, and restart values from the template, and we only add what changes in each one. In this case, the specific command and, in the web service, the database dependency.

If tomorrow you need to change an environment variable or a logging option for all services, you only need to edit one place, the anchor block, and the web and worker containers will automatically receive the update. Much simpler than remembering to change the same setting in two or three different places.

It is worth noting that Compose also supports the native **extends** directive. With it, you could, for example, have a common-services.yml file that defines a base service and then, in your docker-compose.yml, make a service extend that external definition. The effect is the same: configuration reuse. It also allows you to override some values when needed.

Example:

Imagine the common-services.yml file in this example has the following configuration:

services:  
  webapp:  
    build: .  
    ports:  
      - "8000:8000"  
    volumes:  
      - "/data"
Enter fullscreen mode Exit fullscreen mode

You could then extend these settings in your docker-compose.yml using the extends directive in the desired services:

services:  
  web:  
    build: alpine  
    command: echo  
    extends:  
      file: common-services.yml  
      service: webapp  
  webapp:  
    extends:  
      file: common-services.yml  
      service: webapp
Enter fullscreen mode Exit fullscreen mode

Result

You will get exactly the same result as if you had written a docker-compose.yaml file with the same build, ports, and volumes configuration values defined directly, or hardcoded.

Use the approach you prefer: YAML anchors are great within a single file, while extends shines when splitting Compose files across multiple files.

Benefits for the workflow

By removing duplication, you reduce errors and keep your Compose code cleaner. In development teams, this means all members share consistent settings. If several microservices use the same image or variables, you make sure they are all using exactly the same values. The file also becomes smaller and easier to understand. New developers can quickly identify what is common to all services and what is specific to each one.

Orchestrating Startup Order with depends_on

Another classic challenge in multi-container environments is making sure certain services only start when others are already ready. Imagine your web API depends on a database. If the API container starts before the database is up, the application will probably fail when trying to connect, and its startup may even be compromised. These situations are frustrating, but Docker Compose helps us with the depends_on parameter.

depends_on allows you to declare explicit dependencies between services, making Compose start containers in the correct order. In the short syntax, you simply list the names of the services that another service depends on, and Compose will make sure to start the dependency containers first and stop them last. For example:

services:  
  api:  
    image: my-api:latest  
    depends_on:  
      - db  
      - redis  
  db:  
    image: mysql:8.0  
  redis:  
    image: redis:7-alpine
Enter fullscreen mode Exit fullscreen mode

In the example above, when running docker compose up, Compose will first start the db and redis services, and only then start the api container. Likewise, when stopping the containers, it would stop api first before shutting down db and redis. This helps avoid many startup and shutdown ordering problems.

But what if we want to make sure the database is really ready for connections before starting the API?

The good news is that newer versions of Compose support the long syntax for depends_on, which includes conditions. We can specify a condition such as service_healthy to indicate that the dependency service only counts as “ready” when its healthcheck is OK. Let’s improve the example by adding a healthcheck to the database:

services:  
  api:  
    image: my-api:latest  
    depends_on:  
      db:  
        condition: service_healthy   # waits for the db healthcheck to pass  
      redis:
        condition: service_started   # waits for redis to start (container started only)  
    # ... (remaining API config, ports, etc.)  

  db:  
    image: mysql:8.0  
    healthcheck:  
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]  # checks whether MySQL responds  
      interval: 5s  
      timeout: 5s  
      retries: 5  

  redis:  
    image: redis:7-alpine  
    # (we can also include a healthcheck here if we want to monitor readiness)
Enter fullscreen mode Exit fullscreen mode

Now Compose will wait for the MySQL healthcheck to return success. In this case, the mysqladmin ping command indicates that the server is responding before starting the api service.

For Redis, we use service_started simply to make sure the container has started. There is no wait for a “Healthy” status, because we may not have defined a healthcheck for it.

This configuration reflects a common scenario: waiting for the database to become available and also making sure the Redis cache has started before the application goes online. In practice, api only starts initializing when Compose verifies that db is healthy and redis is already running, avoiding immediate “could not connect to the database” failures.

Important tips:

To use condition: service_healthy, make sure to define an appropriate healthcheck on the dependency service. Otherwise, Compose will have no way to know its health status and will treat the container as ready as soon as the process starts.

In the example, we use a native MySQL command, mysqladmin ping, to check availability. For PostgreSQL, for example, there is pg_isready.

Also keep in mind that, although Compose waits for the healthcheck OK, it is still recommended that your application have connection retry logic.

This gives you extra robustness if an unexpected condition occurs, such as a slight delay even after the healthcheck or a temporary connection loss. In short, depends_on handles the initial orchestration, and it already helps a lot, but good resilience practices in the application are never too much.

In a team development context, using depends_on, especially with healthchecks, standardizes how everyone starts the environment. New developers do not need to “guess” the order for starting each service manually or run wait scripts. A simple docker compose up already does everything in the right sequence. This greatly reduces the kind of “it works on my machine” errors caused by startup race conditions, making everyone’s workflow more reliable.

How do these features speed up teamwork?

Profiles

Flexibility for different scenarios: They allow each team member to run only the set of services needed for their task. This speeds up the development cycle.

For example, running integration tests without starting the entire stack, or running debugging tools only when they are actually needed. Teams can define standard profiles, such as dev, test, and debug, and avoid the need for multiple Compose files for environment variations. The result is a customizable environment that is still centralized in a single file.

Extends

Consistent configuration and less repeated code: By reusing settings with extends or anchors, you make sure related services share identical parameters where it makes sense: the same base image, the same credentials, the same logging policies, and so on.

This prevents discrepancies that could cause “it works here but not there” problems. Reducing repetition also makes editing the Compose file faster. A change to a shared port or variable is reflected everywhere, saving time and avoiding forgotten updates. In teams, this consistency means fewer configuration bugs and smoother onboarding for anyone reading or editing the Compose file for the first time.

Depends_on

Ordered startup and fewer failures: With dependencies properly defined and healthcheck conditions used when applicable, starting the environment becomes much more reliable. Developers do not need to perform manual steps to make sure “the database is already up” before running the application, for example. In CI pipelines and local demos, everything starts in the right order automatically. This reduces time wasted on silly troubleshooting like “oh, never mind, I just had to start service X first…” and keeps the focus on the application logic.

In short, these advanced Docker Compose features work as small productivity multipliers for teams: less time spent adjusting configuration and more time working on the application itself.

tha-tha-tha-that's all, folks!

Docker Compose can look simple at first, but features like profiles, extends, and depends_on make a real difference when a project starts to grow. With profiles, you keep optional services under control. With extends and YAML anchors, you reduce repetition and make configuration easier to maintain. With depends_on and healthchecks, you make local and team environments more predictable.

Used together, these features help keep your Compose files cleaner, your development workflow faster, and your team less exposed to avoidable configuration problems. And that is exactly the kind of small improvement that pays off every day.

Top comments (0)