Two configuration layers
- Base process env — DB_*, Redis, MODE, paths from base/backend/.env or Docker Compose; read with process.env (e.g. @Database options)
- Plugin Env (@InjectEnv) — key/value scoped to the current plugin; loaded from Postgres on boot; editable from the ERP env UI / /env API
- Do not put secrets in git — keep them in .env (local) or the env table (runtime)
- Full base process-env catalog — see Environment variables
Base environment (.env)
Infrastructure connection settings for the base app and workers. Typical keys live in base/backend/.env and are wired through Docker Compose. For the complete key list, open Environment variables.
- DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_SCHEMA — Postgres
- DB_SYNC — optional TypeORM synchronize flag (dev only)
- REDIS_HOST, REDIS_PORT, REDIS_PASSWORD — cache, queues, BullMQ cron
- In Compose, DB_HOST is usually the service name db (not localhost inside containers)
- On the host for local psql / module seed, use localhost / 127.0.0.1
1DB_HOST=db
2DB_PORT=5432
3DB_USERNAME=postgres
4DB_PASSWORD=postgres
5DB_SCHEMA=quan_erp
6DB_SYNC=false
7REDIS_HOST=redis
8REDIS_PORT=6379
9REDIS_PASSWORD=@InjectEnv
Inject the Env helper on a module, service, workflow node, or other DI-resolved class. The instance is scoped to the current plugin — get/set only see that plugin’s keys.
1import {
2 Env,
3 InjectEnv,
4 Module,
5 OnInit,
6 Service,
7} from "@quan-erp/shared-backend-core";
8import metadata from "../../module.metadata.json" with { type: "json" };
9
10@Service()
11export class PaymentConfigService {
12 @InjectEnv()
13 private env: Env;
14
15 getApiKey() {
16 return this.env.get("API_KEY");
17 }
18}
19
20@Module({
21 name: metadata.name,
22 providers: [PaymentConfigService],
23 controllers: [],
24 entities: [],
25})
26export class MyPluginModule {
27 @InjectEnv()
28 env: Env;
29
30 @OnInit()
31 async init() {
32 // seed defaults — see below
33 }
34}Env API
- get(key) — read a plugin-scoped value (secrets decrypted)
- set(key, value, options?) — write; defaults from injection: { isPublic: false, sync: true, isSecret: false }
- sync() — persist the in-memory registry to the env table
- onChanged(key, callback) — react when that key changes (UI update, set, etc.)
- clear() — wipe this plugin’s env entries and clear DB via EnvService
- all() — all keys for this plugin with value / isPublic / isSecret
1import { Env, InjectEnv, OnInit, Service } from "@quan-erp/shared-backend-core";
2
3@Service()
4export class IntegrationSettingsService {
5 @InjectEnv()
6 private env: Env;
7
8 @OnInit()
9 async init() {
10 if (!this.env.get("API_BASE_URL")) {
11 await this.env.set("API_BASE_URL", "https://api.example.com", {
12 isPublic: true,
13 isSecret: false,
14 sync: true,
15 });
16 }
17
18 if (!this.env.get("API_KEY")) {
19 await this.env.set("API_KEY", "", {
20 isPublic: false,
21 isSecret: true,
22 sync: true,
23 });
24 }
25
26 this.env.onChanged("API_KEY", () => {
27 // reload clients that cache the key
28 });
29 }
30
31 async rotateKey(next: string) {
32 await this.env.set("API_KEY", next, {
33 isSecret: true,
34 sync: true,
35 });
36 }
37}set() options
- sync (default true via InjectEnv proxy) — write through to Postgres immediately
- isPublic — exposed on GET /env/public for frontend / unauthenticated public env
- isSecret — encrypted at rest; masked in owner env listings (maskSecret)
- Prefer isSecret: true for API keys, tokens, and passwords
- Prefer isPublic: true only for non-sensitive values the UI must read without owner rights
Persistence & boot
On startup the core loads rows from the env table into PluginEnvConfigManager. Changes via set({ sync: true }) or env.sync() upsert through EnvService. Uninstall / clear removes plugin-scoped entries.
- Table: env (EnvEntity) — pluginName, key, value, isPublic, isSecret
- Boot: PluginEnvConfigManager.loadFromDB()
- Secrets stored encrypted (base64 of encrypt JSON); get() decrypts for the owning plugin
- Admin UI / API can bulk-update and may broadcast a restart status after /env/many
HTTP surface (core)
- GET /env — owner; all plugins’ env with secrets masked
- GET /env/public — public keys only (isPublic: true)
- GET /env/db — owner; raw rows from EnvService
- PUT /env — set one key (plugin, key, val) with sync
- PUT /env/many — bulk set then syncToDB
- Plugin code should use @InjectEnv() rather than calling these HTTP routes
When to use which
- process.env / .env — hosts, ports, Redis, compose wiring, @Database connection options
- @InjectEnv — per-tenant or per-plugin settings operators change from the ERP UI without redeploy
- DeveloperConfigService — typed plugin developer config (different from Env); see Builtin Services
- SettingEntity / SettingService — user or system settings UI; not the same as plugin Env
Local-only operations
Module table seeding and similar setup scripts that read DB_* from .env must target local/dev hosts only (localhost / 127.0.0.1). Never run against UAT or production.
- See Module seed for the local INSERT pattern
- If DB_HOST is a remote UAT/prod host, stop
Package versions
Align @quan-erp/* versions in plugin package.json with the base image / shared-backend-core version so Env injection and crypto helpers stay compatible.
Checklist
- Keep infrastructure secrets in base/backend/.env — not in source
- Inject with @InjectEnv() on property (constructor injection unsupported)
- Seed missing keys in @OnInit with explicit isPublic / isSecret / sync
- Mark API keys isSecret: true
- Use isPublic only for safe, UI-readable values
- Call sync() (or sync: true) after writes you need persisted across restart