← Back to DevBytes

Docker Compose Multiple Dockerfiles: Best Practices and Common Pitfalls

Understanding Docker Compose and Multiple Dockerfiles

Docker Compose is the go-to tool for defining and running multi-container Docker applications. By default, each service in a docker-compose.yml file builds from a Dockerfile assumed to be named Dockerfile and located at the root of the build context. However, real-world projects often demand multiple Dockerfiles β€” for example, separate development and production configurations, distinct build stages, or microservices with their own containerization logic. This tutorial dives deep into leveraging multiple Dockerfiles with Docker Compose, covering syntax, strategies, best practices, and the pitfalls that can waste hours of debugging.

What It Is

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

When we talk about multiple Dockerfiles in Docker Compose, we mean using the dockerfile property inside the build section of a service definition to point to a Dockerfile with a custom name or path, rather than relying on the default Dockerfile. Additionally, you can use multiple Compose files (like docker-compose.yml and docker-compose.override.yml) that reference different Dockerfiles for different environments.

A typical scenario looks like this:

Why It Matters

Relying on a single Dockerfile for every service or environment quickly becomes a maintenance nightmare. Multiple Dockerfiles bring clear benefits:

How to Use Multiple Dockerfiles in Docker Compose

The Basic Syntax

Inside your docker-compose.yml, the build key for a service accepts two crucial properties when using a non-default Dockerfile:

Here’s a minimal example:

version: "3.9"
services:
  web:
    build:
      context: ./web
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - ./web/src:/app/src
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.api
    ports:
      - "8000:8000"

In this example, the web service uses Dockerfile.dev inside the web directory, while the api service uses Dockerfile.api inside the api directory. The default Dockerfile is ignored for these services because we explicitly set dockerfile.

Using Multiple Compose Files for Different Environments

A powerful pattern is to keep a base docker-compose.yml and override it with environment-specific files. This lets you swap Dockerfiles per environment seamlessly.

Example project structure:

project/
β”œβ”€β”€ docker-compose.yml          # base configuration
β”œβ”€β”€ docker-compose.dev.yml      # overrides for development
β”œβ”€β”€ docker-compose.prod.yml     # overrides for production
β”œβ”€β”€ web/
β”‚   β”œβ”€β”€ Dockerfile.dev
β”‚   └── Dockerfile.prod
└── api/
    β”œβ”€β”€ Dockerfile.dev
    └── Dockerfile.prod

Base docker-compose.yml (no build defined, or only defaults):

version: "3.9"
services:
  web:
    image: myapp-web
  api:
    image: myapp-api

Development override docker-compose.dev.yml:

version: "3.9"
services:
  web:
    build:
      context: ./web
      dockerfile: Dockerfile.dev
    volumes:
      - ./web/src:/app/src
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.dev
    volumes:
      - ./api:/app

Production override docker-compose.prod.yml:

version: "3.9"
services:
  web:
    build:
      context: ./web
      dockerfile: Dockerfile.prod
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.prod

Run them with:

# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build

Build Arguments and Multiple Dockerfiles

You can pass build-time arguments (--build-arg) to customize the same Dockerfile for different scenarios, but using separate Dockerfiles often yields cleaner logic. Still, combining both approaches is valid:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.app
      args:
        ENVIRONMENT: production
        VERSION: 2.0.1

The corresponding Dockerfile.app would use ARG ENVIRONMENT and ARG VERSION.

Best Practices

1. Use Descriptive Dockerfile Names

Avoid cryptic names like D1 or df. Instead, adopt a naming convention that immediately communicates intent: Dockerfile.dev, Dockerfile.prod, Dockerfile.worker, Dockerfile.api. This helps new team members and CI/CD pipelines understand the build process without digging into Compose files.

2. Keep Dockerfiles Focused and Single-Purpose

Each Dockerfile should represent one clear environment or component. If a Dockerfile tries to cover both dev and prod with huge if blocks, split it. Use multi-stage builds to share common base layers but keep the final stage environment-specific.

3. Always Specify context Explicitly

When using the dockerfile property, always define context explicitly. It's easy to assume the context is the current directory, but Docker Compose resolves paths relative to the Compose file location. Explicit context avoids nasty surprises when running from different directories or with -f flags.

4. Leverage .dockerignore Per Context

