Docker Style Guide
crefft > Infrastructure > Docker
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Targets Docker Engine 27.5+ and Docker Compose v5.0+.
Quick Reference
| Task | Tool | Command |
|---|---|---|
| Build image | docker build | docker build -t myapp:1.0.0 . |
| Run container | docker run | docker run -d --name myapp myapp:1.0.0 |
| Compose up | docker compose | docker compose up -d |
| Compose down | docker compose | docker compose down |
| Image scan | Trivy | trivy image myapp:1.0.0 |
| Format Dockerfile | Hadolint | hadolint Dockerfile |
| Compose validate | docker compose | docker compose config --quiet |
myapp:1.0.0 is the exact local tag produced by the build row, not a registry
release. Application image names and abbreviated digests in the marked YAML
templates below are nonexecuting placeholders; replace each with your own
registry-verified digest before deployment.
Docker Compose File Organization
Projects MUST organize Compose files using the include directive for modularity.
Why: The include directive (introduced in Compose 2.20) enables modular,
reusable configurations. It manages relative path resolution correctly and
allows teams to work on separate service configurations simultaneously. This
approach scales better than monolithic docker-compose.yml files as
applications grow.
File Structure
project/
├── compose.yml # Main entry point
├── compose.override.yml # Local development overrides
├── compose.production.yml # Production configuration
├── compose.database.yml # Database services (included)
├── compose.cache.yml # Cache layer (included)
├── compose.monitoring.yml # Observability stack (included)
├── .dockerignore # Keeps secrets out of every build context
├── .gitignore # Keeps secrets out of version control
├── .env # Non-secret defaults only
├── .env.production # Non-secret production values only
└── secrets/ # Development fixtures only; ignored by
├── db_password # Git and Docker, mode 0400
└── api_key
Projects MUST keep secret material out of both version control and every
build context, and MUST read production secrets from a path outside the
context (for example /etc/myapp/secrets/) that deployment tooling
materializes from a secret manager.
Why: A secrets/ directory inside the project root is inside the default
build context, so COPY . . copies it into an image layer unless
.dockerignore excludes it. Anything committed to Git additionally survives in
history after deletion. The .dockerignore section gives the
exclusion list, and Secrets vs Environment Variables
covers how the files are consumed at runtime.
Per-Service Subdirectories
Projects with multiple services SHOULD namespace configuration files under service subdirectories.
Why: Multiple services often need similar file names (config.yaml,
settings.json). Flat structures create conflicts. Per-service subdirectories
provide clear namespacing, enable service-specific data directories, and keep
compose files readable.
stacks/platform/
├── compose.yml # Service definitions
├── images.yml # Pinned digests per architecture (optional)
├── traefik/
│ ├── traefik.yml # Static config
│ ├── config/ # Dynamic config
│ │ └── security.yml
│ └── data/ # Persistent data (if using bind mounts)
├── alloy/
│ └── config.alloy
└── prometheus/
├── prometheus.yml
└── rules/
└── alerts.yml
Service Directory Pattern:
| Subdirectory | Purpose | Mount Type |
|---|---|---|
{service}/config/ | Configuration files | :ro bind mount |
{service}/data/ | Persistent state | Read-write bind mount |
{service}/secrets/ | Service-specific secrets | :ro bind mount, excluded from build contexts |
Volume Mounting:
services:
traefik:
volumes:
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik/config:/etc/traefik/dynamic:ro
- traefik-certs:/letsencrypt # Docker volume for certs
Volume Strategy
Projects SHOULD choose volume types based on access patterns.
| Volume Type | Example | Use Case |
|---|---|---|
| Docker Volume | traefik-certs:/letsencrypt | Infrastructure state |
| Bind Mount | ./service/data:/app/data | Data snapshots with stack |
| External Path | ${MEDIA_PATH}:/media:ro | Large storage on separate pools |
Guidelines:
- Use Docker volumes for single-instance infrastructure services
- Use bind mounts when data should be visible alongside compose files
- Use environment variable paths for large external storage (photos, video)
- With ZFS storage driver, Docker volumes are on ZFS automatically
Main Compose File with Include
# compose.yml
# Main entry point that includes modular sub-stacks
include:
- compose.database.yml
- compose.cache.yml
- compose.monitoring.yml # Optional: comment out to disable
networks:
app:
external: true
internal:
internal: true
Include with Override and Environment
Projects MAY use the long syntax for environment-specific includes.
include:
- path: compose.database.yml
env_file: .env.database
project_directory: ./services/database
Why: The long syntax provides fine-grained control over project directories and environment files, enabling complex multi-environment configurations while maintaining modularity.
YAML Anchors and Fragments
Projects MUST use YAML anchors (x- extensions) to reduce duplication.
Why: YAML anchors create reusable configuration fragments that ensure
consistency across services and reduce maintenance burden. The x- prefix
marks them as extension fields that Compose ignores during validation.
Service Defaults Fragment
# Fragments (top-level, before services)
x-service-defaults: &service-defaults
restart: unless-stopped
init: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
services:
api:
<<: *service-defaults
# crefft: illustrative -- Supply your own application's full registry digest.
image: myapp/api@sha256:abc123...
cap_add:
- NET_BIND_SERVICE # Add back only required capabilities
Health Check Fragments
x-healthcheck-fast: &healthcheck-fast
interval: 10s
timeout: 5s
retries: 3
start_interval: 2s
x-healthcheck-standard: &healthcheck-standard
interval: 30s
timeout: 10s
retries: 3
start_interval: 2s
start_period: 30s
services:
web:
healthcheck:
<<: *healthcheck-fast
test: ["CMD", "curl", "-f", "http://localhost/health"]
Why: The start_interval parameter (Compose 2.23+) enables faster health
checks during container startup, improving startup time while using longer
intervals for steady-state monitoring.
Logging Fragments
x-logging-standard: &logging-standard
driver: json-file
options:
max-size: "50m"
max-file: "5"
x-logging-high-volume: &logging-high-volume
driver: json-file
options:
max-size: "100m"
max-file: "10"
services:
api:
logging:
<<: *logging-standard
proxy:
logging:
<<: *logging-high-volume
Resource Limits Fragment
x-ulimits-high: &ulimits-high
nofile:
soft: 65536
hard: 65536
services:
database:
ulimits:
<<: *ulimits-high
deploy:
resources:
limits:
memory: 4G
cpus: '2'
pids: 200
reservations:
memory: 1G
Security Hardening
Image Digest Pinning
Projects MUST pin images by SHA256 digest, not tags.
Why: Tags are mutable and can point to different images over time. Digest pinning guarantees the exact same image is used for every deployment, preventing supply chain attacks and ensuring reproducibility. Even if a registry is compromised, digest-pinned images remain safe.
# WRONG: Tags are mutable
services:
api:
# crefft: allow-floating -- Negative example contrasts a mutable tag with the digest below.
image: nginx:latest
# CORRECT: SHA256 digest pinning
services:
api:
# nginx:1.25.3; registry manifest digest.
image: nginx@sha256:c7a6ad68be85142c7fe1089e48faa1e7c7166a194caa9180ddea66345876b9d2
Finding Digests:
# Get digest for a specific tag
docker pull nginx:1.25.3
docker inspect nginx:1.25.3 --format='{{.RepoDigests}}'
# Or use crane (faster, no download)
crane digest nginx:1.25.3
Multi-Architecture Image Pinning
Projects deploying to multiple architectures SHOULD maintain per-architecture digests.
Why: A manifest-index digest preserves automatic platform selection, while a platform-manifest digest selects only that platform and can fail on another architecture with “exec format error.” Per-architecture digests let reviewers approve ARM64 and AMD64 images independently; the examples otherwise pin the registry’s manifest index.
images.yml Pattern:
# stacks/platform/images.yml
images:
traefik:
version: "3.6.5"
pinned_at: "2026-10-18"
digests:
amd64: "sha256:d944e3693bbf5a361ddd2e411bb713049cfb4f5ff3da200b30ee7a347dbd6abd"
arm64: "sha256:4df45c2d1101f440d989afb8d54ce557fda8c1028c63b4edb46e1387f20e252d"
Compose Integration:
# compose.yml - deployment tooling renders a reviewed manifest digest
services:
traefik:
# traefik:v3.6.5; registry manifest digest.
image: traefik@sha256:67622638cd88dbfcfba40159bc652ecf0aea0e032f8a3c7e3134ae7c037b9910
Finding Per-Architecture Digests:
# Get manifest for all platforms
docker manifest inspect traefik:v3.6.5
# Get specific platform digest
docker manifest inspect traefik:v3.6.5 | \
jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest'
Security Options
Projects MUST use no-new-privileges:true to prevent privilege escalation.
Why: The no-new-privileges flag prevents containers from gaining
additional privileges via setuid/setgid binaries. This blocks a common
container escape vector where attackers exploit setuid binaries to escalate
privileges.
services:
app:
security_opt:
- no-new-privileges:true
Capability Dropping
Projects MUST drop all capabilities and MUST add back only required capabilities.
Why: Linux capabilities allow fine-grained privilege control. Dropping all
capabilities (CAP_DROP: [ALL]) creates a minimal privilege baseline.
Selectively adding required capabilities (like NET_BIND_SERVICE for ports
<1024) follows the principle of least privilege.
services:
app:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE # Required to bind to port 80/443
Common Required Capabilities:
NET_BIND_SERVICE: Bind to privileged ports (<1024)CHOWN: Change file ownershipDAC_OVERRIDE: Bypass file permission checksSETUID/SETGID: Change user/group IDs (use sparingly)
Projects MUST NOT use privileged: true except for specialized
infrastructure containers (e.g., cAdvisor, node-exporter).
Read-Only Root Filesystem
Projects SHOULD use read_only: true with tmpfs for temporary directories.
Why: A read-only root filesystem prevents attackers from modifying
container binaries or persisting malware. Combining this with tmpfs for /tmp
allows ephemeral writes while maintaining immutability.
services:
app:
read_only: true
tmpfs:
- /tmp
- /var/run
Non-Root Users
Projects MUST run containers as non-root users.
Why: Running as root increases the attack surface. If an attacker escapes the container, they have root privileges. Non-root users limit damage even if the container is compromised.
services:
app:
user: "1000:1000"
# Or by name (if user exists in image)
# user: "nobody:nobody"
Dockerfile Configuration:
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
USER appuser
Resource Limits
Projects MUST define memory, CPU, and PID limits.
Why: Resource limits prevent denial-of-service attacks and noisy neighbor problems. Without limits, a compromised or buggy container can exhaust host resources, affecting other containers.
services:
app:
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
pids: 100 # Prevent fork bombs
reservations:
memory: 128M
Secrets vs Environment Variables
Projects MUST deliver sensitive data as secret files, not environment variables.
Why: Environment variables are visible in docker inspect, are inherited
by every child process, and are routinely captured in crash dumps and debug
logs. A secret file is mounted at /run/secrets/<name>, is readable only by
the services granted it, and is subject to ordinary filesystem permissions.
# WRONG: Secrets in environment variables
services:
app:
environment:
- DB_PASSWORD=supersecret # Visible in docker inspect!
# CORRECT: Secret file, referenced by path
secrets:
db_password:
# Development fixture; production reads a path outside the build context
file: ./secrets/db_password
services:
app:
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
DB_PASSWORD_FILE above is application configuration, not a Docker feature.
The _FILE suffix is a convention implemented by some images (including the
mysql and postgres Docker Official Images); every other image needs
application code that reads the file.
Application Code:
# Read secret from file
def get_db_password():
secret_file = os.getenv("DB_PASSWORD_FILE", "/run/secrets/db_password")
with open(secret_file) as f:
return f.read().strip()
Compose Secrets Are Host Files, Not Encrypted Storage
Compose secrets declared with file: are NOT encrypted and are NOT
held in memory. Compose bind-mounts the named host file into the container;
docker inspect reports it as a bind mount whose source is the host path, and
the plaintext stays on the host disk for as long as that file exists.
Projects MUST NOT treat a Compose secret as protected storage. Projects
MUST protect the source file (mode 0400, owned by the account that runs
the stack, on a directory excluded from Git and from every build context) or
materialize it at deploy time from an external secret manager such as SOPS,
Vault, or a cloud secrets service, and remove it when the stack stops.
Compose secrets work with Linux containers only: Compose delivers each secret as a single-file bind mount, and Windows containers can bind-mount directories only.
# Verify what a Compose secret actually is
docker inspect myapp-app-1 --format '{{json .Mounts}}' | jq '.[] | {Type, Source}'
# {"Type": "bind", "Source": "/etc/myapp/secrets/db_password"}
# Materialize it outside the build context, readable only by the deploy account
umask 077
mkdir -p /etc/myapp/secrets
vault kv get -field=password secret/myapp/db > /etc/myapp/secrets/db_password
chown deploy:deploy /etc/myapp/secrets/db_password
chmod 0400 /etc/myapp/secrets/db_password
Swarm Secrets
Only Swarm services get encrypted-at-rest secret storage. Projects that need
that guarantee MUST deploy to Swarm (docker stack deploy) or another
orchestrator with a secret store; standalone containers and plain
docker compose up cannot use it.
Why: A Swarm secret is sent to the manager over mutual TLS and stored in the encrypted Raft log. When a task starts, the decrypted secret is mounted into an in-memory filesystem, and it is unmounted and flushed from the node’s memory when the task stops. On Windows containers, secrets are instead persisted in clear text on the container’s root disk (removed when the container stops), so BitLocker on the volume holding the Docker root directory is REQUIRED for encryption at rest there.
# Create the secret in the swarm, not on a shared filesystem
printf '%s' "$DB_PASSWORD" | docker secret create db_password -
# compose.production.yml, deployed with: docker stack deploy -c ... myapp
secrets:
db_password:
external: true # Managed by the swarm, not read from a host file
services:
app:
secrets:
- db_password
environment:
- DB_PASSWORD_FILE=/run/secrets/db_password
Health Checks
Projects MUST define health checks for all services.
Why: A health check is the only machine-readable statement of whether a
service can serve traffic. Compose gates depends_on on it, orchestrators use
it to decide where to route traffic, and operators read it from docker ps.
Health checks MUST NOT be relied on for recovery. A failing check sets the
container’s health status to unhealthy and nothing else: the process keeps
running, and Docker Engine restart policies act on container exit, not on
health status. A container that is hung but alive stays up indefinitely under
restart: unless-stopped.
Projects therefore MUST obtain recovery from one of:
- The application: retry with exponential back-off and jitter on dependency failures, and exit non-zero on unrecoverable state so the restart policy applies.
- An orchestrator: Kubernetes restarts a container that fails its liveness probe more times than the configured tolerance. Compose and Docker Engine provide no equivalent.
- An explicit controller: a supervisor that consumes
docker events --filter event=health_statusand acts on it.
# An unhealthy container is not restarted: the restart count stays at 0
docker inspect api \
--format '{{.State.Health.Status}} {{.State.Running}} {{.RestartCount}}'
# unhealthy true 0
HTTP Health Check
services:
api:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
start_interval: 2s
Parameters:
interval: Time between health checks (steady state)timeout: Maximum time to wait for check to completeretries: Number of consecutive failures before marking unhealthystart_period: Grace period before first check (Compose 2.20+)start_interval: Fast interval during startup (Compose 2.23+)
TCP Health Check
services:
database:
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 10s
timeout: 5s
retries: 5
Exec Health Check
services:
redis:
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 3
wget vs curl
Projects SHOULD prefer wget for health checks in Alpine-based images.
Why: wget is included in busybox (Alpine base), while curl requires
installation. Using wget reduces image size and dependency count.
# Alpine/busybox (wget included)
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
# Debian/Ubuntu (curl often pre-installed)
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
Custom Health Check Scripts
Projects MAY use custom scripts for complex health checks.
services:
app:
healthcheck:
test: ["CMD", "/app/healthcheck.sh"]
interval: 30s
Example Script:
#!/bin/sh
# healthcheck.sh
# Check HTTP endpoint
wget -q --spider http://localhost:8080/health || exit 1
# Check database connectivity
psql -U user -d db -c "SELECT 1" > /dev/null 2>&1 || exit 1
# Check disk space
[ $(df -h / | tail -1 | awk '{print $5}' | sed 's/%//') -lt 90 ] || exit 1
exit 0
Networking
External Networks
Projects SHOULD use external networks for cross-stack communication.
Why: External networks enable service discovery across multiple Compose stacks. This allows modular deployments where monitoring, databases, and applications are managed independently but can communicate.
# Stack 1: Create network
networks:
traefik:
driver: bridge
# Stack 2: Reference external network
networks:
traefik:
external: true
services:
app:
networks:
- traefik
Creating External Networks:
docker network create traefik
docker network create --internal backend
Internal Networks
Projects MUST use internal networks for backend services.
Why: Internal networks have no external connectivity, preventing backend services from initiating outbound connections. This limits the blast radius if a backend service is compromised.
networks:
frontend:
# External connectivity allowed
backend:
internal: true # No external access
services:
web:
networks:
- frontend
- backend
database:
networks:
- backend # Cannot reach internet
Port Binding
Projects SHOULD bind ports to specific IPs for security.
Why: Binding to 0.0.0.0 exposes services to all network interfaces. Binding
to specific IPs (like Tailscale) restricts access to trusted networks only.
services:
database:
# WRONG: Exposed to all interfaces
ports:
- "5432:5432"
# CORRECT: Bind to localhost only
ports:
- "127.0.0.1:5432:5432"
# BEST: Bind to Tailscale IP (VPN-only access)
ports:
- "${TAILSCALE_IP:-127.0.0.1}:5432:5432"
Network Isolation Example
networks:
public:
driver: bridge
private:
internal: true
services:
proxy:
networks:
- public
ports:
- "443:443"
api:
networks:
- public
- private
database:
networks:
- private # Not directly accessible
Logging
Projects MUST configure log rotation to prevent disk exhaustion.
Why: Docker logs are unbounded by default and can fill disk space, causing system failures. Configuring max size and file count prevents this.
JSON File Driver
x-logging-standard: &logging-standard
driver: json-file
options:
max-size: "50m" # Rotate after 50 MB
max-file: "5" # Keep 5 rotated files (250 MB total)
x-logging-high-volume: &logging-high-volume
driver: json-file
options:
max-size: "100m" # Larger logs for high-traffic services
max-file: "10" # 1 GB total
services:
api:
logging:
<<: *logging-standard
proxy:
logging:
<<: *logging-high-volume
Structured Logging
Projects SHOULD use structured JSON logging.
Why: Structured logs are easier to parse, search, and analyze. They enable powerful log aggregation and filtering in tools like Loki, Elasticsearch, or CloudWatch.
services:
app:
environment:
- LOG_FORMAT=json
Application Code (Python):
import structlog
logger = structlog.get_logger()
logger.info("user.login", user_id=123, ip="192.168.1.1")
# Output: {"event": "user.login", "user_id": 123, "ip": "192.168.1.1"}
Log Labels
Projects SHOULD add labels for log collection tools.
services:
app:
labels:
- "logging=promtail"
- "log_level=info"
Dockerfile Best Practices
Multi-Stage Builds
Projects MUST use multi-stage builds to minimize image size.
Why: Multi-stage builds separate build dependencies from runtime dependencies, reducing image size by 10-100x. Smaller images have fewer vulnerabilities, faster pull times, and lower storage costs.
# Stage 1: Build
# golang:1.22; registry manifest digest.
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . . # Requires a .dockerignore that excludes secrets and credentials
RUN CGO_ENABLED=0 go build -o /app/server
# Stage 2: Runtime
# gcr.io/distroless/static-debian12:nonroot; registry manifest digest.
FROM gcr.io/distroless/static-debian12@\
sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Base Image Selection
Projects SHOULD use minimal base images.
Why: Smaller base images reduce attack surface, vulnerabilities, and image size. Distroless images contain only the application and runtime dependencies, eliminating shell and package managers.
Image Size Comparison:
ubuntu:22.04: ~77 MBalpine:3.19: ~7 MBdistroless/static: ~2 MBscratch: ~0 MB (static binaries only)
Choosing Base Images:
# Go, Rust (static binaries)
FROM scratch
COPY app /app
ENTRYPOINT ["/app"]
# Go, Rust (with CA certs, timezone data)
# gcr.io/distroless/static-debian12:nonroot; registry manifest digest.
FROM gcr.io/distroless/static-debian12@\
sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab
# Python, Node.js, Ruby
# gcr.io/distroless/python3-debian12:nonroot; registry manifest digest.
FROM gcr.io/distroless/python3-debian12@\
sha256:7d1042ce588ab97019fe95c24ffca7bc5a82ccdac572511d5e09bda4435c89c5
# gcr.io/distroless/nodejs20-debian12:nonroot; registry manifest digest.
FROM gcr.io/distroless/nodejs20-debian12@\
sha256:2cd820156cf039c8b54ae2d2a97e424b6729070714de8707a6b79f20d56f6a9a
# Alpine (when distroless unavailable)
# alpine:3.19; registry manifest digest.
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
RUN apk add --no-cache ca-certificates
Layer Optimization
Projects MUST order Dockerfile instructions from least to most frequently changed.
Why: Docker caches layers. Placing stable instructions early maximizes cache hits and speeds up builds. Installing dependencies before copying source code prevents re-downloading packages on every code change.
# WRONG: Invalidates cache on every code change
# python:3.12-slim; registry manifest digest.
FROM python@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
# CORRECT: Dependencies cached separately
# python:3.12-slim; registry manifest digest.
FROM python@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
A single-stage build ships the whole build context, so every file that
.dockerignore fails to exclude is readable in the published image. Multi-stage
builds narrow that to the intermediate stage, which is still cached and
pushable, so the exclusions matter in both cases.
Combining RUN Commands
Projects SHOULD combine RUN commands to reduce layers.
# WRONG: Multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
# CORRECT: Single layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
.dockerignore
Projects MUST use .dockerignore to exclude unnecessary files, and
MUST exclude every path that can hold credentials.
Why: .dockerignore reduces build context size and speeds up builds, but
it is also the only thing standing between COPY . . and a credential baked
into a layer. Patterns are matched against the whole path relative to the
context root, and * does not cross /, so secrets/ and *.pem exclude
only top-level matches: sub/secrets/db_password and sub/deploy.pem still
enter the context. Credential patterns therefore need a **/ prefix.
# .dockerignore
# Credentials and secret material (never in a build context)
**/secrets/
**/.env*
**/*.pem
**/*.key
**/*.p12
**/*.pfx
**/id_rsa*
**/id_ed25519*
**/.ssh/
**/.aws/
**/.netrc
**/.npmrc
**/.pypirc
**/*.kubeconfig
# Version control and CI metadata
.git
.gitignore
.github
# Build output and caches
node_modules
__pycache__
*.pyc
.pytest_cache
coverage
dist
build
# Documentation and logs
*.md
*.log
The matching .gitignore MUST cover the same credential paths, because a
file removed from the working tree survives in Git history. Git applies these
patterns at every depth, so it needs no **/ prefix.
# .gitignore
secrets/
.env
.env.*
!.env.example
*.pem
*.key
Build-Time Credentials
Projects MUST pass build-time credentials with BuildKit secret or SSH mounts, and MUST NOT pass them as build arguments, environment variables, or copied files.
Why: A build argument persists in the image configuration and in
docker history, so anyone who can pull the image can read it. A secret mount
exposes the value only for the duration of one RUN instruction and writes
nothing to the layer.
# WRONG: a build argument survives in the published image
# alpine:3.22; registry manifest digest.
FROM alpine@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
ARG REGISTRY_TOKEN
ENV REGISTRY_TOKEN=${REGISTRY_TOKEN}
RUN echo "fetching dependencies with $REGISTRY_TOKEN" > /tmp/build.log
# docker inspect myapp:1.0.0 --format '{{json .Config.Env}}'
# ["PATH=...","REGISTRY_TOKEN=fixture-token-value"]
# syntax=docker/dockerfile:1.27
# CORRECT: the credential is mounted for one instruction only
# Exact release tag; verified from the container registry.
FROM golang:1.27.1 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=secret,id=netrc,target=/root/.netrc,mode=0400 \
go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./...
# Supply the secret from a file outside the build context
docker build --secret id=netrc,src="$HOME/.netrc" -t myapp:1.0.0 .
# Or take the value from an environment variable on the build client;
# it is mounted at /run/secrets/npm_token unless the RUN mount sets env=
docker build --secret id=npm_token,env=NPM_TOKEN -t myapp:1.0.0 .
Use --mount=type=ssh for private Git dependencies so the agent socket, not a
key file, is forwarded into the build.
Security Scanning
Projects MUST scan images for vulnerabilities before deployment.
Why: Vulnerability scanning detects known CVEs in base images and dependencies. Automated scanning in CI/CD prevents vulnerable images from reaching production.
# Scan the exact local tag built above
trivy image myapp:1.0.0
# Fail CI if critical vulnerabilities found
trivy image --severity CRITICAL,HIGH --exit-code 1 myapp:1.0.0
# Scan Dockerfile
trivy config Dockerfile
CI Integration:
# GitHub Actions
- name: Scan image
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: myapp:1.0.0 # Exact local tag built above
severity: CRITICAL,HIGH
exit-code: 1
Hadolint for Dockerfile Linting
Projects SHOULD lint Dockerfiles with Hadolint.
# Install Hadolint
# Exact release tag; verified from the container registry.
docker pull hadolint/hadolint:v2.14.0
# Lint Dockerfile
# Exact release tag; verified from the container registry.
docker run --rm -i hadolint/hadolint:v2.14.0 < Dockerfile
# Ignore specific rules
# Exact release tag; verified from the container registry.
docker run --rm -i hadolint/hadolint:v2.14.0 \
--ignore DL3008 --ignore DL3009 < Dockerfile
Labels and Metadata
Prometheus Discovery Labels
Projects SHOULD add Prometheus discovery labels to expose metrics.
Why: Labels enable automatic service discovery by Prometheus or other monitoring tools. This eliminates manual configuration and enables dynamic monitoring as services scale.
services:
api:
labels:
- "prometheus.io/scrape=true"
- "prometheus.io/port=9090"
- "prometheus.io/path=/metrics"
- "prometheus.io/job=api"
Traefik Integration Labels
Projects using Traefik SHOULD configure routing via labels.
services:
web:
labels:
- "traefik.enable=true"
- "traefik.http.routers.web.rule=Host(`example.com`)"
- "traefik.http.routers.web.entrypoints=websecure"
- "traefik.http.routers.web.tls.certresolver=letsencrypt"
- "traefik.http.services.web.loadbalancer.server.port=80"
- "traefik.http.routers.web.middlewares=security-headers@file"
Container Metadata Labels
Projects SHOULD add metadata labels for documentation.
services:
app:
labels:
- "org.opencontainers.image.title=MyApp"
- "org.opencontainers.image.version=1.0.0"
- "org.opencontainers.image.authors=team@example.com"
- "org.opencontainers.image.source=https://github.com/org/repo"
- "com.example.team=platform"
- "com.example.environment=production"
Dependency Management
depends_on with Health Checks
Projects MUST use depends_on with condition: service_healthy.
Why: Without health checks, depends_on only ensures start order, not
readiness. Using service_healthy ensures dependent services wait until
upstream services are actually ready to accept connections.
depends_on gates startup only. It does NOT provide runtime failure
recovery: restart: true restarts the dependent service when an explicit
Compose operation such as docker compose restart or docker compose up
updates or restarts the dependency. A dependency that crashes and is brought
back by its own restart policy does not trigger it, so applications MUST
reconnect with back-off rather than assume Compose will restart them.
services:
api:
depends_on:
database:
condition: service_healthy
# Restarts api when an explicit Compose operation restarts or
# updates database; not on a crash or an automatic restart
restart: true
cache:
condition: service_healthy
restart: true
database:
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
timeout: 3s
retries: 5
Graceful Shutdown
Projects SHOULD configure stop_grace_period for clean shutdowns.
Why: The default 10-second grace period may be insufficient for services to drain connections, finish processing, or flush buffers. Proper grace periods prevent data loss and connection errors.
services:
api:
stop_grace_period: 30s # Wait 30s before SIGKILL
database:
stop_grace_period: 60s # Databases need more time
Application Code:
import signal
import sys
def signal_handler(sig, frame):
print("SIGTERM received, shutting down gracefully...")
# Close database connections
db.close()
# Finish pending tasks
task_queue.drain()
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
Environment-Specific Configurations
Development Override
# compose.override.yml (automatically loaded in development)
services:
api:
build:
context: .
target: development
volumes:
- ./src:/app/src # Hot reload
environment:
- DEBUG=true
ports:
- "8080:8080" # Expose for local debugging
Production Configuration
# compose.production.yml
services:
api:
# crefft: illustrative -- Replace this production API placeholder with a full registry digest.
image: registry.example.com/api@sha256:abc123...
restart: always
deploy:
replicas: 3
resources:
limits:
memory: 1G
cpus: '2'
logging:
driver: syslog
options:
syslog-address: "tcp://logs.example.com:514"
Deployment:
# Development (uses compose.yml + compose.override.yml)
docker compose up -d
# Production
docker compose -f compose.yml -f compose.production.yml up -d
Complete Example
# compose.yml - Production-ready configuration
# Optional sub-stacks
include:
- compose.database.yml
- compose.cache.yml
- compose.monitoring.yml
# Reusable fragments
x-service-defaults: &service-defaults
restart: unless-stopped
init: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
x-healthcheck-standard: &healthcheck-standard
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
start_interval: 2s
x-logging-standard: &logging-standard
driver: json-file
options:
max-size: "50m"
max-file: "5"
# Networks
networks:
frontend:
driver: bridge
backend:
internal: true
# Secrets
# Host files bind-mounted into the container: not encrypted, not in memory.
# Keep the source outside the build context, mode 0400, materialized by
# deployment tooling from a secret manager.
secrets:
api_key:
file: ${SECRETS_DIR:-/etc/myapp/secrets}/api_key
# Volumes
volumes:
app-data:
# Services
services:
api:
# crefft: illustrative -- Replace this application placeholder with a full registry digest.
image: myapp/api@sha256:abc123def456...
container_name: api
<<: *service-defaults
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
logging:
<<: *logging-standard
networks:
- frontend
- backend
depends_on:
postgres:
condition: service_healthy
# Startup gating and explicit Compose operations only; the api
# process reconnects with back-off if postgres restarts on its own
restart: true
secrets:
- api_key
environment:
- API_KEY_FILE=/run/secrets/api_key
- DATABASE_URL=postgres://user@postgres:5432/db
volumes:
- app-data:/data
healthcheck:
<<: *healthcheck-standard
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
deploy:
resources:
limits:
memory: 512M
cpus: '1'
pids: 100
reservations:
memory: 128M
labels:
# Prometheus discovery
- "prometheus.io/scrape=true"
- "prometheus.io/port=8080"
- "prometheus.io/path=/metrics"
# Metadata
- "org.opencontainers.image.version=1.0.0"
CI/CD Integration
Build and Push
# .github/workflows/docker.yml
name: Docker Build
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5.4.0
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
Compose Validation
- name: Validate Compose files
run: |
docker compose config --quiet
docker compose -f compose.yml -f compose.production.yml config --quiet
Troubleshooting
Container Logs
# Follow logs
docker compose logs -f api
# Last 100 lines
docker compose logs --tail=100 api
# Logs since timestamp
docker compose logs --since 2024-01-01T10:00:00 api
# All containers
docker compose logs -f
Health Check Debugging
# Inspect health status
docker inspect api --format='{{.State.Health.Status}}'
# View health check logs
docker inspect api --format='{{json .State.Health.Log}}' | jq
Network Debugging
# List networks
docker network ls
# Inspect network
docker network inspect frontend
# Test connectivity between containers
docker exec api ping -c 3 postgres
docker exec api nc -zv postgres 5432
Resource Usage
# Real-time stats
docker stats
# Specific container
docker stats api
# Memory usage
docker stats --format "table {{.Name}}\t{{.MemUsage}}"
See Also
- Ansible Guide - Configuration management and deployment
- CI/CD Guide - Continuous integration patterns
- Testing Guide - Container testing strategies
References
- Docker Engine 27.5 Release Notes
- Docker Compose v5 Release Notes
- Manage Secrets in Docker Compose
- Manage Sensitive Data with Docker Secrets (Swarm)
- Build Secrets
- Start Containers Automatically
- Control Startup and Shutdown Order in Compose
- Kubernetes Liveness, Readiness and Startup Probes
- Docker Compose Include Directive
- OWASP Docker Security Cheat Sheet
- Docker Security Best Practices 2025
- Snyk Docker Security Best Practices
- Docker Image Digest Pinning
- Docker Compose Modularity with Include
- Dockerfile Best Practices 2025