Who should use these practices
These practices are written for engineers who write, review or maintain Dockerfiles for Node.js or Java services:
- Backend engineers who build images locally and wait on rebuilds during development.
- Platform and CI owners whose shared runners build images on every commit, where cache reuse and smaller build contexts add up across teams.
- SREs and on-call engineers who deal with slow scale-out, containers killed during deploys, and JVMs that the kernel OOM-kills.
- Security reviewers who triage image scanner output and need to confirm that credentials never end up in image layers.
They matter less for throwaway prototypes, one-off scripts, or images rebuilt a few times a year. The examples assume BuildKit, the default builder in current Docker releases. The RUN --mount=type=secret and # syntax= features depend on it.
How it works: from Dockerfile to running container
Each practice in this article targets one step in the path an image takes:
- Build context. The client sends the files in the build directory to the builder, minus anything matched by
.dockerignore. Smaller contexts upload faster and change less often. - Build. The builder runs each instruction in order. Filesystem-changing instructions produce layers, and each layer is cached against its parent and its inputs. Instruction order decides how much of the cache survives a code change.
- Stage selection. In a multi-stage build, only the final stage becomes the shipped image. Everything else stays on the builder.
- Push and pull. Registries store layers by content digest. A node downloads only the layers it does not already have, so smaller images and shared base layers shorten pulls.
- Run. The runtime starts the configured command as PID 1, as the configured user, with the configured limits. Signal delivery, zombie reaping and JVM heap sizing are all decided at this step.
The sections below follow roughly that order: caching and context, multi-stage builds and base images (size and pull time), non-root users and secrets (security), process and memory behaviour (runtime), and finally how to measure the result.
How layer caching works and why instruction order matters
An image is a stack of read-only layers. Each filesystem-changing instruction (RUN, COPY, ADD) produces a layer that records only what changed relative to the layer below it. Instructions like ENV and WORKDIR add metadata rather than file content.
When you rebuild, the builder walks the Dockerfile top to bottom and asks whether it already has a result for this instruction on top of this exact parent. For most instructions, the cache key is the instruction text. For COPY and ADD, the builder also checksums the files being copied, so any content change is a cache miss. A RUN line whose text is unchanged is treated as a hit, even if the packages it downloads have changed upstream. Once one layer misses, every layer after it is rebuilt, because each layer's parent has changed.
That cascade is why order matters. Copy only the dependency manifests first: package.json and package-lock.json, or pom.xml / build.gradle. Install dependencies next, and copy the source last. A code edit then invalidates only the final layers, and the slow npm ci or Maven resolution step comes from cache.
The build context is the set of files sent to the builder, and .dockerignore controls it. Exclude node_modules, target/build/dist, .git, .env files and credentials. This shrinks the upload, stops irrelevant changes from busting COPY . ., and keeps secrets out of layers.
Collapsing everything into one RUN, or squashing layers, can trim size. The cost is losing layer reuse, so CI pays the full build time on every commit.
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
A Node.js Dockerfile that copies package.json and package-lock.json before the source, so code edits reuse the cached npm ci layer, with matching .dockerignore entries shown in the header comment.
Multi-stage builds for Node.js and Java
A multi-stage build fixes the size problem without giving up the cache. One Dockerfile defines several FROM stages. Compilers, package-manager caches and test tooling stay in the build stage, and the final image only gets the files you name in COPY --from. Earlier stages are still cached, but they never ship.
BUILD STAGE RUNTIME STAGE
┌──────────────────────────┐ ┌──────────────────────┐
│ compiler / JDK, Maven │ │ runtime / JRE only │
│ dev dependencies │ COPY │ built output (dist/, │
│ source, tests │ ──────▶ │ app.jar, layers) │
│ built artifacts ─────────┼─────────│ prod dependencies │
└──────────────────────────┘ └──────────────────────┘
left behind: toolchain, dev deps, sources, caches
Node.js
The build stage runs npm ci with dev dependencies, so the TypeScript compiler is available, and then compiles to dist/. The runtime stage copies package.json, the lockfile and dist/, then runs npm ci --omit=dev. Running a fresh production install, instead of copying node_modules across, keeps type definitions and build tools out of the final image.
Java
A JDK stage with Maven or Gradle produces the jar. The runtime stage uses a JRE-only or smaller base and copies just that jar. You can go further by unpacking the jar into separate layers: dependency jars change rarely, so put them in an early layer and application classes in a later one. A code change then only rebuilds the last few megabytes.
Drift between stages
Native Node modules are compiled against the libc of the build stage. If you build on a glibc image and run on a musl-based one, those binaries can fail when they load, and the build won't catch it. Use the same base family for both stages, for example Debian-slim for build and runtime, so that what you compile is what runs.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
A two-stage Node.js Dockerfile. The build stage installs dev dependencies and compiles TypeScript, and the runtime stage uses the same Debian base, does a production-only npm ci and copies across only dist/.
Choosing and pinning a base image
Each base family trades size against tooling and compatibility. The right choice depends on what your runtime and dependencies expect.
| Base | Relative size | Shell / package manager | libc | Debuggability | Native addons / JVM |
|---|---|---|---|---|---|
| Full Debian | Largest | Yes / apt | glibc | Easiest | Works with almost everything |
| Debian slim | Medium | Yes / apt | glibc | Good | Safe default for both |
| Alpine | Small | Yes / apk | musl | Good | Prebuilt addons may break; needs musl JDK builds |
| Distroless-style | Smallest | No / no | glibc (usually) | Hard; needs ephemeral debug containers | Fine if the build stage matches |
Smaller does not automatically mean better. Many prebuilt Node.js native addons ship glibc binaries. On musl they fail or fall back to compiling from source, which needs a toolchain in the build stage. musl's resolver and memory allocator have also caused DNS lookup and throughput surprises in the past. Saving a few dozen megabytes rarely justifies debugging those problems.
For Java, jlink can build a custom JRE that contains only the modules you list, which shrinks the runtime considerably. The cost is extra build logic and a new class of failure: if you leave out a module that is only loaded reflectively or at runtime, the build passes and the app fails in production.
Pinning
A version tag such as node:22-slim is readable, and it moves when the publisher ships patches. A digest pin (@sha256:…) is fully reproducible, but it freezes security fixes too. Use digest pins only if you also run scheduled rebuilds or automated dependency-update PRs that bump the digest. Without that, the image quietly falls behind on security patches.
ARG NODE_IMAGE=node:22-slim
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
FROM ${NODE_IMAGE}
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app ./
USER node
CMD ["node", "server.js"]
A multi-stage Node.js Dockerfile on a glibc-based slim image. A single ARG lets you switch between a readable version tag and a reproducible digest pin.
Running as a non-root user and keeping secrets out of layers
Containers run as root by default. A process that escapes through an application bug then holds UID 0 inside the container, and on a host without user-namespace remapping, that is the host's UID 0 as well. Switch users with USER before CMD. The official Node images already include a node user (UID 1000), so USER node is enough. Most Java base images have no such user, so create one with groupadd/useradd on Debian- or Ubuntu-based images, or addgroup/adduser on Alpine. Give it a fixed numeric UID so Kubernetes runAsNonRoot checks can verify it.
What breaks under non-root
When you copy files in, use COPY --chown=app:app rather than a later RUN chown -R. The separate chown duplicates every affected file into a new layer, which roughly doubles their size. Three other problems are common:
- Writes to the app directory. Apps that write logs, caches, or temp files there will fail. Create those paths explicitly and give the user ownership of only those paths.
- Privileged ports. Binding below 1024 needs
CAP_NET_BIND_SERVICE. Listen on 8080 and map the port externally instead. - Mounted volumes. Bind mounts keep the host's ownership, so they often arrive root-owned. Fix this with
fsGroupin Kubernetes or by chowning the host directory. Don't solve it by switching back to root.
Secrets
ARG values appear in docker history. ENV values are stored in the image config. A file you delete in a later layer still exists in the earlier layer, and anyone who pulls the image can extract it. For build-time credentials such as a private registry token, use RUN --mount=type=secret. BuildKit mounts the secret only for that one instruction and never writes it to a layer.
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
COPY --chown=node:node . .
RUN mkdir -p /app/tmp && chown node:node /app/tmp
ENV PORT=8080
USER 1000
EXPOSE 8080
CMD ["node", "server.js"]
A Node.js Dockerfile that installs private dependencies with a BuildKit secret mount, sets file ownership with COPY --chown, creates one writable directory, and runs as the built-in non-root node user on port 8080.
Process and runtime correctness: signals, PID 1 and JVM memory
When an orchestrator stops a container, it sends SIGTERM to PID 1 and waits for a grace period before sending SIGKILL. Your app shuts down cleanly only if it actually receives that SIGTERM. Use the exec form, CMD ["node", "server.js"], so that node itself becomes PID 1. The shell form, CMD node server.js, runs your process under /bin/sh -c, and that shell may not forward signals. The app then never sees SIGTERM, keeps serving until the grace period ends, and is killed mid-request. Launching through npm start adds another wrapper with the same risk.
Being PID 1 has its own problems. The kernel does not apply default signal actions to PID 1, so a process with no SIGTERM handler ignores the signal. Node installs no such handler by default. PID 1 is also responsible for reaping orphaned child processes. A runtime that never reaps them accumulates zombies. You have two fixes:
- Add explicit signal handling in the app, and avoid spawning children.
- Run a minimal init as PID 1.
docker run --initinjects one at runtime, or you can installtiniordumb-initin the image and set it as theENTRYPOINT. Either option forwards signals and reaps zombies.
For Java, current JVMs are container-aware. They read the cgroup memory limit rather than the host's RAM. A fixed -Xmx ignores that limit: set it too high and the kernel OOM-kills the container, set it too low and memory sits idle. Size the heap as a share of the limit with -XX:MaxRAMPercentage, and leave headroom for metaspace, thread stacks and direct buffers.
Illustrative estimate (assumed numbers, not a benchmark): max heap ≈ container limit × MaxRAMPercentage ÷ 100. With an assumed 1 GiB limit and a setting of 75, that gives 1024 MiB × 0.75 ≈ 768 MiB of heap and about 256 MiB for everything else.
Measuring image size and vulnerabilities instead of guessing
Before changing anything, measure. docker image ls reports the total size of each tagged image, which gives you a baseline to compare against after every Dockerfile change. To find out where the bytes come from, run docker history on the image. It lists one row per layer, newest first, along with the instruction that created it and its size. Look for the largest row. It is usually a COPY . . that pulled in node_modules or build output, a package install that never cleaned its cache, or a full JDK left in the final stage. Zero-byte rows come from metadata instructions such as ENV or USER and can be ignored.
Size is half the picture. Run an image vulnerability scanner in CI on every build so you can see what the base image brings in and catch regressions when it changes. Treat CVE counts as a noisy proxy. A lower count does not make your application secure, because your own code and dependencies can still be exploitable. Chasing zero findings often means patching packages your process never loads. Prioritize findings that are fixable and reachable at runtime.
Illustrative estimate (assumed inputs, not a measured result): compressed pull sizes of 400 MB for a single-stage image and 120 MB for a multi-stage image, pulled over a 100 Mbit/s link (≈12.5 MB/s). These figures cover transfer time only. Decompression and extraction add more.
| Image | Assumed compressed size | Pull time at 12.5 MB/s |
|---|---|---|
| Single-stage, full Node/JDK | 400 MB | ≈32 s |
| Multi-stage, slim runtime | 120 MB | ≈9.6 s |
How these practices save engineering time
The savings come from four places, and each one maps to a practice above:
- Shorter rebuild loops. With manifest-first ordering and a tight
.dockerignore, a code-only change reuses the cached dependency layer. Engineers skipnpm cior Maven/Gradle resolution on most rebuilds, locally and in CI. - Faster deploys and scale-out. Multi-stage builds and slim bases shrink what each node has to pull. Base layers that are already cached on a node are not downloaded again, so services that share a base image pull even less.
- Fewer production incidents to debug. Exec-form
CMDor an init process gives you graceful shutdown, so deploys stop dropping in-flight requests. Percentage-based heap sizing prevents the OOM-kill loops that a fixed-Xmxcan cause. Using the same libc in both stages surfaces native-addon problems at build time, not at startup in production. Each of these failures takes hours to diagnose when it shows up as an intermittent error. - Less security toil. A smaller runtime means fewer packages for the scanner to flag and fewer findings to triage. Secret mounts keep credentials out of layers, which removes a whole class of leaks and the credential rotation and image cleanup that follow them.
Illustrative estimate (assumed numbers, not a measured result): developer wait time saved per day = engineers × rebuilds per engineer per day × minutes saved per rebuild. With an assumed 10 engineers, 8 rebuilds each per day and 2 minutes saved per rebuild from a cached dependency layer, that is 10 × 8 × 2 = 160 minutes, or about 2.7 hours of waiting removed per day. Plug in your own team size and your measured cache-hit savings. This covers developer time. The runner-hours estimate in the next section covers CI cost.
When to apply these practices, when to avoid them, and the cost to your team
These practices pay off most for backend and platform engineers shipping Node.js or Java services that are built and deployed many times a day, that autoscale (image pull sits on the scale-out critical path), or that run on shared CI runners where cache reuse compounds across teams. A service rebuilt once a quarter gets little back from aggressive optimisation.
When to hold back
| Practice | Hold back when | Do this first |
|---|---|---|
| Distroless or shell-less runtime | Your team relies on docker exec to debug |
Publish a debug image variant or enable ephemeral debug containers in the orchestrator |
| Alpine (musl libc) | Native addons ship prebuilt glibc binaries | Test on a slim glibc base before switching |
| jlink custom runtime | Required modules are uncertain (reflection, service loaders) | Run jdeps and full integration tests, and ship a full JRE until the module set is confirmed |
| Size chasing beyond "slim" | Savings are small and remove diagnostic tools | Check whether the vulnerability report actually changes |
Removing the build toolchain shrinks the attack surface substantially. Stripping the last few utilities usually costs debuggability and adds little security.
If you don't want to maintain Dockerfiles, two alternatives exist. Java build plugins can produce layered images directly from the build. Buildpack-style builders detect the language and assemble the image, and the base layer is patched centrally. Both give up fine-grained control in exchange for less maintenance.
The cost is real. Multi-stage files are harder to read, and a scheduled rebuild-and-scan process needs an owner. Weigh that work against CI time saved:
Illustrative estimate (assumed numbers, not a benchmark): saved runner hours per month = builds/month × minutes saved per build ÷ 60. With 600 builds and 3 minutes saved per build, that is 600 × 3 ÷ 60 = 30 hours. Compare the result with your monthly maintenance hours. Then add the benefits that are harder to measure: fewer leaked secrets and less manual base-image patching.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to dockerfile best practices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.