---
title: "Dockerfile Best Practices: Secure, Efficient Container Images"
description: "Practical guide to writing Dockerfiles that produce small, secure, reproducible container images using multi-stage builds, non-root users, layer optimization, and version pinning."
url: https://agent-zone.ai/knowledge/kubernetes/dockerfile-best-practices/
section: knowledge
date: 2026-02-22
categories: ["kubernetes"]
tags: ["dockerfile","multi-stage-builds","container-security","image-optimization","distroless"]
skills: ["dockerfile-authoring","image-size-reduction","container-hardening"]
tools: ["docker","buildah","hadolint"]
levels: ["intermediate"]
word_count: 945
formats:
  json: https://agent-zone.ai/knowledge/kubernetes/dockerfile-best-practices/index.json
  html: https://agent-zone.ai/knowledge/kubernetes/dockerfile-best-practices/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Dockerfile+Best+Practices%3A+Secure%2C+Efficient+Container+Images
---


# Dockerfile Best Practices

A Dockerfile is a security boundary. Every decision -- base image, installed package, file copied in, user the process runs as -- determines the attack surface of your running container. Most Dockerfiles in the wild are bloated, run as root, and ship debug tools an attacker can use. Here is how to fix that.

## Choose the Right Base Image

Your base image choice is the single biggest factor in image size and vulnerability count.

| Base Image | Size (approx.) | Packages | Use Case |
|---|---|---|---|
| `ubuntu:24.04` | 78 MB | Full apt ecosystem | When you need apt and a shell |
| `python:3.12-slim` | 52 MB | Minimal Debian + Python | Python apps needing some OS libs |
| `alpine:3.20` | 7 MB | musl libc, apk | Small images, but musl can cause subtle bugs |
| `gcr.io/distroless/static` | 2 MB | Nothing -- no shell, no package manager | Go/Rust static binaries |
| `gcr.io/distroless/cc` | 5 MB | glibc only | C/C++ apps needing glibc |

Alpine uses musl libc instead of glibc, which can cause DNS resolution issues, performance differences in memory allocation, and crashes with some C extensions. Test thoroughly before committing to Alpine for anything beyond trivial workloads.

Distroless images have no shell and no package manager. An attacker who gets code execution inside the container cannot easily install tools or explore. This is the strongest default for compiled languages.

## Multi-Stage Builds

The most impactful optimization. Build in one stage, copy only the artifact to a minimal runtime stage.

**Before -- single stage, 1.2 GB:**

```dockerfile
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["/app/server"]
```

This ships the entire Go toolchain, source code, and module cache.

**After -- multi-stage, 8 MB:**

```dockerfile
# Build stage
FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .

# Runtime stage
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
```

The runtime image contains only the static binary. No compiler, no source code, no shell.

For Python, the pattern uses a builder for pip installs:

```dockerfile
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim
COPY --from=build /install /usr/local
WORKDIR /app
COPY . .
USER 1000
CMD ["python", "main.py"]
```

## Run as Non-Root

By default, containers run as root. If your application gets compromised, the attacker has root inside the container. Combined with a kernel vulnerability, that can mean root on the host.

```dockerfile
# Create a non-root user
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

# Set ownership on the app directory
COPY --chown=appuser:appgroup . /app

USER appuser
```

For distroless, use the built-in nonroot tag or set `USER 65534`:

```dockerfile
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
```

Kubernetes can enforce this at the pod level too, but the Dockerfile should not depend on that:

```yaml
securityContext:
  runAsNonRoot: true
  runAsUser: 1001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
```

## Pin Versions Everywhere

Never use `:latest` in production. It is not reproducible and breaks cache invalidation.

```dockerfile
# Bad -- what version is this next month?
FROM python:latest

# Better -- pinned minor version
FROM python:3.12-slim

# Best -- pinned to digest for reproducibility
FROM python:3.12-slim@sha256:abcdef1234567890...
```

Pin package versions too:

```dockerfile
# Bad
RUN apt-get install -y curl

# Good
RUN apt-get install -y curl=8.5.0-2ubuntu10.6
```

## Minimize Layers and Use .dockerignore

Each `RUN`, `COPY`, and `ADD` instruction creates a layer. Combine related commands:

```dockerfile
# Bad -- 3 layers, apt cache persisted in first layer
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good -- 1 layer, cache cleaned in same layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl=8.5.0-2ubuntu10.6 && \
    rm -rf /var/lib/apt/lists/*
```

A `.dockerignore` prevents unnecessary files from entering the build context:

```text
.git
.env
node_modules
*.md
docker-compose*.yml
.github
__pycache__
*.pyc
```

Without this, `COPY . .` sends everything to the Docker daemon, including your `.git` directory (often hundreds of megabytes) and potentially your `.env` file with secrets.

## COPY vs ADD

Use `COPY`. Always. `ADD` has two extra behaviors: it auto-extracts tar archives and can fetch URLs. Both are implicit and surprising. If you need to extract a tar file, be explicit:

```dockerfile
# Bad -- implicit extraction, unclear intent
ADD app.tar.gz /app

# Good -- explicit about what is happening
COPY app.tar.gz /tmp/app.tar.gz
RUN tar -xzf /tmp/app.tar.gz -C /app && rm /tmp/app.tar.gz
```

## Never Put Secrets in Build Args

Build args are visible in the image history. Anyone with `docker history` can see them.

```dockerfile
# NEVER do this
ARG DB_PASSWORD
RUN echo "password=$DB_PASSWORD" > /etc/app/config
```

Instead, inject secrets at runtime via environment variables or mounted volumes. If you need secrets during the build (e.g., for pulling private packages), use BuildKit secret mounts:

```dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm install
```

```bash
docker build --secret id=npmrc,src=$HOME/.npmrc .
```

The secret is available during the build step but is never written to a layer.

## Add a HEALTHCHECK

The `HEALTHCHECK` instruction tells Docker (and Kubernetes, if not overridden by probes) how to verify the container is working:

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["/server", "--health-check"]
```

For Kubernetes deployments, you will typically use `livenessProbe` and `readinessProbe` in the pod spec instead, but `HEALTHCHECK` is valuable for Docker Compose and standalone Docker usage.

## Lint Your Dockerfiles

Use hadolint to catch common mistakes:

```bash
hadolint Dockerfile
```

It flags issues like missing version pins, use of `ADD` instead of `COPY`, running as root, and `apt-get` without `--no-install-recommends`. Run it in CI as a gate.

## Quick Checklist

- Multi-stage build separating build and runtime stages
- Minimal base image (distroless for compiled languages, slim variants for interpreted)
- `USER` directive set to non-root
- All image tags and package versions pinned
- `.dockerignore` excludes `.git`, `.env`, `node_modules`
- No secrets in build args -- use BuildKit secret mounts
- Layers combined where possible, apt cache cleaned in the same `RUN`
- `COPY` used instead of `ADD`
- hadolint running in CI

