Skip to content

Dockerfile Best Practices

All applications at DIT must be containerized using Docker. This document provides comprehensive guidelines for writing secure, efficient, and maintainable Dockerfiles.

Base Image Requirements

Use DIT's Internal Registry

Base images should be pulled from DIT's on-premise container registry (reg.dev.krd) when available:

dockerfile
# ✅ Preferred - Using DIT's registry
FROM reg.dev.krd/dotnet/aspnet:10-noble

# ✅ Also OK - Public registry when not available internally
FROM node:22-alpine

Why use DIT's registry

  • Ensures availability if external registries are unreachable
  • Images are scanned for vulnerabilities

If a commonly used image is not available in the internal registry, request it from the DevOps Team to be mirrored.

Pin Image Versions

Use specific version tags for application base images:

dockerfile
# ✅ Correct - Pinned version for application base
FROM reg.dev.krd/library/python:3.12-slim

# ❌ Wrong - Unpinned version for application base
FROM reg.dev.krd/library/python:latest

When latest is acceptable

Using latest is acceptable for utility stages that don't affect the final image, such as extracting CA certificates:

dockerfile
# ✅ OK - Utility stage for extracting certs
FROM alpine:latest AS certs
RUN apk --update add ca-certificates

The key distinction is that utility stages are discarded after the build—only their artifacts are copied to the final image.

Prefer Minimal Base Images

Use the smallest base image that meets your requirements:

Image TypeUse Case
alpineGeneral-purpose, minimal footprint
slimDebian-based, smaller than full
distrolessProduction runtimes, no shell
chiseled.NET optimized, Ubuntu-based, no shell
scratchStatic binaries (Go, Rust)

For .NET applications, prefer chiseled images (aspnet:9.0-noble-chiseled) which:

  • Have no shell or package manager
  • Include only essential runtime dependencies
  • Are significantly smaller than standard images
  • Reduce attack surface

Multi-Stage Builds

Multi-stage builds are mandatory for all production images. They separate build-time dependencies from runtime, resulting in smaller and more secure images.

Example: Node.js Application

dockerfile
# Stage 1: Build
FROM node:22-alpine AS build

WORKDIR /app

COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN pnpm install --frozen-lockfile

COPY . .
RUN pnpm build

# Stage 2: Production
FROM node:22-alpine AS production

WORKDIR /app

COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./

# Use built-in 'node' user (UID 1000)
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Layer Optimization

Docker builds images using a layered filesystem. Proper layer ordering dramatically improves build times through caching.

Order Instructions by Change Frequency

Place instructions that change least frequently at the top:

dockerfile
FROM node:22-alpine

# 1. System dependencies (rarely change)
RUN apk add --no-cache dumb-init

# 2. Package manager setup (rarely changes)
RUN corepack enable && corepack prepare pnpm@latest --activate

# 3. Dependency files (change occasionally)
COPY package.json pnpm-lock.yaml ./

# 4. Install dependencies (invalidated by step 3)
RUN pnpm install --frozen-lockfile --prod

# 5. Application code (changes frequently)
COPY . .

# 6. Metadata and runtime config (stable)
USER node
EXPOSE 3000
CMD ["node", "src/index.js"]

Combine RUN Commands

Reduce layers by combining related commands:

