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 Dockerfile
s 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.