// Engineering Log

Secure Development: Part 5 — The Proper Dockerfile

Published on 2026-09-22

// Fast route

This article belongs to the topic Deploy and reliability.

Dockerfile of three lines — FROM node, COPY . ., CMD npm start — works, but in a pipeline such an image builds slowly, is slightly different each time and stops poorly. This section is about proper building: reproducibility, cache, secrets and the process inside the container. Minimal base images, scanning and runtime restrictions are covered in the container security lifecycle part.

Base image: version and digest

The latest tag and tags without a version change without warning: today’s build and tomorrow’s may get different language versions. At minimum — specify the version. More reliably, pin the image by digest, as Docker documentation recommends: then the build will use exactly that image even if the publisher updates the tag.

dockerfile
FROM python:3.13-slim@sha256:<digest>

You can find the digest with the command docker buildx imagetools inspect python:3.13-slim. To keep pinned images from getting outdated, they are updated via Dependabot (package-ecosystem: "docker") — it creates merge requests with new tags and digests on a schedule.

The version should be supported. As of September 2026:

  • Node.js 18 stopped receiving security updates on April 30, 2025; Node.js 20 — on April 30, 2026; for new images use LTS branches 22 (security fixes until April 30, 2027) and 24.
  • Python 3.9 is not supported since October 31, 2025, support for 3.10 ends on October 31, 2026; for new images — 3.12 or 3.13.

Build context and .dockerignore

All contents of the build directory are sent to the builder unless excluded. A .dockerignore file speeds up the build and prevents unnecessary files — including files with passwords — from getting into the image:

.git
node_modules
.env
*.log
dist

Instruction order and cache

Each instruction creates a layer. If a layer changes, all subsequent layers are rebuilt. Therefore copy dependency files and install them first, and copy the code afterwards:

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Now changing the code does not trigger reinstalling dependencies. npm ci installs exactly what’s recorded in the lockfile — this is reproducible build.

Install system packages in a single instruction with list update and cleanup, as Docker documentation recommends:

dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
 && rm -rf /var/lib/apt/lists/*

Multi-stage builds

Compilers, dev-dependencies and sources are needed for building but not for running. Multi-stage builds leave only what’s necessary in the final image:

dockerfile
# syntax=docker/dockerfile:1
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The first stage builds the application, the second takes only the ready result and the runtime dependencies. The USER node instruction runs the app as an unprivileged user, which already exists in the official Node.js images.

Build secrets

A token for a private package registry must not be passed via ARG or ENV: those values are stored in the image and visible in its history. For this there are BuildKit secrets — they are mounted for a single instruction and do not end up in layers:

dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
bash
docker build --secret id=npmrc,src=$HOME/.npmrc .

By default the secret is available as the file /run/secrets/<id>; the env= parameter allows passing it into an environment variable only for that command.

Process in the container: PID 1 and signals

The container stop command sends SIGTERM to the main process. If CMD is written in shell form (CMD node server.js), the shell becomes the main process and the application may not receive the signal — after a timeout the container is forcibly terminated and in-flight requests are lost.

  • Use the exec form: CMD ["node", "dist/server.js"].
  • If a startup script is needed, it should end with exec "$@" so the app becomes PID 1 and receives signals — this pattern is in Docker documentation.
  • If the app spawns child processes, run the container with docker run --init (in Compose — init: true): Docker will add a small init process based on tini, which forwards signals and reaps finished processes.

Linting the Dockerfile

The static analyzer hadolint finds common Dockerfile mistakes — unpinned versions, shell-form commands, unnecessary layers — and is easy to integrate into a pipeline:

bash
docker run --rm -i hadolint/hadolint < Dockerfile

Common mistakes

  • COPY . . at the top of the file. Dependency cache is invalidated by any code change.
  • No .dockerignore. .git, local node_modules and .env get into the image.
  • npm install instead of npm ci. The build may get different package versions than those recorded in the lockfile.
  • Tokens in ARG and ENV. They can be extracted from the image history.
  • Shell-form CMD. The app does not get SIGTERM and cannot shut down cleanly.
  • Outdated base images. Node.js 18 and Python 3.9 no longer receive security fixes.

// Similar task

If you are dealing with something similar

This article belongs to one of the main working topics. You can keep reading on the topic, go to the homepage to understand what I do, or open the service pages directly.

Article topic

Deploy and reliability

Docker, CI/CD, releases, monitoring, observability, and incident handling.

Typical tasks behind this topic

  • Set up deployment without manual chaos
  • Add monitoring, alerts, and baseline observability
  • Investigate incidents and stabilize production

// Next step

If you need help with this topic, not just another article, it is better to go straight to the service page. The homepage and topic collection stay available as secondary routes.

Open services

// Reviews

Related reviews

I came with an expensive request to configure a VPS server, but during the consultation Mikhail suggested a much simpler, more affordable solution. In the end I saved time and money. Mikhail — a true expert who works for the client's result, not for the fee. I recommend him!

I came with an expensive request to configure a VPS server, but during the consultation Mikhail suggested a much simpler and more cost-effective solution. In the end I saved budget and time. Mikhail — a true expert who …

kfhzasorin

VPS setup, server setup

2026-05-12 · ★ 5/5

// Contact

Need help?

Get in touch with me and I'll help solve the problem

I reply within one business day (03:00-13:00 GMT)

Или оставьте заявку здесь:

Confirm that you are not a bot.

Write and get a quick reply