Creating Node.js Docker images that build in 2 seconds*

profile photo
*Without considering download of base Node.js image


  • Install dependencies outside of Docker
  • Bundle your code in one file
  • Copy your bundled code together with eventually needed artifacts inside Docker
I see a lot of Nodejs Docker images are built installing all dependencies inside docker and then bilding the application too in case user files need transpilation.
This can be easily skipped by simply building artifact files outside Docker and copying the bundled code (no node_modules needed) inside the image.

Example Dockerfile

FROM node:16-alpine WORKDIR /usr/src/app COPY dist ./dist ENV NODE_ENV production CMD ["node", "dist/index.js"]
The dist/index.js is the application code bundled in one file

Bundling to a single file

This process relies on a bundler that compiles all your dependencies and src files in a single output, this can be easily done with a build.js script and esbuild
import { build } from 'esbuild' async function main() { await build({ entryPoints: ['src/index.ts'], bundle: true, platform: 'node', target: 'node12', outfile: 'dist/index.js', }) } main()
You can add a script in your package.json with the following
{ ... "scripts": { "docker": "node scripts/build.js && docker build ." } }

Handling native dependencies

If you depend on a native dependency and want to use this method you can externalize the native package and install it separately in the Dockerfile
For example, here is how to build a Dockerfile that depends on node-canvas
# Dockerfile FROM node:14-slim WORKDIR /usr/src/app RUN npm init -y && npm install canvas@2.9.1 COPY dist ./dist ENV NODE_ENV production CMD ["node", "dist/index.js"]
// build.ts import { build } from 'esbuild' async function main() { await build({ // ... external: ['canvas'], }) } main()
Note that here i am using the slim base image, this is because alpine uses musl instead of glibc which means that it’s more difficult to find prebuilt binaries for this architecure.
This is also possible with complex native dependency setup like prisma , you will only need to put @prisma/client as external
# Dockerfile FROM node:14-slim RUN apt update && apt install openssl ca-certificates -y RUN npm i -g prisma@3.4.2 RUN npm init -y && npm install @prisma/client@3.4.2 COPY ./dist ./dist COPY ./schema.prisma ./schema.prisma RUN prisma generate --schema schema.prisma ENV NODE_ENV production CMD ["node", "dist/index.js"]

But my native node_modules are still copied inside the Docker image and override the docker ones, how to fix?

You can simply add the native dependencies names in the .dockerignore file, this will prevent the native addons from being copied to docker and will force nodejs to use the ones installed by Docker.
For example if you are building an image that uses `sharp` you can add the following to the .dockerignore
# skip native package **/node_modules/sharp/

Skipping unnecessary native dependencies

In case you are using a dependecy that has a native addon that is not essential you can tell esbuild to externalize it.
The dependency will then decide to try loading a wasm version (which works on all platforms) or just skipping the native part.
However, some packages require a native dependency and will fail at runtime, in that case you can’t use this approash to biuld your Docker images.
import { build } from 'esbuild' async function main() { await build({ entryPoints: ['src/index.ts'], bundle: true, platform: 'node', plugins: [SkipNativeDeps()], target: 'node12', outfile: 'dist/index.js', }) } main() function SkipNativeDeps() { return { name: 'Skip binaries', setup({ onResolve }) { const path = require('path') onResolve({ filter: /\.node$/ }, (args) => { console.log( `Ignoring binary ${path .resolve(args.resolveDir, args.path) .replace(/.*node_modules(\/|\\)/, '')}`, ) return { external: true } }) }, } }
Blog post sponsored by Notaku, the easiest way to create docs/blog/changelog websites from Notion
Subscribe to my newsletter if you want to read more!

Powered by Notaku