Skip to content

πŸ₯· Full-Stack 🧩 Hexagonal API β€” Node + TS / ESM modules, tsx dev, pkgroll build.

License

leonobitech/fullstack-backend-core

Repository files navigation

fullstack-backend-core

Fullstack Backend Core β€” Leonobitech

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

GitHub stars GitHub forks Open issues License Last commit
Docker Traefik 3.x HTTPS mkcert Status: stable


Prerequisites

  • Node β‰₯ 22.20.0 (LTS) & npm β‰₯ 11
  • Optional local hosts entries (cookie/CORS isolation):
    127.0.0.1  traefik.localhost app.localhost api.localhost

Bootstrap

mkdir fullstack-backend-core && cd fullstack-backend-core
npm init -y

package.json (ESM + scripts)

{
  "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"
}

Install dependencies

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

YourΒ 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).

tsconfig.json (ESM + @ aliases + global types)

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

✨ Highlights

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

πŸ—‚ Tree structure

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


βš™οΈ Quick setup

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.example to .env and adjust values.


πŸš€ Usage (run this command in the root of the core repository)

# Development (watch + pretty logs)
npm run dev

# Build (ESM bundle)
npm run build

# Production (JSON logs)
npm start

Health check:

curl -i http://api.localhost:8000/health

🧱 Design rationale (short)

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

πŸ§ͺ Logging

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

🧭 Base routes

  • GET /healthΒ β†’Β { ok: true, service: "core" }

AddΒ /readyΒ (readiness) andΒ notFound/errorHandlerΒ when you start adding use cases.


🧰 Troubleshooting

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

Nice! Since the baseline runs, we gonna doΒ hardening HTTP + observability.

  • 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:

src/shared/logger.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" });
  }
);

src/infra/http/server.ts (with /health, /ready, 404, error handler)

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")
  );
}

Run locally and check endpoints

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

Containerization

Dockerfile (multi-stage)

# 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"]

.dockerignore

# ─────────────────────────────
# 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

Build & run (optional, without Traefik):

docker build -t core:v1.0.0 .
docker run --rm -p 8000:8000 --env-file .env --name core core:v1.0.0

Compose + Traefik

Drop 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}"

Build & run (optional, without Traefik):

docke compose up -d traefik core

# check status stack:
docker ps

# Check logs inside core contaiiner
docke logs -f core

Verify:

# 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/health

Expected:

/health -> {"ok":true,"service":"core"}
/ready  -> {"ready":true}

Commit

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

Tags (topics)

backend, nodejs, typescript, esm, express, hexagonal-architecture, ports-and-adapters, clean-architecture, ddd, rest-api, cors, logging, pino, dotenv, tsx, pkgroll, scalable, production-ready


License

MIT Β© 2025 β€” Felix Figueroa @ Leonobitech


✨ Maintained by

πŸ₯· 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.