Usa il database per la configurazione del backend NestJS

Gestire la configurazione in un'applicazione complessa è sempre un compromesso tra sicurezza, flessibilità e facilità d'uso. In questo articolo, esploro un approccio ibrido utilizzando sia variabili d'ambiente che un sistema di configurazione basato su database per un backend NestJS.

Configurazione DB vs .env

La metodologia standard "Twelve-Factor App" suggerisce di memorizzare la configurazione nell'ambiente (ad esempio, file .env). Sebbene questo sia eccellente per segreti e impostazioni a livello di infrastruttura, presenta limitazioni per la configurazione a runtime.

Pro e Contro

  • .env: Sicuro e standard, ma richiede un riavvio per modificare i valori ed è difficile da gestire dinamicamente tramite un'interfaccia utente.
  • Database: dinamico e interrogabile, ma aggiunge una dipendenza dalla connessione al DB per l'avvio dell'app.

La mia filosofia

Ho deciso di mantenere una separazione rigorosa:

  1. Obbligatorio per l'avvio: Mantieni all'interno di .env solo ciò che è strettamente necessario per avviare l'applicazione (credenziali del database, segreti JWT, binding della porta di base).
  2. Configurazione Runtime: Sposta la logica di business (ad esempio, feature flags, limiti API esterni, preferenze UI) nel database.

La mia implementazione

Schema SQL

Ho utilizzato una semplice struttura di tabella con una colonna JSONB per memorizzare valori di configurazione flessibili. Questo mi consente di memorizzare stringhe, numeri o oggetti complessi senza modificare lo schema.

CREATE TABLE config (
    key VARCHAR(50) PRIMARY KEY,
    description TEXT,
    is_env_value BOOLEAN NOT NULL DEFAULT FALSE,
    value JSONB NOT NULL,
    created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
    updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
    deleted_at timestamp without time zone
);

Dati di Esempio

INSERT INTO config ("key",description,is_env_value,value,created_at,updated_at,deleted_at) VALUES
	 ('URLS_CONFIG','Configura urls',true,'{"BACKEND_URL":"http://localhost:3000","FRONTEND_URL":"http://localhost:4200","UPLOAD_PUBLIC_URL":"http://localhost:3000/uploads","N8N_BASE_URL":"http://localhost:5678","N8N_FASTAPI_CONFIG":"/webhook/fastapi/config","N8N_INGEST_WEBHOOK":"/webhook/ingest","N8N_QUERY_WEBHOOK":"/webhook/query","N8N_ENGINE_MANAGER":"/webhook/engine-manager","N8N_DELETE_WEBHOOK":"/webhook/delete","N8N_DISCOVER_MODELS_WEBHOOK":"/webhook/discover-models"}','2026-01-25 11:17:33.18413','2026-02-02 14:13:42.607364',NULL),
	 ('LDAP_CONFIG','LDAP authentication settings (migrated from .env)',true,'{"LDAP_AUTH":true,"LDAP_DIRECT_BIND":"false","LDAP_TIMEOUT":"3000","LDAP_SERVER":"ldap://host.docker.internal:389","LDAP_BASE_DN":"ou=peoples,dc=example,dc=org","LDAP_FILTER":"(&(objectClass=person)(uid={username}))","LDAP_APP_USER":"cn=admin,dc=example,dc=org","LDAP_APP_PASS":"[LDAP_APP_PASS]","LDAP_FIELD_MAP":"uid:username,mail:email,cn:fullName","LDAP_PASSWORD_CHANGE":"http://localhost:4200"}','2026-01-23 10:57:36.744778','2026-01-25 14:52:18.87003',NULL),
	 ('EMAIL_CONFIG','Configurazione invio email',true,'{"MAIL_HOST":"smtp.gmail.com","MAIL_PORT":"465","MAIL_SECURE":"true","MAIL_FROM":"[YOUR_EMAIL_HERE]","MAIL_BCC":"","MAIL_USER":"[YOUR_ACCOUNT_HERE]","MAIL_PASSWORD":"[YOUR_PASSWORD_HERE]","MAIL_LANGUAGES":"it,en","EMAIL_TEMPLATE_PATH":"./data/templates/email"}','2026-01-25 10:53:33.312718','2026-01-29 08:32:03.969394',NULL);

Configuration Service

La logica principale risiede in un RagConfigService. All'inizializzazione del modulo, carica tutte le configurazioni dal database in una cache Map in memoria. Ciò garantisce che la lettura della configurazione sia veloce quanto la lettura di una variabile, senza colpire il DB ogni volta.

