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