← Back to DevBytes

Docker Compose Multiple Dockerfiles: Production Guide

Understanding Multiple Dockerfiles in Docker Compose

When working with Docker Compose in production environments, you often encounter scenarios where a single Dockerfile doesn't suffice. Docker Compose allows you to specify multiple Dockerfiles across different services, enabling each service to have its own optimized build context, base image, and build steps. This approach is fundamentally different from using a monolithic Dockerfileβ€”it's about composing distinct, purpose-built containers that work together.

The mechanism is straightforward: in your docker-compose.yml file, each service can point to its own dockerfile directive within the build configuration. You can also specify different context directories, allowing you to organize your project with separate build contexts per service while keeping the orchestration logic centralized.

Basic Syntax: Single vs. Multiple Dockerfiles

Here's the simplest form of specifying a custom Dockerfile per service:

# docker-compose.yml
version: '3.8'

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.production
    ports:
      - "3000:3000"

  worker:
    build:
      context: ./worker
      dockerfile: Dockerfile.production
    depends_on:
      - redis

  redis:
    image: redis:7-alpine

In this example, both api and worker use their own production-specific Dockerfiles, while redis pulls a pre-built image. The context defines the build root directory, and dockerfile points to the specific file within that context. If you omit dockerfile, Docker Compose defaults to Dockerfile in the context root.

Why Multiple Dockerfiles Matter in Production

πŸš€ Deploy your AI agent in 10 minutes

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

Try it free →

Using multiple Dockerfiles is not merely a convenienceβ€”it's a critical architectural decision that impacts build performance, security posture, and deployment reliability. Here's why it matters:

Project Structure for Multiple Dockerfiles

A well-organized project structure makes managing multiple Dockerfiles intuitive. Here's a recommended layout for a production microservices application:

project-root/
β”œβ”€β”€ docker-compose.yml
β”œβ”€β”€ docker-compose.prod.yml          # override file for production
β”œβ”€β”€ api/
β”‚   β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ package.json
β”‚   β”œβ”€β”€ Dockerfile
β”‚   └── Dockerfile.production
β”œβ”€β”€ worker/
β”‚   β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ requirements.txt
β”‚   β”œβ”€β”€ Dockerfile
β”‚   └── Dockerfile.production
β”œβ”€β”€ frontend/
β”‚   β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ package.json
β”‚   β”œβ”€β”€ Dockerfile
β”‚   └── Dockerfile.production
└── nginx/
    β”œβ”€β”€ conf/
    β”‚   └── nginx.conf
    └── Dockerfile

Notice the naming convention: Dockerfile for development (used during local work with docker-compose up --build) and Dockerfile.production for production deployments. This pattern keeps both environments clearly separated while sharing the same compose orchestration logic through override files.

Production Docker Compose Configuration

Let's build a complete production docker-compose.yml that uses multiple Dockerfiles. This example represents a realistic stack with an API, a background worker, a frontend, and an Nginx reverse proxy:

# docker-compose.yml (base configuration)
version: '3.8'

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.production
    image: registry.example.com/api:${IMAGE_TAG:-latest}
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
    volumes:
      - api_uploads:/app/uploads
    networks:
      - backend
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

  worker:
    build:
      context: ./worker
      dockerfile: Dockerfile.production
    image: registry.example.com/worker:${IMAGE_TAG:-latest}
    environment:
      - PYTHONUNBUFFERED=1
      - REDIS_URL=redis://redis:6379
      - DATABASE_URL=${DATABASE_URL}
    depends_on:
      redis:
        condition: service_healthy
    networks:
      - backend
    restart: unless-stopped

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.production
      args:
        - BUILD_ENV=production
        - API_URL=https://api.example.com
    image: registry.example.com/frontend:${IMAGE_TAG:-latest}
    networks:
      - frontend
    restart: unless-stopped

  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf:/etc/nginx/conf.d:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    depends_on:
      - api
      - frontend
    networks:
      - frontend
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks:
      - backend
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    restart: unless-stopped

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true   # prevents direct external access

volumes:
  api_uploads:
  redis_data:

This configuration demonstrates several production-grade patterns: using image alongside build to tag images for a registry, health checks with proper intervals, internal networks for backend isolation, and environment variables passed via ${} substitution.

Production-Optimized Dockerfiles

Now let's examine the actual production Dockerfiles referenced in the compose file above. Each is tailored to its specific service.

API Service: Node.js Production Dockerfile

# api/Dockerfile.production
# Stage 1: Build
FROM node:20-alpine AS builder

WORKDIR /app

# Install dependencies separately for layer caching
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# Copy source and compile if needed (e.g., TypeScript)
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build

# Stage 2: Production runtime
FROM node:20-alpine AS runtime

RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# Copy only production artifacts from builder
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

ENV NODE_ENV=production
ENV PORT=3000

EXPOSE 3000

USER appuser

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (res) => process.exit(res.statusCode === 200 ? 0 : 1))"

CMD ["node", "dist/index.js"]

Worker Service: Python Production Dockerfile

