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.
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