Docker for Developers: Beyond the Basics

Most developers learn enough Docker to run docker build and docker compose up. That gets you surprisingly far. But as your application grows, you start hitting problems: builds that take ten minutes, images that weigh over a gigabyte, containers that run as root, and deployments that feel fragile.

This article covers the patterns that take you from “Docker works” to “Docker works well.” Having worked with containerised applications across dozens of projects, I have found that the difference between a team that struggles with Docker and one that thrives often comes down to five or six key practices.

Quick Reference: Docker Optimisation Techniques

TechniqueImpact on Build TimeImpact on Image SizeDifficulty
Multi-stage buildsModerateVery high (60 to 80% reduction)Low
Layer orderingVery highNoneLow
.dockerignoreHighNoneTrivial
Distroless base imagesNoneVery highMedium
BuildKit cache mountsVery highNoneMedium
Registry-based cachingVery high (CI only)NoneMedium
Non-root userNoneNoneTrivial

Multi-Stage Builds

Multi-stage builds are the single most impactful improvement you can make to your Dockerfiles. They separate the build environment from the runtime environment, resulting in dramatically smaller and more secure images.

The problem with single-stage builds

A typical Node.js Dockerfile installs build tools, compiles TypeScript, runs tests, and then serves the application. The final image contains everything: the TypeScript compiler, test frameworks, devDependencies, and build artefacts that are never used at runtime.

The multi-stage approach

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Runtime
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The final image contains only production dependencies and compiled output. Build tools, source code, and devDependencies are discarded. For a typical Node.js application, this can reduce image size by 60 to 80 percent.

Going further with distroless images

Google’s distroless images ↗ contain only your application and its runtime dependencies. No shell, no package manager, no utilities. This minimises the attack surface dramatically.

FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/server.js"]

The trade-off is that you cannot shell into the container for debugging. For production workloads where security matters, that is an acceptable cost. You will want proper observability and monitoring in place to compensate for the lack of interactive debugging.

Layer Caching Done Right

Docker builds images layer by layer, caching each layer. If a layer has not changed, Docker reuses the cached version. Understanding this mechanism is key to fast builds.

The golden rule

Order your Dockerfile instructions from least frequently changing to most frequently changing.

# Changes rarely: base image
FROM node:20-alpine

# Changes rarely: system dependencies
RUN apk add --no-cache python3 make g++

# Changes occasionally: application dependencies
COPY package*.json ./
RUN npm ci

# Changes often: application code
COPY . .
RUN npm run build

With this ordering, a code change only invalidates the last two layers. Dependency installation (the slowest step) uses the cache unless package.json has changed.

The .dockerignore file

Without a .dockerignore, Docker sends your entire project directory to the build daemon, including node_modules, .git, test fixtures, and local environment files. This wastes time and can leak secrets.

node_modules
.git
.env
*.md
dist
coverage
.nyc_output

A proper .dockerignore reduces build context size and prevents cache invalidation from irrelevant file changes.

Layer Caching: Poor vs Optimised Ordering Poor Ordering FROM node:20-alpine COPY . . (all files including source) RUN npm ci (invalidated every time!) RUN npm run build Every code change rebuilds ALL layers Build time: ~120 seconds Optimised Ordering FROM node:20-alpine (cached) COPY package*.json (cached if unchanged) RUN npm ci (cached if deps unchanged) COPY . . + RUN build (rebuilt) Only source layers rebuild Build time: ~15 seconds Proper layer ordering can reduce build times by 80% or more on code-only changes

Security Hardening

Running containers in production without security hardening is like deploying to a server you have not patched. The defaults are convenient but not safe.

Run as a non-root user

By default, processes inside a container run as root. If an attacker exploits a vulnerability in your application, they have root access inside the container.

RUN addgroup --system appgroup && \
    adduser --system --ingroup appgroup appuser
USER appuser

This simple addition significantly limits the blast radius of a compromise. In my experience, adding a non-root user is the single lowest-effort, highest-impact security improvement you can make to any Dockerfile.

Pin your base image versions

