I’m a Senior Software Engineer living in Berlin. Shifting limits based on quality and robustness. Cutting-edge software development. Defining durable and flexible interfaces. Creating rich and intuitive user experiences.

Cloud-Native Enterprise Node.js — Part 1

(Part 1 of a new article series)

This post is the first entry in a series of articles that will walk you through building, testing, and deploying a truly cloud‑native Node.js application. The base of the series is the Cloud‑native Enterprise Node.js – Revamped project, which is a TypeScript‑first, ESM‑only, Fastify‑based micro‑service skeleton that already ships with a modern tool chain (tsdown, c8, direnv, etc.).

In this first part we’ll:

  1. Explain the motivation for the project and its “revamped” features.
  2. Walk through the project setup – from Git init to the first curl.
  3. Dive into the 12‑Factor App principles that shape the architecture.
  4. Show you the build & test workflow using tsdown and c8.
  5. Give a quick overview of the folder layout and the next steps you’ll encounter.

1. Why a Revamped Starter?

The original “cloud‑native‑enterprise‑nodejs” repo was a great introduction to building modern Node.js services, but it left out some of the latest best‑practice tools. The revamped version brings:

FeatureOldNew
LanguageJavaScript (CommonJS)TypeScript (ESM)
Bundleresbuildtsdown (powered by rolldown & oxc)
Coveragenycc8 (V8 native)
Loggingconsole.logpino (optionally pino‑pretty)
Environment.env onlydirenv + .envrc + dotenvx
Testingjesttsx + built‑in test runner
Buildnpm scripts onlyMakefile (future)
DeploymentManualDocker + k3s (planned)

The idea is to give you a single, opinionated skeleton that’s ready for production – just add your business logic, pull in your database, and you’re good to ship.

2. Quick Start – From Zero to “Hello”

Below is a shortened version of the README’s bootstrap sequence, but with an explanation of why each step matters.

2.1 Initialise a repo

git init

Keeps your history version‑controlled. The project will be published on GitHub, so we’ll commit from the start.

2.2 Pick a Node version

echo "nodejs 24.8.0" >> .tool-versions

asdf (or nvm) will read this file and automatically install the exact Node release that the project expects.

2.3 Create the package

npm init \
--init-author-name "Marco Lehmann" \
--init-author-url "https://m99.io" \
--init-license "MIT" \
--init-version "1.0.0" \
--yes

Fast‑track the package.json with sensible defaults.

2.4 Force ESM

npm pkg set type=module

Modern Node.js apps are increasingly ESM‑only; it eliminates the CommonJS/ESM interop headache.

2.5 Add direnv support

brew install direnv
touch .envrc
echo "dotenv" >> .envrc

direnv loads environment variables automatically when you cd into the project. The dotenv hook lets us use the classic .env file syntax as well.

2.6 Install Fastify & friends

npm install --save \
fastify@5.5.0 \
fastify-type-provider-zod@6.0.0 \
zod@4.1.8
npm install --save-dev \
typescript@5.9.2 \
@types/node@24.3.0
echo "node_modules" >> .gitignore

fastify is the fastest HTTP framework in Node.js. fastify-type-provider-zod gives us schema‑driven request validation with Zod. TypeScript 5.9 is the newest LTS, and the @types/node definitions keep the compiler happy.

2.7 Build & Run

Add a handful of npm scripts that use tsdown to bundle, c8 for coverage, and tsx for tests.

# Install the build‑time tools
npm install --save-dev \
tsdown@0.15.0 \
concurrently@9.2.1 \
c8@10.1.3 \
tsx@3.14.0 \
@dotenvx/dotenvx@1.49.0
# Add scripts (quick copy‑paste)
npm pkg set scripts.clean="rm -rf ./dist"
npm pkg set scripts.build="tsdown --sourcemap --minify"
npm pkg set scripts.build:watch="tsdown --sourcemap --minify --watch ./src"
npm pkg set scripts.start:watch="node --watch-path=./dist ./dist/server.js"
npm pkg set scripts.start:debug="node --inspect ./dist/server.js"
npm pkg set scripts.start="node dist/server.js"
npm pkg set scripts.dev="npm run build && concurrently -r \"npm run build:watch\" \"npm run start:watch\""
npm pkg set scripts.test="NODE_V8_COVERAGE=./coverage c8 -r html npx tsx --test --experimental-test-coverage src/**/*.test.ts"
npm pkg set scripts.test:watch="npx tsx --watch --test src/**/*.test.ts"
echo "dist" >> .gitignore
echo "coverage" >> .gitignore

