Typescript production deploys with docker

Here's my preferred production Dockerfile. It uses a multi-stage build to ensure layers are cached for package dependencies, source code, and build artifacts. Alpine Linux is used for the build stages and distroless for the final production image for improved security. For more details on how I arrived at this and a comparison of other approaches see below.

See the (Github repo) repo for the example service and Dockerfiles for each of the approaches.

Dockerfile:

FROM node:18-alpine as builder
WORKDIR /usr/app
COPY package*.json ./
RUN npm ci
COPY . .
FROM builder as compiler
RUN npm run build
FROM node:18-alpine as production
WORKDIR /usr/app
COPY --from=compiler /usr/app/package*.json ./
COPY --from=compiler /usr/app/build ./
RUN npm ci --only=production
FROM gcr.io/distroless/nodejs:18
WORKDIR /usr/app
COPY --from=production /usr/app ./
EXPOSE 8080
CMD ["./server.js"]

Progression

The simple approach is to use the nodejs base image. The npm package install is separated into a separate stage so it's cached as long as our dependencies don't change. This enables much faster builds that only re-build when our source code changes.

FROM node:18 as base
WORKDIR /usr/app
COPY package*.json ./
RUN npm ci
COPY . .
FROM base as production
RUN npm run build
EXPOSE 8080
CMD ["node", "./build/server.js"]

Let's build it:

$ docker build -t test-express-api .

It weighs in at 1.03GB which is pretty hefty for such a tiny API. The first optimization to try is using Alpine linux which is a much lighter weight distribution:

FROM node:18-alpine as base

Building this results in a 261MB image which is much better! But we can do even better. This image still has our source code in it along with the dev dependencies we needed to build it. In a large project that can add up quickly. Let’s add a new stage that uses a fresh node Alpine image and only copies over the build artifacts and production dependencies:

FROM node:18-alpine as builder
WORKDIR /usr/app
COPY package*.json ./
RUN npm ci
COPY . .
FROM builder as compiler
RUN npm run build
FROM node:18-alpine as production
WORKDIR /usr/app
COPY --from=compiler /usr/app/package*.json ./
COPY --from=compiler /usr/app/build ./
RUN npm ci --only=production
EXPOSE 8080
CMD ["node", "./server.js"]

This results in a 178MB image which is another nice reduction in size and this image has only what we need to run the application in production.

One final optimization we can make is to use distroless for our production image. This results in a small size reduction (163MB vs. 178MB) but provides security gains since distroless does not contain anything beyond what is needed to run the base (no package managers, shells, etc.).

Here's the resulting Dockerfile using distroless for the production stage:

FROM node:18-alpine as builder
WORKDIR /usr/app
COPY package*.json ./
RUN npm ci
COPY . .
FROM builder as compiler
RUN npm run build
FROM node:18-alpine as production
WORKDIR /usr/app
COPY --from=compiler /usr/app/package*.json ./
COPY --from=compiler /usr/app/build ./
RUN npm ci --only=production
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /usr/app
COPY --from=production /usr/app ./
EXPOSE 8080
CMD ["./server.js"]

Caveats

If your application relies on other libraries and binaries you may find distroless or Alpine too limiting.