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:
- Microservices: Each service has its own dedicated Dockerfile, often named something like
Dockerfile.service-aorDockerfile.api. - Multi-stage builds: You might have a
Dockerfile.devfor hot-reload development and aDockerfile.prodfor an optimized production image. - Base images: One Dockerfile creates a common base layer, and others use it as a foundation β though this is more about multi-stage builds, Compose can orchestrate building those different stages.
Why It Matters
Relying on a single Dockerfile for every service or environment quickly becomes a maintenance nightmare. Multiple Dockerfiles bring clear benefits:
- Separation of concerns: Each service or environment gets exactly the dependencies and instructions it needs, without bloating a single Dockerfile with conditional logic.
- Optimized images: Production images can be slimmed down (no dev tools, minimal layers), while development images include live-reload, debugging tools, and mounted volumes.
- Parallel development: Teams can work on different services independently, each with its own Dockerfile, without stepping on each otherβs toes.
- Clarity and maintainability: Naming Dockerfiles descriptively (
Dockerfile.api,Dockerfile.worker) makes the project structure self-documenting.
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:
context: The path to the build context (the directory sent to the Docker daemon).dockerfile: The path to the Dockerfile relative to the context, including the filename.
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.