Skip to main content

Deploying Modulix

Modulix runs as two stateless Node.js services, an API server and a web server, in front of a PostgreSQL or MySQL database. This page explains how to run it on a development machine, with Docker Compose and on Kubernetes with Helm, and lists every setting.

How the pieces fit​

browser
│ https://erp.example.com
▼
┌───────────────────────┐ /api/* (proxied, streamed) ┌───────────────────────┐
│ web (port 3000) │ ───────────────────────────────────────► │ api (port 4000) │
│ TanStack Start/Nitro │ │ NestJS on Fastify │
└───────────────────────┘ └──────────┬────────────┘
│
┌───────────────────────────────────┼───────────────────┐
▼ ▼ ▼
PostgreSQL 17 or MySQL 8 file storage (local or S3) SMTP server
  • web serves the application and forwards every /api/* request to the API (API_URL). Browsers only ever talk to the web server, so cookies stay first-party and you usually need no CORS settings.
  • api serves the REST API (/api/v1), its documentation (/api/docs) and health checks (/health/live, /health/ready). It also runs background workers (webhook deliveries, imports and exports, workflows, notifications, reminders).
  • migrate is a one-shot run of the API image that applies database migrations (and, optionally, seeds the first organization) before the API starts.

Both services are stateless and can run several replicas. Everything they share lives in the database and in file storage.

Requirements​

ComponentVersion
Node.js22.12 or later (the Docker images use Node.js 24)
pnpmThe version pinned in the repository's package.json (packageManager), for building from source
DatabasePostgreSQL (the Compose files use 17) or MySQL 8 (the Compose files use 8.4)
File storageA persistent directory, or an S3-compatible bucket (required with several API replicas)
EmailAn SMTP server, for invitations, password resets and notifications (optional, but needed in production)
Docker / KubernetesDocker with Compose v2, or Kubernetes 1.23+ with Helm 3, for container deployments

Choosing a database​

Modulix supports PostgreSQL and MySQL from one schema definition, with the same features on both. The dialect is inferred from the scheme of DATABASE_URL:

DatabaseDATABASE_URL
PostgreSQLpostgres://user:password@host:5432/modulix (or postgresql://)
MySQLmysql://user:password@host:3306/modulix

You can also set DB_DIALECT (pg or mysql) explicitly. Choose the database your team already operates and backs up. There is no tool to move an existing installation from one database to the other, so pick before you go live.

note

The application creates its own tables through migrations. Create an empty database and a user that owns it (able to create and alter tables); nothing else is needed.

Local development​

From a clone of the repository:

cp .env.example .env
pnpm install
pnpm db:up # PostgreSQL and MySQL in Docker (ports 55432 and 33406)
pnpm build # builds packages, modules and apps
pnpm seed --install-all # migrate, create the admin and the first organization, install every module
pnpm dev # API on :4000, web on :3000

Open http://localhost:3000 and sign in with the email and password in SEED_ADMIN_EMAIL and SEED_ADMIN_PASSWORD from your .env. Then go to Administration → Modules and enable the modules you want for the organization.

  • pnpm db:up starts the development databases from deploy/docker/compose.dev.yaml and creates separate test databases. pnpm db:down stops them.
  • To develop on MySQL, set DATABASE_URL=mysql://modulix:modulix@localhost:33406/modulix (and DB_DIALECT=mysql, or remove DB_DIALECT) in .env.
  • pnpm dev loads the repository's .env, rebuilds on change and restarts the API.

Common commands:

CommandWhat it does
pnpm devAPI and web in watch mode
pnpm build / pnpm typecheck / pnpm test / pnpm lintTurborepo pipelines
pnpm migrateApply core and installed-module migrations and upgrades
pnpm seedMigrate, then create the platform administrator and the first organization (idempotent)
pnpm --filter @modulix/api exec node dist/cli.js modulesList modules and their status
node packages/cli/bin/modulix.js module:new invoicesScaffold a new module (see Building modules)
pnpm --filter @modulix/api openapi --out openapi.jsonExport the OpenAPI document

Tests that need databases read TEST_PG_URL and TEST_MYSQL_URL and are skipped when they are unset.

Docker images​

One multi-target Dockerfile (deploy/docker/Dockerfile) builds both images from the repository root:

docker build -f deploy/docker/Dockerfile --target api -t modulix/api:0.1.0 .
docker build -f deploy/docker/Dockerfile --target web -t modulix/web:0.1.0 .
ImageContentsPortCommandHealth check
apiA self-contained production tree of the API with every module it depends on (pnpm deploy)4000node --enable-source-maps dist/main.jsGET /health/live
webThe built web app (Nitro .output)3000node .output/server/index.mjsGET /login

Both images run as the unprivileged node user. The API image stores local files in /app/data/storage; mount a volume there, or use S3.

Modules are compiled into the images: the API and web builds include every module listed in the dependencies of apps/api and apps/web. See Adding modules and plugins.

Docker Compose​

deploy/docker/compose.yaml runs the full stack: PostgreSQL, the one-shot migrate service, the API and the web server.

docker compose -f deploy/docker/compose.yaml up --build

To use MySQL instead of PostgreSQL, add the override file:

docker compose -f deploy/docker/compose.yaml -f deploy/docker/compose.mysql.yaml up --build
ServiceRole
postgres (or mysql)The database, with a named volume
migrateRuns node dist/cli.js seed --install-all once: applies migrations, installs every module and creates the first administrator and organization if they do not exist. The API starts only after it succeeds
apiThe API, published on API_PORT (default 4000), with a storage volume for uploaded files
webThe web app, published on WEB_PORT (default 3000)

The Compose file reads these settings from your shell environment (or from a file passed with docker compose --env-file): DATABASE_URL, JWT_SECRET, CORS_ORIGINS, LOG_LEVEL, SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD, SEED_TENANT_SLUG, SEED_TENANT_NAME, TAG (image tag), API_PORT and WEB_PORT.

warning

The Compose defaults are for trying Modulix out. Before exposing it, set a strong JWT_SECRET, a SECRETS_KEY, a real administrator password, APP_URL and your mail server, and put the web service behind HTTPS. See the production checklist.

To set variables that the Compose file does not list (such as SECRETS_KEY, APP_URL, SMTP_URL or STORAGE_DRIVER), add them to the x-api-env block in a copy of the file or in an override file.

Run CLI commands in the API container:

docker compose -f deploy/docker/compose.yaml exec api node dist/cli.js modules

Kubernetes with Helm​

The chart in deploy/helm/modulix deploys the API and the web server. It does not include a database: point it at an existing PostgreSQL or MySQL server.

Minimal install:

helm install modulix deploy/helm/modulix \
--set database.url=postgres://modulix:PASSWORD@postgres.db.svc:5432/modulix

A more complete values.yaml:

my-values.yaml
database:
url: "" # or use secrets.existingSecret
secrets:
existingSecret: modulix-secrets # holds DATABASE_URL and JWT_SECRET (and SEED_ADMIN_PASSWORD when seeding)

config:
logLevel: info
trustProxy: true
extra:
APP_URL: https://erp.example.com
STORAGE_DRIVER: s3
S3_BUCKET: modulix-files
S3_REGION: ca-central-1

api:
replicaCount: 2
extraEnvFrom:
- secretRef:
name: modulix-extra # SECRETS_KEY, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, SMTP_URL, MAIL_FROM

web:
replicaCount: 2

migrations:
enabled: true
seed:
enabled: true # first install only; set to false afterwards
args: ["--install-all"]
adminEmail: admin@example.com
tenantSlug: brightline
tenantName: Brightline Supply Co.

ingress:
enabled: true
className: nginx
hosts:
- host: erp.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: modulix-tls
hosts: [erp.example.com]

Key points:

  • Secrets. Without secrets.existingSecret, the chart creates a Secret from database.url, auth.jwtSecret and migrations.seed.adminPassword. If auth.jwtSecret is empty, a random secret is generated on first install and kept across upgrades. Put other sensitive values (SECRETS_KEY, S3 keys, SMTP_URL) in your own Secret and reference it with api.extraEnvFrom.
  • Configuration. config.* and config.extra are rendered into a ConfigMap shared by the API and the migration Job. Set APP_URL there.
  • Migrations. With migrations.enabled (the default), a Job runs node dist/cli.js migrate as a Helm pre-install and pre-upgrade hook, so the schema is up to date before new API pods start. With migrations.seed.enabled, it runs seed instead, which also creates the first administrator and organization. Seeding is idempotent, but turn it off after the first install.
  • Ingress. The ingress routes to the web service, which proxies /api/*. Set ingress.routeApiDirectly: true to send /api straight to the API service.
  • File storage. Pods run with a read-only root filesystem and several API replicas, so use S3-compatible storage (STORAGE_DRIVER=s3). Uploads are staged in /tmp (an emptyDir limited by tmpVolume.sizeLimit, 256 Mi by default); raise it if people upload large files.
  • Scaling. api.autoscaling, web.autoscaling and the podDisruptionBudget settings are available and off by default.
  • Uninstalling. The Secret and ConfigMap are hook resources (so the first migration Job can use them); helm uninstall does not delete them.

Logs of the migration Job:

kubectl -n <namespace> logs job/modulix-migrate

If you disable the Job, run migrations yourself:

kubectl -n <namespace> exec deploy/modulix-api -- node dist/cli.js migrate

Migrations and seeding​

The API image includes a small CLI (dist/cli.js; pnpm migrate and pnpm seed from source):

CommandWhat it does
migrateApplies core migrations and the migrations of every installed module, runs module upgrades, and installs system modules
seedRuns migrate, then creates the platform administrator (SEED_ADMIN_EMAIL, SEED_ADMIN_PASSWORD) and an organization (SEED_TENANT_SLUG, SEED_TENANT_NAME) owned by them, if they do not exist
seed --install-allAlso installs every module found in the image (each organization still enables the ones it uses)
modulesLists modules: available, installed version, upgrade available
modules:install <id>Installs a module for the whole deployment
modules:enable <id> --tenant <slug>Enables a module for one organization
openapi [--out file]Writes the OpenAPI document

Migrations are idempotent and serialized by a database lock, so it is safe to run them from several places at once. Run migrate (or let the Compose service or Helm Job do it) on every deployment, before the new API version starts.

For a single server, you can instead set AUTO_MIGRATE=true: the API then migrates and installs system modules at startup. Prefer a separate migration step when you run several replicas.

note

The seeded administrator is a platform administrator (super admin): they can install, upgrade and uninstall modules and create organizations. Change their password after the first sign-in, and give other people organization roles instead.

Adding modules and plugins​

Modules are part of the build. To add one:

  1. Add the package to the dependencies of apps/api and apps/web (modules scaffolded with module:new are added for you), or put a plugin package in plugins/.
  2. Rebuild the images (or pnpm build) and deploy.
  3. A platform administrator installs it in Administration → Modules (or node dist/cli.js modules:install <id>). This runs its migrations.
  4. Each organization enables it in Administration → Modules.

The API can also load server-only packages from directories listed in MODULIX_MODULE_DIRS (for example a volume mounted into the container). The web half of a module must always be part of the web build.

Upgrading​

  1. Build or pull the new images.
  2. Run migrations with the new API image (migrate). This also upgrades installed modules whose version changed; a module upgrade can also be started from Administration → Modules → Upgrade.
  3. Roll out the new API and web containers.

With Compose, docker compose -f deploy/docker/compose.yaml up --build -d does all three. With Helm, helm upgrade runs the migration Job before replacing the pods.

Back up the database (and file storage) before upgrading. Migrations are not reversed automatically.

File storage​

Uploaded files (documents, attachments, receipts, profile pictures, import and export files) go to the storage configured on the API:

DriverSettingsUse when
local (default)STORAGE_DIR (default ./data/storage; /app/data/storage in the image)One API instance with a persistent volume
s3S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, optional S3_ENDPOINT and S3_FORCE_PATH_STYLESeveral API replicas, Kubernetes, or managed storage (AWS S3, MinIO, Cloudflare R2, ...)

All API replicas must see the same storage. Include it in your backups.

Email​

Account emails (invitations, password resets) and notifications of organizations that have not connected their own email provider are sent through the deployment's mail server:

  • SMTP_URL, for example smtp://user:password@smtp.example.com:587
  • MAIL_FROM, for example Modulix <no-reply@example.com>
  • APP_URL, the public address of the web app, used for links in emails

Without SMTP_URL, emails are only written to the API log. Organizations can connect their own provider in the Notifications module; those credentials are encrypted with SECRETS_KEY.

Browser push notifications use VAPID keys. When VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are unset, a key pair is generated and stored in the database.

Production checklist​

  • JWT_SECRET is a long random string (at least 32 characters) kept in a secret store.
  • SECRETS_KEY is set (32 bytes, openssl rand -base64 32). Without it, stored credentials are encrypted with a key derived from JWT_SECRET, and rotating JWT_SECRET would make them unreadable.
  • APP_URL is the public HTTPS address of the web app.
  • The web service is served over HTTPS, and TRUST_PROXY=true is set on the API behind your load balancer or ingress (correct client addresses for rate limits and the audit log).
  • SMTP_URL and MAIL_FROM are set and tested (invite a user).
  • File storage is persistent (a volume) or S3 for several replicas.
  • Migrations run on every deployment (Compose migrate service, Helm Job, or AUTO_MIGRATE on a single node).
  • The seed administrator's password has been changed, and seeding is turned off after the first install.
  • SIGNUP_ENABLED stays unset unless anyone on the internet should be able to create an organization.
  • The database and the file storage are backed up, and you have tested a restore.
  • Monitoring checks /health/ready on the API (it fails when the database is unreachable).

Environment variables​

All variables are read by the API unless noted. Values in .env.example are development defaults; never reuse them in production.

Core​

VariableDefaultDescription
DATABASE_URLrequiredDatabase connection URL (postgres://… or mysql://…)
DB_DIALECTinferred from DATABASE_URLpg or mysql
DB_POOL_SIZE10Database connections per API process
JWT_SECRETrequiredSigns access tokens; at least 32 characters
SECRETS_KEYderived from JWT_SECRET32 bytes (base64 or hex) encrypting stored credentials (email providers, webhook secrets). Set it in production
ACCESS_TOKEN_TTL15mLifetime of access tokens
REFRESH_TOKEN_TTL_DAYS30Lifetime of refresh tokens (sessions)
NODE_ENVdevelopment (production in the images)Production enables secure cookies and refuses http webhook URLs
API_PORT4000API listening port
API_HOST0.0.0.0API listening address
CORS_ORIGINSempty (CORS off)Comma-separated origins allowed to call the API from a browser. Not needed when browsers go through the web server
LOG_LEVELinfofatal, error, warn, info, debug, trace or silent
TRUST_PROXYfalseTrust X-Forwarded-* headers; set to true behind a proxy or load balancer
RATE_LIMIT_MAX600Requests per minute per client address, for every endpoint
AUTO_MIGRATEfalseRun migrations and install system modules when the API starts
MODULIX_MODULE_DIRSempty (the repository's plugins/ when run from source)Comma-separated directories scanned for drop-in module packages

Accounts and email​

VariableDefaultDescription
APP_URLhttp://localhost:3000Public URL of the web app, for links in emails
SIGNUP_ENABLEDfalseLet anyone create an account and an organization at /signup
INVITATION_TTL_DAYS7Validity of invitation links
PASSWORD_RESET_TTL_MINUTES60Validity of password reset links
SMTP_URLnone (emails are logged)Platform mail server, for example smtp://user:password@smtp.example.com:587
MAIL_FROMModulix <no-reply@localhost>Sender of platform emails
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEYgenerated and stored in the databaseKeys for browser push notifications
VAPID_SUBJECTmailto: the address in MAIL_FROMContact sent to push services

File storage​

VariableDefaultDescription
STORAGE_DRIVERlocallocal or s3
STORAGE_DIR./data/storage (/app/data/storage in the image)Directory for the local driver
S3_BUCKETnoneBucket name
S3_REGIONnoneBucket region
S3_ENDPOINTAWSEndpoint of an S3-compatible service (MinIO, R2, ...)
S3_FORCE_PATH_STYLEfalsePath-style URLs, needed by some S3-compatible services
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEYnoneCredentials; keep them in a secret
DOCUMENTS_MAX_FILE_SIZE_MB10240Upper limit on the file size organizations can allow in the Documents module

Seeding​

Read by seed only.

VariableDefaultDescription
SEED_ADMIN_EMAILadmin@example.comEmail of the platform administrator to create
SEED_ADMIN_PASSWORDdevelopment defaultTheir password. Always set it
SEED_TENANT_SLUGacmeSlug of the first organization
SEED_TENANT_NAMEAcme Inc.Name of the first organization

Background workers​

Each API process runs these workers. Set a switch to off to stop a worker on that process (for example to run it only on dedicated replicas).

VariableDefaultDescription
WEBHOOKS_WORKERonDelivers outgoing webhooks
WEBHOOKS_POLL_MS2000How often the webhook worker looks for due deliveries
JOBS_POLL_MS2000How often import and export jobs are picked up
AUTOMATION_RUNNERonRuns workflow automation
AUTOMATION_POLL_MS2000How often the workflow runner polls
NOTIFICATIONS_WORKERonDelivers notifications (email, push)
NOTIFICATIONS_POLL_MS3000How often the notification worker polls
ACTIVITIES_REMINDERSonSends task reminders
ACTIVITIES_REMINDER_INTERVAL_MS60000Interval of the reminder check
SALES_OVERDUEonChecks for invoices that have become overdue
SALES_OVERDUE_INTERVAL_MS900000 (15 minutes)Interval of the overdue check
LEADS_JANITORonHourly housekeeping of the Leads module
FORMS_JANITORonHourly retention clean-up of form responses
DOCUMENTS_JANITORonPurges expired items from the Documents trash every 6 hours

Outbound network and anti-spam​

VariableDefaultDescription
WEBHOOKS_ALLOW_PRIVATEfalseAllow webhooks to private and local addresses, and http URLs in production. Development only
AUTOMATION_WEBHOOK_ALLOW_PRIVATEfalseAllow workflow webhook steps to call private and local addresses. Development only
NOTIFICATIONS_SMTP_ALLOW_PRIVATEfalseAllow organizations' own SMTP servers on private addresses
NOTIFICATIONS_PUSH_ALLOW_ANY_ENDPOINTfalseAccept any push endpoint. For tests only
FORMS_MIN_FILL_MS3000Public form submissions sent faster than this after opening the form are treated as bots

Web server​

VariableDefaultDescription
API_URLhttp://localhost:4000 (http://api:4000 in the image)Address the web server uses to reach the API
PORT3000Web server listening port

Tests​

VariableDescription
TEST_PG_URLPostgreSQL database for integration tests
TEST_MYSQL_URLMySQL database for integration tests

Health checks and logs​

EndpointMeaning
GET /health/live (API)The process is up
GET /health/ready (API)The database answers; returns the dialect and the number of modules, or 503
GET /login (web)The web server answers

The API writes structured JSON logs (pino) to standard output. Authorization headers, cookies and X-Api-Key are redacted. Health checks are not logged.