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 Dockerfile
s for each of the approaches.
Dockerfile
:
FROM node:18-alpine as builderWORKDIR /usr/appCOPY package*.json ./RUN npm ciCOPY . .FROM builder as compilerRUN npm run buildFROM node:18-alpine as productionWORKDIR /usr/appCOPY /usr/app/package*.json ./COPY /usr/app/build ./RUN npm ci --only=productionFROM gcr.io/distroless/nodejs:18WORKDIR /usr/appCOPY /usr/app ./EXPOSE 8080CMD ["./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 baseWORKDIR /usr/appCOPY package*.json ./RUN npm ciCOPY . .FROM base as productionRUN npm run buildEXPOSE 8080CMD ["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 builderWORKDIR /usr/appCOPY package*.json ./RUN npm ciCOPY . .FROM builder as compilerRUN npm run buildFROM node:18-alpine as productionWORKDIR /usr/appCOPY /usr/app/package*.json ./COPY /usr/app/build ./RUN npm ci --only=productionEXPOSE 8080CMD ["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 builderWORKDIR /usr/appCOPY package*.json ./RUN npm ciCOPY . .FROM builder as compilerRUN npm run buildFROM node:18-alpine as productionWORKDIR /usr/appCOPY /usr/app/package*.json ./COPY /usr/app/build ./RUN npm ci --only=productionFROM gcr.io/distroless/nodejs18-debian11WORKDIR /usr/appCOPY /usr/app ./EXPOSE 8080CMD ["./server.js"]
Caveats
If your application relies on other libraries and binaries you may find distroless or Alpine too limiting.