Use when hardening Dockerfiles for production deployment or choosing base images. Prevents containers running as root, missing HEALTHCHECK definitions, and zombie processes from shell-form ENTRYPOINT. Covers base image selection, USER, HEALTHCHECK, exec form, init, entrypoint scripts, OCI labels, digest pinning, and distroless images. Keywords: USER, HEALTHCHECK, ENTRYPOINT, scratch, alpine, distroless, tini, exec form, production ready, deploy container, secure container, optimize for production, smaller image, harden Dockerfile.
license
MIT
compatibility
Designed for Claude Code. Requires Docker Engine 24+.
metadata
{"author":"OpenAEC-Foundation","version":"1.0"}
docker-impl-production
Quick Reference
Base Image Selection
Image Type
Size
Shell
Package Mgr
Use Case
scratch
0 MB
No
No
Statically compiled binaries (Go, Rust)
alpine
~6 MB
Yes (ash)
apk
Minimal Linux with package management
*-slim
~30-80 MB
Yes (bash)
apt
Reduced Debian without extras
distroless
~20 MB
No
No
Google's minimal runtime images
Full (e.g., ubuntu)
~75-200 MB
Yes (bash)
apt
Development, debugging, complex dependencies
ALWAYS use a full image for the build stage and a minimal image for the runtime stage.
See references/base-images.md for detailed comparison with pros, cons, and language-specific recommendations.
Production Checklist
Requirement
Implementation
Priority
Non-root user
USER instruction with explicit UID/GID
MUST
Signal handling
Exec form ENTRYPOINT + exec "$@" in scripts
MUST
Health check
HEALTHCHECK instruction
MUST
Pinned base image
Tag + digest (image:tag@sha256:...)
MUST
OCI labels
LABEL org.opencontainers.image.*
SHOULD
Minimal attack surface
Multi-stage build, no shell in final image if possible
SHOULD
Read-only filesystem
--read-only flag at runtime
SHOULD
No secrets in layers
--mount=type=secret for build-time secrets
MUST
Critical Warnings
NEVER use shell form for ENTRYPOINT in production -- the application runs under /bin/sh -c and does NOT receive signals. ALWAYS use exec form: ENTRYPOINT ["executable"].
NEVER run production containers as root -- a compromised process with root inside the container can escalate to host root. ALWAYS add a USER instruction.
NEVER use the latest tag in production -- builds become non-reproducible and may break without warning. ALWAYS pin to a specific version tag and digest.
NEVER store secrets in ENV, ARG, or COPY layers -- they persist in image history and can be extracted. ALWAYS use --mount=type=secret during build and runtime secrets management (Docker secrets, env injection).
NEVER install debugging tools (curl, wget, vim, strace) in production images -- they increase attack surface. Keep them in a separate debug stage built with --target debug.
Non-Root USER Configuration
Standard Pattern (Debian/Ubuntu)
RUN groupadd -r -g 1001 appuser && \
useradd --no-log-init -r -u 1001 -g appuser appuser
USER 1001:1001
ALWAYS assign explicit UID/GID for deterministic behavior across rebuilds.
ALWAYS use --no-log-init to prevent /var/log/faillog from filling with NULL characters.
ALWAYS reference UID/GID numbers in the USER instruction for clarity in ps and log output.
Alpine Pattern
RUN addgroup -S -g 1001 appuser && \
adduser -S -u 1001 -G appuser -h /app appuser
USER 1001:1001
Distroless Pattern
Distroless images include a nonroot user (UID 65534):
FROM gcr.io/distroless/static-debian12:nonroot
No RUN needed -- the user is pre-configured.
File Ownership
COPY --chown=1001:1001 --from=build /app/binary /usr/bin/app
WORKDIR /app
RUN chown -R 1001:1001 /app
USER 1001:1001
ALWAYS set file ownership BEFORE switching to the non-root user.
Signal Handling
The PID 1 Problem
The first process in a container (PID 1) receives all signals. If PID 1 is a shell (/bin/sh), it does NOT forward signals to child processes. The application never receives SIGTERM and cannot shut down gracefully -- Docker kills it after the timeout (default 10s).
Exec Form (Required)
# CORRECT: app is PID 1, receives SIGTERM directly
ENTRYPOINT ["/usr/bin/app"]
# WRONG: /bin/sh is PID 1, app never receives signals
ENTRYPOINT /usr/bin/app
Init Process (--init / tini / dumb-init)
When your application spawns child processes, use an init process to reap zombies and forward signals:
# Option 1: Docker --init flag (uses tini)
# docker run --init myimage
# Option 2: Tini embedded in image
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/bin/app"]
# Option 3: dumb-init
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/usr/bin/app"]
ALWAYS use an init process when the application forks child processes.
NEVER rely on the default Docker behavior for zombie reaping -- PID 1 must handle SIGCHLD.
Custom STOPSIGNAL
# Default is SIGTERM; override if your app uses a different signal
STOPSIGNAL SIGQUIT # e.g., Nginx uses SIGQUIT for graceful shutdown
For distroless or scratch images, compile a static health check binary:
FROM golang:1.22 AS healthcheck
WORKDIR /src
COPY <<'EOF' main.go
package main
import ("net/http"; "os")
func main() {
_, err := http.Get("http://localhost:8080/health")
if err != nil { os.Exit(1) }
}
EOF
RUN CGO_ENABLED=0 go build -o /healthcheck main.go
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
COPY --from=healthcheck /healthcheck /healthcheck
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD ["/healthcheck"]
ALWAYS set --start-period to allow time for application initialization.
ALWAYS use || exit 1 with shell-form health checks -- the exit code determines health status.
NEVER use curl in health checks for production images -- it adds unnecessary attack surface. Use wget (included in alpine) or a compiled binary.
#!/bin/shset -e
# Pre-flight: run migrations, wait for dependencies, etc.if [ "$1" = 'app' ]; thenecho"Running database migrations..."
/usr/bin/app migrate
fi# CRITICAL: exec replaces shell with app, making app PID 1exec"$@"
ALWAYS end entrypoint scripts with exec "$@" -- this replaces the shell process with the application, ensuring proper signal handling.
ALWAYS use set -e to exit on any error during initialization.
NEVER use #!/bin/bash unless bash features are required -- prefer #!/bin/sh for portability and smaller images.
Wait-for-Dependencies Pattern
#!/bin/shset -e
# Wait for databaseuntil nc -z "$DB_HOST""$DB_PORT" 2>/dev/null; doecho"Waiting for database at $DB_HOST:$DB_PORT..."sleep 1
doneexec"$@"
ALWAYS use OCI standard keys (org.opencontainers.image.*) -- they are recognized by registries, scanners, and orchestrators.
ALWAYS inject created and revision via build args for accuracy:
# Tag alone is mutable -- the same tag can point to different images
FROM node:20-slim
# Tag + digest is immutable -- guarantees exact same image
FROM node:20-slim@sha256:4b19478e60dfe3a05c3ca13d822e40c45a3cdc633b4c63da8ef0ac2c01feee84
ALWAYS pin production base images by digest. Tags are mutable pointers -- a registry push can change what node:20-slim resolves to.