Typescript production deploys with docker

December 1, 2024

Too frequently I see giant bloated docker images for very simple services. This is typically because someone just used the base image and didn’t even bother with the simplest possible optimizations like a multi-stage build.

I’ve evaluated several approaches and without getting too fancy, this is typical 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.

FROM node:22-alpine AS builder

WORKDIR /usr/app

COPY package*.json ./
RUN npm ci

COPY . .

FROM builder AS compiler

RUN npm run build

FROM node:22-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/nodejs22-debian12
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:22 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.21GB 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:22-alpine AS base

Building this results in a 246MB 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:22-alpine AS builder

WORKDIR /usr/app

COPY package*.json ./
RUN npm ci

COPY . .

FROM builder AS compiler

RUN npm run build

FROM node:22-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 164MB 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 (158MB vs. 164MB) 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:22-alpine AS builder

WORKDIR /usr/app

COPY package*.json ./
RUN npm ci

COPY . .

FROM builder AS compiler

RUN npm run build

FROM node:22-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/nodejs22-debian12
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.