Links#
https://hub.docker.com/_/node
https://docs.docker.com/build/building/best-practices/
https://docs.docker.com/build/building/secrets/
https://docs.npmjs.com/cli/v11/commands/npm-ci
1. Important Points#
TypeScript container rule:
build TypeScript in build stage
run compiled JavaScript in runtime stage
do not run tsx / ts-node in production container
do not copy .env into image
use npm ci for reproducible install
run as non-root user
pass runtime config by env / secret / mounted file
common runtime env:
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
NODE_OPTIONS=--enable-source-maps --max-old-space-size=512
2. Multi-stage Dockerfile#
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
LOG_LEVEL=info \
NODE_OPTIONS="--enable-source-maps --max-old-space-size=512"
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
why:
deps:
install all dependencies including devDependencies for build
build:
compile TypeScript
remove devDependencies after build
runtime:
only contains production dependencies and dist
runs as node user
3. Runtime-only Dockerfile#
use this when:
CI already ran npm ci / npm run build
dist exists before docker build
image should only package runtime files
required files before build:
package.json
package-lock.json
dist/
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
LOG_LEVEL=info \
NODE_OPTIONS="--enable-source-maps --max-old-space-size=512"
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
why:
single stage is simpler
no TypeScript source needed in image
build happens outside Docker
runtime env can still override image defaults
tradeoff:
CI must guarantee dist is fresh
Docker build is less self-contained than multi-stage build
npm ci
npm run typecheck
npm run lint
npm test
npm run build
docker build \
-f Dockerfile.runtime \
-t order-api:runtime .
4. Runtime Environment Variables#
docker run --rm \
-p 3000:3000 \
-e NODE_ENV=production \
-e PORT=3000 \
-e LOG_LEVEL=info \
-e NODE_OPTIONS="--enable-source-maps --max-old-space-size=768" \
order-api:runtime
NODE_OPTIONS:
pass Node.js runtime flags
should be set by runtime platform
do not hardcode every option in Dockerfile
common Node.js runtime env#
| Env |
Example |
What It Does |
NODE_ENV |
production |
application/runtime mode, many libraries use it |
PORT |
3000 |
app listen port |
LOG_LEVEL |
info |
application log level |
NODE_OPTIONS |
--max-old-space-size=768 |
pass Node.js CLI flags through env |
NODE_EXTRA_CA_CERTS |
/etc/ssl/private/company-ca.pem |
add extra trusted CA certificates |
NODE_TLS_REJECT_UNAUTHORIZED |
1 |
TLS certificate verification; never set 0 in production |
HTTP_PROXY |
http://proxy:8080 |
outbound HTTP proxy |
HTTPS_PROXY |
http://proxy:8080 |
outbound HTTPS proxy |
NO_PROXY |
localhost,127.0.0.1,.svc |
hosts that bypass proxy |
TZ |
UTC |
container timezone |
common NODE_OPTIONS#
memory:
--max-old-space-size=768
set V8 old generation heap max size in MB
useful when container memory limit is known
diagnostics:
--enable-source-maps
map stack traces back to TypeScript source maps
--trace-warnings
print stack traces for process warnings
--heapsnapshot-near-heap-limit=3
write heap snapshots when heap approaches limit
--report-uncaught-exception
generate diagnostic report on uncaught exception
--report-on-fatalerror
generate diagnostic report on fatal error
security / crypto:
--tls-min-v1.2
reject TLS versions below 1.2
--tls-cipher-list=<cipher-list>
customize default TLS cipher list
only use when security team requires a specific cipher policy
--use-openssl-ca
use OpenSSL CA store
--use-bundled-ca
use Node.js bundled CA store
module / runtime:
--conditions=production
add custom package exports condition
--experimental-strip-types
strip TypeScript types at runtime in Node versions that support it
production services should still typecheck and usually run compiled dist
TLS / CA#
docker run --rm \
-p 3000:3000 \
-e NODE_ENV=production \
-e NODE_EXTRA_CA_CERTS=/etc/ssl/private/company-ca.pem \
-e NODE_OPTIONS="--tls-min-v1.2 --enable-source-maps --max-old-space-size=768" \
-v "$PWD/certs/company-ca.pem:/etc/ssl/private/company-ca.pem:ro" \
order-api:runtime
TLS notes:
NODE_EXTRA_CA_CERTS:
use when internal APIs / private registries use company CA
NODE_TLS_REJECT_UNAUTHORIZED=0:
disables certificate verification
only acceptable for temporary local debugging
never use in production
--tls-cipher-list:
use only when compliance/security policy requires explicit ciphers
test with every upstream dependency before rollout
5. .dockerignore#
for multi-stage build#
node_modules
dist
coverage
.git
.env
.env.*
npm-debug.log
Dockerfile
docker-compose.yml
important:
do not copy local node_modules
do not copy .env files
keep image build context small
for runtime-only build#
node_modules
coverage
.git
.env
.env.*
npm-debug.log
Dockerfile
docker-compose.yml
runtime-only note:
do not ignore dist
runtime-only Dockerfile needs COPY dist ./dist
CI must run npm run build before docker build
6. Build And Run#
docker build -t order-api:local .
docker run --rm \
-p 3000:3000 \
-e NODE_ENV=local \
-e PORT=3000 \
-e LOG_LEVEL=info \
-e NODE_OPTIONS="--enable-source-maps --max-old-space-size=512" \
order-api:local
7. Docker Compose#
services:
order-api:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
env_file:
- .env.local
environment:
NODE_ENV: local
PORT: "3000"
LOG_LEVEL: debug
NODE_OPTIONS: "--enable-source-maps --max-old-space-size=512"
restart: unless-stopped
env precedence:
environment:
explicit values in compose
env_file:
values loaded from file
image ENV:
fallback defaults baked in image
8. Build Secrets#
build secret is for image build time:
private npm token
private package registry credential
runtime secret is for application runtime:
database password
API token
do not confuse them
docker build \
--secret id=npmrc,src="$HOME/.npmrc" \
-t order-api:local .
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
9. Production Checklist#
image:
multi-stage build
runtime-only Dockerfile can be used when CI builds dist first
production dependencies only
no .env copied
no source map if policy disallows it
non-root user
pinned Node major version
runtime:
config injected by platform
secrets injected by platform
runtime flags injected by env, for example NODE_OPTIONS
health check configured
memory limit configured
logs go to stdout/stderr
CI:
npm ci
npm run typecheck
npm run lint
npm test
docker build
image scan