
Containers
- 3 installs
- 19 repo stars
- Updated August 1, 2026
- xobotyi/cc-foundry
Helps with ai & agent building tasks.
About
containers is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- containers
- AI & Agent Building
- AI-coding skill
Containers by the numbers
- 3 all-time installs (skills.sh)
- Ranked #13,657 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xobotyi/cc-foundry --skill containersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 19 |
| Last updated | August 1, 2026 |
| Repository | xobotyi/cc-foundry ↗ |
What it does
Helps with ai & agent building tasks.
Files
Containers
Security is not optional. Every container runs non-root, with dropped capabilities, on a minimal base image. Convenience defaults that weaken security posture are bugs, not trade-offs.
Security Rules
These are non-negotiable defaults for every container configuration. Apply unconditionally — no exceptions for development, convenience, or "temporary" setups.
- Run as non-root. Add a
USERinstruction with explicit UID/GID. Never run production containers as root. - Drop all capabilities.
--cap-drop=ALL, then add back only what the application genuinely requires. Most
applications need zero.
- Enable no-new-privileges.
--security-opt=no-new-privilegesprevents setuid/setgid escalation. - Use read-only filesystem.
--read-onlywithtmpfsmounts for/tmp,/run, and any directories the app writes
to.
- Never pass secrets via ENV. Visible in
docker inspect, logs, and child processes. Use mounted secret files or
Docker secrets.
- Scan images before deployment. Use Docker Scout, Trivy, or Grype in CI. Block images with critical CVEs.
- Use distroless for production when possible. No shell means no shell-based attacks and dramatically fewer CVEs.
- Never use `--privileged`. It removes almost all security restrictions. If a workload claims to need it, decompose
the requirement into specific capabilities.
- Never mount the Docker socket (
/var/run/docker.sock) into containers. It grants root-equivalent control over the
host. Use purpose-built APIs or socket proxies with restricted access.
- Use `--init` or tini/dumb-init for PID 1. Regular applications don't reap zombie processes or handle signals
correctly as PID 1. Use docker run --init, RunInit=true in Quadlets, or install tini in the Dockerfile. Without this, docker stop waits for the kill timeout and zombies accumulate.
- Verify supply chain integrity. Pin images to digest, generate SBOMs (Syft, Docker Scout), sign images with
cosign/Sigstore. Use VEX documents to distinguish exploitable from non-exploitable CVEs. Block unsigned images in CI.
References
- Dockerfile patterns — [
${CLAUDE_SKILL_DIR}/references/dockerfile-patterns.md]: Multi-stage templates, layer
optimization, base image selection, .dockerignore, ENTRYPOINT/CMD, BuildKit cache mounts, signal handling, OCI labels
- Compose orchestration — [
${CLAUDE_SKILL_DIR}/references/compose-orchestration.md]: Service structure, depends_on
conditions, env vars, secrets, networks, volumes, profiles, restart policies, override files, zero-downtime patterns
- Security hardening — [
${CLAUDE_SKILL_DIR}/references/security-hardening.md]: Non-root patterns, read-only FS,
capabilities, distroless, secrets, scanning, supply chain security, SBOM, image signing, VEX, hardening checklist
- Networking — [
${CLAUDE_SKILL_DIR}/references/networking.md]: Driver selection, bridge/host/macvlan/ipvlan usage,
port publishing, DNS, multi-network, common mistakes, iptables bypass
- Storage and volumes — [
${CLAUDE_SKILL_DIR}/references/storage-and-volumes.md]: Volume types, named/bind/tmpfs,
NFS/CIFS drivers, backup/restore, permissions, storage drivers, performance
- Operations — [
${CLAUDE_SKILL_DIR}/references/operations.md]: Health checks, resource constraints, logging
drivers, structured logging, debugging, monitoring, Quadlet patterns, Docker/Podman CLI compat
Dockerfile Rules
- Use multi-stage builds. Separate build dependencies from the runtime image. Final stage contains only the
binary/app and its runtime dependencies.
- Choose minimal base images.
alpine,*-slim,distroless, orscratchfor static binaries. Never use full OS
images in production.
- Pin base image versions. Use digest pinning (
@sha256:...) in CI for reproducibility. Use minor version tags
(3.13-slim) in development. Never use :latest.
- Order layers by change frequency. Stable instructions first (OS packages), volatile instructions last (source
code). Copy dependency manifests before source code for layer caching.
- Combine RUN statements. Merge
apt-get updatewithapt-get installin the sameRUN. Clean caches in the same
layer. Sort packages alphabetically.
- Use COPY, not ADD.
COPYfor local files.ADDonly when you need remote URL fetching or automatic tar
extraction.
- Use exec form for CMD/ENTRYPOINT.
CMD ["app", "--flag"]— notCMD app --flag. Shell form wraps in
/bin/sh -c, making the shell PID 1. Shells don't forward signals to children — your app never receives SIGTERM on docker stop.
- Use `exec "$@"` in entrypoint scripts. Without
exec, the shell spawns the app as a child and swallows signals.
exec replaces the shell process with the app, making it PID 1.
- Always create a .dockerignore. Exclude
.git,node_modules,.env, build artifacts, and documentation from the
build context.
- Use `--mount=type=secret` for build-time secrets. Never
COPYorENVsecrets — they persist in image layers. - Set WORKDIR to an absolute path. Never use
RUN cd ... && .... - Use BuildKit cache mounts for package manager caches:
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt. Avoids re-downloading dependencies on every build.
- Use `docker buildx` for multi-platform images.
docker buildx build --platform linux/amd64,linux/arm64produces
images for multiple architectures. Required for ARM/x86 portability.
- Add OCI labels. Use
LABEL org.opencontainers.image.*for source, version, description. Enables registry
identification and automated tooling.
Compose Rules
- Use Compose v2 specification. No
version:field. Usecompose.yml(notdocker-compose.yml). - Use `depends_on` with `condition: service_healthy` for services that need initialization time (databases, caches).
- Use `service_completed_successfully` for one-shot dependencies like migrations.
- Isolate networks. Create separate networks for frontend/backend tiers. Use
internal: trueon backend networks to
block outbound internet access.
- Use named volumes for persistent data. Bind mounts for development, named volumes for production. Never rely on
anonymous volumes.
- Always set resource limits.
deploy.resources.limitsfor memory and CPU. A container without memory limits can
OOM-kill the host.
- Configure log rotation. Use
locallogging driver, or configurejson-filewithmax-sizeandmax-file.
Default has no rotation.
- Use restart policies.
unless-stoppedfor production services.on-failurefor tasks that should retry but
eventually stop.
- Use profiles for optional services. Debug tools, monitoring, and test runners behind
profiles:— not started by
default.
- Use env_file for environment variables. Keep
.env.examplein version control,.envin.gitignore. - Use file-based secrets. Compose
secrets:mounts files at/run/secrets/<name>— granular per-service access,
not visible in docker inspect or process listings like env vars.
- Use override files for multi-environment.
compose.ymlfor common defaults,compose.prod.ymlfor production
overrides. Use explicit -f flags in production — never rely on automatic compose.override.yml loading.
- Use structured logging (JSON). Configure apps to emit JSON logs to stdout. Use Pino (Node), Zap (Go), or stdlib
JSON formatters. Structured logs enable filtering and aggregation.
Networking Rules
- Always use user-defined bridge networks. Default bridge has no DNS resolution and no isolation. User-defined
bridges provide automatic service discovery by container name.
- Use `host` network only for performance-critical workloads that bind many dynamic ports. Not available on Docker
Desktop.
- Use `macvlan` when containers need LAN presence with unique MAC addresses. Use
ipvlanwhen the switch limits MAC
count.
- Bind published ports to `127.0.0.1` when the service should not be externally accessible. Docker port mapping
bypasses host firewall rules.
- Use `internal: true` on backend Compose networks to prevent outbound internet access from database and cache
containers.
- Bind services to `0.0.0.0` inside containers. A service bound to
localhostinside a container is unreachable
from other containers — localhost refers to the container's own namespace.
- Use a reverse proxy for public-facing services. Only the proxy publishes ports. Backend services stay on internal
networks with no published ports. This centralizes TLS, rate limiting, and access logs.
Volume Rules
- Named volumes for persistent data. Databases, uploads, state that must survive container recreation.
- Bind mounts for development. Source code, config files injected from host. Always use
:rowhen the container
should not modify.
- tmpfs for sensitive ephemeral data. Secrets at runtime, session files, scratch space. Never persisted to disk.
- Set volume permissions at build time.
RUN mkdir && chownbeforeUSERinstruction. Avoids permission denied
errors at runtime.
- Monitor volume disk usage.
docker system dfshows space used by images, containers, and volumes. Dangling
anonymous volumes accumulate silently — run docker volume prune periodically.
- For Podman: use
:Uto chown volume contents to match container user,:Z/:zfor SELinux relabeling.
Health Check Rules
- Define health checks for every long-running service. Use
HEALTHCHECKin Dockerfile orhealthcheck:in Compose. - Use appropriate check commands.
curl -ffor HTTP services,pg_isreadyfor Postgres,redis-cli pingfor
Redis, nc -z for TCP ports.
- Set `start_period` for services with slow initialization. Failures during start period don't count toward retry
limit.
- For distroless images, build the health check into the application binary. No shell or curl available.
- Health checks run inside the container. Only check local endpoints, never external dependencies.
Resource Limit Rules
- Always set memory limits in production.
--memoryordeploy.resources.limits.memory. Prevents OOM cascade. - Set `--memory-swap` equal to `--memory` to disable swap. Swapping containers cause unpredictable latency.
- Use `--cpus` for CPU limits. Simpler than
--cpu-period/--cpu-quota.--cpus=1.5means 150% of one core. - Use `--cpu-shares` for relative priority under contention. Not a hard limit — only enforced when CPU is scarce.
Podman Considerations
- Podman is rootless by default. No daemon, no root required. Uses user namespaces to map container root to
unprivileged host user. Grants only 11 default capabilities (vs Docker's 14) — tighter least-privilege baseline out of the box.
- Requires `/etc/subuid` and `/etc/subgid` ranges for the user running containers. Run
podman system migrateafter
changes.
- Rootless cannot bind ports < 1024 without
net.ipv4.ip_unprivileged_port_startsysctl. - Rootless networking uses `pasta` or `slirp4netns` — not kernel bridging. Performance differs from Docker's bridge
driver. DNS resolution within custom networks is handled by aardvark-dns; network setup by netavark.
- Use Quadlet files for systemd integration. Place
.container,.volume,.network,.podfiles in
~/.config/containers/systemd/ (rootless) or /etc/containers/systemd/ (rootful). Replaces deprecated podman generate systemd. Run systemctl --user daemon-reload after changes. Enable user lingering (loginctl enable-linger) for rootless services to survive logout.
- Use `AutoUpdate=registry` in Quadlet containers. Requires fully-qualified image names. Enable the timer:
systemctl --user enable --now podman-auto-update.timer. Podman pulls new images and restarts affected services. Dry-run with podman auto-update --dry-run.
- Use `RunInit=true` in Quadlet containers. Equivalent to
docker run --init— adds tini as PID 1 for signal
forwarding and zombie reaping.
- Use `TimeoutStartSec=900` for slow image pulls. Systemd defaults to 90s — large images will fail the service
start.
- `podman pod` groups containers sharing a network namespace, similar to Kubernetes pods. Containers in a pod
communicate over localhost. Use .pod Quadlet files with Pod= directive in container files to join.
- SELinux integration is first-class. Use
:Z(private) or:z(shared) volume flags.:Zapplies MCS labels
isolating containers from each other. Always use :Z on SELinux systems.
- Use `Secret=` in Quadlets for credentials. Create with
podman secret create, reference with
Secret=name,type=env,target=VAR. Cleaner than env files for sensitive data.
- Docker Compose compatibility:
podman composewraps docker-compose. Use fully-qualified image names for registry
operations. Most Docker CLI commands work identically with Podman.
Application
When writing Dockerfiles or Compose files: Apply all rules silently. Produce secure, optimized configurations by default — multi-stage builds, non-root users, minimal images, init process, health checks, resource limits, log rotation, and file-based secrets. Never produce a Dockerfile without a USER instruction or a Compose service without deploy.resources.limits.
When reviewing container configurations: Check every security rule first. Cite the specific rule violated and show the fix inline. Review priority: security violations > missing resource limits > missing health checks > missing log rotation > layer optimization.
When debugging container issues:
1. docker logs -f --tail 100 <container> — check application output 2. docker inspect <container> — verify config, mounts, network, env 3. docker exec -it <container> sh — interactive inspection (if shell available) 4. docker run -it --rm --network container:<target> nicolaka/netshoot — network debugging with full toolkit 5. For distroless: docker run -it --rm --pid container:<target> --network container:<target> busybox — ephemeral debug sidecar
Integration
the-coderprovides overall coding discipline for configuration filesnetworkingskill in this plugin covers network infrastructure beyond container networking (VLANs, firewalls, reverse
proxies)
- Language plugins (golang, javascript, python) provide language-specific build patterns referenced in multi-stage
Dockerfiles
Every container is non-root, read-only filesystem, all capabilities dropped, with an init process for signal handling. Every image is minimal, scanned, signed, and pinned. Every service has health checks, resource limits, log rotation, and structured logging. No exceptions.
{
"sources": {
"Dockerfile Reference": "https://docs.docker.com/reference/dockerfile/",
"Docker Build Best Practices": "https://docs.docker.com/build/building/best-practices/",
"Multi-stage Builds": "https://docs.docker.com/build/building/multi-stage/",
"Compose Specification": "https://docs.docker.com/reference/compose-file/",
"Docker Security Best Practices": "https://docs.docker.com/build/building/best-practices/#security",
"Docker Networking Overview": "https://docs.docker.com/engine/network/",
"Docker Network Drivers": "https://docs.docker.com/engine/network/drivers/",
"Docker Volumes": "https://docs.docker.com/engine/storage/volumes/",
"Docker Resource Constraints": "https://docs.docker.com/engine/containers/resource_constraints/",
"Dockerfile HEALTHCHECK": "https://docs.docker.com/reference/dockerfile/#healthcheck",
"Docker Logging Drivers": "https://docs.docker.com/engine/logging/configure/",
"Podman Rootless Tutorial": "https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md",
"Podman Docker Compatibility": "https://github.com/containers/podman/blob/main/transfer.md",
"OCI Image Specification": "https://github.com/opencontainers/image-spec/blob/main/spec.md"
},
"lastFetched": "2026-03-13T19:27:25.806Z"
}
Compose Orchestration
Docker Compose v2 patterns for multi-container applications. Compose files use the Compose Specification (no version: field needed).
Service Structure
services:
app:
build:
context: .
dockerfile: Dockerfile
target: runtime
image: myapp:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
secrets:
- db_password
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
memory: 256M
db:
image: postgres:17-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password.txtdepends_on with Health Checks
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
migrations:
condition: service_completed_successfully- `service_started` — Container started (default)
- `service_healthy` — Health check passes
- `service_completed_successfully` — Container exits with code 0
Use service_healthy for databases and services that need init time. Use service_completed_successfully for one-shot tasks like migrations.
Environment Variables
services:
app:
# Inline values
environment:
NODE_ENV: production
LOG_LEVEL: info
# From .env file
env_file:
- .env
- .env.local
# Interpolation from host/shell
environment:
API_KEY: ${API_KEY:?API_KEY must be set}${VAR:-default}— use default if unset${VAR:?error}— fail with error if unset.envfile in project root is loaded automatically- Never commit
.envfiles with secrets — use.env.exampleas template
Networks
services:
frontend:
networks:
- frontend
- backend
api:
networks:
- backend
db:
networks:
- backend
networks:
frontend:
backend:
internal: true # No external access- Services on the same network resolve each other by service name
internal: trueblocks outbound internet access — use for backend tiers- Default network is created automatically if none specified
- Explicit networks provide isolation between service groups
Volumes
volumes:
pgdata: # Named volume (Docker-managed)
grafana-storage:
driver: local
services:
db:
volumes:
- pgdata:/var/lib/postgresql/data # Named volume
- ./init.sql:/docker-entrypoint-initdb.d/ # Bind mount
- /data/backups:/backups:ro # Read-only bind- Named volume —
volname:/path: Persistent data (databases, uploads) - Bind mount —
./host:/container: Development, config files - tmpfs —
type: tmpfs: Ephemeral scratch data, secrets at runtime
- Named volumes survive
docker compose down(but notdown -v) - Bind mounts for development; named volumes for production
- Use
:rosuffix for read-only mounts
Multi-environment Compose
Override files
# Base + development overrides (automatic)
docker compose up
# Base + production overrides
docker compose -f compose.yml -f compose.prod.yml upcompose.override.yml is loaded automatically with compose.yml.
Profile-based services
services:
app:
# Always starts
debug:
profiles: ["debug"]
image: busybox
# Only starts with: docker compose --profile debug up
test:
profiles: ["test"]Restart Policies
- `no` — Never restart (default)
- `always` — Always restart, including on daemon startup
- `unless-stopped` — Like
always, but not if manually stopped - `on-failure[:max]` — Restart only on non-zero exit, optional retry limit
Use unless-stopped for production services. Use on-failure for tasks that should retry but eventually give up.
Resource Limits
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
cpus: "0.5"
memory: 256Mlimits— hard ceiling, container is killed/throttled if exceededreservations— guaranteed minimum, used for scheduling- Always set memory limits for production — prevents OOM cascading
deploy.resourcesworks withdocker compose up(not just Swarm)
Secrets
File-based secrets are safer than environment variables — env vars leak into docker inspect, process listings, and crash dumps.
services:
app:
secrets:
- db_password
- api_key
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
file: ./secrets/api_key.txt- Secrets mount as files at
/run/secrets/<name>— read-only by default - Many official images support
*_FILEenv vars (Postgres, MySQL, etc.) - For custom apps, read the secret file path from an env var at startup
- Granular per-service access — only services that declare the secret can read it
Override Files
Layer environment-specific configuration without duplicating code:
# Development (automatic — loads compose.override.yml)
docker compose up
# Production (explicit file selection)
docker compose -f compose.yml -f compose.prod.yml up -d
# Validate merged result before deploying
docker compose -f compose.yml -f compose.prod.yml configMerge behavior
- Scalar values (image, command): replaced by override
- Arrays (ports, volumes): combined — watch for port conflicts
- Maps (environment, labels): merged by key
Project organization
project/
├── compose.yml # Common defaults
├── compose.override.yml # Development (auto-loaded, gitignored)
├── compose.prod.yml # Production overrides
├── compose.monitoring.yml # Optional observability stack
├── .env # Variable substitution (gitignored)
└── .env.example # Template (committed)- Never rely on automatic
compose.override.ymlin production - Use explicit
-fflags for production deployments - Use
docker compose configto verify merged output
Zero-downtime Patterns
Standard docker compose up stops the old container before starting the new one — 10-20 seconds of downtime.
docker-rollout plugin
# Install the plugin, then:
docker rollout myserviceScales to 2 instances, waits for health check, updates proxy routing, then removes the old container. Requires a reverse proxy (Traefik, Nginx).
Blue/green with project names
# Start green alongside blue
TAG=v2.0 docker compose -p green up -d
# Verify green health, flip proxy upstream
# Remove blue
docker compose -p blue downTrade-offs: requires enough resources for both stacks briefly, migrations must be backward-compatible.
Rollback
# Re-deploy the previous tag
TAG=v1.9 docker compose up -dIf migrations were destructive, rollback may be impossible. Use additive schema changes and feature flags.
Init Process
Enable tini as PID 1 for signal forwarding and zombie reaping:
services:
app:
init: trueUse for any service where the application is not designed to run as PID 1 (most applications).
Compose Commands
docker compose up -d # Start detached
docker compose down # Stop and remove containers
docker compose down -v # Also remove volumes
docker compose logs -f app # Follow logs for service
docker compose exec app sh # Shell into running container
docker compose build --no-cache # Rebuild images
docker compose ps # List running services
docker compose config # Validate and print merged configDockerfile Patterns
Multi-stage Build Patterns
Standard two-stage (build + runtime)
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]Three-stage with testing
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
FROM deps AS test
COPY . .
RUN npm test
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]Shared base stage (DRY across images)
FROM python:3.13-slim AS base
RUN groupadd -r app && useradd -r -g app -d /app app
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS api
COPY api/ ./api/
USER app
CMD ["python", "-m", "api"]
FROM base AS worker
COPY worker/ ./worker/
USER app
CMD ["python", "-m", "worker"]Layer Optimization
Package installation (Debian/Ubuntu)
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
&& rm -rf /var/lib/apt/lists/*- Always combine
apt-get updatewithapt-get installin the sameRUN - Use
--no-install-recommendsto skip suggested packages - Clean apt cache in the same layer:
rm -rf /var/lib/apt/lists/* - Sort packages alphabetically for readable diffs
Package installation (Alpine)
RUN apk add --no-cache \
curl \
openssl \
tiniAlpine uses --no-cache instead of manual cache cleanup.
Dependency caching pattern
Copy dependency manifests before source code so dependency layers cache independently of code changes:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Order instructions from least-frequently-changed to most-frequently-changed.
Base Image Selection
- Go, Rust (static binaries) —
scratchordistroless/static(~2MB): No shell, no package manager - General minimal —
alpine:3.21(~6MB): musl libc — test for glibc compatibility - Debian-based apps —
debian:bookworm-slim(~75MB): glibc, smaller than full debian - Python —
python:3.13-slim(~150MB): Debian slim variant - Node.js —
node:22-alpine(~55MB): Alpine variant saves ~900MB over full - Distroless (Google) —
gcr.io/distroless/*(varies): No shell — hardened for production
Image pinning
# Pin to digest for reproducible builds
FROM python:3.13-slim@sha256:abc123...
# Pin to minor version for security patches
FROM node:22-alpine- Pin to digest (
@sha256:...) for supply chain integrity in CI - Pin to minor version tag (
3.13-slim) for development convenience - Never use
:latestin production Dockerfiles
.dockerignore
.git
.github
.env
.env.*
node_modules
__pycache__
*.pyc
dist
build
*.md
!README.md
docker-compose*.yml
Dockerfile*
.dockerignoreAlways create a .dockerignore to exclude version control, local dependencies, build artifacts, and sensitive files from the build context.
ENTRYPOINT vs CMD
- `ENTRYPOINT ["app"]` + `CMD ["--default-flag"]` — App with overridable defaults
- `CMD ["app", "--flag"]` — Simple service, fully overridable
- `ENTRYPOINT ["entrypoint.sh"]` + `CMD ["app"]` — Init script pattern (Postgres-style)
- Use exec form
["binary", "arg"]— not shell formbinary arg - Shell form wraps in
/bin/sh -c, preventing signal forwarding to PID 1 - Entrypoint scripts must use
exec "$@"to replace shell with the app process
USER and Non-root
RUN groupadd -r app --gid=1000 \
&& useradd -r -g app --uid=1000 -d /app app
USER app- Use explicit UID/GID for deterministic file ownership across rebuilds
- Place
USERafter allRUNcommands that need root (package installs) - Never install or use
sudo— usegosuif root-then-drop is needed - For Alpine:
addgroup -S -g 1000 app && adduser -S -G app -u 1000 app
WORKDIR
Always use absolute paths. Use WORKDIR instead of RUN cd ... && ....
WORKDIR /appBuildKit Cache Mounts
Cache package manager downloads across builds without bloating layers:
# Python — cache pip downloads
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Node.js — cache npm packages
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Go — cache module downloads and build cache
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app ./cmd/server
# Apt — cache package downloads
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends curl- Cache mounts persist across builds but are never included in image layers
- Combine with multi-stage builds — cache in the build stage, copy only artifacts to runtime stage
- Requires BuildKit (
DOCKER_BUILDKIT=1or Docker 23.0+ where it's default)
Multi-platform Builds
Build images for multiple CPU architectures with docker buildx:
# Create a builder with multi-platform support
docker buildx create --name multiarch --use
# Build and push for multiple platforms
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myimage:latest \
--push .- Use
--platforminFROMto handle architecture-specific base images:
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build
ARG TARGETOS TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /app$BUILDPLATFORM— the platform running the build (for cross-compilation)$TARGETPLATFORM,$TARGETOS,$TARGETARCH— the target platform- Essential for ARM/x86 portability (Apple Silicon, Raspberry Pi, cloud ARM instances)
Signal Handling and Graceful Shutdown
The PID 1 problem
In containers, the entrypoint process becomes PID 1. PID 1 has special responsibilities in Linux:
- Must forward signals to child processes
- Must reap zombie (orphaned) processes
- Default signal handlers don't work for PID 1 — SIGTERM is ignored unless the process explicitly installs a handler
Most applications are not designed to be PID 1 and will not handle these responsibilities correctly.
Shell form breaks signal forwarding
# BAD — shell form: /bin/sh -c wraps the command
CMD npm start
# Shell becomes PID 1, npm becomes PID 2
# SIGTERM hits shell, shell ignores it, app never gets signal
# docker stop waits for timeout, then SIGKILL
# GOOD — exec form: app is PID 1 directly
CMD ["node", "index.js"]Entrypoint scripts must use exec
#!/bin/sh
# Setup work...
export DB_HOST="${DB_HOST:-localhost}"
# BAD — spawns app as child, shell stays PID 1
node index.js
# GOOD — replaces shell with app, app becomes PID 1
exec node index.js
# GOOD — with arguments passed from CMD
exec "$@"Use tini or --init for zombie reaping
# Option 1: Use Docker's built-in init
# docker run --init myimage
# Option 2: Install tini in the image
RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "index.js"]
# Option 3: Use dumb-init (common in Python)
RUN pip install dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "-m", "myapp"]--inituses tini bundled with Docker (available since Docker 1.13)- Tini spawns your app, forwards all signals, reaps zombies transparently
- In Podman Quadlets:
RunInit=true - In Compose:
init: truein the service definition
Listening for the right signal
Some frameworks expect SIGINT (Ctrl+C), not SIGTERM:
# Override the stop signal for the container
STOPSIGNAL SIGINT- Docker sends SIGTERM by default on
docker stop - Python/Flask often uses SIGINT for graceful shutdown
- Node.js handles both SIGTERM and SIGINT
- Check your framework's documentation for the expected signal
OCI Labels
Tag images with metadata for registry identification and tooling:
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.version="1.2.3"
LABEL org.opencontainers.image.description="API service"
LABEL org.opencontainers.image.authors="team@example.com"
LABEL org.opencontainers.image.licenses="MIT"In CI, inject labels dynamically from build context:
ARG BUILD_VERSION
ARG BUILD_SHA
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${BUILD_SHA}"ENV Persistence Caveat
Each ENV creates a layer. Even if unset in a later layer, the value persists in intermediate layers and can be extracted. For secrets, use build-time --mount=type=secret or ARG (which doesn't persist in the final image).
# Secret available only during this RUN
RUN --mount=type=secret,id=api_key \
API_KEY=$(cat /run/secrets/api_key) \
&& ./configure --key="$API_KEY"Container Networking
Network Driver Selection
- `bridge` (user-defined) — Container-level isolation, good performance: Default for single-host apps
- `bridge` (default) — Container-level isolation, good performance: Quick testing only — no DNS
- `host` — No isolation, native performance: Performance-critical workloads (no NAT overhead)
- `macvlan` — Full L2 isolation, native performance: Containers need LAN presence (unique MAC)
- `ipvlan` (L2) — Full L2 isolation, native performance: Like macvlan but shared MAC (switch limits)
- `ipvlan` (L3) — Full L3 isolation, native performance: Routed container networking
- `none` — Complete isolation: Security-sensitive isolated workloads
Bridge Networks (Default Choice)
User-defined vs default bridge
Always use user-defined bridge networks — never the default docker0 bridge.
| Feature | Default bridge | User-defined bridge |
|---|---|---|
| DNS resolution | No (IP only) | Yes (by container name) |
| Isolation | Shared with all | Per-network |
| Hot-connect | No | Yes (docker network connect) |
| Custom subnets | No | Yes |
Create and use
docker network create mynet
docker run --network mynet --name app myimage
docker run --network mynet --name db postgres:17
# "app" can reach "db" by name# compose.yml — automatic per-project network
services:
app:
networks:
- backend
db:
networks:
- backend
networks:
backend:Host Networking
docker run --network host myimageContainer shares the host's network namespace — no port mapping needed, no NAT overhead. Use when:
- Application binds to many dynamic ports (media servers)
- Maximum network performance is required
- Container needs access to host network interfaces
Not available on Docker Desktop (macOS/Windows) — only Linux.
Macvlan Networks
Containers appear as physical devices on the LAN with unique MAC addresses.
docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
-o parent=eth0 \
macnet
docker run --network macnet --ip 192.168.1.100 myimageUse when containers need to be directly addressable on the physical network (IoT gateways, DHCP servers, legacy apps).
Gotcha: The host cannot communicate with macvlan containers through the parent interface. Create a macvlan sub-interface on the host for host-to-container communication.
IPvlan Networks
Similar to macvlan but containers share the parent interface's MAC address.
# L2 mode (default) — same subnet as host
docker network create -d ipvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
-o parent=eth0 \
ipvnet
# L3 mode — routed, no broadcast/multicast
docker network create -d ipvlan \
--subnet=10.10.0.0/24 \
-o parent=eth0 \
-o ipvlan_mode=l3 \
ipvl3netUse L2 when switch limits MAC count. Use L3 for routed container networking.
Port Publishing
# Map host:container
docker run -p 8080:80 myimage
# Bind to specific interface
docker run -p 127.0.0.1:8080:80 myimage
# Random host port
docker run -p 80 myimageservices:
app:
ports:
- "8080:80" # host:container
- "127.0.0.1:9090:80" # localhost only- Bind to
127.0.0.1when the service should not be externally accessible - Docker port mapping bypasses
iptables/ufwfirewall rules by default — published ports are accessible from outside
the host regardless of firewall configuration
- Use
DOCKER_IPTABLES=falseor configuredaemon.jsonto prevent this
DNS and Service Discovery
- User-defined bridge networks provide automatic DNS — containers resolve each other by name
- The embedded DNS server (
127.0.0.11) forwards external lookups to host-configured DNS servers --dnsflag overrides DNS servers per container--network-aliasadds additional DNS names for a container- Compose services are resolvable by service name within shared networks
Multi-network Patterns
services:
proxy:
networks:
- frontend
app:
networks:
- frontend
- backend
db:
networks:
- backend
networks:
frontend:
backend:
internal: trueproxycan reachappbut notdbdbhas no external network access (internal: true)appbridges both networks
Common Networking Mistakes
Services binding to localhost
A service inside a container that binds to 127.0.0.1 is unreachable from other containers — localhost refers to the container's own isolated network namespace. Services must listen on 0.0.0.0.
Docker iptables bypass
Docker automatically installs iptables rules for port publishing. These rules bypass ufw, firewalld, and other host firewalls — published ports are accessible from the network regardless of firewall configuration.
Mitigations:
- Bind published ports to
127.0.0.1and use a reverse proxy - Set
"iptables": falsein/etc/docker/daemon.json(requires manual network rule management) - Use
internal: trueon backend networks
DNS failures on default bridge
The default docker0 bridge network does not provide DNS resolution — containers can only reach each other by IP. Always use user-defined networks for service discovery.
If external DNS fails, check /etc/resolv.conf inside the container. Override with --dns per container or in daemon.json.
Stale DNS after container recreation
Docker's embedded DNS caches entries briefly. After recreating a container, old IPs may persist. Use docker network disconnect / connect or restart dependent containers to refresh DNS.
Port conflicts
-p 80:80 fails if port 80 is already in use on the host. With host networking, multiple containers cannot share the same port. Use a reverse proxy to multiplex services on a single published port.
Reverse Proxy Pattern
In production, only the reverse proxy publishes ports. All backend services stay on internal networks:
services:
proxy:
image: caddy:2
ports:
- "443:443"
- "80:80"
networks:
- edge
app:
networks:
- edge
- backend
db:
networks:
- backend
networks:
edge:
backend:
internal: trueBenefits:
- Centralized TLS termination
- Rate limiting and access logs in one place
- Backend services invisible to the network
- Single point of port management
Container Operations
Health Checks
Dockerfile HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/healthz || exit 1- `--interval` (default: 30s) — Time between checks
- `--timeout` (default: 30s) — Max time for a single check
- `--start-period` (default: 0s) — Grace period for startup (failures don't count)
- `--retries` (default: 3) — Consecutive failures before
unhealthy - `--start-interval` (default: 5s) — Interval during start period (Docker 25+)
Health check patterns by service type
# HTTP service
HEALTHCHECK CMD curl -f http://localhost:8080/healthz || exit 1
# TCP service (no curl available)
HEALTHCHECK CMD nc -z localhost 5432 || exit 1
# PostgreSQL
HEALTHCHECK CMD pg_isready -U postgres || exit 1
# Redis
HEALTHCHECK CMD redis-cli ping || exit 1
# Process check (distroless — no shell)
HEALTHCHECK CMD ["/app", "--healthcheck"]HEALTHCHECK NONEdisables health checks inherited from base image- Exit code 0 = healthy, 1 = unhealthy
- For distroless images, build the health check into the application binary
- Health checks run inside the container — only check local endpoints
Compose health checks
services:
app:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
depends_on:
db:
condition: service_healthyResource Constraints
Memory
docker run --memory=512m --memory-swap=512m myimage--memory— hard limit (container OOM-killed if exceeded)--memory-swapequal to--memory— disables swap--memory-reservation— soft limit for scheduling
CPU
docker run --cpus=1.5 myimage # Limit to 1.5 CPU cores
docker run --cpuset-cpus=0,1 myimage # Pin to specific cores
docker run --cpu-shares=512 myimage # Relative weight (soft limit)--cpus— hard limit on CPU usage (1.5 = 150% of one core)--cpuset-cpus— pin to physical cores (NUMA-aware workloads)--cpu-shares— relative weight, only enforced under contention
In Compose
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 256MAlways set memory limits in production. A single container without limits can OOM-kill the Docker daemon or other containers.
Logging
Driver selection
- `json-file` — Manual rotation, supports
docker logs: Default — configure rotation - `local` — Built-in rotation, supports
docker logs: Recommended for production - `journald` — Via journald, supports
docker logs: Systemd integration - `syslog` — Via syslog, no
docker logs: Central syslog server - `fluentd` — Via fluentd, no
docker logs: Log aggregation pipeline - `none` — No rotation, no
docker logs: Disable logging entirely
Configure log rotation (critical for production)
// /etc/docker/daemon.json
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}Or per container:
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"- Default
json-filedriver has NO rotation — logs grow until disk is full - Use
localdriver for built-in rotation, or configurejson-filerotation max-sizeper file,max-filetotal files — container getsmax-size * max-filetotal log space
Application logging rules
- Write to stdout/stderr — Docker captures both streams
- Use structured logging (JSON) for machine-parseable output
- Never log secrets, tokens, or passwords
- For non-blocking logging:
docker run --log-opt mode=non-blocking --log-opt max-buffer-size=4m myimageStructured logging examples
Configure applications to emit JSON for parsing and aggregation:
# Python — stdlib JSON formatter
import logging, json
class JSONFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"ts": self.formatTime(record),
"level": record.levelname,
"msg": record.getMessage(),
"logger": record.name,
})// Node.js — use Pino (high-performance JSON logger)
const pino = require('pino');
const logger = pino({ level: 'info' });// Go — use Zap (structured, leveled logging)
logger, _ := zap.NewProduction()
defer logger.Sync()
logger.Info("request", zap.String("path", "/api"))Centralized log collection
For multi-service deployments, ship logs to a central store:
- Fluentd / Fluent Bit / Vector — lightweight collectors that aggregate and forward logs
- Use the
fluentdlogging driver or ship viajournald - EFK stack (Elasticsearch + Fluentd + Kibana) for searchable logs
- Configure async mode and retries for remote log drivers to prevent log loss during collector outages
Monitoring
Resource monitoring
docker stats # Real-time CPU/memory/network
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"
docker system df # Disk usage by images/containers/volumesFor production monitoring:
- Export metrics via cAdvisor (Docker) or podman-exporter (Podman)
- Feed into Prometheus + Grafana dashboards
- Podman dashboard: Grafana ID 21559
Health endpoint patterns
Implement multiple health endpoints for fine-grained monitoring:
- `/livez` — Liveness (process is alive): Always 200 if event loop runs
- `/readyz` — Readiness (can handle traffic): DB + cache connectivity
- `/healthz` — Combined (simple apps): Basic connectivity check
Configure Compose health checks against the readiness endpoint:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30sQuadlet Patterns (Podman systemd Integration)
Quadlet is the declarative model for running containers under systemd. Place files in ~/.config/containers/systemd/ (rootless) or /etc/containers/systemd/ (rootful).
Container unit
# ~/.config/containers/systemd/myapp.container
[Unit]
Description=My Application
[Container]
Image=docker.io/myorg/myapp:v1.2.3
PublishPort=127.0.0.1:8080:8080
Network=app.network
Volume=app-data.volume:/app/data:Z
AutoUpdate=registry
RunInit=true
NoNewPrivileges=true
DropCapability=ALL
ReadOnly=true
Tmpfs=/tmp
[Service]
TimeoutStartSec=300
Restart=always
[Install]
WantedBy=default.targetVolume unit
# ~/.config/containers/systemd/app-data.volume
[Volume]
VolumeName=myapp_dataNetwork unit
# ~/.config/containers/systemd/app.network
[Network]
NetworkName=myapp_net
Internal=truePod unit (shared network namespace)
# ~/.config/containers/systemd/stack.pod
[Pod]
PodName=mystack
PublishPort=8080:80
[Install]
WantedBy=default.targetContainers join with Pod=stack.pod in their .container file.
Operations
systemctl --user daemon-reload # Regenerate units after changes
systemctl --user start myapp # Start the service
systemctl --user status myapp # Check status
journalctl --user -u myapp -f # Follow logs
# Auto-update
systemctl --user enable --now podman-auto-update.timer
podman auto-update --dry-run # Preview updates
podman auto-update # Apply updatesKey conventions
- Use
After=other.servicefor dependencies (not.containernames) - Use
EnvironmentFile=for non-sensitive config - Use
Secret=for credentials - Use fully-qualified image names with
AutoUpdate=registry - Use drop-ins (
*.container.d/*.conf) for environment overrides
Debugging Running Containers
# Shell into running container
docker exec -it <container> sh
# View logs (follow)
docker logs -f --tail 100 <container>
# Inspect container config, network, mounts
docker inspect <container>
# View resource usage
docker stats <container>
# View processes
docker top <container>
# Copy files out for inspection
docker cp <container>:/path/to/file ./local-copy
# Ephemeral debug container (shared network namespace)
docker run -it --rm --network container:<target> nicolaka/netshoot
# Ephemeral debug container for distroless (shared PID + network)
docker run -it --rm \
--pid container:<target> \
--network container:<target> \
busyboxDocker vs Podman CLI Compatibility
Most Docker CLI commands work identically with Podman:
alias docker=podman # Common alias for Podman users- `docker compose` →
podman compose: Podman compose is a wrapper - `docker build` →
podman build: Uses Buildah under the hood - `docker run` →
podman run: Rootless by default in Podman - `docker volume` →
podman volume: Same interface - `docker network` →
podman network: Rootless uses pasta/slirp4netns
Key Podman differences
- No daemon — each command is an independent process
- Rootless by default — no
sudoneeded - Uses Buildah for builds, Skopeo for registry operations
- Quadlet files for systemd service generation (replaces
podman generate systemd) podman podgroups containers sharing network namespace (like k8s pods)podman machinemanages Linux VM on macOS/Windows (like Docker Desktop)
Security Hardening
Detailed patterns and examples for container security rules defined in SKILL.md. Read this reference for implementation specifics.
Non-root Containers
Dockerfile non-root pattern
RUN groupadd -r app --gid=1000 \
&& useradd -r -g app --uid=1000 -d /app app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["./app"]Runtime non-root
docker run --user 1000:1000 myimage# compose.yml
services:
app:
user: "1000:1000"Read-only Filesystem
docker run --read-only --tmpfs /tmp --tmpfs /run myimageservices:
app:
read_only: true
tmpfs:
- /tmp
- /runMount tmpfs for directories the app needs to write to (/tmp, /run, /var/cache). Everything else is immutable.
Drop Capabilities
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myimageservices:
app:
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICEDrop all capabilities, then add back only what's needed:
- `NET_BIND_SERVICE` — Bind to ports < 1024
- `CHOWN` — Change file ownership
- `SETUID` / `SETGID` — Switch users (init systems)
- `DAC_OVERRIDE` — Bypass file permissions
Most applications need zero capabilities.
No New Privileges
docker run --security-opt=no-new-privileges myimageservices:
app:
security_opt:
- no-new-privileges:truePrevents privilege escalation via setuid/setgid binaries inside the container.
Distroless and Minimal Images
Distroless images contain only the application and its runtime dependencies — no shell, no package manager, no OS utilities.
FROM gcr.io/distroless/static-debian12 # Static binaries (Go, Rust)
FROM gcr.io/distroless/base-debian12 # Dynamically linked (C/C++)
FROM gcr.io/distroless/java21-debian12 # Java
FROM gcr.io/distroless/python3-debian12 # Python
FROM gcr.io/distroless/nodejs22-debian12 # Node.js- No shell means no shell-based attacks
- Dramatically reduced CVE surface
- Debugging requires ephemeral debug containers or distroless
:debugtag
Secrets Management
Build-time secrets
# Mount secrets during build — not persisted in layers
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm cidocker build --secret id=npmrc,src=.npmrc .Runtime secrets (Compose)
services:
app:
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt- Never pass secrets via
ENV— visible indocker inspect, logs, and child processes - Never
COPYsecret files — persisted in image layers permanently - Use
--mount=type=secretfor build-time secrets - Use Docker secrets or mount secret files at runtime
Image Scanning
docker scout cves myimage:latest # Docker Scout
trivy image myimage:latest # Trivy
grype myimage:latest # GrypeScan images in CI before pushing to registries. Block images with critical/high CVEs.
Podman Rootless
Podman runs containers without a daemon and without root by default.
Key differences from Docker
- No daemon — each
podmancommand is a direct process - Rootless by default — uses user namespaces to map container root to unprivileged host user
- Uses
/etc/subuidand/etc/subgidfor UID/GID mapping - Systemd integration via
podman generate systemdor Quadlet files
Rootless setup requirements
# Verify subuid/subgid ranges exist for user
grep $USER /etc/subuid /etc/subgid
# If missing, allocate ranges
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
# Apply changes
podman system migrateRootless limitations
- Cannot bind to ports < 1024 without
net.ipv4.ip_unprivileged_port_start - Cannot use
--privilegedor most--cap-addoptions - Some volume mount permissions require
:Zor:Usuffix for SELinux/ownership - Networking uses
slirp4netnsorpastainstead of bridged networking
Supply Chain Security
SBOM generation
Generate a Software Bill of Materials for every image in CI:
# Syft — generate CycloneDX SBOM
syft myimage:latest -o cyclonedx-json > sbom.json
# Docker Scout — generates SBOM automatically
docker scout sbom myimage:latest- Use standard formats: SPDX or CycloneDX
- Generate SBOMs as a default CI step, not an afterthought
- Cross-reference SBOMs against vulnerability databases continuously
Image signing with cosign
# Generate a keypair
cosign generate-key-pair
# Sign an image
cosign sign --key cosign.key myregistry/myimage:v1.2.3
# Verify a signature
cosign verify --key cosign.pub myregistry/myimage:v1.2.3- Keyless signing with Sigstore OIDC eliminates key management — signatures use short-lived certificates tied to CI
identity
- Record signatures in Rekor transparency logs for auditability
- Enforce signature verification in CI before deployment
VEX (Vulnerability Exploitability eXchange)
VEX documents distinguish exploitable CVEs from non-exploitable ones:
- A scanner may report 50 CVEs, but VEX can reduce actionable CVEs to near-zero by filtering out those not exploitable
in your config
- Docker Scout reads VEX attestations automatically from OCI layers
- For Trivy, configure VEX ingestion to reconcile scanner output
- VEX reduces "security noise" — focus on real risk, not CVE count
SLSA provenance
- SLSA Build Level 3 provides cryptographic proof linking an image to its exact source and build environment
- Attach provenance as OCI attestation layers — they travel with the image and are verifiable via
cosign verify-attestation
- Docker Hardened Images (DHIs) ship SLSA L3 provenance by default
Container Hardening Checklist
- [ ] Non-root USER in Dockerfile
- [ ] Explicit UID/GID (not default auto-assigned)
- [ ]
--read-onlyfilesystem with tmpfs where needed - [ ]
--cap-drop=ALLwith minimum add-back - [ ]
--security-opt=no-new-privileges - [ ]
--initor tini/dumb-init for PID 1 signal handling - [ ] No secrets in ENV or COPY — use mounted secrets
- [ ] Minimal base image (Alpine, distroless, slim)
- [ ] Image scanned for CVEs before deployment
- [ ] SBOM generated and stored alongside image
- [ ] Image signed (cosign/Sigstore) before push to registry
- [ ] Base image pinned to digest or minor version
- [ ]
.dockerignoreexcludes sensitive files
Storage and Volumes
Storage Types
- Named volume — Managed by Docker, persists, accessible via
docker volume: Database data, persistent state - Anonymous volume — Managed by Docker, persists until container removed, no direct host access: Temporary
per-container data
- Bind mount — Managed by user, is host FS (direct access): Development, config injection
- tmpfs — Managed by kernel, RAM only (never persisted), no host access: Secrets, scratch data, caches
Named Volumes
docker volume create pgdata
docker run -v pgdata:/var/lib/postgresql/data postgres:17services:
db:
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:- Preferred for production persistent data
- Survive
docker compose down(but notdown -v) - Docker pre-populates empty volumes from container contents at mount point
--mountsyntax is more explicit than-v:
docker run --mount source=pgdata,target=/var/lib/postgresql/data postgres:17Bind Mounts
docker run -v $(pwd)/config:/app/config:ro myimageservices:
app:
volumes:
- ./src:/app/src # Development live reload
- ./config:/app/config:ro- Use for development (live code reload) and config file injection
- Always use
:rowhen the container should not modify host files - Bind mounts depend on host directory structure — not portable
- Path must be absolute or start with
./in Compose
tmpfs Mounts
docker run --tmpfs /tmp:rw,noexec,nosuid,size=64m myimageservices:
app:
tmpfs:
- /tmp
- /run- Stored in host memory only — never written to disk
- Use for sensitive temporary data (secrets, session files)
- Use with
--read-onlyto provide writable scratch space - Set
sizelimit to prevent memory exhaustion
Volume Drivers
NFS volume
docker volume create --driver local \
--opt type=nfs \
--opt o=addr=10.0.0.10,rw,nfsvers=4 \
--opt device=:/exports/data \
nfs-dataCIFS/Samba volume
docker volume create --driver local \
--opt type=cifs \
--opt device=//server/share \
--opt o=addr=server,username=user,password=pass \
smb-dataVolume Backup and Restore
Backup
docker run --rm \
-v pgdata:/data:ro \
-v $(pwd):/backup \
alpine tar czf /backup/pgdata-backup.tar.gz -C /data .Restore
docker run --rm \
-v pgdata:/data \
-v $(pwd):/backup \
alpine tar xzf /backup/pgdata-backup.tar.gz -C /dataVolume Permissions
Common issue: container user cannot write to mounted volume because UID/GID doesn't match.
Fix at build time
RUN mkdir -p /app/data && chown 1000:1000 /app/data
USER 1000:1000Fix at runtime
docker run --user 1000:1000 -v mydata:/app/data myimagePodman-specific
# :U — chown volume contents to match container user
podman run -v mydata:/app/data:U myimage
# :Z — relabel for SELinux (single container access)
podman run -v mydata:/app/data:Z myimage
# :z — relabel for SELinux (shared multi-container access)
podman run -v mydata:/app/data:z myimageVolume Subpaths
Mount a subdirectory of a volume instead of the entire volume:
docker run --mount source=logs,target=/var/log/app,volume-subpath=app1 myimageUseful for sharing one volume across multiple containers with isolated subdirectories.
VOLUME Instruction in Dockerfile
VOLUME ["/data"]- Creates an anonymous volume at the specified path
- Data at that path is not included in image layers
- Avoid in application Dockerfiles — creates anonymous volumes that accumulate and are hard to manage
- Appropriate for database images where data must not be in the writable layer
- Named volumes in
compose.ymlordocker run -voverride theVOLUMEinstruction
Cleanup
docker volume prune # Remove unused volumes
docker volume prune --all # Remove ALL unused volumes (including named)
docker system prune --volumes # Full cleanup including volumesDangling anonymous volumes accumulate silently. Run docker volume prune periodically.
Storage Drivers
The storage driver manages the container's layered filesystem using copy-on-write. Writing to the container's writable layer is slower than writing to a volume — never store databases or logs in the container layer.
- `overlay2` — Default for Docker on Linux: Best performance, recommended
- `fuse-overlayfs` — Rootless Podman (legacy): Slower than native overlay
- `overlay` (native) — Rootless Podman (kernel 5.12+): Matches Docker overlay2 performance
- `btrfs` / `zfs` — Specialized host filesystems: Use when host FS requires it
- `vfs` — Fallback / nested builds: No CoW — simple but least efficient
Performance best practices
- Never write heavy data to the container layer. Use named volumes for databases, uploads, and logs. The union
filesystem adds overhead on every write.
- Use native overlay for rootless Podman. On kernel 5.12+, configure
driver = "overlay"instorage.confto avoid
fuse-overlayfs performance penalties.
- Monitor disk usage.
docker system dfshows space by images, containers, and volumes. Set alerts on production
hosts.
- For nested builds (container-in-container): Mount a host volume to
/var/lib/containers/storageinstead of
relying on fuse-overlayfs. Or use driver = "vfs" as a faster-than-fuse fallback.