DEV Community

Cover image for I built a 45MB, 0-Vulnerability Node.js Runtime (and why you should stop using node:alpine)
Runtime Node
Runtime Node

Posted on

I built a 45MB, 0-Vulnerability Node.js Runtime (and why you should stop using node:alpine)

The Problem with "Standard" Images

If you are a Node.js developer, your Dockerfile probably starts with one of two lines:

  1. FROM node:24 or FROM node:25 (Debian-based)
  2. FROM node:24-alpine or FROM node:25-alpine (Alpine-based)

The Debian image is massive (~400MB+ uncompressed) and often carries a backlog of CVEs (Common Vulnerabilities and Exposures) in system libraries you never use.

The Alpine image is much better (~130MB uncompressed), but it still includes a shell (/bin/sh), a package manager (apk), and tools like wget or curl.

Ask yourself: Does your production container really need a shell? Does it need to be able to install new packages at runtime?

If the answer is no, then those tools represent attack surface. If a hacker manages to inject code into your app, providing them with /bin/sh and wget is like rolling out the red carpet.

The Solution: Going "Distroless" with Scratch\

I decided to engineer a "Gold Standard" runtime image. The goal was simple: Zero Bloat.

I call the project Runtime Node.

Icon

Here is the architecture of how I built it.

1. The Foundation: FROM scratch

In Docker, scratch isn't a Linux distribution. It is null. It is an empty file system with absolutely nothing in it.

By starting here, we opt out of the "Supply Chain" risks associated with Debian or Alpine. There is no OS to patch because there is no OS.

2. The Surgery: Extracting Only What Matters

Node.js cannot run in a vacuum; it needs shared libraries. Using a multi-stage build, I used the official Alpine image as a donor to extract only the specific libraries Node needs to function:

  • libstdc++: For C++ standard library support.
  • libgcc_s: For GCC runtime support.
  • ld-musl: The dynamic linker.
  • ca-certificates: Essential for making HTTPS requests.

Here is a snippet of the Dockerfile logic:

# Stage 1: The Donor
FROM node:25.7.0-alpine3.22 AS builder

# Stage 2: The Runtime
FROM scratch

# Copy only the essentials
COPY --from=builder /lib/ld-musl-*.so.1 /lib/
COPY --from=builder /usr/lib/libstdc++.so.6 /usr/lib/
COPY --from=builder /usr/lib/libgcc_s.so.1 /usr/lib/
COPY --from=builder /usr/local/bin/node /usr/local/bin/node
Enter fullscreen mode Exit fullscreen mode

3. The Hardening: chmod 555

Most images leave system binaries as writable (755). This allows a root user (or an attacker with root escalation) to modify the binary.

In Runtime-Node, I implemented a strict permission model. All binaries and libraries are copied with chmod 555 (Read/Execute, No Write).

COPY --from=builder --chmod=555 /usr/local/bin/node /usr/local/bin/
Enter fullscreen mode Exit fullscreen mode

This makes the runtime immutable. Even if the container runs as root, the filesystem fights back against modification.

4. The "Gotchas": DNS and /tmp

Running on scratch usually breaks two things: DNS Resolution and Temporary Files. I fixed both:

  • DNS: I injected a minimal nsswitch.conf file so the runtime knows how to resolve google.com or your database host.
  • Tmp: I created a /tmp directory with 1777 permissions (Sticky Bit), allowing the app to write temp files (uploads/processing) without needing a full OS.

The Result: v2.0.0 (Node 25)

I just released version v2.0.0-25.7.0, which brings the bleeding-edge Node.js 25.7.0 to this infrastructure.

Screenshot 1
Screenshot 2

The Stats:

  • Size: ~48 MB
  • Vulnerabilities: 0
  • Base: Scratch
  • Architecture: AMD64 & ARM64 (Works on Apple Silicon/Raspberry Pi)

How to use it

It is designed to be a drop-in replacement for your final stage. You build your app, and then copy it onto this runtime.

# 1. Build your app
FROM node:25.7.0-alpine3.22 AS builder
WORKDIR /app
COPY . .
RUN npm ci --omit=dev

# 2. Run it on Runtime-Node
FROM runtimenode/runtime-node:v2.0.0-25.7.0

COPY --from=builder --chown=1000:1000 --chmod=770 /app /app
WORKDIR /app

# Run as non-root
USER 1000:1000
ENTRYPOINT ["/usr/local/bin/node", "index.js"]
Enter fullscreen mode Exit fullscreen mode

Conclusion

You don't need a full Operating System to run a JavaScript file. By stripping away the shell and package manager, we gain performance, reduce storage costs, and significantly improve security posture.

The project is fully Open Source (Apache 2.0) and available on Docker Hub and GitHub Container Registry.

Give it a star if you dig the minimalism! ⭐

Top comments (0)