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
| Component | Version |
|---|---|
| Node.js | 22.12 or later (the Docker images use Node.js 24) |
| pnpm | The version pinned in the repository's package.json (packageManager), for building from source |
| Database | PostgreSQL (the Compose files use 17) or MySQL 8 (the Compose files use 8.4) |
| File storage | A persistent directory, or an S3-compatible bucket (required with several API replicas) |
| An SMTP server, for invitations, password resets and notifications (optional, but needed in production) | |
| Docker / Kubernetes | Docker 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:
| Database | DATABASE_URL |
|---|---|
| PostgreSQL | postgres://user:password@host:5432/modulix (or postgresql://) |
| MySQL | mysql://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.
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:upstarts the development databases fromdeploy/docker/compose.dev.yamland creates separate test databases.pnpm db:downstops them.- To develop on MySQL, set
DATABASE_URL=mysql://modulix:modulix@localhost:33406/modulix(andDB_DIALECT=mysql, or removeDB_DIALECT) in.env. pnpm devloads the repository's.env, rebuilds on change and restarts the API.
Common commands:
| Command | What it does |
|---|---|
pnpm dev | API and web in watch mode |
pnpm build / pnpm typecheck / pnpm test / pnpm lint | Turborepo pipelines |
pnpm migrate | Apply core and installed-module migrations and upgrades |
pnpm seed | Migrate, then create the platform administrator and the first organization (idempotent) |
pnpm --filter @modulix/api exec node dist/cli.js modules | List modules and their status |
node packages/cli/bin/modulix.js module:new invoices | Scaffold a new module (see Building modules) |
pnpm --filter @modulix/api openapi --out openapi.json | Export 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 .
| Image | Contents | Port | Command | Health check |
|---|---|---|---|---|
api | A self-contained production tree of the API with every module it depends on (pnpm deploy) | 4000 | node --enable-source-maps dist/main.js | GET /health/live |
web | The built web app (Nitro .output) | 3000 | node .output/server/index.mjs | GET /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
| Service | Role |
|---|---|
postgres (or mysql) | The database, with a named volume |
migrate | Runs 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 |
api | The API, published on API_PORT (default 4000), with a storage volume for uploaded files |
web | The 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.
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:
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 fromdatabase.url,auth.jwtSecretandmigrations.seed.adminPassword. Ifauth.jwtSecretis 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 withapi.extraEnvFrom. - Configuration.
config.*andconfig.extraare rendered into a ConfigMap shared by the API and the migration Job. SetAPP_URLthere. - Migrations. With
migrations.enabled(the default), a Job runsnode dist/cli.js migrateas a Helm pre-install and pre-upgrade hook, so the schema is up to date before new API pods start. Withmigrations.seed.enabled, it runsseedinstead, 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/*. Setingress.routeApiDirectly: trueto send/apistraight 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(anemptyDirlimited bytmpVolume.sizeLimit, 256 Mi by default); raise it if people upload large files. - Scaling.
api.autoscaling,web.autoscalingand thepodDisruptionBudgetsettings are available and off by default. - Uninstalling. The Secret and ConfigMap are hook resources (so the first migration Job can use them);
helm uninstalldoes 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):
| Command | What it does |
|---|---|
migrate | Applies core migrations and the migrations of every installed module, runs module upgrades, and installs system modules |
seed | Runs 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-all | Also installs every module found in the image (each organization still enables the ones it uses) |
modules | Lists 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.
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:
- Add the package to the dependencies of
apps/apiandapps/web(modules scaffolded withmodule:neware added for you), or put a plugin package inplugins/. - Rebuild the images (or
pnpm build) and deploy. - A platform administrator installs it in Administration → Modules (or
node dist/cli.js modules:install <id>). This runs its migrations. - 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
- Build or pull the new images.
- 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. - 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:
| Driver | Settings | Use when |
|---|---|---|
local (default) | STORAGE_DIR (default ./data/storage; /app/data/storage in the image) | One API instance with a persistent volume |
s3 | S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, optional S3_ENDPOINT and S3_FORCE_PATH_STYLE | Several 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 examplesmtp://user:password@smtp.example.com:587MAIL_FROM, for exampleModulix <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_SECRETis a long random string (at least 32 characters) kept in a secret store. -
SECRETS_KEYis set (32 bytes,openssl rand -base64 32). Without it, stored credentials are encrypted with a key derived fromJWT_SECRET, and rotatingJWT_SECRETwould make them unreadable. -
APP_URLis the public HTTPS address of the web app. - The web service is served over HTTPS, and
TRUST_PROXY=trueis set on the API behind your load balancer or ingress (correct client addresses for rate limits and the audit log). -
SMTP_URLandMAIL_FROMare set and tested (invite a user). - File storage is persistent (a volume) or S3 for several replicas.
- Migrations run on every deployment (Compose
migrateservice, Helm Job, orAUTO_MIGRATEon a single node). - The seed administrator's password has been changed, and seeding is turned off after the first install.
-
SIGNUP_ENABLEDstays 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/readyon 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
| Variable | Default | Description |
|---|---|---|
DATABASE_URL | required | Database connection URL (postgres://… or mysql://…) |
DB_DIALECT | inferred from DATABASE_URL | pg or mysql |
DB_POOL_SIZE | 10 | Database connections per API process |
JWT_SECRET | required | Signs access tokens; at least 32 characters |
SECRETS_KEY | derived from JWT_SECRET | 32 bytes (base64 or hex) encrypting stored credentials (email providers, webhook secrets). Set it in production |
ACCESS_TOKEN_TTL | 15m | Lifetime of access tokens |
REFRESH_TOKEN_TTL_DAYS | 30 | Lifetime of refresh tokens (sessions) |
NODE_ENV | development (production in the images) | Production enables secure cookies and refuses http webhook URLs |
API_PORT | 4000 | API listening port |
API_HOST | 0.0.0.0 | API listening address |
CORS_ORIGINS | empty (CORS off) | Comma-separated origins allowed to call the API from a browser. Not needed when browsers go through the web server |
LOG_LEVEL | info | fatal, error, warn, info, debug, trace or silent |
TRUST_PROXY | false | Trust X-Forwarded-* headers; set to true behind a proxy or load balancer |
RATE_LIMIT_MAX | 600 | Requests per minute per client address, for every endpoint |
AUTO_MIGRATE | false | Run migrations and install system modules when the API starts |
MODULIX_MODULE_DIRS | empty (the repository's plugins/ when run from source) | Comma-separated directories scanned for drop-in module packages |
Accounts and email
| Variable | Default | Description |
|---|---|---|
APP_URL | http://localhost:3000 | Public URL of the web app, for links in emails |
SIGNUP_ENABLED | false | Let anyone create an account and an organization at /signup |
INVITATION_TTL_DAYS | 7 | Validity of invitation links |
PASSWORD_RESET_TTL_MINUTES | 60 | Validity of password reset links |
SMTP_URL | none (emails are logged) | Platform mail server, for example smtp://user:password@smtp.example.com:587 |
MAIL_FROM | Modulix <no-reply@localhost> | Sender of platform emails |
VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY | generated and stored in the database | Keys for browser push notifications |
VAPID_SUBJECT | mailto: the address in MAIL_FROM | Contact sent to push services |
File storage
| Variable | Default | Description |
|---|---|---|
STORAGE_DRIVER | local | local or s3 |
STORAGE_DIR | ./data/storage (/app/data/storage in the image) | Directory for the local driver |
S3_BUCKET | none | Bucket name |
S3_REGION | none | Bucket region |
S3_ENDPOINT | AWS | Endpoint of an S3-compatible service (MinIO, R2, ...) |
S3_FORCE_PATH_STYLE | false | Path-style URLs, needed by some S3-compatible services |
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | none | Credentials; keep them in a secret |
DOCUMENTS_MAX_FILE_SIZE_MB | 10240 | Upper limit on the file size organizations can allow in the Documents module |
Seeding
Read by seed only.
| Variable | Default | Description |
|---|---|---|
SEED_ADMIN_EMAIL | admin@example.com | Email of the platform administrator to create |
SEED_ADMIN_PASSWORD | development default | Their password. Always set it |
SEED_TENANT_SLUG | acme | Slug of the first organization |
SEED_TENANT_NAME | Acme 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).
| Variable | Default | Description |
|---|---|---|
WEBHOOKS_WORKER | on | Delivers outgoing webhooks |
WEBHOOKS_POLL_MS | 2000 | How often the webhook worker looks for due deliveries |
JOBS_POLL_MS | 2000 | How often import and export jobs are picked up |
AUTOMATION_RUNNER | on | Runs workflow automation |
AUTOMATION_POLL_MS | 2000 | How often the workflow runner polls |
NOTIFICATIONS_WORKER | on | Delivers notifications (email, push) |
NOTIFICATIONS_POLL_MS | 3000 | How often the notification worker polls |
ACTIVITIES_REMINDERS | on | Sends task reminders |
ACTIVITIES_REMINDER_INTERVAL_MS | 60000 | Interval of the reminder check |
SALES_OVERDUE | on | Checks for invoices that have become overdue |
SALES_OVERDUE_INTERVAL_MS | 900000 (15 minutes) | Interval of the overdue check |
LEADS_JANITOR | on | Hourly housekeeping of the Leads module |
FORMS_JANITOR | on | Hourly retention clean-up of form responses |
DOCUMENTS_JANITOR | on | Purges expired items from the Documents trash every 6 hours |
Outbound network and anti-spam
| Variable | Default | Description |
|---|---|---|
WEBHOOKS_ALLOW_PRIVATE | false | Allow webhooks to private and local addresses, and http URLs in production. Development only |
AUTOMATION_WEBHOOK_ALLOW_PRIVATE | false | Allow workflow webhook steps to call private and local addresses. Development only |
NOTIFICATIONS_SMTP_ALLOW_PRIVATE | false | Allow organizations' own SMTP servers on private addresses |
NOTIFICATIONS_PUSH_ALLOW_ANY_ENDPOINT | false | Accept any push endpoint. For tests only |
FORMS_MIN_FILL_MS | 3000 | Public form submissions sent faster than this after opening the form are treated as bots |
Web server
| Variable | Default | Description |
|---|---|---|
API_URL | http://localhost:4000 (http://api:4000 in the image) | Address the web server uses to reach the API |
PORT | 3000 | Web server listening port |
Tests
| Variable | Description |
|---|---|
TEST_PG_URL | PostgreSQL database for integration tests |
TEST_MYSQL_URL | MySQL database for integration tests |
Health checks and logs
| Endpoint | Meaning |
|---|---|
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.