06 · Deployment with Docker¶
Shipping a TypeScript service means shipping compiled JavaScript, not
source — and a naive Dockerfile that copies everything and runs
npm install in production ends up bloated with dev dependencies,
the TypeScript compiler itself, and source files nobody needs at
runtime. This module builds a multi-stage Dockerfile around the Task
API project from Level 3, Module 10.
The project being containerized¶
Reusing task-api/ exactly as built in Level 3:
Verified locally (Level 3, Module 10) that npm run build && npm start
produces a server on port 4500 responding correctly to the full CRUD
curl suite — that's the artifact this Dockerfile needs to reproduce
inside a container.
A naive (bad) Dockerfile¶
This works, but ships the entire node_modules including
typescript, @types/*, and every dev tool, plus the full src/
tree, inside the final image — often hundreds of megabytes of things
that provide zero runtime value and increase attack surface.
Multi-stage build¶
# --- Stage 1: build ---
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# --- Stage 2: production ---
FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 4500
USER node
CMD ["node", "dist/index.js"]
The build stage has TypeScript, @types/express, and the full source
— everything needed to run tsc. The production stage starts fresh
from a clean node:20-slim, installs only production dependencies
(npm ci --omit=dev skips typescript and @types/* entirely), and
copies across just the compiled dist/ output via
COPY --from=build. The final image contains no TypeScript compiler,
no .ts source, and no dev tooling — only what node dist/index.js
actually needs.
.dockerignore¶
Excluding node_modules and dist from the build context matters even
though the Dockerfile only COPYs specific paths — without a
.dockerignore, Docker still uploads the entire directory (including a
possibly stale local node_modules or dist) to the build daemon
before the COPY instructions even run, slowing every build and
risking a stale local build accidentally getting baked in via a
misconfigured COPY . elsewhere in the file.
Health check and non-root user¶
HEALTHCHECK --interval=30s --timeout=3s \
CMD node -e "require('http').get('http://localhost:4500/tasks', r => process.exit(r.statusCode === 200 ? 0 : 1))"
USER node (already in the Dockerfile above) switches off root
before the final CMD runs — the official node images ship a
pre-created node user specifically for this. Running a containerized
Node process as root is unnecessary privilege: if the app is
compromised, the attacker inherits whatever the process user can do,
and inside most container runtimes root in the container often maps
close to root capabilities on the host unless user namespaces are
configured.
Environment configuration¶
The app already reads PORT from the environment (Level 3, Module 10)
— this is exactly why: a containerized service should take its
listening port from an env var, since the orchestrator (Docker Compose,
Kubernetes, ECS) decides port mapping, not the app itself.
Traps¶
COPY . . before RUN npm ci invalidates Docker's layer cache on
every source change, forcing a full dependency reinstall even when
package.json didn't change. Copying package*.json first, installing,
then copying source (as the build stage above does) means Docker
reuses the cached npm ci layer whenever only source files changed —
a large real-world build-time difference.
npm install instead of npm ci in a Docker build can silently
produce a different dependency tree than what was tested locally if
package-lock.json and package.json have drifted — npm ci fails
loudly on drift instead, which is what you want to catch before an
image ships.
Forgetting --omit=dev in the production stage's install still
pulls in typescript and @types/* even though the final CMD never
touches them — the whole point of splitting into stages is defeated if
both stages install the same full dependency set.
A HEALTHCHECK hitting a stateful endpoint (like the task list,
which starts empty and changes) can pass even when a route the health
check doesn't exercise is broken. A dedicated /health route that
does nothing but return 200 is more common in real services than
reusing a business endpoint for this purpose.
How It Actually Works¶
Because type erasure means the emitted JS is all a Node runtime ever needs, a well-built Docker image for a TypeScript app should never contain the TypeScript compiler, the .ts source, or devDependencies at all in its final layer — a multi-stage Dockerfile's build stage runs tsc to produce plain .js in a dist/ directory, and the final runtime stage copies only that dist/ output plus production node_modules into a fresh, minimal base image, because at runtime absolutely nothing about the original TypeScript source is needed or referenced; this is the clearest possible illustration that type information has zero existence past compile time — the running container literally cannot tell it was ever TypeScript.
node_modules pruning interacts with the @types/* packages from the third-party-types lesson in a way worth calling out explicitly: @types packages are typically devDependencies (or bundled types under dependencies for packages that self-type), and since they're consumed only by the checker during the build stage, a production image that installs with --omit=dev correctly drops them with no runtime effect — they were never going to be referenced by the running JS regardless.
Source maps (sourceMap: true) are the one artifact worth deliberately choosing whether to ship: a source map is a separate JSON file mapping positions in the emitted .js back to the original .ts source, generated during emit and consumed only by error-reporting/debugging tools (a crash reporter, browser devtools, node --enable-source-maps) — it's not needed for the program to run, but omitting it from a production image trades off debuggability (stack traces point at generated JS positions, not your original source lines) against not shipping your original source layout inside the container.
Cheat sheet¶
| Dockerfile technique | Why it matters for TypeScript specifically |
|---|---|
| Multi-stage build | Compiler and dev deps stay out of the shipped image |
npm ci --omit=dev in the final stage |
Excludes typescript, @types/* from production |
COPY --from=build /app/dist ./dist |
Ships compiled JS, never .ts source |
package*.json copied before source |
Preserves the npm ci layer cache across source-only changes |
.dockerignore with node_modules, dist |
Prevents stale local artifacts leaking into the build context |
USER node |
Runs the container process unprivileged |
Exercise¶
Build the two-stage Dockerfile above locally (docker build -t task-api .),
run it with docker run -p 4500:4500 task-api, and repeat the full
curl CRUD sequence from Level 3, Module 10 against the containerized
server. Then run docker images and compare the image size against a
single-stage build using the "naive" Dockerfile from earlier in this
module, to see the multi-stage size difference for yourself.