BytePane
DevOps

Docker Container Best Practices: Dockerfile, Security & Optimization

14 min read

Docker containers have become the standard deployment unit for modern applications. But a poorly written Dockerfile can produce images that are bloated (2GB+), insecure (running as root with known CVEs), and slow to build (20+ minutes for cache misses). This guide covers production-tested best practices for writing efficient Dockerfiles, securing containers, optimizing image size, and deploying reliably. Every recommendation includes concrete examples with before/after comparisons.

Writing Efficient Dockerfiles

The order of instructions in your Dockerfile directly impacts build speed through Docker's layer caching mechanism. Each instruction creates a cached layer. When a layer changes, all subsequent layers are rebuilt. The key principle: order instructions from least frequently changed to most frequently changed.

# BAD: Copies everything before installing dependencies
FROM node:20-alpine
COPY . /app
WORKDIR /app
RUN npm install
CMD ["node", "server.js"]

# GOOD: Copies package files first for better caching
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
CMD ["node", "server.js"]

In the good example, the npm ci layer is only rebuilt when package.json or package-lock.json change — not on every source code edit. For a typical Node.js project, this reduces rebuild time from 2–3 minutes to 5–10 seconds. Use our diff checker to compare Dockerfile versions side by side.

Multi-Stage Builds

Multi-stage builds are the single most impactful optimization for Docker image size. They let you use a full build environment (compilers, dev dependencies) in one stage and copy only the compiled output to a minimal runtime image. The result: images that are 70–90% smaller.

# Multi-stage build for a Go application
# Stage 1: Build (1.2GB with Go compiler)
FROM golang:1.22-alpine AS builder
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 .

# Stage 2: Runtime (12MB final image)
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
LanguageSingle-Stage SizeMulti-Stage SizeReduction
Go1.2 GB12 MB99%
Java (Spring Boot)680 MB165 MB76%
Node.js950 MB55 MB94%
Python1.1 GB120 MB89%
Rust1.8 GB8 MB99.6%

Base Image Selection

Your base image choice determines the starting size, available system libraries, and security attack surface of your container. Here are the primary options ranked by size:

Base ImageSizelibcBest For
scratch0 MBNoneStatic Go/Rust binaries
gcr.io/distroless2 MBglibcJava, Python, Node.js (no shell)
alpine5 MBmuslGeneral purpose (small)
debian-slim80 MBglibcC-dependent apps, ML
ubuntu78 MBglibcDevelopment, full tooling

Pin your base image to a specific digest rather than a mutable tag to ensure reproducible builds: FROM node:20-alpine@sha256:abc123.... Tags like :latest or even :20-alpine can change without notice when the upstream publishes a new patch.

Container Security Hardening

By default, Docker containers run as root with broad Linux capabilities. This means a container escape vulnerability gives an attacker root access on the host. Apply these security layers:

# Create non-root user and set permissions
FROM node:20-alpine
RUN addgroup -g 1001 appgroup && \
    adduser -u 1001 -G appgroup -D appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
USER appuser

# Run with security flags
# docker run --read-only \
#   --cap-drop ALL \
#   --security-opt no-new-privileges \
#   --tmpfs /tmp \
#   myapp:latest

Scan your images for known CVEs using open-source tools. Trivy scans in under 10 seconds and covers OS packages, language dependencies, and misconfigurations. Integrate scanning into your CI/CD pipeline to block deployments with critical vulnerabilities. Use our hash generator to verify image integrity with SHA256 digests.

Health Checks and Graceful Shutdown

Docker health checks tell orchestrators (Kubernetes, Docker Swarm, ECS) whether your container is actually serving traffic, not just running. Without health checks, a container with a deadlocked application thread still appears “healthy” because the process hasn't exited.

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

For graceful shutdown, handle SIGTERM in your application. Docker sends SIGTERM first, waits 10 seconds (configurable with --stop-timeout), then sends SIGKILL. Your app should stop accepting new requests, finish in-flight requests, close database connections, and exit cleanly within that window.

.dockerignore Best Practices

The .dockerignore file prevents unnecessary files from being sent to the Docker daemon as build context. A missing .dockerignore can add hundreds of megabytes to build context and accidentally include secrets.