Why tsdown?

tsdown is a thin wrapper around rolldown (the fast bundler) and oxc (for .d.ts), giving you an ESM‑only build with source maps and minification, all without Webpack’s complexity.

Why c8?

c8 leverages V8’s native coverage tooling, producing accurate .json and HTML reports.

Why concurrently?

Run the build watcher and the dev server in parallel; fast feedback loop.

2.8 Bootstrap the Fastify server

Create src/server.ts with a minimal health‑check and a couple of demo routes:

import Fastify from "fastify";
import { TypeSchema } from "fastify-type-provider-zod";
import { z } from "zod";
const app = Fastify();
app.get("/health", async () => ({ status: "ok" }));
app.get(
"/headers",
{
schema: TypeSchema(z.object({})),
},
async (request, reply) => {
return { headers: request.headers };
}
);
app.get(
"/codes",
{
schema: TypeSchema(
z.object({ code: z.coerce.number().int().min(200).max(599) })
),
},
async (request, reply) => {
reply.code(request.query.code);
return { status: "ok" };
}
);
const start = async () => {
try {
await app.listen({ port: 3000, host: "0.0.0.0" });
console.log("🚀 Server listening on http://localhost:3000");
} catch (err) {
console.error(err);
process.exit(1);
}
};
start();

Run the dev server:

npm run dev

2.9 Test the endpoints

curl -i "http://localhost:3000/health"
curl -i "http://localhost:3000/headers" \
-H "Authorization: Bearer my-secret-token-that-will-not-get-logged" \
-H "X-Will-Get-Logged: This header will still get logged"
curl -i "http://localhost:3000/codes?code=304"

You should see JSON responses and appropriate HTTP status codes.

3. The 12‑Factor App in Action

FactorHow this repo implements it
ConfigurationUses .envrc + dotenvx – env vars are injected automatically.
Backing ServicesAny service (DB, cache, message broker) is reached via env vars; no hard‑coded URLs.
Build / Release / Runnpm run build produces a production‑ready bundle; npm run start is the run stage.
ProcessesFastify workers are stateless; persistence goes to external services.
Port Bindingapp.listen({ port: 3000 }) – binds to an external port.
Disposabilitynpm run start can be started/stopped at will; graceful shutdown via SIGTERM.
Dev/Prod ParitySame build scripts, same TypeScript config – just different env vars.
LogsCurrently console.log; next step will switch to pino with optional pretty printing.

The next part of this series will demonstrate a real Kubernetes deployment, Grafana observability stack, and the NestJS + Fastify bonus.

4. Project Structure (Sketch)

.
├─ src/
│ ├─ server.ts # Fastify entry point
│ ├─ routes/
│ ├─ services/
│ ├─ config/
│ └─ …
├─ tests/
│ └─ server.test.ts
├─ Dockerfile
├─ .env
├─ .envrc
├─ .gitignore
├─ Makefile # to be added
└─ README.md

Potential TODO list (for the next article):

  • Reorganise the directory structure.
  • Add pino for structured logging, with a pino-pretty option for dev.
  • Write a Makefile that wraps npm run clean, npm run build, npm run start, npm run test, npm run docker-build, npm run docker-run, etc.
  • Build a multi‑stage Docker image (builder → production).
  • Deploy to a local k3s cluster and attach the Grafana + Loki + Tempo stack.
  • Maybe show how NestJS can be dropped in as a Fastify plugin.

5. Wrap‑Up

You’re now standing on a solid foundation.

  • The project compiles TypeScript to a fast ESM bundle.
  • It uses modern coverage tools.
  • It follows 12‑factor principles.
  • It is ready for continuous deployment (CI/CD) once you add the missing parts.

Happy coding! 🚀