DevOps7 MIN READ

Docker in Production: Minimal Images and Reproducible Deployments

How to build lean, deterministic, and auditable images with multi-stage builds, non-root users, and explicit resource limits.

Klyntex Team
#Docker#Containers#CI/CD#DevOps
Technologies:DockerLinuxGitHub ActionsPostgreSQL

In most projects the container image is an infrastructure detail that gets defined once and then forgotten. By the time the first production incident arrives, that detail stops being cosmetic: image size, the user the process runs as, and how dependencies are versioned determine how long a deploy takes, how much attack surface the service exposes, and whether two environments actually behave the same way.

This article collects concrete decisions we apply when packaging services with Docker, prioritizing reproducibility and auditability over the convenience of a three-line Dockerfile.

1. Why image size is an operational problem

A multi-gigabyte image is not just an awkward number in the registry. It has direct consequences:

  • Slower deployments: every node must pull the full layer set before starting the container.
  • Larger attack surface: every binary and every operating-system library in the base image is a dependency that needs patching.
  • More expensive cold starts on platforms with autoscaling, where containers are created on demand.

The goal is not “the smallest possible image” as an end in itself, but an image that contains only what is required to run the service and nothing else.

2. Multi-stage builds

The most effective technique is to separate the build environment from the runtime environment. The build stage can include compilers, headers, and tooling; the final stage copies only the artifacts that were already produced.

# syntax=docker/dockerfile:1

FROM node:22-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
CMD ["node", "dist/server.js"]

The build stage can afford development dependencies. The runtime stage starts from a smaller base and receives only production node_modules and compiled code. No source code, tests, or build caches travel to production.

3. Running without privileges

By default a container runs as root. If a compromised process escapes isolation, that privilege amplifies the impact. The fix is simple and carries no performance cost: create an unprivileged user and use it.

FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN useradd --system --uid 10001 --create-home appuser
COPY --from=build --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appuser /app/dist ./dist
USER appuser
CMD ["node", "dist/server.js"]

It is worth complementing this with a read-only filesystem when the service does not need to write to disk. This is declared at run time, not in the image:

docker run \
  --read-only \
  --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  -p 3000:3000 \
  klyntex/api:1.4.0

Each of these flags reduces the set of actions a compromised process can perform inside the container.

4. Determinism: pin versions and order layers

A “reproducible” image is one where the same code revision produces the same result today and six months from now. Three habits help achieve this:

  1. Pin the base image by digest, not by tag. The bookworm-slim tag may point to different contents over time; the digest is immutable.
  2. Use deterministic installs. npm ci respects the lockfile and fails when package.json and the lockfile disagree, something npm install does not do.
  3. Order layers from stable to changing. Copying manifests first and code afterwards lets dependency installation stay cached instead of rerunning on every single-line change.
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .

A careful .dockerignore completes the picture: excluding node_modules, .git, local build artifacts, and environment files avoids invalidating the cache for no reason and shrinks the context sent to the daemon.

5. Resource limits and health checks

A container without limits can consume all of a node’s memory and drag neighboring services down with it. Defining explicit limits and a health probe lets the orchestrator make informed decisions.

docker run \
  --memory 512m \
  --cpus 1.0 \
  --health-cmd "node -e \"require('http').get('http://localhost:3000/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))\" " \
  --health-interval 30s \
  --health-retries 3 \
  klyntex/api:1.4.0

The probe should verify that the service responds, not merely that the process is still alive. A process that answers every request with an error is, for practical purposes, down.

6. Conclusion

Packaging a service well does not require exotic tooling. Multi-stage builds, non-root execution, pinned versions, resource limits, and an honest health probe cover most of the operational risk. The payoff is a predictable deployment: when something breaks, the problem is in the code or the data, not in silent differences between environments.

TECHNICAL DISCUSSION

Want to discuss this architecture or propose a use case?

Reach out to our engineering team to exchange perspectives, analyze technical trade-offs, or evaluate applying these patterns to your system.

All trademarks and product names mentioned are the property of their respective owners and are referenced for identification purposes only; their mention implies no sponsorship or affiliation. This content is informational and provided as is, without warranty.