Skip to content

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:

yaml
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
  with:
    node-version: 22
    cache: pnpm
- run: pnpm install --frozen-lockfile

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

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

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

typescript
// 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');
yaml
- run: pnpm tsx scripts/boot-check.ts
  env:
    NODE_ENV: production
    # ... whatever env vars production needs

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

typescript
// 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:

  1. Set migrationsRun: false in production.
  2. Run the database:migration:run CLI as a separate step before rolling out new pods.
yaml
# 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/app

A 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-cli adds openapi:export, which writes openapi.json from 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-cli writes hashed asset files plus assets-manifest.json. The HTTP serving layer reads the manifest to compute hashed URLs via the assetUrl template helper.
yaml
- run: pnpm build
- run: node dist/cli.js openapi:export --output dist/openapi.json
- run: node dist/cli.js assets:collect dist/assets

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

typescript
// src/main-web.ts        — HTTP only
// src/main-worker.ts     — outbox dispatcher + queue consumer
// src/main-scheduler.ts  — scheduler-croner only

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

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

yaml
spec:
  terminationGracePeriodSeconds: 60 # > slowest onShutdown drain

Inside 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

yaml
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

yaml
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
  • TestingcreateTestHarness for unit + integration tests
  • Health — readiness / liveness indicators
  • Database — migration CLI commands