Core API in Node 22 + TypeScript (ESM) with hexagonal architecture. Dev uses tsx (watch); build uses pkgroll (ESM bundle). Pretty logs in dev, structured JSON in prod with pino.
Goal: deliver a small hello world core wired to the existing Traefik infra.
FROM LOCALHOST TO PRODUCTION β BUILT LIKE A HACKER
- Node β₯ 22.20.0 (LTS) & npm β₯ 11
- Optional local hosts entries (cookie/CORS isolation):
127.0.0.1 traefik.localhost app.localhost api.localhost
mkdir fullstack-backend-core && cd fullstack-backend-core
npm init -y{
"name": "@repositories/core",
"version": "0.1.0",
"description": "fullstack-backend-core",
"type": "module",
"main": "dist/index.js",
"exports": "./dist/index.mjs",
"engines": { "node": ">=22.20.0" },
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "pkgroll",
"start": "node dist/index.mjs",
"test": "echo \"No test suite defined yet.\" && exit 0"
},
"keywords": ["leonobitech", "microservice", "core", "typescript", "express"],
"author": "Leonobitech",
"license": "MIT"
}npm i express cors cookie-parser zod dotenv pino pino-http
npm i -D typescript tsx pkgroll pino-pretty @types/node @types/express @types/cors @types/cookie-parserYourΒ package.jsonΒ will have something like:
{
"dependencies": {
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.x",
"express": "^5.1.0",
"pino": "^10.x",
"pino-http": "^11.x",
"zod": "^3.x"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.x",
"@types/cors": "^2.8.x",
"@types/express": "^5.0.x",
"@types/node": "^22.x",
"pino-pretty": "^13.x",
"pkgroll": "^2.x",
"tsx": "^4.x",
"typescript": "^5.x"
}
}Why this setup?
- Native ESMΒ (
type: "module"Β + TSΒmodule: "ESNext") for modern imports. tsxΒ for fast dev (TypeScript on the fly + watch).pkgrollΒ to bundle to ESM inΒdist/Β so Node runs cleanly.@Β aliasesΒ via TSΒpathsΒ (no custom loaders needed in dev; bundle resolves them for prod).
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"rootDir": "./src",
"outDir": "./dist",
"baseUrl": "./src",
"paths": {
"@app/*": ["app/*"],
"@domain/*": ["domain/*"],
"@infra/*": ["infra/*"],
"@config/*": ["config/*"],
"@middlewares/*": ["middlewares/*"],
"@shared/*": ["shared/*"],
"@utils/*": ["utils/*"],
"@types/*": ["types/*"]
},
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
/* Tipos globales (opcional pero recomendado) */
"typeRoots": ["./node_modules/@types", "./types"]
},
"include": ["src", "types"],
"exclude": ["node_modules", "dist"]
}Notes
baseUrlΒ +ΒpathsΒ enableΒ@Β imports.typeRootsΒ +ΒincludeΒ makeΒ/typesΒ visible for ambient/globalΒ.d.ts.
- Native ESM:Β
type: "module"Β + TSΒmodule: "ESNext". - DX:Β
tsx watchΒ in dev. - Solid build:Β
pkgrollΒ bundles ESM. @Β aliasesΒ viaΒtsconfig.json.- Hexagonal: domain decoupled from infra.
- Logging: pino + pino-http (pretty in dev, JSON in prod).
- CORSΒ viaΒ
.envΒ with local domains separation.
fullstack-backend-core/
ββ src/
β ββ app/
β β ββ use-cases/
β ββ domain/
β β ββ entities/
β β ββ ports/
β ββ infra/
β β ββ http/
β β ββ server.ts
β ββ config/
β β ββ env.ts
β ββ shared/
β β ββ logger.ts
β ββ index.ts
ββ types/
ββ global.d.ts
Create everything fast (structure, base files, .env.example).
# Create structure + base files + .env.example
mkdir -p \
src/app/use-cases \
src/domain/entities \
src/domain/ports \
src/infra/http \
src/config \
src/shared \
types
# .env.example
cat > .env.example <<'ENV'
# βββββββββββββββββββββββββββββββββββββββββββββ
# Local domains (separate cookies/CORS)
# βββββββββββββββββββββββββββββββββββββββββββββ
TRAEFIK_DOMAIN=traefik.localhost
FRONTEND_DOMAIN=app.localhost
BACKEND_DOMAIN=api.localhost
# βββββββββββββββββββββββββββββββββββββββββββββ
# Runtime
# βββββββββββββββββββββββββββββββββββββββββββββ
# development | test | production
NODE_ENV=development
PORT=8000
LOG_LEVEL=debug # fatal|error|warn|info|debug|trace|silent
# βββββββββββββββββββββββββββββββββββββββββββββ
# CORS
# βββββββββββββββββββββββββββββββββββββββββββββ
# Option A (recommended): leave empty to allow http://app.localhost:3000
# CORS_ORIGIN=
# Option B: comma-separated list
# CORS_ORIGIN=http://app.localhost:3000,http://another.local
# βββββββββββββββββββββββββββββββββββββββββββββ
# Service identity (logs/monitoring)
# βββββββββββββββββββββββββββββββββββββββββββββ
SERVICE_NAME=core
ENV
# src/config/env.ts
cat > src/config/env.ts <<'TS'
import "dotenv/config";
import { z } from "zod";
const Env = z.object({
NODE_ENV: z.enum(["development","test","production"]).default("development"),
PORT: z.coerce.number().default(8000),
LOG_LEVEL: z.enum(["fatal","error","warn","info","debug","trace","silent"]).default("debug"),
SERVICE_NAME: z.string().default("core"),
TRAEFIK_DOMAIN: z.string(),
FRONTEND_DOMAIN: z.string(),
BACKEND_DOMAIN: z.string(),
CORS_ORIGIN: z.string().optional()
});
export const env = Env.parse(process.env);
export function getCorsOrigin(): true | string[] {
if (env.CORS_ORIGIN?.trim()) {
return env.CORS_ORIGIN.split(",").map(s => s.trim()).filter(Boolean);
}
return [`http://${env.FRONTEND_DOMAIN}:3000`];
}
TS
# src/shared/logger.ts
cat > src/shared/logger.ts <<'TS'
import pino from "pino";
import { env } from "@config/env";
const isProd = env.NODE_ENV === "production";
export const logger = pino({
name: env.SERVICE_NAME,
level: env.LOG_LEVEL,
timestamp: pino.stdTimeFunctions.isoTime,
base: isProd ? { service: env.SERVICE_NAME } : null,
transport: isProd ? undefined : {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
singleLine: false,
ignore: "pid,hostname"
}
}
});
export default logger;
TS
# src/infra/http/server.ts
cat > src/infra/http/server.ts <<'TS'
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import pinoHttp from "pino-http";
import { env, getCorsOrigin } from "@config/env";
import logger from "@shared/logger";
const IGNORE = new Set<string>([
"/favicon.ico",
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png"
]);
export function buildServer() {
const app = express();
app.disable("x-powered-by");
app.use(cors({ origin: getCorsOrigin(), credentials: true }));
app.use(cookieParser());
app.use(express.json());
app.use(pinoHttp({
logger,
redact: { paths: ["req.headers.cookie"], censor: "[redacted]" },
autoLogging: { ignore: (req) => IGNORE.has(req.url ?? req.originalUrl ?? "") },
serializers: {
req: (req) => ({ method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode })
}
}));
app.get("/health", (_req, res) => res.json({ ok: true, service: env.SERVICE_NAME }));
return app;
}
export async function start() {
const app = buildServer();
app.listen(env.PORT, () =>
logger.info({ port: env.PORT, host: env.BACKEND_DOMAIN }, "HTTP server up")
);
}
TS
# src/index.ts
cat > src/index.ts <<'TS'
import { start } from "@infra/http/server";
start();
TS
# types/global.d.ts
cat > types/global.d.ts <<'TS'
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV?: "development" | "test" | "production";
PORT?: string;
LOG_LEVEL?: "fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent";
SERVICE_NAME?: string;
TRAEFIK_DOMAIN?: string;
FRONTEND_DOMAIN?: string;
BACKEND_DOMAIN?: string;
CORS_ORIGIN?: string;
}
}
TS
echo "β
Structure created. Copy .env.example to .env and adjust values."Copy
.env.exampleto.envand adjust values.
# Development (watch + pretty logs)
npm run dev
# Build (ESM bundle)
npm run build
# Production (JSON logs)
npm start
curl -i http://api.localhost:8000/health
- ESM + Node 22Β for modern imports without extra runtime layers.
@Β aliasesΒ improve readability and keep domain/infra separate; the bundle resolves them.tsxΒ for fast dev;ΒpkgrollΒ for clean ESM output.- Hexagonal:Β
domainΒ (rules/ports) isolated fromΒinfraΒ (Express, DB adapters, etc.).
- Dev:Β
pino-prettyΒ with readable timestamps; noΒpid/hostnameΒ noise. - Prod: structured JSON (log aggregation friendly).
- pino-http: request logging with cookies redacted and noisy icon routes ignored.
GET /healthΒ βΒ{ ok: true, service: "core" }
AddΒ /readyΒ (readiness) andΒ notFound/errorHandlerΒ when you start adding use cases.
@aliasesΒ not resolved in dev β checkΒbaseUrl/pathsΒ and restart TS server in your editor.- No logs forΒ
/healthΒ β maybe itβs ignored inΒautoLogging; remove it from the ignore list. - Seeing cookies from another app β useΒ
app.localhostΒ vsΒapi.localhostΒ domains (already supported) and redaction (enabled). - CORS blocked β setΒ
CORS_ORIGINΒ or adjustΒFRONTEND_DOMAIN.
- Harden HTTP + cleaner logs (quick win):
notFoundΒ +ΒerrorHandler- request id in every log
/readyΒ endpoint- hide Express header
Install (nothing new):Β you already have what we need.
UpdateΒ src/infra/http/server.ts:
// add near the other imports
import { randomUUID } from "node:crypto";
// ...inside buildServer(), after app.use(express.json())
app.disable("x-powered-by");
// request id + logging (keeps your previous options)
app.use(
pinoHttp({
logger,
genReqId: () => randomUUID(),
redact: { paths: ["req.headers.cookie"], censor: "[redacted]" },
autoLogging: {
ignore: (req) =>
new Set([
"/favicon.ico",
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
]).has(req.url ?? req.originalUrl ?? ""),
},
serializers: {
req: (req) => ({ id: (req as any).id, method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
})
);
// health (already)
app.get("/health", (_req, res) =>
res.json({ ok: true, service: env.SERVICE_NAME })
);
// readiness (expand later to check DB/cache/etc)
app.get("/ready", (_req, res) => res.json({ ready: true }));
// 404
app.use((_req, res) => res.status(404).json({ error: "Not Found" }));
// error handler
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use(
(
err: unknown,
_req: express.Request,
res: express.Response,
_next: express.NextFunction
) => {
logger.error({ err }, "Unhandled error");
res.status(500).json({ error: "Internal Error" });
}
);import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import pinoHttp from "pino-http";
import { env, getCorsOrigin } from "@config/env";
import logger from "@shared/logger";
const IGNORE = new Set<string>([
"/favicon.ico",
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
]);
export function buildServer() {
const app = express();
app.disable("x-powered-by");
app.use(cors({ origin: getCorsOrigin(), credentials: true }));
app.use(cookieParser());
app.use(express.json());
app.use(
pinoHttp({
logger,
redact: { paths: ["req.headers.cookie"], censor: "[redacted]" },
autoLogging: {
ignore: (req) => IGNORE.has(req.url ?? req.originalUrl ?? ""),
},
serializers: {
req: (req) => ({ method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
})
);
// Health & Readiness
app.get("/health", (_req, res) =>
res.json({ ok: true, service: env.SERVICE_NAME })
);
app.get("/ready", (_req, res) => res.json({ ready: true }));
// 404 (after routes)
app.use((_req, res) => res.status(404).json({ error: "Not Found" }));
// Error handler (last)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
app.use(
(
err: unknown,
_req: express.Request,
res: express.Response,
_next: express.NextFunction
) => {
logger.error({ err }, "Unhandled error");
res.status(500).json({ error: "Internal Error" });
}
);
return app;
}
export async function start() {
const app = buildServer();
app.listen(env.PORT, () =>
logger.info({ port: env.PORT, host: env.BACKEND_DOMAIN }, "HTTP server up")
);
}npm run dev # watch + pretty logs
# in another shell
curl -i http://localhost:8000/health
curl -i http://localhost:8000/ready
curl -i http://localhost:8000/not-exists# Build stage
# π§ Build stage
FROM node:22-slim AS builder
WORKDIR /app
# 1) Install deps with best cache usage
COPY package*.json ./
RUN npm ci
# 2) Copy only what we need to build
COPY tsconfig.json ./
COPY src ./src
COPY types ./types
# 3) Build to ESM bundle (dist/)
RUN npm run build
# 4) Prune dev deps for runtime
RUN npm prune --omit=dev
# π‘οΈ Runtime stage
FROM node:22-slim AS production
WORKDIR /app
# (optional) Install curl for container healthchecks
RUN apt-get update -y && apt-get install -y --no-install-recommends curl \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# 5) Copy runtime artifacts
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
ENV PORT=8000
# Useful for stack traces if you later enable source maps
ENV NODE_OPTIONS=--enable-source-maps
EXPOSE 8000
# (optional) basic container-level healthcheck (works because we installed curl)
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD curl -fsS http://localhost:${PORT}/health || exit 1
CMD ["node", "dist/index.mjs"]# βββββββββββββββββββββββββββββ
# Ignorar archivos sensibles
# βββββββββββββββββββββββββββββ
.env
.env.*
*.pem
*.key
*.crt
# βββββββββββββββββββββββββββββ
# Ignorar archivos de configuraciΓ³n de Docker
# βββββββββββββββββββββββββββββ
Dockerfile
docker-compose.yml
# βββββββββββββββββββββββββββββ
# Archivos de construcciΓ³n
# βββββββββββββββββββββββββββββ
node_modules
dist
*.tsbuildinfo
coverage/
*.log
npm-debug.log*
assets
# βββββββββββββββββββββββββββββ
# Archivos de tests o solo desarrollo
# βββββββββββββββββββββββββββββ
*.test.ts
__tests__/
.vscode/
.git
.gitignore
.idea/
# βββββββββββββββββββββββββββββ
# Bases de datos locales
# βββββββββββββββββββββββββββββ
*.sqlite
*.db
# βββββββββββββββββββββββββββββ
# Prisma (si usΓ‘s SQLite local)
# βββββββββββββββββββββββββββββ
prisma/dev.db
docker build -t core:v1.0.0 .
docker run --rm -p 8000:8000 --env-file .env --name core core:v1.0.0Drop this service below your existing traefik in the same docker-compose.yml (network leonobitech-net).
services:
core:
build:
context: ./repositories/core
dockerfile: Dockerfile
image: core:v1.0.0
container_name: core
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
env_file:
- ./repositories/core/.env
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=${PORT:-8000}
# Expose only to the mesh; Traefik will route inbound traffic.
# If you also want to hit it directly (http://localhost:8000), uncomment ports:
# ports:
# - "8000:8000"
volumes:
- ./repositories/core/keys:/app/keys:ro
networks:
- leonobitech-net
depends_on:
traefik:
condition: service_started
healthcheck:
test:
[
"CMD-SHELL",
"curl -fsS http://localhost:${PORT:-8000}/health || exit 1",
]
interval: 15s
timeout: 3s
retries: 3
start_period: 10s
labels:
- "traefik.enable=true"
# Route: https://api.localhost (or your BACKEND_DOMAIN)
- "traefik.http.routers.core.rule=Host(`${BACKEND_DOMAIN}`)"
- "traefik.http.routers.core.entrypoints=websecure"
- "traefik.http.routers.core.tls=true"
# Middlewares defined in traefik/dynamic (optional; keep or remove as you like)
- "traefik.http.routers.core.middlewares=block-trackers@file,secure-strict@file"
# Tell Traefik which internal port to hit
- "traefik.http.services.core.loadbalancer.server.port=${PORT:-8000}"docke compose up -d traefik core
# check status stack:
docker ps
# Check logs inside core contaiiner
docke logs -f coreVerify:
# Through Traefik (TLS; -k for self-signed)
curl -k --resolve api.localhost:443:127.0.0.1 https://api.localhost/health
curl -k --resolve api.localhost:443:127.0.0.1 https://api.localhost/ready
# Or plain HTTP if you also add an HTTP router on entrypoint web:
# curl http://api.localhost/healthExpected:
/health -> {"ok":true,"service":"core"}
/ready -> {"ready":true}Recommended Conventional Commit:
feat(http): add readiness, 404 and error handler; refine health; wire container
- Routes:
- /health (kept; clean headers/log)
- /ready (new; simple true for now)
- 404 (catch-all JSON)
- error handler (centralized logging)
- Docker:
- built image core:v1.0.0
- container `core` on `leonobitech-net` with Traefik
- healthcheck on /health
backend, nodejs, typescript, esm, express, hexagonal-architecture, ports-and-adapters, clean-architecture, ddd, rest-api, cors, logging, pino, dotenv, tsx, pkgroll, scalable, production-ready
MIT Β© 2025 β Felix Figueroa @ Leonobitech
π₯· Leonobitech Dev Team
https://www.leonobitech.com
Made with π§ , π₯·, and Docker love π³
π₯Β This isnβt just an environment. Itβs your sandbox, your testing ground, your launchpad. Clone it, break it, build on it β and ship like a hacker.