Using node:latest or node:20 means your build can produce different images on different days. Pin to a specific digest or patch version for reproducible builds.

FROM node:20.11.1-alpine3.19

Pair this with a tool like Renovate ↗ or Dependabot to keep base images updated in a controlled, reviewable manner.

Scan your images

Tools like docker scout, Trivy, and Snyk scan your images for known vulnerabilities in OS packages and application dependencies. Integrate these into your CI/CD pipeline so that vulnerable images never reach production.

docker scout cves myapp:latest

Do not store secrets in images

Environment variables set with ENV are baked into the image and visible to anyone who inspects it. Pass secrets at runtime via environment variables, mounted secret files, or a secrets manager.

Docker Compose for Local Development

Docker Compose is excellent for local development, but many teams under-use it. If you are looking to automate your development environment more broadly, Docker Compose is often the best place to start.

Health checks

Add health checks to your services so that dependent containers wait for readiness, not just port availability.

services:
  postgres:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

  api:
    build: .
    depends_on:
      postgres:
        condition: service_healthy

Development overrides

Use a docker-compose.override.yml for development-specific configuration like volume mounts, debug ports, and hot reloading. The override file is loaded automatically alongside the base file.

# docker-compose.override.yml
services:
  api:
    volumes:
      - ./src:/app/src
    environment:
      - DEBUG=true
    ports:
      - "9229:9229" # Node.js debugger

Named volumes for persistence

Use named volumes instead of bind mounts for database data. Bind mounts can cause permission issues across operating systems; named volumes are managed by Docker and work consistently.

Build Optimisation for CI

In CI environments, layer caching behaviour is different from local builds. Each CI run typically starts with a clean Docker cache.

Registry-based caching

Push your build cache to a container registry and pull it in subsequent builds:

docker build \
  --cache-from myregistry/myapp:cache \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  -t myregistry/myapp:latest .

BuildKit

Docker BuildKit (enabled by default in recent versions) offers parallel layer building, better caching, and secret mounting. If you are not using it, set DOCKER_BUILDKIT=1 in your environment.

BuildKit’s --mount=type=cache lets you persist package manager caches across builds:

RUN --mount=type=cache,target=/root/.npm \
    npm ci

This is particularly effective in CI where dependency installation is often the slowest step. For teams using infrastructure as code, these Docker optimisations should be codified in your pipeline definitions alongside your infrastructure configuration.

Moving Beyond the Basics

Docker mastery is not about memorising Dockerfile syntax. It is about understanding layers, caching, security boundaries, and the difference between a development convenience and a production concern.

Start with multi-stage builds and proper layer ordering. Add security hardening. Optimise your CI caching. Each improvement compounds, resulting in faster builds, smaller images, and more reliable deployments.

The container you build in development should closely resemble what runs in production. The closer those two things are, the fewer surprises you get on deploy day.

Frequently asked questions

What is a multi-stage Docker build?

A multi-stage build uses multiple FROM statements in a single Dockerfile. Each stage can use a different base image and copy artefacts from previous stages. This lets you compile code in a full development image but ship only the final binary in a minimal runtime image.

How do I reduce Docker image size?

Use multi-stage builds to separate build dependencies from runtime dependencies. Choose minimal base images like Alpine or distroless. Remove package manager caches after installing dependencies. Order your layers so that frequently changing content comes last.

Should I run containers as root?

No. Running containers as root is a security risk. Always create a non-root user in your Dockerfile and switch to it with the USER instruction. This limits the damage if a container is compromised.

Why is my Docker build so slow?

The most common cause is poor layer ordering. Docker caches layers from top to bottom, so put your dependency installation steps before copying your source code. This way, dependencies are only reinstalled when they actually change, not on every code edit.

What is the difference between COPY and ADD in a Dockerfile?

COPY simply copies files from the build context into the image. ADD does the same but also supports extracting tar archives and fetching remote URLs. Prefer COPY in most cases for clarity and predictability.

Enjoyed this article? Get more developer tips straight to your inbox.

Comments

Join the conversation. Share your experience or ask a question below.

0/1000

No comments yet. Be the first to share your thoughts.