Dockerfile


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