Deploying NodeJS with pnpm and tsx

In the previous post we packaged our NodeJS application as a single “binary” by bundling everything into single file that can deployed without the need to for the node_modules. Unfortunately this is a bit of an esoteric way to package a NodeJS application, with many packages relying on the node_modules to be included on disk. Meaning you either can’t package your application this way when using packages that dynamically read files from the node_modules folder or you have to copy the required files over to the dist package, which ends up being a bit of a hack.

A simpler approach with pnpm and tsx

So let’s try a more “traditional” approach where we don’t package the application and just copy the all the files over, with the node_modules and not compiling the typescript.

The secret behind Thor’s Hammer

In order to keep things lean and mean we going to be leveraging 2 packages:

pnpm

pnpm is a fast, disk space efficient package manager. Which is quickly becoming the default package manager in the NodeJS community. It achieves this by:

Single package store: PNPM uses a single package store where it installs packages only once.

Symbolic links: PNPM utilizes symbolic links to share package installations across projects.

Parallel installation: PNPM performs package installations in parallel, leveraging the capabilities of modern multi-core processors.

Selective dependency installation: PNPM installs only the dependencies required by a specific project, ignoring redundant or unused dependencies.

Efficient disk caching: PNPM employs an optimized disk caching mechanism that stores packages and their dependencies.

pnpm Flash

tsx

tsx is NodeJS enhanced to run Typescript and ESM files, powered by ESbuild to keep everthing snappy. It supports:

  • Blazing fast on-demand TypeScript & ESM compilation
  • Works in both CommonJS and ESM packages
  • Supports next-gen TypeScript extensions (.cts & .mts)
  • Hides experimental feature warnings
  • TypeScript REPL
  • Resolves tsconfig.json paths

With these two tools we can stick to building our NodeJS app as if we were using npm and JavaScript, with the same simplicity of using those tools. But can still get the advantages that come with typescript and maintain slim build for faster deploys.

Our Application

Our application is a simple Fastify API, that gets packaged and shipped as a Docker container.

src/index.ts

import { Server, IncomingMessage, ServerResponse } from 'http'
import fastify, { FastifyInstance } from 'fastify'
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.get('/health', (_req, reply) => {
    reply.send({}).code(200)
  })

  server.get('/', async (_req, reply) => {
    return { message: `Hello, World!` }
  })

  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()

package.json

{
  "name": "fastify-tsx-docker",
  "version": "1.0.0",
  "scripts": {
    "start": "tsx ./src/index.ts",
    "dev": "tsx watch ./src/index.ts",
    "test": "NODE_ENV=test jest --verbose --watch",
    "test:ci": "NODE_ENV=test jest --ci --coverage --coverageReporters=lcov",
    "format": "prettier --ignore-path .eslintignore --write \"./src/**/*.+(js|ts|tsx|json)\"",
    "lint": "eslint --ignore-path .eslintignore --ext .js,.ts ./src/**",
    "types": "tsc --noEmit"
  },
  "dependencies": {
    "@fastify/under-pressure": "^8.2.0",
    "dotenv": "^16.0.3",
    "fastify": "^4.11.0",
    "pino-pretty": "^9.1.1",
    "tsx": "^3.14.0"
  },
  "devDependencies": {
    "@jest/globals": "^29.5.0",
    "@types/jest": "^29.5.0",
    "@types/node": "^18.11.18",
    "@typescript-eslint/eslint-plugin": "^5.0.0",
    "eslint": "^8.0.1",
    "eslint-config-prettier": "^8.6.0",
    "eslint-config-standard-with-typescript": "^31.0.0",
    "eslint-plugin-import": "^2.25.2",
    "eslint-plugin-n": "^15.0.0",
    "eslint-plugin-promise": "^6.0.0",
    "jest": "^29.5.0",
    "prettier": "2.8.3",
    "ts-jest": "^29.1.0",
    "typescript": "<5.2.0"
  }
}

Dockerfile

FROM node:20-alpine

ENV PNPM_HOME "/pnpm"
ENV PATH "$PNPM_HOME:$PATH"
RUN corepack enable

COPY . /app
WORKDIR /app

RUN pnpm install --prod --frozen-lockfile

ENV NODE_ENV production
EXPOSE 3000

CMD ["pnpm","start"]

As you can see everything becomes a lot simpler, we don’t need any fancy build step to run our application. To deploy it we simply need to copy our files across, install our dependencies and run our application.

So what does our final image size look like?

martinffx/fastify-tsx-docker latest 1cddfd98daa7 6 days ago 221MB

A lean and mean 221MB which should see us comfortable stay under 500MB no matter how big and complex our application get.

As always you can find my demo repo on my Github