Docker Basics
10 examples to containerize a TypeScript Node.js API on Node 24 LTS - 7 basic and 3 intermediate.
Prerequisites
mkdir node-api && cd node-api
npm init -y
npm pkg set type=module
npm install express@5
npm install -D typescript@5.6 tsx @types/express @types/nodeFor multi-stage patterns and image hardening, see Multi-Stage Builds and Non-Root Containers.
Basic Examples
1. Minimal Dockerfile
FROM node:24-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
ENV NODE_ENV=production
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]npm ciuses the lockfile for reproducible installs- Copy
package*.jsonbefore source so dependency layers cache well USER noderuns the process without root privileges
2. .dockerignore
node_modules
dist
.git
.env
.env.*
*.md
coverage
.vscode
Dockerfile*
docker-compose*.yml
- Smaller build context means faster
docker build - Never copy
.envinto an image layer - Ignore test and docs artifacts you do not need at runtime
3. Build TypeScript in CI, Run JS in Image
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]- Compile TypeScript in a build stage or in CI before
docker build - Runtime image contains only
dist/and productionnode_modules - See Multi-Stage Builds for deeper patterns
4. PORT from the Environment
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", () => {
console.log(`listening on ${port}`);
});- Bind to
0.0.0.0inside containers, not127.0.0.1 - Kubernetes, ECS, and Cloud Run all inject
PORT - Health endpoint is required for load balancer checks
5. docker build and docker run
docker build -t my-api:local .
docker run --rm -p 3000:3000 -e PORT=3000 my-api:local
curl http://localhost:3000/health-p 3000:3000maps host port to container port--rmremoves the container after exit (good for local smoke tests)- Pass secrets via
-elocally; use a secrets manager in production
6. package.json Scripts for Docker
{
"scripts": {
"build": "tsc",
"start": "node dist/main.js",
"docker:build": "docker build -t my-api:local .",
"docker:run": "docker run --rm -p 3000:3000 -e PORT=3000 my-api:local"
}
}- One command for the team to build and smoke-test locally
- CI can call
npm run docker:buildafter tests pass - Keep
startas plainnode, nottsx, in production images
7. HEALTHCHECK in Dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT||3000) + '/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"- Docker marks unhealthy containers for restart policies
- Orchestrators (K8s) prefer their own probes over
HEALTHCHECK - Keep
/healthfast: no database calls (use/readyfor that)
Intermediate Examples
8. Multi-Stage with Dev and Prod Targets
FROM node:24-bookworm-slim AS base
WORKDIR /app
COPY package*.json ./
FROM base AS dev
RUN npm ci
COPY . .
CMD ["npx", "tsx", "watch", "src/main.ts"]
FROM base AS prod
RUN npm ci --omit=dev
COPY dist ./dist
USER node
CMD ["node", "dist/main.js"]docker build --target dev -t my-api:dev .
docker build --target prod -t my-api:prod .- One Dockerfile serves local dev (
tsx watch) and production --targetselects the final stage- Dev target can include devDependencies; prod cannot
9. docker compose for Local Stack
# compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
NODE_ENV: development
PORT: 3000
DATABASE_URL: postgres://postgres:postgres@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5depends_onwithcondition: service_healthyavoids race on boot- Compose is for local dev; production uses K8s or managed platforms
- See Health & Readiness Probes
10. Pin Base Image by Digest
FROM node:24-bookworm-slim@sha256:abc123def456...- Digests prevent surprise rebuilds when a tag moves
- Renovate or Dependabot can open PRs when base images update
- Pair with image scanning in CI - Docker Best Practices
FAQs
Should I use Alpine or Debian slim?
Default to node:24-bookworm-slim (glibc). Alpine (musl) breaks some native npm modules. See Distroless & Alpine Trade-offs.
Do I need Docker locally if we deploy to Kubernetes?
Yes for debugging image issues. CI builds images; developers still benefit from docker build and compose for integration tests.
Where does TypeScript compilation happen?
Either in a Docker build stage or in CI before docker build. Do not ship tsx or typescript in production images unless you have a strong reason.
How big should a Node production image be?
A slim Express API is often 150-250 MB. If yours is 800 MB+, audit layers with Image Slimming.
One container or separate worker container?
One process per container. Run API and background workers as separate Deployments so you can scale and deploy them independently.
How do I pass secrets into containers?
Environment variables from orchestrator secrets (K8s Secrets, ECS task secrets, SSM). Never bake secrets into image layers. See ConfigMaps & Secrets.
Related
- Multi-Stage Builds - dev deps out of prod
- Non-Root Containers -
USER nodepatterns - Health & Readiness Probes -
/healthvs/ready - Image Slimming - smaller layers
- 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.