# worker/Dockerfile.production
# Stage 1: Build with dependencies
FROM python:3.12-slim AS builder

WORKDIR /app

# Install build tools for any native extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt ./
RUN pip install --user --no-cache-dir -r requirements.txt

# Stage 2: Production runtime
FROM python:3.12-slim AS runtime

RUN groupadd -r worker && useradd -r -g worker worker

WORKDIR /app

# Copy only installed packages from builder stage
COPY --from=builder --chown=worker:worker /root/.local /home/worker/.local
COPY --chown=worker:worker src/ ./src/

ENV PATH="/home/worker/.local/bin:${PATH}"
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

USER worker

HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
  CMD python -c "import sys; sys.exit(0)"

CMD ["python", "src/main.py"]

Frontend Service: Multi-Stage Static Build

# frontend/Dockerfile.production
# Stage 1: Build static assets
FROM node:20-alpine AS builder

ARG BUILD_ENV=production
ARG API_URL

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci && npm cache clean --force

COPY . .
RUN npm run build -- --environment=${BUILD_ENV}

# Stage 2: Serve with Nginx
FROM nginx:1.25-alpine AS runtime

RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist /usr/share/nginx/html

COPY nginx.conf /etc/nginx/conf.d/default.conf

RUN addgroup -S appgroup && adduser -S appuser -G appgroup && \
    chown -R appuser:appgroup /usr/share/nginx/html && \
    chown -R appuser:appgroup /var/cache/nginx && \
    chown -R appuser:appgroup /var/log/nginx

USER appuser

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget -q --spider http://localhost:80 || exit 1

CMD ["nginx", "-g", "daemon off;"]

Nginx Reverse Proxy Dockerfile

# nginx/Dockerfile
FROM nginx:1.25-alpine

# Install envsubst for template processing if needed
RUN apk add --no-cache bash curl

COPY conf/nginx.conf /etc/nginx/nginx.conf
COPY conf/default.conf.template /etc/nginx/conf.d/default.conf.template

# Entrypoint that substitutes environment variables at runtime
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

RUN addgroup -S nginxgroup && adduser -S nginxuser -G nginxgroup && \
    chown -R nginxuser:nginxgroup /var/cache/nginx && \
    chown -R nginxuser:nginxgroup /var/log/nginx && \
    chown -R nginxuser:nginxgroup /etc/nginx/conf.d && \
    touch /var/run/nginx.pid && \
    chown nginxuser:nginxgroup /var/run/nginx.pid

USER nginxuser

EXPOSE 80 443

ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]

Using Docker Compose Override Files for Environment-Specific Builds

A powerful pattern is combining multiple Dockerfiles with Docker Compose's override mechanism. You maintain a base docker-compose.yml for common configuration and an override file for production specifics:

# docker-compose.override.yml (development - loaded automatically)
version: '3.8'

services:
  api:
    build:
      dockerfile: Dockerfile          # development Dockerfile
    environment:
      - NODE_ENV=development
    volumes:
      - ./api/src:/app/src:ro
    command: npm run dev

  worker:
    build:
      dockerfile: Dockerfile          # development Dockerfile
    volumes:
      - ./worker/src:/app/src:ro

  frontend:
    build:
      dockerfile: Dockerfile
    command: npm run dev
    volumes:
      - ./frontend/src:/app/src:ro
# docker-compose.prod.yml (production - explicitly specified)
version: '3.8'

services:
  api:
    build:
      dockerfile: Dockerfile.production
    image: registry.example.com/api:${IMAGE_TAG:-latest}
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '2'
          memory: 512M

  worker:
    build:
      dockerfile: Dockerfile.production
    image: registry.example.com/worker:${IMAGE_TAG:-latest}
    deploy:
      replicas: 2

  frontend:
    build:
      dockerfile: Dockerfile.production
    image: registry.example.com/frontend:${IMAGE_TAG:-latest}

  redis:
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 256M

To use the production override, you specify it explicitly with the -f flag:

# Development (auto-loads docker-compose.override.yml)
docker compose up --build

# Production (explicitly specify base + production override)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d

# Production build only (no run)
docker compose -f docker-compose.yml -f docker-compose.prod.yml build

This approach keeps your configurations DRY. The base file defines the service relationships and shared settings, the override files switch Dockerfiles and add environment-specific configurations like replica counts or resource limits.

Build Arguments Across Multiple Dockerfiles

When using multiple Dockerfiles, you often need to pass build-time arguments that differ per service. Docker Compose supports args within each service's build block:

# docker-compose.yml with per-service build args
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.production
      args:
        - NODE_VERSION=20
        - APP_VERSION=${APP_VERSION}

  worker:
    build:
      context: ./worker
      dockerfile: Dockerfile.production
      args:
        - PYTHON_VERSION=3.12
        - APP_VERSION=${APP_VERSION}

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.production
      args:
        - API_URL=https://api.example.com
        - ANALYTICS_KEY=${ANALYTICS_KEY:-disabled}

Build arguments are declared in your Dockerfiles using the ARG instruction. Here's how the API Dockerfile consumes them:

