Build & Deploy
Most CI/CD advice for Node services applies to ModularityJS apps unchanged — pin Node, lint, typecheck, test, build, ship a container, run a graceful shutdown. This page focuses on the parts that are framework-specific: the boot-time contract check that doubles as a smoke gate, swapping drivers per environment, deploying database migrations, and shipping build-time artifacts like the OpenAPI spec.
Pin the toolchain
ModularityJS requires Node 22+ and pnpm (the package manager is pinned in package.json via packageManager). Enable Corepack in CI so the right pnpm version is used automatically:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfileOrder matters: pnpm/action-setup has to run before actions/setup-node, because cache: pnpm resolves the pnpm binary at setup-node time. Corepack isn't needed when pnpm/action-setup is in use — it installs the right pnpm version for you (and reads packageManager from package.json).
--frozen-lockfile fails the build if pnpm-lock.yaml is out of date — a useful safeguard.
Private registry access
@modularityjs/* packages live on a private npm registry at https://npm.modularityjs.com/. The committed project .npmrc provides the scope mapping; CI has to inject the auth line from a secret at install time — same pattern, just dropped into your pipeline. The canonical example (with MODULARITYJS_NPM_TOKEN and a dedicated read-only CI user) is in Registry Access. The short version:
- name: Authenticate to the registry
env:
MODULARITYJS_NPM_TOKEN: ${{ secrets.MODULARITYJS_NPM_TOKEN }}
run: |
AUTH=$(printf '%s' "ci-user:${MODULARITYJS_NPM_TOKEN}" | base64 -w0)
echo "//npm.modularityjs.com/:_auth=${AUTH}" >> .npmrc
- run: pnpm install --frozen-lockfileBoot as a smoke gate
Every contract must have a provider at boot — missing drivers, misconfigured forRoot(), broken peer-dep upgrades all fail at createApp(), not on the first request. Use this in CI: boot the app, then immediately shut down. If it boots, the whole DI graph is wired.
// scripts/boot-check.ts
import { buildModules } from '../src/modules.js';
import { createApp } from '@modularityjs/modularity';
import { inversify } from '@modularityjs/di-inversify';
const app = await createApp({
di: inversify,
modules: buildModules(),
signals: false,
});
await app.shutdown();
console.log('boot ok');- run: pnpm tsx scripts/boot-check.ts
env:
NODE_ENV: production
# ... whatever env vars production needsThis catches a class of bugs that unit tests miss — a forgotten driver module, a renamed config key, a peer-dep mismatch — before the artifact reaches a deploy environment.
Swap drivers per environment
The whole point of the contract/driver split is that only the module list changes between environments. Keep app code free of NODE_ENV branches; put the branching in one place — the modules file:
// src/modules.ts
import { CacheModule } from '@modularityjs/cache';
import { CacheMemoryModule } from '@modularityjs/cache-memory';
import { CacheRedisModule } from '@modularityjs/cache-redis';
import { LoggerModule } from '@modularityjs/logger';
import { LoggerConsoleModule } from '@modularityjs/logger-console';
import { LoggerFileModule } from '@modularityjs/logger-file';
import { RedisModule } from '@modularityjs/redis';
export function buildModules() {
const isProd = process.env.NODE_ENV === 'production';
return [
ModularityModule,
CacheModule,
isProd
? [
RedisModule.forRoot({ host: process.env.REDIS_HOST! }),
CacheRedisModule,
]
: [CacheMemoryModule],
LoggerModule,
isProd
? LoggerFileModule.forRoot({ path: '/var/log/app.log' })
: LoggerConsoleModule,
AppModule,
].flat();
}The application code (AppModule, services, controllers) doesn't change — only the wiring does.
Validate config at boot
Every config class should implement validate(). The framework calls it during boot, so misconfiguration fails fast — before traffic, before background workers start. This pairs well with the boot smoke gate: a missing DATABASE_URL in CI's "production-like" env breaks pnpm boot-check instead of breaking a live pod.
See Configuration for the schema and validation patterns. For secret material, the @modularityjs/secrets-config extension pre-loads secrets from your vault into ConfigSource so the same ConfigService.get() works everywhere.
Database migrations
The migrationsRun: true shortcut on DatabaseModule.forRoot() is single-node only. The moment you scale past one replica, two pods will race the migration table on startup. The supported deploy pattern is:
- Set
migrationsRun: falsein production. - Run the
database:migration:runCLI as a separate step before rolling out new pods.
# deploy pipeline
- name: Run database migrations
run: node dist/cli.js database:migration:run
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Roll out new revision
run: kubectl rollout restart deployment/appA failed migration aborts the deploy before any new pod tries to serve traffic. See Database for the CLI surface.
Ship build-time artifacts
A few packages emit artifacts at build time that belong alongside your container image — not generated at runtime:
- OpenAPI spec —
@modularityjs/openapi-cliaddsopenapi:export, which writesopenapi.jsonfrom your controller + validation metadata. Ship this with your image so docs sites, contract tests, and SDK generators have a stable artifact. - Asset manifest —
@modularityjs/assets-cliwrites hashed asset files plusassets-manifest.json. The HTTP serving layer reads the manifest to compute hashed URLs via theassetUrltemplate helper.
- run: pnpm build
- run: node dist/cli.js openapi:export --output dist/openapi.json
- run: node dist/cli.js assets:collect dist/assetsProcess topology
You can run the whole app in one process or split it: web + worker + scheduler. The framework supports both with the same image — only the modules list changes per process:
// src/main-web.ts — HTTP only
// src/main-worker.ts — outbox dispatcher + queue consumer
// src/main-scheduler.ts — scheduler-croner onlyShared modules sit in a BaseAppModule; each entrypoint imports it and adds the role-specific drivers (OutboxScheduler, HttpFastifyModule, etc.). Use the boot smoke gate (above) on each entrypoint.
Health probes
@modularityjs/health exposes a HealthService.check() that aggregates every contributor to HealthIndicatorsPool. The framework does not register HTTP routes — apps own the controller. See Observability → Health probes for the controller template. Once routed, wire the paths into your orchestrator:
livenessProbe:
httpGet: { path: /health/live, port: http }
readinessProbe:
httpGet: { path: /health/ready, port: http }The readiness probe should fail while the app is starting up (during onInit) and during shutdown (onShutdown) — pods get pulled from the load balancer before connections close. See Health.
Graceful shutdown
createApp() registers SIGTERM/SIGINT handlers by default — pass signals: false to disable, or a custom NodeJS.Signals[] to override. On shutdown, every module's onShutdown runs in reverse order (drain in-flight work) followed by onDestroy (close connections). This means Kubernetes terminationGracePeriodSeconds needs to cover your slowest drain — typically the queue consumer.
spec:
terminationGracePeriodSeconds: 60 # > slowest onShutdown drainInside the container, don't trap SIGTERM yourself — the framework already handles it. If you're using a process supervisor that swallows signals (some Docker entrypoints), forward them explicitly with tini or dumb-init.
Reference pipelines
The same shape works on either platform: cheap checks first (lint → typecheck → test), then the slow ones (build → boot-check → image build). A failed boot-check is a strong signal — it means staging will fail too.
GitHub Actions
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Authenticate to the registry
env:
MODULARITYJS_NPM_TOKEN: ${{ secrets.MODULARITYJS_NPM_TOKEN }}
run: |
AUTH=$(printf '%s' "ci-user:${MODULARITYJS_NPM_TOKEN}" | base64 -w0)
echo "//npm.modularityjs.com/:_auth=${AUTH}" >> .npmrc
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
# Framework-specific smoke gate
- run: pnpm tsx scripts/boot-check.ts
env:
NODE_ENV: production
DATABASE_URL: ${{ secrets.DATABASE_URL_CI }}
- run: node dist/cli.js openapi:export --output dist/openapi.json
# Container build
- run: docker build -t app:${{ github.sha }} .Always pin actions to a major-version tag (@v6), and verify the current latest tag on each action's GitHub releases page before bumping — actions/checkout and actions/setup-node cut new majors every year or two.
GitLab CI
image: node:22
stages:
- check
- build
- smoke
- package
variables:
PNPM_HOME: /root/.local/share/pnpm
PATH: '$PNPM_HOME:$PATH'
.pnpm:
before_script:
- corepack enable
- corepack prepare --activate
- AUTH=$(printf '%s' "ci-user:${MODULARITYJS_NPM_TOKEN}" | base64 -w0)
- echo "//npm.modularityjs.com/:_auth=${AUTH}" >> .npmrc
- pnpm install --frozen-lockfile
cache:
key:
files: [pnpm-lock.yaml]
paths:
- .pnpm-store
- node_modules
lint:
stage: check
extends: .pnpm
script: pnpm lint
typecheck:
stage: check
extends: .pnpm
script: pnpm typecheck
test:
stage: check
extends: .pnpm
script: pnpm test
build:
stage: build
extends: .pnpm
script: pnpm build
artifacts:
paths: [dist/]
expire_in: 1 day
boot-check:
stage: smoke
extends: .pnpm
needs: [build]
variables:
NODE_ENV: production
script:
- pnpm tsx scripts/boot-check.ts
- node dist/cli.js openapi:export --output dist/openapi.json
artifacts:
paths: [dist/openapi.json]
package:
stage: package
image: docker:27
services: [docker:27-dind]
needs: [boot-check]
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"Set MODULARITYJS_NPM_TOKEN (and DATABASE_URL_CI for the smoke gate) as masked, protected variables in Settings → CI/CD → Variables. The .pnpm job template DRYs the install/cache/auth setup across every stage; needs: keeps later stages from re-running checks they don't depend on.
Next Steps
- Configuration — config sources,
validate(), secrets extension - Testing —
createTestHarnessfor unit + integration tests - Health — readiness / liveness indicators
- Database — migration CLI commands