Use multi-stage builds

One Paragraph Explainer

Multi-stage builds allow to separate build- and runtime-specific environment details, such as available binaries, exposed environment variables, and even the underlying operating system. Splitting up your Dockerfiles into multiple stages will help to reduce final image and container size as you’ll only ship what you really need to run your application. Sometimes you’ll need to include tools that are only needed during the build phase, for example development dependencies such as the TypeScript CLI. You can install it during the build stage and only use the final output in the run stage. This also means your image will shrink as some dependencies won’t get copied over. You might also have to expose environment variables during build that should not be present at runtime (see avoid build time secrets), such as API Keys and secrets used for communicating with specific services. In the final stage, you can copy in pre-built resources such as your build folder, or production-only dependencies (which you can also fetch in a subsequent step).

Example

Let’s imagine the following directory structure

  1. - Dockerfile
  2. - src/
  3. - index.ts
  4. - package.json
  5. - yarn.lock
  6. - .dockerignore
  7. - docs/
  8. - README.md

Your .dockerignore will already filter out files that won’t be needed for building and running your application.

  1. # Don't copy in existing node_modules, we'll fetch our own
  2. node_modules
  3. # Docs are large, we don't need them in our Docker image
  4. docs

Dockerfile with multiple stages

Since Docker is often used in continuous integration environments it is recommended to use the npm ci command (instead of npm install). It is faster, stricter and reduces inconsistencies by using only the versions specified in the package-lock.json file. See here for more info. This example uses yarn as package manager for which the equivalent to npm ci is the yarn install --frozen-lockfile command.

  1. FROM node:14.4.0 AS build
  2. COPY --chown=node:node . .
  3. RUN yarn install --frozen-lockfile && yarn build
  4. FROM node:14.4.0
  5. USER node
  6. EXPOSE 8080
  7. # Copy results from previous stage
  8. COPY --chown=node:node --from=build /home/node/app/dist /home/node/app/package.json /home/node/app/yarn.lock ./
  9. RUN yarn install --frozen-lockfile --production
  10. CMD [ "node", "dist/app.js" ]

Dockerfile with multiple stages and different base images

  1. FROM node:14.4.0 AS build
  2. COPY --chown=node:node . .
  3. RUN yarn install --frozen-lockfile && yarn build
  4. # This will use a minimal base image for the runtime
  5. FROM node:14.4.0-alpine
  6. USER node
  7. EXPOSE 8080
  8. # Copy results from previous stage
  9. COPY --chown=node:node --from=build /home/node/app/dist /home/node/app/package.json /home/node/app/yarn.lock ./
  10. RUN yarn install --frozen-lockfile --production
  11. CMD [ "node", "dist/app.js" ]

Full Dockerfile with multiple stages and different base images

Our Dockerfile will contain two phases: One for building the application using the fully-featured Node.js Docker image, and a second phase for running the application, based on the minimal Alpine image. We’ll only copy over the built files to our second stage, and then install production dependencies.

  1. # Start with fully-featured Node.js base image
  2. FROM node:14.4.0 AS build
  3. USER node
  4. WORKDIR /home/node/app
  5. # Copy dependency information and install all dependencies
  6. COPY --chown=node:node package.json yarn.lock ./
  7. RUN yarn install --frozen-lockfile
  8. # Copy source code (and all other relevant files)
  9. COPY --chown=node:node src ./src
  10. # Build code
  11. RUN yarn build
  12. # Run-time stage
  13. FROM node:14.4.0-alpine
  14. # Set non-root user and expose port 8080
  15. USER node
  16. EXPOSE 8080
  17. WORKDIR /home/node/app
  18. # Copy dependency information and install production-only dependencies
  19. COPY --chown=node:node package.json yarn.lock ./
  20. RUN yarn install --frozen-lockfile --production
  21. # Copy results from previous stage
  22. COPY --chown=node:node --from=build /home/node/app/dist ./dist
  23. CMD [ "node", "dist/app.js" ]