A Node.js application can work perfectly in a container while its image remains unnecessarily large, runs as root, contains build tools, leaks package-manager caches, or accepts more Linux privileges than it needs. Those choices increase download time and make a future dependency vulnerability more valuable to an attacker.

This tutorial builds a production-oriented Docker image for a TypeScript Node.js service. It uses separate dependency, build, and runtime stages; copies only runtime artifacts; runs as a non-root user; handles health checks and signals; and applies practical restrictions at container startup.

Threat model and goals

Container hardening does not make vulnerable application code safe. It reduces what an attacker can access after a compromise and removes tools that are unnecessary at runtime.

The finished image should:

  • install dependencies reproducibly from a lockfile;
  • run tests and compilation before producing the runtime stage;
  • exclude source control data, local secrets, and development files;
  • contain production dependencies only;
  • run as an unprivileged user;
  • write only to intentional temporary locations;
  • expose an application-level health endpoint; and
  • preserve logs and shutdown signals for the container platform.

1. Start with a strict .dockerignore

Docker sends a build context to the builder. Excluding a file later in the Dockerfile is not enough if the file should never enter that context.

# .dockerignore
.git
.github
node_modules
dist
coverage
.env
.env.*
!.env.example
npm-debug.log*
Dockerfile*
docker-compose*.yml
README.md

Review this list for your application. If a build legitimately needs one of these files, copy only the safe input it requires. Never bake production .env files or cloud credentials into an image.

2. Separate dependencies, build, and runtime

Here is a multi-stage Dockerfile for a typical TypeScript service:

# syntax=docker/dockerfile:1.7

ARG NODE_IMAGE=node:24-bookworm-slim

FROM ${NODE_IMAGE} AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci

FROM deps AS build WORKDIR /app COPY tsconfig.json ./ COPY src ./src RUN npm test RUN npm run build

FROM ${NODE_IMAGE} AS prod-deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force

FROM ${NODE_IMAGE} AS runtime ENV NODE_ENV=production WORKDIR /app

COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/dist ./dist COPY --chown=node:node package.json ./package.json

USER node EXPOSE 3000 CMD ["node", "dist/server.js"]

Replace the example base tag with a currently supported Node.js release approved by your project. For repeatable production builds, pin the base image by digest through a dependency-update process instead of relying indefinitely on a mutable tag.

Why multiple stages matter

The deps and build stages contain TypeScript, test tools, and source files. The final stage receives only compiled output, the manifest, and production dependencies. Docker does not copy the earlier filesystem automatically.

This separation usually produces a smaller image and removes compilers that an attacker does not need. It also makes the build a release gate: if tests or compilation fail, no runtime image is created.

3. Order layers for useful caching

Copy package manifests before application source. Dependency installation then stays cached when a source file changes but the lockfile does not.

BuildKit cache mounts accelerate repeated npm downloads without storing the cache in the final image. A cache mount is not a reason to omit the lockfile: npm ci still provides deterministic installation and fails when the lockfile disagrees with package.json.

4. Run as a non-root user

Official Node images include a node user. The Dockerfile copies files with the correct ownership and switches users before starting the service:

COPY --from=build --chown=node:node /app/dist ./dist
USER node

Do not switch to root inside an entrypoint to repair permissions. Create required directories and ownership during the image build:

USER root
RUN mkdir -p /app/tmp && chown node:node /app/tmp
USER node

An unprivileged user limits damage, but it is only one layer. A container may still have writable filesystems and kernel capabilities unless the runtime restricts them.

5. Keep secrets out of image layers

Build arguments and ordinary environment variables can remain visible in image metadata or build history. When a private registry credential is required during a build, use a BuildKit secret mount:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc     npm ci
docker build   --secret id=npmrc,src="$HOME/.npmrc"   -t example-api:local .

The application runtime should receive its database password and signing keys from the deployment platform's secret mechanism. Do not copy them into the image or commit them to Compose files.

6. Add an application health endpoint