dockerfile
# ✅ Good - Single layer
RUN apk add --no-cache \
    curl \
    git \
    openssh-client \
  && rm -rf /var/cache/apk/*

# ❌ Bad - Multiple layers
RUN apk add --no-cache curl
RUN apk add --no-cache git
RUN apk add --no-cache openssh-client

Security Best Practices

Run as Non-Root User

All containers must run as non-root. This is enforced by the restricted Pod Security Standard.

Use Built-in Non-Root Users When Available

If the base image provides a non-root user, use it instead of creating a custom one. This is simpler and ensures compatibility with the base image.

Base ImageBuilt-in UserUID
node:*-alpinenode1000
aspnet:*-chiseled(default non-root)1654
python:*(none - create one)-
scratch(none - create one)-
dockerfile
# ✅ Best - Use built-in user if available
USER node              # Node.js images

# ✅ Good - Create user with UID 1001 if no built-in user exists
RUN addgroup -g 1001 -S appgroup \
  && adduser -u 1001 -S appuser -G appgroup
USER 1001

# ✅ For scratch images, create a passwd entry in the builder
RUN echo "appuser:x:1001:1001:appuser:/:" > /etc_passwd

Recommended Custom UID: 1001

When creating a custom user, use UID 1001 for consistency across DIT applications. Any non-root UID will work, but 1001 is the recommended standard.

Standard ports for non-root applications:

PortUse Case
8080HTTP APIs (Go, .NET, Java)
3000Node.js / Frontend apps

Use .dockerignore

Always include a .dockerignore file to exclude sensitive and unnecessary files:

dockerignore
# Version control
.git
.gitignore

# Dependencies (will be installed fresh)
node_modules
vendor

# Build outputs
dist
build
*.log

# IDE and editor files
.vscode
.idea
*.swp

# Secrets and environment files
.env
.env.*
*.pem
*.key

# Documentation
README.md
docs/

# Test files
__tests__
*.test.js
*.spec.js
coverage/

Don't Store Secrets in Images

Secrets must never be baked into images:

dockerfile
# ❌ NEVER do this
ENV DATABASE_PASSWORD=secret123
COPY .env /app/.env

# ✅ Correct - Use runtime environment variables
# Secrets are injected at runtime via Kubernetes Secrets

Scan Images for Vulnerabilities

All images must pass vulnerability scanning before deployment. This is enforced automatically in CI/CD pipelines.

Health Checks

Do not include HEALTHCHECK instructions in Dockerfiles. Health checks are handled by Kubernetes liveness and readiness probes, which provide more flexibility and better integration with the orchestration layer.

Applications must expose separate probe endpoints under /probes/ as explained in the Kubernetes documentation for implementation details.

Liveness ≠ Readiness

Never use the same endpoint for both probes. The liveness probe should only check if the application process is alive, while the readiness probe should verify dependencies (database, cache, external services).

Common Patterns

Static File Server

dockerfile
FROM node:22-alpine AS build

WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM reg.dev.krd/joseluisq/static-web-server:2 AS production

COPY --from=build /app/dist /public

ENV SERVER_PORT=8080
ENV SERVER_LOG_LEVEL=info

# Use built-in 'nobody' user provided by the base image
USER nobody
EXPOSE 8080

.NET Application

dockerfile
FROM reg.dev.krd/dotnet/sdk:9.0-noble AS build
WORKDIR /src

# Copy project file and restore dependencies
COPY ["src/MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore -r linux-x64 "MyApi/MyApi.csproj"

# Copy source code
COPY src .

WORKDIR "/src/MyApi"

# Publish the application
RUN dotnet publish "MyApi.csproj" --no-restore -r linux-x64 -c Release -o /app

# Use chiseled image for minimal attack surface
# Chiseled images run as UID 1654 by default (non-root)
FROM reg.dev.krd/dotnet/aspnet:9.0-noble-chiseled AS final

WORKDIR /app
COPY --from=build /app .

ENV DOTNET_EnableDiagnostics=0
ENV DOTNET_SYSTEM_GLOBALIZATION_PREDEFINED_CULTURES_ONLY=false

# Chiseled images already run as non-root (UID 1654)
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]

Key points for .NET Dockerfiles

  • Specify the linux-x64 runtime for smaller image size
  • .NET chiseled images already run as non-root user (UID 1654). No additional user configuration is needed.

Go applications

dockerfile
FROM alpine:latest AS certs
RUN apk --update add ca-certificates

FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o /go/bin/app \
&& echo "nobody:x:1001:1001:nobody:/:" > /etc_passwd

# Use scarch image (no deps required)
FROM scratch

COPY --from=builder /etc_passwd /etc_passwd
COPY --from=builder /go/bin/app /app
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

# Copy the dockerfile, go.mod, go.sum to support SBOM generation
COPY Dockerfile go.mod go.sum ./

USER 1001
EXPOSE 8080
ENTRYPOINT ["/app"]

Key points for Go Dockerfiles

  • Use scratch base image (empty, ~0MB overhead)
  • CGO_ENABLED=0 produces a static binary with no C dependencies
  • -ldflags='-w -s' strips debug information, reducing binary size
  • Copy CA certificates for HTTPS/TLS connections
  • Include go.mod/go.sum for Software Bill of Materials (SBOM) generation

Checklist

Before submitting a Dockerfile for review, verify:

  • Base image is from reg.dev.krd (preferred) or trusted public registry
  • Image version is pinned (except utility stages)
  • Multi-stage build is used
  • Runs as non-root user (built-in or UID 1001)
  • .dockerignore exists and is comprehensive
  • No secrets or credentials in the image
  • Layers are optimized for caching
  • Application exposes /probes/alive, /probes/ready, and /probes/startup endpoints

Following these best practices ensures that DIT container images are secure, efficient, and maintainable across all environments.