*Without considering download of base Node.js image
TLDR
- 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
dockerFROM node:16-alpine WORKDIR /usr/src/app COPY dist ./dist ENV NODE_ENV production CMD ["node", "dist/index.js"]
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 esbuildtypescriptimport { 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 followingjson{ ... "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
docker# 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"]
typescript// 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 externaldocker# 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
docker# 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.
typescriptimport { 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!