A health endpoint should prove that the process can serve requests without exposing sensitive diagnostics:

app.get('/health', async (_request, response) => {
  response.status(200).json({ status: 'ok' })
})

You can define a container check if the runtime image contains an appropriate client. Avoid installing a large package only for health checks. A platform-native HTTP probe is often cleaner.

HEALTHCHECK --interval=30s --timeout=3s   --start-period=10s --retries=3   CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"

A health check is not a restart strategy by itself. Configure your orchestrator to distinguish startup, readiness, and liveness behavior where supported.

7. Use exec-form commands and graceful shutdown

The JSON form of CMD starts Node as the container process instead of wrapping it in a shell:

CMD ["node", "dist/server.js"]

Handle termination signals so deployments can drain requests:

const server = app.listen(3000)

process.on('SIGTERM', () => { server.close(error => { if (error) { console.error(error) process.exit(1) }

process.exit(0)

}) })

Set the platform's termination grace period longer than the application's expected shutdown time. Do not use process managers inside a single-process container unless they solve a measured requirement.

8. Apply runtime restrictions

Build-time hardening and runtime policy work together. A local test can use:

docker run --rm   --read-only   --tmpfs /tmp:rw,noexec,nosuid,size=64m   --cap-drop ALL   --security-opt no-new-privileges:true   -p 3000:3000   example-api:local

--read-only quickly exposes code that writes to the application directory. Provide a bounded tmpfs only when the application or a dependency legitimately needs temporary storage.

Dropping capabilities and preventing privilege escalation reduce access to kernel features that a web application normally does not need. Confirm the policy in staging because native modules or low-port binding may require adjustments.

9. Inspect what actually shipped

Do not assume the Dockerfile produced the image you intended:

docker build --pull -t example-api:local .

docker image inspect example-api:local docker history --no-trunc example-api:local

docker run --rm example-api:local node -e "console.log(process.getuid?.())"

Confirm that the UID is not zero, no secret appears in history, build tools are absent, and the application works with the restricted runtime flags. Run your organization's vulnerability and software-bill-of-materials tooling on the final image, not only the builder stage.

10. Keep images patched without losing reproducibility

A pinned digest gives the same base bytes on every build, but it also stays vulnerable unless something updates it. Use an automated dependency updater to propose digest changes, rebuild regularly with --pull, run tests, scan the final image, and promote the exact tested digest.

Avoid installing general operating-system upgrades in an old base layer as a permanent strategy. Prefer a newly published, maintained base image and rebuild the application.

Common Docker hardening mistakes

  • Copying the entire repository before npm install: it invalidates dependency cache layers and may include unwanted files.
  • Using one stage for build and runtime: compilers and development dependencies ship to production.
  • Running as root because permissions failed: create directories and ownership during the build instead.
  • Passing secrets through ARG: use secret mounts for builds and the platform's secret store at runtime.
  • Using Alpine without testing native modules: choose a base for compatibility and measured size, not popularity.
  • Adding curl only for a health check: use a platform HTTP probe or a small runtime-native check.
  • Pinning forever: reproducibility requires an update process, not permanent staleness.
  • Treating containers as a security boundary by themselves: patch the application and enforce runtime isolation too.

Production checklist

  • The build context excludes secrets, Git data, local dependencies, and test output.
  • Dependencies install with a committed lockfile and deterministic command.
  • Tests and compilation complete in a builder stage.
  • The runtime image contains production dependencies and compiled artifacts only.
  • The service runs with a non-root UID.
  • No secret is stored in image configuration, layers, or history.
  • The process handles SIGTERM and stops within the platform grace period.
  • Health checks are lightweight and do not expose private details.
  • The container works with a read-only root filesystem and dropped capabilities.
  • The final image is scanned, tested, and promoted by digest.
A hardened Node.js image is not defined by one clever Dockerfile instruction. It comes from minimizing what ships, removing unnecessary privilege, protecting secrets, and continuously rebuilding from maintained inputs.

References