This guide compiles valuable tips and structured approaches to enhance the architecture, quality, and maintainability of your Docker projects, covering both Dockerfiles and Docker Compose.
Optimizing your Dockerfile is the foundation for
efficient and secure container images.
Select minimal, trusted base images:
node:18-alpine)
for significantly smaller footprints, but be aware of potential
compatibility differences (musl vs glibc).
ubuntu:22.04,
node:18.17.1-alpine) instead of using floating tags
like latest or alpine to ensure
reproducibility and avoid unexpected breaking changes.
This reduces image size, attack surface, and ensures consistent builds. [Reference]
Multi-stage builds dramatically reduce your final image size by separating build-time dependencies from runtime needs. Only copy necessary artifacts to the final stage.
FROM node:18 AS builder # Build stage
WORKDIR /app
COPY package*.json ./
RUN npm install # Install all deps, including devDeps
COPY . .
RUN npm run build # Create build artifacts in /app/dist
FROM node:18-alpine AS production # Final stage
WORKDIR /app
# Copy only build artifacts and necessary package files from builder stage
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --production # Install only runtime deps
USER node
CMD ["node", "dist/server.js"]
This creates smaller, more secure production images. [Reference]
Leverage BuildKit cache mounts to speed up dependency installation during builds without bloating image layers. Enable BuildKit (often default in newer Docker versions, or use `DOCKER_BUILDKIT=1`).
# Example for npm
RUN --mount=type=cache,target=/root/.npm npm install
# Example for apt
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y ...
# Example for pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
This caches package manager data between builds, significantly speeding up installs if dependency files haven't changed.
Only install packages absolutely required for your application to
run. Combine installation and cleanup in a single
RUN command to minimize layer size.
RUN apt-get update && apt-get install -y --no-install-recommends \
package1 package2 \
&& rm -rf /var/lib/apt/lists/* # Clean up apt cache
This reduces image size, potential vulnerabilities, and build time. [Reference]
FROM instructions with specific versions
(see Base Image).
RUN commands using
&& and backslashes (\) for readability,
and clean up temporary files within the same layer.
WORKDIR to set context instead of chaining
cd in RUN commands. Use absolute paths
for clarity (e.g., WORKDIR /app).
COPY over ADD unless you
specifically need ADD's auto-extraction or URL
features.
LABEL metadata (maintainer,
description, version).
USER for security.HEALTHCHECK, EXPOSE,
VOLUME as appropriate.
ENTRYPOINT and
CMD.
Docker provides guides and examples tailored to specific programming languages and frameworks. Reviewing these can help you adopt conventional practices for your stack.
Docker Compose helps define and run multi-container applications. These practices improve Compose configurations.
Each container should ideally have a single responsibility (e.g., web server, database, cache, message queue). This promotes the "one process per container" principle.
compose.yaml.
This makes scaling, maintenance, and reuse easier. [Reference]
Be familiar with the core concepts defined in a Compose file:
Docker Compose is excellent for creating reproducible development environments.
compose.yaml.
volumes: - ./src:/app/src).
depends_on with
condition: service_healthy, ensuring dependencies are
ready.
services:
web:
build: .
ports:
- "8080:80"
volumes:
- ./src:/app/src # Mount source code
depends_on:
db:
condition: service_healthy # Wait for db healthcheck
db:
image: postgres:15
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 10s
timeout: 5s
retries: 5
# ... other db config
Review the configuration for each service in your Compose file:
restart: unless-stopped for production services).
healthcheck) so
Docker knows if your service is running correctly.
environment: or
env_file:) for configuration, but prefer secrets for
sensitive data.
deploy: resources: limits: ...) in production environments, especially when using Swarm or
Kubernetes.
Avoid hardcoding passwords, API keys, or other sensitive information directly in your Compose file or environment variables. Use Docker secrets instead.
services:
app:
image: my-app
environment:
DB_PASSWORD_FILE: /run/secrets/db_password # App reads from this file
secrets:
- db_password # Grant access to the secret
db:
image: postgres:15
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # Postgres reads from this file
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # Source the secret from this host file
Secrets are mounted as files into /run/secrets/ within
the container, preventing exposure in logs or via
docker inspect.
extends)
Use the extends attribute to share common
configurations between services or across multiple Compose files,
promoting the DRY (Don't Repeat Yourself) principle.
# common-services.yaml
services:
base-app:
restart: unless-stopped
networks:
- backend
# compose.yaml
services:
web:
extends:
file: common-services.yaml
service: base-app
build: ./web
ports: ["80:80"]
api:
extends:
file: common-services.yaml
service: base-app
build: ./api
Use multiple Compose files to manage configurations for different environments (e.g., development, testing, production).
compose.yaml with common
configurations.
compose.dev.yaml,
compose.prod.yaml.
-f flag to specify which files to merge:
docker compose -f compose.yaml -f compose.prod.yaml up
Explore the Awesome Compose repository for examples of how to structure Compose projects for various technology stacks.
A well-organized folder structure makes managing multi-service applications with different environments much easier.
my-app/
├── docker/ # Docker related files
│ ├── common/ # Shared Dockerfile stages/components (optional)
│ │ └── base.Dockerfile
│ ├── development/ # Development-specific Dockerfiles
│ │ ├── service1/
│ │ │ └── Dockerfile
│ │ └── service2/
│ │ └── Dockerfile
│ └── production/ # Production-specific Dockerfiles
│ ├── service1/
│ │ └── Dockerfile
│ └── service2/
│ └── Dockerfile
├── services/ # Application code for each service
│ ├── service1/
│ │ ├── src/ # Service1 source code
│ │ └── .dockerignore # Service-specific dockerignore
│ └── service2/
│ ├── src/ # Service2 source code
│ └── .dockerignore # Service-specific dockerignore
├── secrets/ # Directory for secret files (add to .gitignore!)
│ └── db_password.txt
├── compose.yaml # Base Compose file (common services/config)
├── compose.dev.yaml # Development environment overrides
├── compose.prod.yaml # Production environment overrides
└── .dockerignore # Root dockerignore
└── .env # Environment variables file (optional, add to .gitignore!)
Inspired by guides like [Laravel Docker Guides].
docker/ directory: Centralizes
Dockerfiles, separating them by environment (dev/prod) and
potentially service. Allows tailored builds (debug tools in dev,
optimized multi-stage in prod).
services/ directory: Contains the
actual source code for each distinct application component or
microservice.
compose.yaml: Defines the core, common service
configurations, networks, volumes.
compose.dev.yaml /
compose.prod.yaml: Override or add configurations
specific to an environment (e.g., different ports, volume
mounts for code, different Dockerfiles, environment
variables).
secrets/ directory: Holds plain text
secret files referenced by the
secrets: file: directive in Compose.
Crucially, add this directory to your
.gitignore!
.env file: Can be used to set
default environment variables for Compose using
env_file: or variable substitution
(${VAR_NAME}).
Add this to .gitignore if it contains
secrets!
Prefer Docker secrets for sensitive data.
Profiles allow defining subsets of services that can be enabled selectively, useful for optional tools or specific deployment scenarios.
services:
frontend:
# ... config
profiles: [frontend, dev] # Runs if 'frontend' OR 'dev' profile is active
phpmyadmin:
# ... config
profiles: [debug] # Only runs if 'debug' profile is active
backend:
# ... config
# No profile means always enabled (unless ALL services have profiles)
Activate profiles using the --profile flag:
# Run default services + services with 'dev' profile
docker compose --profile dev up
# Run only services with 'debug' profile (+ defaults if any)
docker compose --profile debug up -d
Exclude files/directories from the build context sent to the Docker daemon to speed up builds, reduce image size, and avoid leaking sensitive files.
.dockerignore: Applies by
default when the build context is the root (.).
Ignore common files like .git,
node_modules, *.log,
secrets/, .env.
.dockerignore:
Place inside a service directory (e.g.,
services/service1/.dockerignore) if the build context
is set to that directory in compose.yaml.
Combine base and environment-specific Compose files using the
-f flag:
# Development (merges compose.yaml and compose.dev.yaml)
docker compose -f compose.yaml -f compose.dev.yaml up -d --build
# Production (merges compose.yaml and compose.prod.yaml)
docker compose -f compose.yaml -f compose.prod.yaml up -d
This structured approach helps manage overrides and keeps relative paths within Compose files consistent.
Addressing specific tasks like refining projects and rebuilding services.
Follow these steps to systematically optimize your Docker project:
Focus on the individual components (Dockerfiles) first, then the orchestration (Compose).
To rebuild images and recreate containers, possibly removing old artifacts:
docker compose up --build --force-recreatedocker compose build --no-cachedocker compose up.
docker system prune -f --filter "until=24h"docker compose builddocker compose rm -f -s -vdocker compose builddocker compose updocker compose watch --pruneMost Thorough Approach (Clean Slate):
docker system prune -a -f # Prune all unused objects
docker compose build --no-cache # Rebuild without cache
docker compose up -d # Start fresh containers
docker compose build --pull to ensure base images are
updated, or use --no-cache periodically.
[Reference]
Full examples of Dockerfiles and Docker Compose files incorporating many of the practices discussed.
Illustrates various instructions and techniques in a single file.
# syntax=docker/dockerfile:1
FROM ubuntu:22.04
LABEL maintainer="Your Name <your.email@example.com>" version="1.0"
ENV APP_HOME=/app NODE_ENV=production DEBIAN_FRONTEND=noninteractive
ARG VERSION=1.0
ARG UID=1000 GID=1000
RUN groupadd --gid $GID appuser && \
useradd --uid $UID --gid $GID --shell /bin/bash --create-home appuser
WORKDIR $APP_HOME
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl nodejs npm python3 python3-pip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY package*.json requirements.txt ./
RUN --mount=type=cache,target=/root/.npm npm install --production
RUN --mount=type=cache,target=/root/.cache/pip pip3 install --no-cache-dir -r requirements.txt
COPY . .
RUN chown -R appuser:appuser $APP_HOME
USER appuser
RUN --mount=type=cache,target=/app/node_modules/.cache npm run build
EXPOSE 8080
VOLUME ["/app/data", "/app/logs"]
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["npm", "start"]
Demonstrates separating build and runtime concerns for smaller production images.
# syntax=docker/dockerfile:1
# --- Build Stage ---
FROM node:18-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm install
COPY . .
RUN --mount=type=cache,target=/build/node_modules/.cache npm run build
# --- Production Stage ---
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package*.json ./
RUN --mount=type=cache,target=/root/.npm npm install --production
USER node
EXPOSE 8080
CMD ["node", "dist/server.js"]
A typical compose.yaml defining multiple services,
networks, and volumes.
services:
app:
build:
context: .
dockerfile: ./docker/production/app/Dockerfile
args:
BUILD_ENV: production
container_name: my-application
restart: unless-stopped
ports:
- "8080:80"
environment:
- NODE_ENV=production
- DB_HOST=db
- DB_USER=prod_user
- DB_PASSWORD=supersecret # Avoid this, use secrets!
volumes:
- app-data:/var/data
depends_on:
db:
condition: service_healthy
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:14-alpine
container_name: database
restart: always
environment:
POSTGRES_USER: prod_user
POSTGRES_PASSWORD: supersecret # Avoid this, use secrets!
POSTGRES_DB: production_db
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U prod_user -d production_db"]
interval: 10s
timeout: 5s
retries: 5
volumes:
app-data:
db-data:
networks:
frontend:
backend:
Demonstrates the secure way to handle sensitive data using Docker secrets.
services:
app:
build:
context: .
dockerfile: ./docker/production/app/Dockerfile
secrets: # Build-time secrets (optional)
- npm_token
container_name: my-application-secure
restart: unless-stopped
ports:
- "8080:80"
environment:
NODE_ENV: production
DB_HOST: db
DB_USER_FILE: /run/secrets/db_user # Path to user secret file
DB_PASSWORD_FILE: /run/secrets/db_password # Path to password secret file
API_KEY_FILE: /run/secrets/ext_api_key
volumes:
- app-data:/var/data
depends_on:
db:
condition: service_healthy
networks:
- frontend
- backend
healthcheck: # (same as above)
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
secrets: # Grant runtime access to these secrets
- db_user
- db_password
- ext_api_key
db:
image: postgres:14-alpine
container_name: database-secure
restart: always
environment:
POSTGRES_USER_FILE: /run/secrets/db_user # Postgres reads user from file
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # Postgres reads password from file
POSTGRES_DB: production_db
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
healthcheck: # Needs adjustment if user is from secret
test: ["CMD-SHELL", "pg_isready -d production_db -U `cat /run/secrets/db_user`"]
interval: 10s
timeout: 5s
retries: 5
secrets: # Grant runtime access
- db_user
- db_password
volumes:
app-data:
db-data:
networks:
frontend:
backend:
secrets:
db_user:
file: ./secrets/db_user.txt
db_password:
file: ./secrets/db_password.txt
ext_api_key: # Example using host environment variable
environment: EXTERNAL_API_KEY
npm_token: # Build-time secret
file: ./secrets/npm_token.txt
This guide combines best practices and examples from Docker's documentation to help you build efficient, secure, and maintainable containerized applications.