Building NodeJS Apps with ESBuild and Docker

NodeJS is famous for it’s the ridiculous size of the node_modules, which can easily baloon into a gigabyte of files on disk. In the frontend world it is common to treat your final app as essentially a compiled binary, packaging and compressing the application and it’s depencdencies into a single file.

Heaviest object in the universe!

We can do the same with our NodeJS projects! Compiling them into a single file and putting it in a Docker image.

Packaging our App with ESBuild

I like ESBuild as a simple build tool to package our app, which will be a Fastify application that renders some server side HTML templates with pug.

Our Application

Our application is a simple fastify application with a couple of plugins to render static files and pug views.

import { Server, IncomingMessage, ServerResponse } from "http";
import fastify, { FastifyInstance } from "fastify";
import fastifyStatic from "@fastify/static";
import fastifyView from "@fastify/view";
import * as pug from "pug";
import path from "path";
import fastifyUnderPressure from "@fastify/under-pressure";

const buildServer = (): FastifyInstance => {
  const server = fastify<Server, IncomingMessage, ServerResponse>({
    logger: {
      transport: {
        targets: [
          {
            target: "pino-pretty",
            level: "debug",
            options: {
              colorize: true,
            },
          },
        ],
      },
    },
  });

  server.register(fastifyUnderPressure, {
    maxEventLoopDelay: 10000,
    maxHeapUsedBytes: 1000000000,
    maxRssBytes: 1000000000,
    maxEventLoopUtilization: 0.98,
  });

  server.register(fastifyStatic, {
    root: path.join(__dirname, "static"),
    prefix: "/static/",
  });

  server.register(fastifyView, {
    engine: {
      pug: pug,
    },
    root: path.join(__dirname, "./templates"),
    defaultContext: {
      companyName: "NodeJS + ESBuild + Docker",
    },
  });

  server.get("/healthz", (_req, reply) => {
    reply.send({}).code(200);
  });

  server.get("/", async (_req, reply) => {
    return reply.view("home.pug");
  });

  return server;
};

const start = async () => {
  const server = buildServer();
  try {
    await server.listen({ port: 3000, host: "0.0.0.0" });
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
};
start();

Which comes to 29M in node_modules without the dev depencdencies, with the dev depencdencies we are sitting at 192M.

The Build

Let’s get that down into a single “binay” file.

import esbuild from "esbuild";
import copyStaticFiles from "esbuild-copy-static-files";
import pino from "esbuild-plugin-pino";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import chokidar from "chokidar";

const argv = yargs(hideBin(process.argv))
  .option("watch", {
    alias: "w",
    description: "watch for changes",
    type: "boolean",
    default: false,
  })
  .help()
  .alias("help", "h").argv;

const build = () => {
  console.log("Building...");
  esbuild.build({
    entryPoints: ["./src/index.ts"],
    outdir: `./dist`,
    bundle: true,
    minify: true,
    sourcemap: true,
    platform: "node",
    target: "node20",
    logLevel: "info",
    color: true,
    plugins: [
      copyStaticFiles({
        src: "./templates",
        dest: `./dist/templates`,
        recursive: true,
      }),
      pino({ transports: ["pino-pretty"] }),
    ],
  });
};

build();
if (argv.watch) {
  console.log("Watching...");
  const watcher = chokidar.watch(["src", "templates", "static"], {
    ignored: /(^|[\/\\])\../,
    persistent: true,
  });

  watcher.on("change", () => build(argv.target));
}

Before we get into the results, let’s explain the 2 plugins in use and why?

First even though we are compiling our application down into a single file we are not including the pug templates in the final file. So when the application starts up it is going to look on disk for those templates, we need to include those files in the final dist folder.

Second fastify uses pino for logging, and pino runs in a seperate worker thread allowing for the absolutely fastest logging. But the workers in NodeJS, esbuild-plugin-pino ensures that the required files are included in the file dist folder.

$ node build.mjs

Building...

  dist/index.js                       1.6mb ⚠️
  dist/pino-worker.js               142.7kb
  dist/pino-pretty.js               123.7kb
  dist/pino-file.js                  58.0kb
  dist/thread-stream-worker.js        2.9kb
  dist/pino-pipeline-worker.js        1.3kb
  dist/index.js.map                   5.5mb
  dist/pino-worker.js.map           584.8kb
  dist/pino-pretty.js.map           520.0kb
  dist/pino-file.js.map             225.2kb
  dist/thread-stream-worker.js.map   11.5kb
  dist/pino-pipeline-worker.js.map    5.1kb

⚡ Done in 144ms

Even with sourcemaps we are coming in well under 10M and without sourcemaps we are comfortably under 2M.

Putting it in a Docker Image

Finally let’s put it all together in a Dockerfile

FROM node:20-alpine as base

# install any shared runtime / buildtime deps

FROM base as builder

WORKDIR /app
COPY . .
RUN npm install && npm run build


FROM base

RUN mkdir -p /usr/src
WORKDIR /usr/src
COPY --from=builder /app/dist /usr/src

ENV NODE_ENV production
EXPOSE 3000

CMD [ "node", "./dist/index.js" ]

and there we have it:

martinffx/fastify-esbuild-docker latest 7f26c6089f97 13 seconds ago 190MB

our full application in a 190MB docker image.

You can see the final repo here.

Enjoy the faster deploys x