# Inside api/Dockerfile.production
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder

ARG APP_VERSION
ENV APP_VERSION=${APP_VERSION}

# ... rest of build steps

Build arguments are only available during image build timeβ€”they don't persist into the running container unless explicitly assigned to an ENV. This distinction is crucial for secrets: never pass sensitive values as build args without understanding they'll be visible in image layers. Use Docker secrets or runtime environment variables for sensitive data instead.

CI/CD Pipeline Integration

In a CI/CD pipeline, multiple Dockerfiles paired with Docker Compose enable efficient, parallelized builds. Here's a typical GitHub Actions workflow excerpt that builds and pushes images for each service independently:

# .github/workflows/build.yml (excerpt)
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: [api, worker, frontend, nginx]
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Registry
        uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_TOKEN }}

      - name: Build and push ${{ matrix.service }}
        run: |
          docker compose -f docker-compose.yml -f docker-compose.prod.yml \
            build ${{ matrix.service }}
          docker compose -f docker-compose.yml -f docker-compose.prod.yml \
            push ${{ matrix.service }}

Using the matrix strategy, each service builds in parallel. Docker Compose's build subcommand with a service name argument builds only that specific service, leveraging its dedicated Dockerfile and context. The push command then pushes the tagged image to your registry.

Best Practices for Multiple Dockerfiles in Production

Common Pitfalls and Solutions

Pitfall 1: Build Context Confusion

When specifying a custom dockerfile, the path is always relative to the context, not relative to the compose file's location. This means:

# Correct: dockerfile path is inside the context directory
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.production   # resolves to ./api/Dockerfile.production

# Also correct: dockerfile can be a subdirectory path within context
services:
  api:
    build:
      context: ./api
      dockerfile: docker/production/Dockerfile   # resolves to ./api/docker/production/Dockerfile

# Wrong: dockerfile outside context will fail
services:
  api:
    build:
      context: ./api
      dockerfile: ../Dockerfile.production   # ERROR: outside build context

Pitfall 2: Missing Build Arguments in CI/CD

Build arguments defined in Docker Compose must be passed during CI/CD builds or the Dockerfile's default values will be used silently. Always verify that required build args are supplied:

# CI/CD command that passes build args
docker compose build \
  --build-arg APP_VERSION=${GITHUB_SHA} \
  --build-arg NODE_VERSION=20.11.0

Pitfall 3: Layer Cache Invalidation

When multiple services share common dependencies (like a base image layer), changes to one service don't automatically benefit the others. Each Dockerfile has its own independent cache. For shared base layers, consider creating a separate base image that all services inherit from:

# base/Dockerfile - shared base image
FROM node:20-alpine
RUN apk add --no-cache curl ca-certificates
# Build once: docker build -t registry.example.com/base:latest ./base

# api/Dockerfile.production
FROM registry.example.com/base:latest
# ... service-specific steps

Putting It All Together: A Complete Production Deployment Script

Here's a comprehensive deployment script that ties together all the conceptsβ€”multiple Dockerfiles, production overrides, registry pushing, and rolling updates:

#!/bin/bash
# deploy-production.sh - Complete production deployment script
set -euo pipefail

# Configuration
export IMAGE_TAG="${GITHUB_SHA:-$(git rev-parse HEAD)}"
export DOCKER_REGISTRY="registry.example.com"
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.prod.yml"

echo "=== Building production images with multiple Dockerfiles ==="
docker compose ${COMPOSE_FILES} build --parallel

echo "=== Pushing tagged images to registry ==="
docker compose ${COMPOSE_FILES} push

echo "=== Deploying stack to production swarm ==="
docker stack deploy \
  --compose-file <(docker compose ${COMPOSE_FILES} config) \
  --with-registry-auth \
  myapp-production

echo "=== Verifying deployment health ==="
sleep 15
docker service ls --filter name=myapp-production --format "table {{.Name}} {{.Replicas}}"

echo "=== Production deployment complete ==="
echo "Image tag: ${IMAGE_TAG}"
echo "Rollback command: docker stack deploy --compose-file ... myapp-production"

This script demonstrates the full lifecycle: building each service with its dedicated production Dockerfile, pushing images to a registry, deploying as a Docker Swarm stack (or equivalently, using docker compose up -d for single-host deployments), and verifying service health.

Conclusion

Docker Compose with multiple Dockerfiles transforms how you build and deploy production microservices. By giving each service its own optimized, security-hardened Dockerfile, you achieve faster builds through independent caching, smaller attack surfaces through minimal per-service images, and greater deployment flexibility through targeted updates. The combination of dockerfile directives, per-service build contexts, multi-stage builds, and Compose override files gives you a powerful, maintainable pattern that scales from small projects to large distributed systems. Adopt these practices incrementallyβ€”start by splitting your monolith's Dockerfile into service-specific ones, then progressively add multi-stage builds, non-root users, health checks, and registry tagging. The result is a production deployment pipeline that is fast, secure, and reproducible.

πŸš€ 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