Non-Root Containers
Run Node.js as a non-root user so a container escape or RCE does not grant host-level privileges.
Recipe
Quick-reference recipe card - copy-paste ready.
FROM node:24-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY dist ./dist
RUN chown -R node:node /app
USER node
ENV NODE_ENV=production
CMD ["node", "dist/main.js"]When to reach for this: Every production Node image. CIS Docker benchmarks and most K8s policies require non-root UIDs.
Working Example
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json src ./
RUN npm run build
FROM node:24-bookworm-slim AS prod
WORKDIR /app
# Install as root, then hand off ownership
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
# Writable dirs the app needs at runtime
RUN mkdir -p /app/tmp /app/logs \
&& chown -R node:node /app
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]// src/main.ts - bind non-privileged port
import express from "express";
const app = express();
const port = Number(process.env.PORT ?? 3000);
app.get("/health", (_req, res) => res.json({ status: "ok" }));
app.listen(port, "0.0.0.0");What this demonstrates:
- Official
nodeimage includes anodeuser (UID 1000) chownbeforeUSERso the app can readnode_modulesand write logs- Port 3000 does not require root (only ports below 1024 do)
Deep Dive
How It Works
- Linux containers share the host kernel; root in container is still dangerous (capabilities, socket mounts)
USER nodedrops to UID 1000 for theCMDprocess and children- Kubernetes
securityContext.runAsNonRoot: truerejects images that start as UID 0
Kubernetes Security Context
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
containers:
- name: api
image: my-api:1.2.3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /app/tmp
volumes:
- name: tmp
emptyDir: {}readOnlyRootFilesystem requires writable mounts for temp files and Unix sockets.
Custom UID for OpenShift
Some platforms require arbitrary UIDs. Build with group-writable dirs:
RUN chgrp -R 0 /app && chmod -R g=u /app
USER 1001OpenShift runs as a random UID in the root group; g=u grants write access.
File Permissions Checklist
| Path | Permission need |
|---|---|
node_modules/ | read + execute |
dist/ | read + execute |
/app/tmp | write (uploads, pid files) |
/app/logs | write if file logging (prefer stdout) |
Gotchas
USER nodebeforeCOPY- files owned by root, app cannot write. Fix:chownafter all copies.- Binding port 80 - fails without root. Fix: listen on 3000; ingress maps 443 to 3000.
- npm install as
nodein build - cache permission errors. Fix: install as root in build stage,chown, thenUSER node. - Volume mounts override ownership - host dirs owned by root. Fix:
fsGroupin K8s or initContainerchown. - Prisma/sqlite writing to
/app- read-only root FS breaks. Fix: mountemptyDirat/app/data. - Assuming
nodeuser exists on distroless - usenonrootuser instead.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
USER node (UID 1000) | Standard K8s and ECS | OpenShift arbitrary UID required |
| Custom app user | Multi-tenant image hardening | Unnecessary for most internal APIs |
Distroless nonroot | Minimal image + non-root | You need shell debugging in container |
| Root + dropped capabilities | Legacy images migrating | New images (use non-root from day one) |
FAQs
Does non-root break npm in the running container?
Production containers should not run npm install at runtime. Install in build stage as root, run app as node.
What UID should I document?
Document 1000 for official Node images. If using custom UIDs, document in the Helm chart and Dockerfile comments.
Can NestJS cluster mode run non-root?
Yes. Primary and workers all run as node. Use readOnlyRootFilesystem with a tmp volume for IPC if needed.
How do I verify locally?
docker run --rm my-api id
# uid=1000(node) gid=1000(node)Does AWS Fargate require non-root?
Not strictly, but AWS security best practices and customer audits expect it. Set user in task definition to 1000.
What about init scripts that need root?
Use an initContainer in K8s for migrations (prisma migrate) with elevated perms; app container stays non-root.
Related
- Docker Basics - first Dockerfile
- Multi-Stage Builds - chown in final stage
- Distroless & Alpine Trade-offs -
nonrootuser - Health & Readiness Probes - probes as non-root
- Docker Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.