
Docker Impl Production
- 14 installs
- 9 repo stars
- Updated July 8, 2026
- openaec-foundation/docker-claude-skill-package
Helps with devops & ci/cd tasks.
About
docker-impl-production is a Claude Code skill for devops & ci/cd. It helps solo builders move faster with AI-assisted development.
- docker-impl-production
- DevOps & CI/CD
- AI-coding skill
Docker Impl Production by the numbers
- 14 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #957 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/docker-claude-skill-package --skill docker-impl-productionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 14 |
|---|---|
| repo stars | ★ 9 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/docker-claude-skill-package ↗ |
What it does
Helps with devops & ci/cd tasks.
Files
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:1001ALWAYS 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:1001Distroless Pattern
Distroless images include a nonroot user (UID 65534):
FROM gcr.io/distroless/static-debian12:nonrootNo 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:1001ALWAYS 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/appInit 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---
HEALTHCHECK Patterns
HTTP Health Check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1TCP Health Check (No HTTP)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD nc -z localhost 5432 || exit 1File-Based Health Check (No Network)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD test -f /tmp/healthy || exit 1Minimal Image Health Check (No curl/wget)
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.
---
Entrypoint Scripts
Standard Pattern
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["app", "--serve"]#!/bin/sh
set -e
# Pre-flight: run migrations, wait for dependencies, etc.
if [ "$1" = 'app' ]; then
echo "Running database migrations..."
/usr/bin/app migrate
fi
# CRITICAL: exec replaces shell with app, making app PID 1
exec "$@"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/sh
set -e
# Wait for database
until nc -z "$DB_HOST" "$DB_PORT" 2>/dev/null; do
echo "Waiting for database at $DB_HOST:$DB_PORT..."
sleep 1
done
exec "$@"---
OCI Metadata Labels
LABEL org.opencontainers.image.title="My Application" \
org.opencontainers.image.description="Production API server" \
org.opencontainers.image.version="1.2.3" \
org.opencontainers.image.authors="team@example.com" \
org.opencontainers.image.url="https://example.com" \
org.opencontainers.image.source="https://github.com/org/repo" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.created="2024-01-15T10:30:00Z" \
org.opencontainers.image.revision="abc123def"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:
ARG BUILD_DATE
ARG VCS_REF
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}"docker build \
--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--build-arg VCS_REF=$(git rev-parse --short HEAD) .---
Reproducible Builds
Digest Pinning
# 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:4b19478e60dfe3a05c3ca13d822e40c45a3cdc633b4c63da8ef0ac2c01feee84ALWAYS pin production base images by digest. Tags are mutable pointers -- a registry push can change what node:20-slim resolves to.
Get the Current Digest
docker pull node:20-slim
docker inspect --format='{{index .RepoDigests 0}}' node:20-slimPin Package Versions
RUN apt-get update && apt-get install -y --no-install-recommends \
curl=7.88.1-10+deb12u5 \
&& rm -rf /var/lib/apt/lists/*Reproducible Timestamps
docker build --build-arg SOURCE_DATE_EPOCH=0 .---
Production Dockerfile Template
# syntax=docker/dockerfile:1
# ---- Build Stage ----
FROM node:20-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --production=false
COPY . .
RUN npm run build
# ---- Runtime Stage ----
FROM node:20-bookworm-slim@sha256:<pin-digest-here> AS runtime
# OCI metadata
LABEL org.opencontainers.image.title="My App" \
org.opencontainers.image.version="1.0.0" \
org.opencontainers.image.licenses="MIT"
# Non-root user
RUN groupadd -r -g 1001 appuser && \
useradd --no-log-init -r -u 1001 -g appuser appuser
WORKDIR /app
# Copy build artifacts with correct ownership
COPY --chown=1001:1001 --from=build /app/dist ./dist
COPY --chown=1001:1001 --from=build /app/node_modules ./node_modules
COPY --chown=1001:1001 --from=build /app/package.json ./
# Entrypoint script
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Switch to non-root
USER 1001:1001
EXPOSE 3000
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]See references/examples.md for production Dockerfiles per language (Go, Python, Node.js, Rust, Java, .NET).
---
Reference Links
- references/base-images.md -- Base image comparison with pros, cons, size, and language recommendations
- references/examples.md -- Production Dockerfiles per language, entrypoint scripts, health checks
- references/anti-patterns.md -- Production deployment mistakes and corrections
Official Sources
- https://docs.docker.com/build/building/best-practices/
- https://docs.docker.com/reference/dockerfile/
- https://docs.docker.com/build/building/multi-stage/
- https://github.com/GoogleContainerTools/distroless
- https://github.com/opencontainers/image-spec/blob/main/annotations.md
Production Anti-Patterns
AP-01: Running as Root
Problem: Containers run as root by default. A container escape vulnerability combined with root gives the attacker root on the host.
# BAD: No USER instruction -- container runs as root
FROM node:20-slim
WORKDIR /app
COPY . .
CMD ["node", "index.js"]Fix:
FROM node:20-slim
RUN groupadd -r -g 1001 appuser && \
useradd --no-log-init -r -u 1001 -g appuser appuser
WORKDIR /app
COPY --chown=1001:1001 . .
USER 1001:1001
CMD ["node", "index.js"]NEVER run production containers as root. ALWAYS add a USER instruction with explicit UID/GID.
---
AP-02: Shell Form ENTRYPOINT
Problem: Shell form wraps the command in /bin/sh -c, making the shell PID 1 instead of the application. SIGTERM goes to the shell, not the app. The app never shuts down gracefully -- Docker kills it after the stop timeout.
# BAD: /bin/sh is PID 1, app never receives SIGTERM
ENTRYPOINT /usr/bin/myapp --serveFix:
# GOOD: app is PID 1, receives all signals
ENTRYPOINT ["/usr/bin/myapp", "--serve"]ALWAYS use exec form for ENTRYPOINT and CMD in production.
---
AP-03: No HEALTHCHECK
Problem: Without HEALTHCHECK, Docker and orchestrators cannot distinguish between a running container and a healthy one. A deadlocked application that consumes no CPU appears "running" but serves no requests.
# BAD: No health check -- Docker only knows if the process is running
FROM node:20-slim
CMD ["node", "server.js"]Fix:
FROM node:20-slim
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server.js"]ALWAYS include a HEALTHCHECK for production services.
---
AP-04: Using latest Tag
Problem: The latest tag is a mutable pointer. It can change between builds, making deployments non-reproducible. A working build today may fail tomorrow because the base image changed.
# BAD: Non-deterministic
FROM python:latestFix:
# GOOD: Pinned tag
FROM python:3.12-slim-bookworm
# BEST: Pinned tag + digest
FROM python:3.12-slim-bookworm@sha256:abc123...NEVER use latest in production Dockerfiles. ALWAYS pin version tags. Pin digests for critical production workloads.
---
AP-05: Secrets in Image Layers
Problem: ENV, ARG, and COPY instructions persist in image layers and docker history. Anyone who pulls the image can extract secrets.
# BAD: Secret visible in docker history
ENV API_KEY=sk-production-secret-key
ARG DB_PASSWORD=supersecret
COPY credentials.json /app/Fix:
# GOOD: Build-time secrets via mount (not persisted)
RUN --mount=type=secret,id=api_key,env=API_KEY \
some-command-that-needs-api-key
# GOOD: Runtime secrets via orchestrator
# docker run -e API_KEY_FILE=/run/secrets/api_key myapp
# Or: docker service create --secret api_key myappNEVER put secrets in ENV, ARG, or COPY. ALWAYS use --mount=type=secret for build-time and runtime secret management for run-time.
---
AP-06: Installing Unnecessary Packages
Problem: Every package increases image size, attack surface, and CVE exposure. Debugging tools like curl, wget, vim, and strace have no place in production.
# BAD: Build tools and debug utilities in production image
FROM python:3.12-slim
RUN apt-get update && apt-get install -y \
build-essential \
curl \
vim \
strace \
net-tools \
&& rm -rf /var/lib/apt/lists/*Fix:
# GOOD: Multi-stage -- build tools stay in build stage
FROM python:3.12 AS build
RUN apt-get update && apt-get install -y build-essential
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
FROM python:3.12-slim
COPY --from=build /install /usr/local
COPY . /appALWAYS use multi-stage builds to keep build tools out of the runtime image. If debugging tools are needed, create a separate debug stage with --target debug.
---
AP-07: Missing Entrypoint Script exec
Problem: Entrypoint scripts that forget exec "$@" leave the shell as PID 1. The application runs as a child of the shell, receiving no signals.
# BAD: Shell remains PID 1, application is a child process
#!/bin/sh
echo "Starting..."
/usr/bin/myapp --serve
# Shell stays alive as PID 1, myapp is a childFix:
# GOOD: exec replaces shell with application
#!/bin/sh
set -e
echo "Starting..."
exec "$@"ALWAYS end entrypoint scripts with exec "$@". This replaces the shell process with the application, making it PID 1.
---
AP-08: Single-Stage Production Build
Problem: Building and running in a single stage includes compilers, build tools, source code, and intermediate artifacts in the production image.
# BAD: 850MB image with Go compiler, source code, test files
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server
CMD ["./server"]Fix:
# GOOD: 15MB image with only the binary
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server
FROM alpine:3.21
COPY --from=build /server /usr/bin/server
USER 65534:65534
ENTRYPOINT ["/usr/bin/server"]ALWAYS use multi-stage builds for production. The runtime image should contain ONLY the application binary and its runtime dependencies.
---
AP-09: Not Setting start-period on HEALTHCHECK
Problem: Without --start-period, health checks run immediately. If the application takes 15 seconds to start, the first few checks fail and Docker may mark the container as unhealthy before it is ready.
# BAD: No start period -- fails during startup
HEALTHCHECK --interval=5s --retries=3 CMD curl -f http://localhost/ || exit 1Fix:
# GOOD: 30s grace period for Java/heavy apps
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1ALWAYS set --start-period to at least the application's expected startup time.
---
AP-10: Writable Root Filesystem
Problem: A writable root filesystem allows attackers to modify binaries, install tools, or write malicious scripts. If the application does not need to write to the filesystem, the root filesystem should be read-only.
# BAD: Default writable filesystem
docker run myapp
# GOOD: Read-only root filesystem
docker run --read-only --tmpfs /tmp myapp# docker-compose.yml
services:
app:
image: myapp
read_only: true
tmpfs:
- /tmp
volumes:
- app-data:/app/data # Only mount writable where neededALWAYS run production containers with --read-only when possible. Use tmpfs for temporary files and named volumes for persistent data.
---
AP-11: No Resource Limits
Problem: Without resource limits, a container can consume all host memory or CPU, affecting other containers and the host itself.
# BAD: No limits
services:
app:
image: myapp
# GOOD: Explicit limits
services:
app:
image: myapp
deploy:
resources:
limits:
cpus: "2.0"
memory: 512M
reservations:
cpus: "0.5"
memory: 256MALWAYS set memory and CPU limits in production. ALWAYS set reservations to guarantee minimum resources.
---
AP-12: Using VOLUME in Production Dockerfiles
Problem: The VOLUME instruction creates anonymous volumes that are hard to manage and can lead to data loss. It also prevents changes to the specified directory in subsequent Dockerfile layers.
# BAD: Anonymous volume, cannot be easily backed up or managed
FROM postgres:16
VOLUME /var/lib/postgresql/dataFix: Define volumes in docker-compose.yml or docker run, not in the Dockerfile:
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:NEVER use VOLUME in application Dockerfiles. ALWAYS define volumes at the orchestration layer (compose or run command).
---
AP-13: Ignoring Multi-Platform Builds
Problem: Building only for linux/amd64 means the image will not run natively on ARM servers (Graviton, Apple Silicon dev machines), requiring slow emulation.
# BAD: Only builds for the host platform
docker build -t myapp .Fix:
FROM --platform=$BUILDPLATFORM golang:1.22 AS build
ARG TARGETOS TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /app
FROM alpine:3.21
COPY --from=build /app /appdocker buildx build --platform linux/amd64,linux/arm64 -t myapp .ALWAYS consider multi-platform builds if your application runs on diverse infrastructure (cloud, edge, developer machines).
---
AP-14: No Graceful Shutdown Handling
Problem: The application does not handle SIGTERM. When Docker stops the container, in-flight requests are dropped and database connections are not closed cleanly.
// BAD: No signal handling
const server = app.listen(3000);Fix:
// GOOD: Graceful shutdown
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully...');
server.close(() => {
console.log('Server closed.');
process.exit(0);
});
// Force shutdown after timeout
setTimeout(() => process.exit(1), 10000);
});ALWAYS implement SIGTERM handling in the application. ALWAYS close connections and finish in-flight requests before exiting.
---
Summary Table
| # | Anti-Pattern | Risk | Fix |
|---|---|---|---|
| AP-01 | Running as root | Host compromise | USER instruction |
| AP-02 | Shell form ENTRYPOINT | No graceful shutdown | Exec form ["..."] |
| AP-03 | No HEALTHCHECK | Silent failures | Add HEALTHCHECK |
| AP-04 | Using latest tag | Non-reproducible | Pin version + digest |
| AP-05 | Secrets in layers | Credential leak | --mount=type=secret |
| AP-06 | Unnecessary packages | Large attack surface | Multi-stage builds |
| AP-07 | Missing exec in entrypoint | Signal handling broken | exec "$@" |
| AP-08 | Single-stage build | Bloated image | Multi-stage |
| AP-09 | No start-period | False unhealthy | Set --start-period |
| AP-10 | Writable root FS | Tampering risk | --read-only |
| AP-11 | No resource limits | Resource exhaustion | Set limits + reservations |
| AP-12 | VOLUME in Dockerfile | Unmanaged data | Orchestration-level volumes |
| AP-13 | Single-platform only | ARM incompatibility | Multi-platform builds |
| AP-14 | No graceful shutdown | Dropped requests | Handle SIGTERM |
Base Image Comparison
Overview Table
| Image | Compressed Size | Shell | Package Mgr | libc | Best For |
|---|---|---|---|---|---|
scratch | 0 MB | No | No | None | Statically compiled Go, Rust binaries |
alpine:3.21 | ~3.5 MB | ash | apk | musl | Minimal containers needing a package manager |
debian:bookworm-slim | ~30 MB | bash | apt | glibc | Applications requiring glibc compatibility |
ubuntu:24.04 | ~30 MB | bash | apt | glibc | Applications needing Ubuntu-specific packages |
gcr.io/distroless/static-debian12 | ~2 MB | No | No | None | Static binaries with CA certs and timezone data |
gcr.io/distroless/base-debian12 | ~20 MB | No | No | glibc | Dynamic binaries needing glibc |
gcr.io/distroless/cc-debian12 | ~22 MB | No | No | glibc + libstdc++ | C++ applications |
gcr.io/distroless/java21-debian12 | ~90 MB | No | No | glibc + JRE | Java 21 applications |
gcr.io/distroless/python3-debian12 | ~50 MB | No | No | glibc + Python | Python applications |
gcr.io/distroless/nodejs22-debian12 | ~60 MB | No | No | glibc + Node | Node.js 22 applications |
---
scratch
The empty image. Contains absolutely nothing -- no filesystem, no shell, no libraries.
When to Use
- Statically compiled Go binaries (
CGO_ENABLED=0) - Statically compiled Rust binaries (
target x86_64-unknown-linux-musl) - Any binary with zero runtime dependencies
Pros
- Smallest possible image (0 bytes base)
- Zero attack surface -- nothing to exploit
- No shell means no shell-based attacks
Cons
- No shell for debugging (
docker execis useless) - No CA certificates (must copy
/etc/ssl/certs/ca-certificates.crt) - No timezone data (must copy
/usr/share/zoneinfo/) - No user database (must copy
/etc/passwdor use numeric UID) - No DNS resolution config (must copy
/etc/nsswitch.conffor some apps)
Example
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /app /app
USER 65534:65534
ENTRYPOINT ["/app"]---
alpine
Minimal Linux distribution using musl libc and BusyBox.
When to Use
- Applications that need a package manager at build or runtime
- When you need a shell for entrypoint scripts
- Interpreted languages (Python, Ruby, Node) where distroless is not available or practical
Pros
- Very small (~6 MB uncompressed)
- Has
apkpackage manager for runtime dependencies - Has shell (ash) for entrypoint scripts and debugging
- Frequent security updates
Cons
- Uses musl libc instead of glibc -- some applications have compatibility issues
- DNS resolution differs from glibc (musl uses different resolver)
- Python packages with C extensions may need recompilation
- Performance differences in some workloads (musl vs glibc string operations)
musl Compatibility Issues
Applications known to have issues with musl:
- Python packages using NumPy/SciPy (need to compile from source or use
*-musllinuxwheels) - Applications using
nsswitch.conffor name resolution - JVM applications (older versions, modern JVMs work fine on musl)
- Applications using
gethostbynamewith certain DNS configurations
ALWAYS test thoroughly when migrating from glibc-based images to alpine.
Example
FROM alpine:3.21
RUN apk add --no-cache tini
RUN addgroup -S -g 1001 appuser && adduser -S -u 1001 -G appuser appuser
COPY --from=build /app /usr/bin/app
USER 1001:1001
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/bin/app"]---
slim (Debian/Ubuntu Slim Variants)
Reduced versions of full Debian/Ubuntu images with documentation, man pages, and locale data removed.
When to Use
- Applications requiring glibc compatibility
- When alpine/musl causes issues
- Python applications with native C extensions
- Applications needing
aptfor runtime dependencies
Pros
- glibc compatibility -- works with all Linux binaries
aptpackage manager available- Familiar Debian/Ubuntu ecosystem
- Smaller than full images by 50-70%
Cons
- Larger than alpine (~30-80 MB vs ~6 MB)
- More packages installed than strictly necessary
- Slower security update cycle than alpine
Available Variants
| Variant | Base | Example |
|---|---|---|
debian:bookworm-slim | Debian 12 | General purpose |
node:20-bookworm-slim | Debian 12 + Node | Node.js applications |
python:3.12-slim-bookworm | Debian 12 + Python | Python applications |
openjdk:21-slim-bookworm | Debian 12 + JDK | Java applications |
ruby:3.3-slim-bookworm | Debian 12 + Ruby | Ruby applications |
---
distroless (Google Container Tools)
Minimal images containing ONLY the application runtime and its dependencies. No shell, no package manager, no utilities.
When to Use
- Production deployments where security is critical
- When you do not need runtime debugging via shell
- Language-specific runtimes (Java, Python, Node.js, .NET)
- Static binaries that need CA certs and timezone data
Pros
- Minimal attack surface -- no shell, no package manager
- Reduced CVE exposure -- fewer packages to scan
- Smaller than slim images
- Pre-configured
nonrootuser (UID 65534) - Includes CA certificates and timezone data (unlike scratch)
Cons
- No shell -- cannot
docker execinto container for debugging - No package manager -- cannot install tools at runtime
- Debugging requires a separate debug image (
*:debugtags include busybox) - Limited to Google's supported runtimes
Image Tags
| Tag | Contents |
|---|---|
latest | Runs as root |
nonroot | Runs as UID 65534 |
debug | Includes busybox shell for debugging |
debug-nonroot | Debug + nonroot |
ALWAYS use the nonroot tag in production.
Example
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
ENTRYPOINT ["/app"]---
Full Images (ubuntu, debian)
Complete OS images with all standard utilities.
When to Use
- Development and CI/CD stages
- Applications with complex system dependencies
- When debugging tools are needed
- Base for custom organization images
Pros
- Full toolchain available
- Maximum compatibility
- Easy debugging
Cons
- Large image size (75-200 MB)
- Large attack surface
- Many unnecessary packages
- Slower pull times
NEVER use full images as production runtime images. ALWAYS use them only in build stages and use a minimal image for the runtime stage.
---
Language-Specific Recommendations
| Language | Build Stage | Runtime Stage |
|---|---|---|
| Go (static) | golang:1.22 | scratch or distroless/static |
| Go (CGO) | golang:1.22 | distroless/base or alpine |
| Rust (static) | rust:1.77 | scratch or distroless/static |
| Rust (dynamic) | rust:1.77 | distroless/cc or debian:slim |
| Node.js | node:20 | node:20-slim or distroless/nodejs22 |
| Python | python:3.12 | python:3.12-slim or distroless/python3 |
| Java | eclipse-temurin:21-jdk | eclipse-temurin:21-jre-alpine or distroless/java21 |
| .NET | mcr.microsoft.com/dotnet/sdk:8.0 | mcr.microsoft.com/dotnet/runtime:8.0-alpine |
| Ruby | ruby:3.3 | ruby:3.3-slim |
| PHP | php:8.3-cli or php:8.3-fpm | php:8.3-fpm-alpine |
---
Size Comparison Example (Go Application)
| Runtime Image | Final Image Size |
|---|---|
golang:1.22 (full, no multi-stage) | ~850 MB |
ubuntu:24.04 | ~85 MB |
debian:bookworm-slim | ~40 MB |
alpine:3.21 | ~15 MB |
gcr.io/distroless/static | ~8 MB |
scratch | ~7 MB |
The binary itself is ~7 MB. Everything above that is OS overhead.
Production Examples
Go (Static Binary)
# syntax=docker/dockerfile:1
FROM golang:1.22-bookworm AS build
ARG VERSION=dev
WORKDIR /src
# Cache dependencies
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Build static binary
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /app ./cmd/server
FROM scratch
# Copy CA certs and timezone data
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /app /app
LABEL org.opencontainers.image.title="Go App" \
org.opencontainers.image.version="${VERSION}"
USER 65534:65534
ENTRYPOINT ["/app"]---
Python (Django / Flask / FastAPI)
# syntax=docker/dockerfile:1
FROM python:3.12-bookworm AS build
WORKDIR /app
# Install build dependencies
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefix=/install -r requirements.txt
FROM python:3.12-slim-bookworm@sha256:<pin-digest-here>
# OCI metadata
LABEL org.opencontainers.image.title="Python App"
# Non-root user
RUN groupadd -r -g 1001 appuser && \
useradd --no-log-init -r -u 1001 -g appuser -d /app appuser
WORKDIR /app
# Copy installed packages
COPY --from=build /install /usr/local
# Copy application
COPY --chown=1001:1001 . .
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
USER 1001:1001
EXPOSE 8000
ENTRYPOINT ["python", "-m", "gunicorn"]
CMD ["app:application", "--bind", "0.0.0.0:8000", "--workers", "4"]---
Node.js (Express / NestJS / Fastify)
# syntax=docker/dockerfile:1
FROM node:20-bookworm AS build
WORKDIR /app
# Install dependencies (production + dev for build)
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Build application
COPY . .
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
FROM node:20-bookworm-slim@sha256:<pin-digest-here>
LABEL org.opencontainers.image.title="Node.js App"
# Non-root user (node user UID 1000 exists in official images)
# Use it or create a custom one
RUN groupadd -r -g 1001 appuser && \
useradd --no-log-init -r -u 1001 -g appuser appuser
WORKDIR /app
COPY --chown=1001:1001 --from=build /app/dist ./dist
COPY --chown=1001:1001 --from=build /app/node_modules ./node_modules
COPY --chown=1001:1001 --from=build /app/package.json ./
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
USER 1001:1001
EXPOSE 3000
# Use --init for proper signal handling with Node.js
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/index.js"]---
Rust (Static Binary with musl)
# syntax=docker/dockerfile:1
FROM rust:1.77-bookworm AS build
# Install musl target for static linking
RUN rustup target add x86_64-unknown-linux-musl
RUN apt-get update && apt-get install -y --no-install-recommends \
musl-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Cache dependencies via cargo-chef pattern
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release --target x86_64-unknown-linux-musl
# Build real application
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release --target x86_64-unknown-linux-musl && \
cp target/x86_64-unknown-linux-musl/release/myapp /app
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app /app
LABEL org.opencontainers.image.title="Rust App"
USER 65534:65534
ENTRYPOINT ["/app"]---
Java (Spring Boot / Quarkus)
# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk-bookworm AS build
WORKDIR /src
# Cache Gradle/Maven dependencies
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
RUN --mount=type=cache,target=/root/.gradle \
./gradlew dependencies --no-daemon
COPY . .
RUN --mount=type=cache,target=/root/.gradle \
./gradlew bootJar --no-daemon
# Extract Spring Boot layered JAR for optimal Docker caching
RUN java -Djarmode=tools -jar build/libs/*.jar extract --destination /extracted
FROM eclipse-temurin:21-jre-alpine@sha256:<pin-digest-here>
LABEL org.opencontainers.image.title="Java App"
RUN addgroup -S -g 1001 appuser && adduser -S -u 1001 -G appuser appuser
WORKDIR /app
# Copy extracted layers (most stable first for cache efficiency)
COPY --from=build /extracted/dependencies/ ./
COPY --from=build /extracted/spring-boot-loader/ ./
COPY --from=build /extracted/snapshot-dependencies/ ./
COPY --from=build /extracted/application/ ./
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1
USER 1001:1001
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "org.springframework.boot.loader.launch.JarLauncher"]ALWAYS use -XX:+UseContainerSupport (default since JDK 10+) so the JVM respects container memory limits. ALWAYS set -XX:MaxRAMPercentage instead of -Xmx for container-aware memory management.
---
.NET
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Restore dependencies (cached)
COPY *.csproj ./
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore
# Build and publish
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine@sha256:<pin-digest-here>
LABEL org.opencontainers.image.title=".NET App"
RUN addgroup -S -g 1001 appuser && adduser -S -u 1001 -G appuser appuser
WORKDIR /app
COPY --from=build /app .
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5000/health || exit 1
USER 1001:1001
EXPOSE 5000
ENTRYPOINT ["dotnet", "MyApp.dll"]---
Entrypoint Script Examples
Generic Entrypoint with Migrations
#!/bin/sh
set -e
echo "Starting application..."
# Run database migrations if the command is the main app
if [ "$1" = 'serve' ] || [ "$1" = 'node' ] || [ "$1" = 'python' ]; then
echo "Running database migrations..."
/usr/bin/app migrate --apply 2>&1
echo "Migrations complete."
fi
# Replace shell with the application process
exec "$@"Entrypoint with Environment Validation
#!/bin/sh
set -e
# Validate required environment variables
required_vars="DATABASE_URL SECRET_KEY"
for var in $required_vars; do
eval value=\$$var
if [ -z "$value" ]; then
echo "ERROR: Required environment variable $var is not set" >&2
exit 1
fi
done
exec "$@"Entrypoint with Wait-for-Dependencies
#!/bin/sh
set -e
# Wait for PostgreSQL
if [ -n "$DB_HOST" ]; then
echo "Waiting for PostgreSQL at $DB_HOST:${DB_PORT:-5432}..."
timeout=30
elapsed=0
until nc -z "$DB_HOST" "${DB_PORT:-5432}" 2>/dev/null; do
elapsed=$((elapsed + 1))
if [ "$elapsed" -ge "$timeout" ]; then
echo "ERROR: Timed out waiting for database" >&2
exit 1
fi
sleep 1
done
echo "Database is ready."
fi
# Wait for Redis
if [ -n "$REDIS_HOST" ]; then
echo "Waiting for Redis at $REDIS_HOST:${REDIS_PORT:-6379}..."
timeout=15
elapsed=0
until nc -z "$REDIS_HOST" "${REDIS_PORT:-6379}" 2>/dev/null; do
elapsed=$((elapsed + 1))
if [ "$elapsed" -ge "$timeout" ]; then
echo "ERROR: Timed out waiting for Redis" >&2
exit 1
fi
sleep 1
done
echo "Redis is ready."
fi
exec "$@"Entrypoint with Config File Generation
#!/bin/sh
set -e
# Generate config from environment variables
cat > /app/config.json <<CONF
{
"database_url": "${DATABASE_URL}",
"port": ${PORT:-8080},
"log_level": "${LOG_LEVEL:-info}",
"cors_origins": "${CORS_ORIGINS:-*}"
}
CONF
exec "$@"---
Health Check Patterns by Technology
PostgreSQL
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD pg_isready -U postgres || exit 1Redis
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD redis-cli ping | grep -q PONG || exit 1Nginx
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1gRPC Service
# Requires grpc-health-probe binary
COPY --from=grpc-health-probe /bin/grpc_health_probe /bin/grpc_health_probe
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD ["/bin/grpc_health_probe", "-addr=:50051"]Worker Process (No HTTP)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD test -f /tmp/worker-healthy && \
test $(($(date +%s) - $(stat -c %Y /tmp/worker-healthy))) -lt 60 || exit 1The worker writes to /tmp/worker-healthy on each successful processing cycle. The health check verifies the file exists and was updated within the last 60 seconds.