# Essential .dockerignore entries
node_modules
.git
.gitignore
*.md
.env*
.DS_Store
.vscode
.idea
coverage
test
tests
__tests__
*.test.*
*.spec.*
docker-compose*.yml
Dockerfile*
.dockerignore

Layer Optimization Techniques

Each RUN, COPY, and ADD instruction creates a new layer. Combine related commands with && to reduce layers, and clean up caches in the same RUN instruction (otherwise the cleanup layer just masks the data — the cached files still exist in previous layers and inflate the image).

# BAD: 3 layers, apt cache persisted in layer 2
RUN apt-get update
RUN apt-get install -y curl git
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 git && \
    rm -rf /var/lib/apt/lists/*

Use --no-install-recommends with apt-get to skip suggested packages, typically saving 50–200MB. For Python, use pip install --no-cache-dir to avoid caching wheel files. Use our word counter to check your Dockerfile comment documentation length.

Environment Variables and Secrets

Never bake secrets into Docker images. Anyone who pulls your image can extract every layer and read hardcoded passwords, API keys, or certificates. Even if you delete the secret in a later layer, it persists in the image history.

MethodSecurityUse Case
ENV in DockerfileBad — visible in imageNon-sensitive config only
docker run -eOK — visible in inspectDev/testing
Docker secretsGood — encrypted at restSwarm/Compose production
Vault/SOPSBest — rotatable, auditedEnterprise production
BuildKit --secretGood — not in layersBuild-time secrets (npm tokens)

Production Deployment Checklist

Before deploying a container to production, verify these items. Each addresses a common failure mode seen in real-world outages:

  • Image size under 200MB (use docker images to check)
  • Running as non-root user (verify with docker run --rm image whoami)
  • HEALTHCHECK defined in Dockerfile
  • SIGTERM handled for graceful shutdown
  • No secrets in image layers (scan with docker history or Trivy)
  • CVE scan passes (no critical/high vulnerabilities)
  • .dockerignore excludes .git, node_modules, .env files
  • Resource limits set (memory, CPU)
  • Logs go to stdout/stderr (not files inside the container)
  • Base image pinned to digest, not mutable tag

Validate your container configuration files with our JSON formatter for docker-compose.json or YAML to JSON converter for docker-compose.yml validation. Check HTTP status codes to ensure your health check endpoints return proper responses.

Validate Your Container Configs

Use BytePane's free tools to format and validate JSON, YAML, and other config files used in Docker deployments.

Open JSON Formatter

Frequently Asked Questions

What is the ideal Docker image size for production?

Production Docker images should be as small as possible, ideally under 100MB. Use Alpine-based images (5MB base vs 125MB for Ubuntu) or distroless images (2MB base). Multi-stage builds can reduce final image size by 70–90% by separating build dependencies from runtime. For Node.js apps, a typical optimized image is 50–80MB; for Go apps, 5–15MB using scratch base.

Should I use Alpine or Debian-slim as my Docker base image?

Alpine (5MB) is smaller but uses musl libc instead of glibc, which can cause compatibility issues with some C libraries, Python packages (NumPy, pandas), and DNS resolution behavior. Debian-slim (80MB) uses glibc and has broader compatibility. For most Go and Node.js applications, Alpine works well. For Python ML workloads or applications with native C dependencies, use Debian-slim.

How do I reduce Docker build times?

Order Dockerfile instructions from least to most frequently changed to maximize layer caching. Copy dependency files (package.json, go.mod) before source code. Use BuildKit (DOCKER_BUILDKIT=1) for parallel stage execution and better caching. Pin dependency versions to avoid cache invalidation. Use .dockerignore to exclude node_modules, .git, and test files.

What are the most important Docker security practices?

Run containers as non-root users (USER directive), use read-only filesystems (--read-only flag), scan images for CVEs with Trivy or Snyk, never store secrets in images, pin base image digests instead of tags, set resource limits (--memory, --cpus), use no-new-privileges security option, and regularly update base images. Drop all Linux capabilities and add back only what you need.

Related Articles