Caratteristiche principali:

  • Caching: Tutti i valori sono memorizzati nella cache in memoria.
  • Fallback: Se una chiave non viene trovata nel DB, ricorre al ConfigService di NestJS (controllando .env).
  • Flattening: Supporta valori "sparsi", appiattendo gli oggetti JSON in chiavi con notazione a punto per un recupero più semplice.
// rag-config.service.ts (Excerpt)
async loadAllConfigIntoCache(): Promise<void> {
  const allConfigs = await this.findAll();
  this.configCache.clear();

  allConfigs.forEach(config => {
    this.configCache.set(config.key, config.value);
    // Logica di appiattimento per valori sparsi...
  });
  
  // Notifica all'app che la configurazione è cambiata
  this.eventEmitter.emit('config.reloaded');
}

public get<T>(key: string, defaultValue?: any): T {
  // 1. Controlla la cache del DB
  if (this.configCache.has(key)) {
    return this.configCache.get(key) as T;
  }
  // 2. Fallback su .env
  return this.nestConfigService.get<T>(key) ?? defaultValue;
}

Esempio Reale: Mailer Service

Ecco come il MailerService ascolta l'evento di ricaricamento e reinizializza il trasporto con le nuove impostazioni.

@OnEvent('config.reloaded')
async onConfigReloaded() {
  this.initializeMailer();
}

private initializeMailer() {
  const configService = this.configService;
  this.transporter = nodemailer.createTransport({
    host: configService.get<string>('MAIL_HOST'),
    port: configService.get<number>('MAIL_PORT'),
    secure: configService.get<string>('MAIL_SECURE') === 'true',
    auth: {
      user: configService.get<string>('MAIL_USER'),
      pass: configService.get<string>('MAIL_PASSWORD'),
    },
  });
  this.templateBasePath = configService.get<string>('EMAIL_TEMPLATE_PATH') || join(__dirname, 'templates');

  const langs = configService.get<string>('MAIL_LANGUAGES', 'en');
  this.supportedLanguages = langs.split(',').map(l => l.trim()).filter(Boolean);

  const subjectsPath = join(this.templateBasePath, 'email-subjects.json');
  try {
    this.emailSubjects = JSON.parse(fs.readFileSync(subjectsPath, 'utf-8'));
    this.logger.log(`Loaded email subjects for languages: ${Object.keys(this.emailSubjects).join(', ')}`);
  } catch (err) {
    this.logger.error(`Failed to load email subjects: ${err.message}`);
    this.emailSubjects = {};
  }

  const bccEmail = configService.get<string>('MAIL_BCC', '');
  this.emailBcc = this.validateBccEmail(bccEmail);
}

Event Emitter

Per rendere il sistema dinamico senza riavvii, ho utilizzato EventEmitter2. Quando una configurazione viene aggiornata tramite l'API, il servizio emette un evento config.reloaded. Altri servizi possono ascoltare questo evento e reagire immediatamente (ad esempio, riconnettendo un client o cancellando una cache specifica) senza un riavvio completo dell'applicazione.

Frontend manager

Per gestire facilmente queste configurazioni, ho creato un componente Angular utilizzando un editor JSON. Questo mi consente di creare, modificare ed eliminare chiavi di configurazione direttamente dalla dashboard di amministrazione.

Screenshot Config Manager

(Screenshot: Una vista tabellare che mostra chiavi come EMAIL_CONFIG, con un pulsante 'Modifica' che apre un modale con editor JSON)

Il gestore si occupa di:

  • Operazioni CRUD: Creare, Leggere, Aggiornare, Eliminare configurazioni.
  • Validazione JSON: Utilizza un editor visivo JSON per garantire che la colonna value contenga dati validi.
  • Hot Reload: Un pulsante "Aggiorna" attiva la sequenza di ricaricamento del backend.
// config-management.component.ts (Excerpt)
onSave(): void {
  if (this.configForm.valid) {
    const data = this.configForm.getRawValue();
    // Ottieni oggetto complesso dall'editor JSON
    if (this.editor) {
      data.value = this.editor.get();
    }

    const obs = this.isNew
      ? this.configService.create(data)
      : this.configService.update(data.key, data);

    obs.subscribe(() => {
        // Gestisci successo...
    });
  }
}

Questa configurazione offre il meglio di entrambi i mondi: la stabilità delle variabili d'ambiente per l'infrastruttura di base e la flessibilità della configurazione del database per la logica di business.