Each build context should have its own .dockerignore file to prevent sending unnecessary files to the daemon. This speeds up builds and reduces image size. For example, the web/ context might ignore node_modules, while api/ ignores __pycache__.

5. Use Compose File Overrides for Environment Switching

Rather than maintaining multiple nearly identical Compose files, use a base file with overrides. This keeps shared settings (ports, volumes, environment variables) in one place and only changes the build section. It reduces duplication and makes it clear what differs between environments.

6. Version Control All Dockerfiles and Compose Files Together

Keep Dockerfiles and Compose files in the same repository, ideally co-located with the service code they build. This ensures version alignment and allows anyone to reproduce the exact environment from a single commit.

7. Document the Build Process

Include a short README or comments in the Compose file explaining which Dockerfile is used for what. This is especially useful when you have a Dockerfile.ci or experimental Dockerfiles that aren't obvious.

8. Use Profiles or Services Selectively

Docker Compose profiles (version 3.9+) allow you to mark services that are only started in certain scenarios. You can combine this with multiple Dockerfiles: for instance, a debug service using a Dockerfile.debug is only enabled when the profile is active, keeping the default setup lean.

services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
  app-debug:
    build:
      context: ./app
      dockerfile: Dockerfile.debug
    profiles:
      - debug

Then start with docker compose --profile debug up.

Common Pitfalls

1. Wrong Relative Paths for Dockerfile or Context

This is the most frequent mistake. The dockerfile path is relative to the context, not the Compose file. For example, with:

context: ./services/web
dockerfile: ../Dockerfile.common

Docker will look for Dockerfile.common in the parent directory of services/web. But if you intended it to be relative to the Compose file, the build fails. Always double-check your directory structure and test with docker compose build --dry-run if available.

2. Forgetting to Specify dockerfile and Falling Back to Default

If you only set context but omit dockerfile, Compose will look for a file named exactly Dockerfile in the context root. This can lead to confusing errors or, worse, building the wrong image silently. Explicit is better than implicit.

3. Mixing Build Contexts Inappropriately

A common anti-pattern is using a huge monorepo root as the context and then specifying Dockerfiles deep inside subdirectories. This sends the entire repository to the Docker daemon, causing slow builds and potentially leaking sensitive files. Keep contexts tight β€” only what the Dockerfile actually needs.

4. Inconsistent Base Images Across Dockerfiles

If you maintain separate Dockerfile.dev and Dockerfile.prod, ensure they derive from the same base image (or a compatible one). A production image based on alpine while the dev image uses ubuntu might hide bugs due to differing libc or package versions. Use multi-stage builds to share a common base stage.

5. Cache Busting and Layer Ordering Issues

When switching between Dockerfiles, cached layers can be reused if the instructions are identical up to a point. However, if you duplicate the same RUN steps across multiple Dockerfiles, a change in one won't invalidate the other, leading to inconsistent behavior. Keep common steps in a base Dockerfile and use FROM in the specific ones, or rely on build arguments for variation.

6. Not Testing Both Builds Locally

Developers sometimes only test the dev Dockerfile and assume the production one works because it’s β€œjust a stripped-down version.” A missing runtime dependency, wrong entrypoint, or changed file permissions can cause production failures. Always build and smoke-test both Dockerfiles locally before pushing to CI.

7. Hardcoding Paths in Dockerfile Instead of Using ARG

When the same Dockerfile serves multiple services through build arguments, avoid hardcoding paths that rely on a specific context structure. Use ARG to inject paths or configuration, making the Dockerfile reusable across different contexts.

8. Overlooking .dockerignore Mismatch

If you change the context or add a new Dockerfile, ensure the corresponding .dockerignore exists and is up to date. A forgotten .dockerignore can accidentally include secrets or massive node_modules directories, bloating the build context and potentially leaking sensitive data into the image.

Conclusion

Mastering multiple Dockerfiles with Docker Compose unlocks clean, environment-specific builds and smooth multi-service development workflows. The key is to use explicit context and dockerfile properties, adopt descriptive naming, and leverage Compose file overrides to keep configurations DRY. Watch out for relative path pitfalls, oversized build contexts, and environment inconsistencies. With these practices in your toolbelt, your containerized projects will scale gracefully and remain a pleasure to maintain.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles