Module 10 ended with an uncomfortable realization: Escena Viva works. It spreads load across workers, caches the catalog in Redis, processes the ticket-issuing queues in a separate process, measures its own 99th percentile and exposes both a REST API and a GraphQL one. And all of that still runs on a laptop, with the secrets in a .env file that only exists on your disk, with no centralized logs, no supervisor, no container and no automated deployment. This module closes that gap. And it starts where it has to start: with configuration. Not because it is the flashiest part, but because it is the first thing that breaks when an application leaves the laptop. The same codebase has to start on your machine pointing at a local database, on the continuous integration server pointing at ephemeral containers, in staging pointing at a copy of the real data, and in production pointing at the database where Lucía and Marc actually buy their tickets for the Festival de Jazz de Primavera. One codebase, four behaviors. The whole difference lives in the configuration.

Contents

  1. The twelve factors applied to configuration
  2. process.env for real
  3. dotenv and its limits
  4. Validate at startup and fail fast
  5. Per-environment configuration without duplicating files
  6. What never belongs in an environment variable
  7. Secret management done properly
  8. Rotating secrets without downtime
  9. When a secret leaks
  10. Hot reload versus restart
  11. Reference table of the Escena Viva variables

  1. The twelve factors applied to configuration

The Twelve-Factor App is a 2011 manifesto describing how an application deployed to cloud services should be built. Its factor III says, in one sentence: store configuration in the environment. The underlying idea is to separate what changes between deployments from what does not. The Escena Viva code is identical on your laptop and in production: the same createApplication, the same repositories, the same capacity arithmetic. What changes is which database it connects to, which key it signs JWTs with, which port it listens on and how many workers it starts. That is configuration. The practical test for telling one from the other is what the twelve factors call the public repository test:

Could you make the Escena Viva repository public right now, without changing anything, without exposing a single credential?

If the answer is no, you have configuration inside your code. It does not matter whether it lives in a config.json, in a JavaScript object or in a comment: if it is versioned and it is secret, you failed the test. Mind the nuance, though: the test is about credentials, not about any value that varies. The maximum capacity of Sala Bóveda is not per-environment configuration: it is a business rule, and it belongs in the domain or in the database. A very common mistake is turning every value that might one day change into an environment variable, and ending up with forty variables nobody can explain.

Is configuration Is not configuration
PostgreSQL, MongoDB, Redis URLs Table and collection names
JWT_ACCESS_SECRET, third-party API keys The business lifetime of a refresh token, decided by product
Listening port, worker count Capacity rules, base prices, venue types
Origin allowed by CORS Security headers that are always the same
Log level The EV-<year>-<6 digits> ticket code format

The mental rule: if two deployments of the same code need different values, it is configuration. If every deployment needs the same value, it is code.

  1. process.env for real

Node exposes the process environment variables on process.env. It is an ordinary object, but it has three characteristics that cause most of the mistakes. Everything is a string. Always. There are no numbers, no booleans, no nulls.

// Assuming: PORT=3000 DEBUG=false WORKERS=0
typeof process.env.PORT;                    // 'string'
process.env.PORT + 1;                       // '30001'  (concatenation, not addition)
Boolean(process.env.DEBUG);                 // true  (the string 'false' is truthy)
Number(process.env.WORKERS) || 4;           // 4  (the zero is lost)

All three lines are real bugs seen in production. The last one is especially treacherous: || 4 looks like a reasonable default until somebody explicitly asks for zero workers and gets four. That is why we will convert types with a schema later on, not by hand. undefined is not the same as an empty string. A variable that does not exist yields undefined; a variable declared with no value yields ''. Both are falsy in an if, but they mean different things: the first is "you never told me", the second is "I am explicitly telling you it is empty".

node -e "console.log(process.env.ALLOWED_ORIGINS)"                                 # undefined
ALLOWED_ORIGINS= node -e "console.log(JSON.stringify(process.env.ALLOWED_ORIGINS))" # ""

Uppercase by convention. It is not a Node rule, it is an inherited Unix convention: environment variables are written UPPERCASE_WITH_UNDERSCORES. Respect it even if the rest of the project uses camelCase; anyone reading a docker-compose.yml or a Heroku dashboard expects that shape.

Where the variables come from

This is the part that usually stays blurry. Variables do not come from one place: they come from the parent process, whichever it happens to be.

Source When it is used Example
Interactive shell One-off experiments PORT=4000 npm start
.env file + dotenv Local development .env at the project root
systemd unit VPS or your own server Environment= or EnvironmentFile=
PM2 ecosystem / container engine Supervisor or Docker env_production (11-03), ENV/-e (11-04)
PaaS provider dashboard Heroku, Render, Fly Config vars (lesson 11-05)
CI secrets Pipelines GitHub Actions secrets (lesson 11-06)

They all end up in the same place: process.env. That is why the application does not need to know which of them started it. That decoupling is exactly what makes the same code work in all seven scenarios.

  1. dotenv and its limits

In development, exporting fifteen variables by hand in every terminal is unbearable. dotenv reads a .env file and dumps its contents into process.env. The opening line of src/config/index.js, which has existed since Module 6, is simply require('dotenv').config();. Two limits you must be crystal clear about:

  1. dotenv does not overwrite what already exists in process.env. If the real environment already defines PORT, the .env is ignored for that variable. That is the correct behavior: the real environment outranks the convenience file.
  2. dotenv is not a secret manager. It is a plain-text file on your disk. It must not exist in production. What exists in production are variables injected by the orchestrator, the supervisor or the platform.

Since Node 20 there is also node --env-file=.env, which does the same thing with no dependency. We keep dotenv in Escena Viva because the project already uses it and because its behavior is identical across every version we support, but it is worth knowing the native alternative exists.

.env out, .env.example in

The Escena Viva .gitignore ignores .env and all of its local variants. What is versioned is .env.example: the complete list of variables the application needs, with fake or empty values. It is executable documentation, and it is the first thing a new team member looks at.

# .env.example — copy it to .env and fill in the values.
# NEVER put real values here: this file IS versioned.
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug

# Databases
POSTGRES_URL=postgres://escena:escena@localhost:5432/escena_viva
MONGO_URL=mongodb://localhost:27017/escena_viva
REDIS_URL=redis://localhost:6379
# Security — generate values with: openssl rand -hex 32
JWT_ACCESS_SECRET=
JWT_ACCESS_SECRET_PREVIOUS=
SESSION_SECRETS=
CSRF_SECRET=
# Network, processes and observability (11-02)
ALLOWED_ORIGINS=http://localhost:5173
TRUST_PROXY=false
WORKER_COUNT=0
QUEUE_CONCURRENCY=5
METRICS_TOKEN=

Operational rule: every time somebody adds a variable to the code, they add it to .env.example in the same commit. Otherwise the next person to clone the repository will discover the missing variable when the application blows up.

  1. Validate at startup and fail fast

Here is the heart of the lesson. The classic configuration failure is not that a variable is missing: it is when you find out it is missing. Imagine JWT_ACCESS_SECRET is not defined in production. Without validation, the application starts perfectly. It serves the catalog, shows the seven Festival de Jazz sessions, lets people browse. Three hours later Lucía tries to log in to buy two tickets and jsonwebtoken throws secretOrPrivateKey must have a value. A 500, a lost customer and a log line that says nothing useful. With startup validation, the application does not start. The deployment fails in the first second, the platform never routes traffic to the broken instance, and the message says exactly what is missing. That is fail fast. We extend src/config/index.js with a zod schema (the same library we already use to validate requests in Module 6, so we are not adding dependencies).

// src/config/index.js
'use strict';

require('dotenv').config();

const { z } = require('zod');

// Conversion helpers: remember that EVERYTHING arrives as a string.
const asInteger = (fallback) =>
  z.coerce.number().int().nonnegative().default(fallback);

const asBoolean = (fallback) =>
  z.enum(['true', 'false']).default(String(fallback)).transform((v) => v === 'true');

// A useful secret is long enough: 32 hex characters minimum.
const strongSecret = z.string().min(32, 'at least 32 characters: openssl rand -hex 32');

const configurationSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'staging', 'production']).default('development'),
  PORT: asInteger(3000),
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),

  POSTGRES_URL: z.string().url(),
  MONGO_URL: z.string().url(),
  REDIS_URL: z.string().url(),

  JWT_ACCESS_SECRET: strongSecret,
  // Optional: it only exists during a rotation (section 8).
  JWT_ACCESS_SECRET_PREVIOUS: strongSecret.optional(),
  SESSION_SECRETS: strongSecret,
  CSRF_SECRET: strongSecret,

  ALLOWED_ORIGINS: z.string().default('http://localhost:5173'),
  TRUST_PROXY: asBoolean(false),

  WORKER_COUNT: asInteger(0),
  QUEUE_CONCURRENCY: asInteger(5),

  METRICS_TOKEN: z.string().min(16).optional(),
});

const result = configurationSchema.safeParse(process.env);

if (!result.success) {
  // No logger, nothing elaborate: there is no application yet at this point.
  const problems = result.error.issues
    .map((i) => `  - ${i.path.join('.')}: ${i.message}`)
    .join('\n');
  process.stderr.write(
    `\nInvalid configuration. Escena Viva cannot start:\n${problems}\n\n` +
      'Check .env.example and the deployment environment variables.\n\n'
  );
  process.exit(1);
}

const raw = result.data;

// The shape of this object is NOT the shape of the environment: the rest of
// the code asks for configuration.security.jwtAccessSecret, never
// process.env.JWT_ACCESS_SECRET.
const configuration = Object.freeze({
  nodeEnv: raw.NODE_ENV,
  isProduction: raw.NODE_ENV === 'production',
  isTest: raw.NODE_ENV === 'test',
  port: raw.PORT,
  logLevel: raw.LOG_LEVEL,
  trustProxy: raw.TRUST_PROXY,
  allowedOrigins: raw.ALLOWED_ORIGINS.split(',').map((origin) => origin.trim()),
  database: Object.freeze({
    postgres: raw.POSTGRES_URL, mongo: raw.MONGO_URL, redis: raw.REDIS_URL,
  }),
  security: Object.freeze({
    jwtAccessSecret: raw.JWT_ACCESS_SECRET,
    jwtAccessSecretPrevious: raw.JWT_ACCESS_SECRET_PREVIOUS,
    sessionSecrets: raw.SESSION_SECRETS,
    csrfSecret: raw.CSRF_SECRET,
  }),
  processes: Object.freeze({
    workerCount: raw.WORKER_COUNT,
    queueConcurrency: raw.QUEUE_CONCURRENCY,
  }),
  observability: Object.freeze({ metricsToken: raw.METRICS_TOKEN }),
});

module.exports = { configuration };

What each decision is doing:

  • z.coerce.number() converts the string to a number inside the schema, so you never again write Number(process.env.SOMETHING) scattered around the code.
  • asBoolean accepts only 'true' or 'false': if somebody writes TRUST_PROXY=yes, startup fails with a clear message instead of quietly reading it as true.
  • .default() centralizes the defaults in a single place. The scattered || 4 disappears, and the zero problem goes with it.
  • safeParse + process.exit(1) turns any problem into immediate death with a non-zero exit code, which is exactly what PM2, Docker and the PaaS platforms read as "failed startup".
  • Object.freeze at every level stops a module from mutating the configuration at runtime and leaving the rest of the process looking at something else.
  • The shape of the exported object is not the shape of the environment. The rest of the code asks for configuration.security.jwtAccessSecret, not process.env.JWT_ACCESS_SECRET. If we rename the variable tomorrow, we touch one file.

With JWT_ACCESS_SECRET missing, startup produces Invalid configuration. Escena Viva cannot start: - JWT_ACCESS_SECRET: Required. Forty characters of output that save you an entire afternoon.

Golden rule: process.env is read exclusively in src/config/index.js. Anywhere else it is forbidden. You can police it with an ESLint rule (no-restricted-properties) or with a grep in the CI pipeline from lesson 11-06.

  1. Per-environment configuration without duplicating files

The temptation is to create config.development.js, config.staging.js and config.production.js. It is a mistake, because it duplicates structure: when you add a variable you have to touch three files and you always forget one, and because the only real difference between environments is the values, not the shape. In Escena Viva there is a single schema and the values arrive from outside:

Environment NODE_ENV Where the values come from
Development development Local .env with dotenv
Test test .env.test (Module 9) and CI services
Staging staging Provider dashboard or orchestrator secrets
Production production Secret manager + platform variables

Why NODE_ENV=production matters more than it looks

NODE_ENV is not a variable like the others: half the ecosystem looks at it.

  • Express disables the error stack trace in responses, enables the view cache and does less work per request. Running Express with NODE_ENV undefined in production is both slower and leakier.
  • Many libraries (validators, template engines, React on the front end) disable development checks and warnings.
  • npm: npm ci --omit=dev installs only production dependencies. That is what we will do in the Docker image in 11-04 and in the PaaS build in 11-05. And that is why everything the application needs at runtime has to live in dependencies, never in devDependencies. A require of a development package blows up in production and nowhere else.
  • Our own code: configuration.isProduction decides whether pino-pretty is active (11-02), whether cookies carry secure, whether stack traces are exposed.

Only the four values in the enum are accepted. A misspelled NODE_ENV=prod now stops the application from starting instead of silently leaving it in development mode.

  1. What never belongs in an environment variable

Yes No
Connection strings, keys, tokens Large or binary files (full PEM certificates)
Per-environment behavior switches Business logic disguised as configuration
Host names, ports, origins Users' personal data
Log level, simple feature flags Complex structures as encoded JSON

On that last point: VENUE_CONFIG={"teatro-almendra":{"capacity":800}} is an alarm bell. If your configuration needs structure, what you need is a mounted file (which may well come from an orchestrator secret) or a table in the database, not an environment variable with JSON inside it that nobody can read or debug.

  1. Secret management done properly

Environment variables are better than versioned code, but they are not secure. They leak through places you would not expect:

  • Process dumps. A core dump contains the entire environment block.
  • Logs and traces. One console.log(process.env) during an emergency debugging session, or a library that dumps the context into an error report, and your JWT_ACCESS_SECRET ends up in a third-party logging service.
  • /proc/<pid>/environ. On Linux, any process owned by the same user can read another process's environment.
  • Container images. An ENV JWT_ACCESS_SECRET=... in the Dockerfile is baked into an image layer, and the image gets pushed to a registry (11-04).
  • Error output. Some database clients include the full URL — user and password included — in the exception message.
  • Child processes. Every child inherits the parent's environment by default.

That is why secret managers exist. The honest landscape:

Solution How it works For Against
Provider variables (Heroku, Render, Fly) Dashboard or CLI, injected at startup Zero infrastructure, immediate No automatic rotation or fine-grained auditing
HashiCorp Vault Dedicated service; the app requests the secret with a token Rotation, auditing, dynamic short-lived secrets Operating Vault is a project in itself
AWS Secrets Manager / GCP Secret Manager Managed provider service, with IAM Native integration, scheduled rotation Ties you to the provider, costs per secret and access
Mounted files (Kubernetes/Swarm secrets) The secret shows up as a file inside the container Never appears in environ or in the image Requires an orchestrator; you have to read the file
Encryption in the repository (SOPS, git-crypt) Encrypted secrets are versioned and decrypted at deploy time History and PR review of the changes The master key still has to live somewhere

Escena Viva starts with provider variables (11-05) because the size of the project does not justify operating Vault, and it leaves the door open: since every read goes through src/config/index.js, migrating to mounted files means changing fifteen lines in a single file. A very useful middle-ground pattern is the _FILE suffix: if JWT_ACCESS_SECRET_FILE exists, the contents of that file are read; otherwise JWT_ACCESS_SECRET is used. That way the same image serves both a PaaS with variables and an orchestrator with mounted secrets.

  1. Rotating secrets without downtime

Secrets expire. They get rotated because somebody leaves the team, because an audit demands it or because they leaked. The problem is that changing JWT_ACCESS_SECRET all at once invalidates every token in circulation: Lucía, Marc and the organizers of all three venues get logged out mid-purchase. The solution is to accept two keys during the transition. You always sign with the new one and verify against both.

// src/services/tokens.js (excerpt adapted for rotation)
'use strict';

const jwt = require('jsonwebtoken');
const { configuration } = require('../config/index.js');
const { AuthenticationError } = require('../errors.js');
const { jwtAccessSecret, jwtAccessSecretPrevious } = configuration.security;

// We ALWAYS sign with the current secret.
const signAccessToken = (payload) => jwt.sign(payload, jwtAccessSecret, { expiresIn: '15m' });

function verifyAccessToken(token) {
  // Order matters: the current secret first (the common case).
  for (const secret of [jwtAccessSecret, jwtAccessSecretPrevious].filter(Boolean)) {
    try {
      return jwt.verify(token, secret);
    } catch (error) {
      // Expired or malformed token: we do not swallow it.
      if (error.name !== 'JsonWebTokenError') throw error;
    }
  }
  throw new AuthenticationError('Invalid token');
}

module.exports = { signAccessToken, verifyAccessToken };

The full procedure, with 15-minute access tokens: (1) generate the new key with openssl rand -hex 32; (2) put the current value into JWT_ACCESS_SECRET_PREVIOUS and the new one into JWT_ACCESS_SECRET; (3) restart with a zero-downtime reload (lesson 11-03) — from that moment on you sign with the new key and still accept the old one; (4) wait longer than the lifetime of the longest token — with 15-minute access tokens and 7-day refresh tokens, until the refresh tokens expire or you force their rotation; and (5) delete JWT_ACCESS_SECRET_PREVIOUS and restart once more. Notice that the schema already supports this: JWT_ACCESS_SECRET_PREVIOUS is .optional(), so the application starts just the same with one key or with two. Rotation requires no code changes.

  1. When a secret leaks

It happens. Somebody pastes the PostgreSQL URL into a chat, or commits the .env on a Friday. The order of the actions matters:

  1. Revoke and rotate first. A leaked secret is valid until it stops being valid; everything else can wait.
  2. Review access afterwards, using the Module 8 audit log: were there connections from unknown IPs? were any odd tokens issued?
  3. Clean up the history last, knowing it is not enough. Rewriting the Git history (git filter-repo) does not remove the secret from the clones that already exist, nor from the forks, nor from the web interface's cache, nor from the automated crawlers that scan GitHub looking for credentials. A secret that has been in a remote repository is compromised forever. Deleting the commit is hygiene, not a cure.
  4. Prevent a repeat. A pre-commit hook that detects credential patterns (the project has had husky since Module 9) plus secret scanning in the CI pipeline (11-06).

  1. Hot reload versus restart

There are two ways to apply a configuration change: reload it in the running process, or restart the process.

Hot reload Restart
Complexity High: every module must react to the change None
State Risk of inconsistency between modules Everything coherent from second zero
Service interruption None None if you have a zero-downtime reload
Auditing Hard to know which values were in effect when Startup leaves a record

Escena Viva restarts, and that is why configuration is frozen. The reason is that we already have graceful shutdown (Module 6) and we are about to get rolling worker reloads (11-03): restarting costs seconds and does not drop a single request. The complexity of hot reloading is only justified when startup is extremely expensive, which is not our case. One important note: a feature flag is not deployment configuration. If you want to open early sales for the Festival de Jazz at 10:00 without restarting, that belongs in the database or in a feature-flag service, not in process.env.

  1. Reference table of the Escena Viva variables

This table is the reference we will use throughout the rest of the module: the .env.example, the PM2 ecosystem.config.js, the docker-compose.yml, the PaaS dashboard and the CI secrets are all derived from it.

Variable Type Required Default Where it is used
NODE_ENV enum No development Express, npm, configuration.isProduction
PORT integer No 3000 src/server.js
LOG_LEVEL enum No info pino (11-02)
POSTGRES_URL url Yes — src/db/sequelize.js
MONGO_URL url Yes — src/db/connection.js
REDIS_URL url Yes — src/db/redis.js, session, limits, queues
JWT_ACCESS_SECRET secret ≥32 Yes — src/services/tokens.js
JWT_ACCESS_SECRET_PREVIOUS secret ≥32 No — Rotation (section 8)
SESSION_SECRETS secret ≥32 Yes — src/middleware/session.js
CSRF_SECRET secret ≥32 Yes — src/middleware/csrf.js
ALLOWED_ORIGINS list No http://localhost:5173 src/middleware/cors.js
TRUST_PROXY boolean No false trust proxy (11-05)
WORKER_COUNT integer No 0 (= cores) src/cluster.js, Sequelize pool
QUEUE_CONCURRENCY integer No 5 src/processes/ticket-consumer.js
METRICS_TOKEN secret ≥16 No — protected /metrics (11-02)

Common Mistakes and Tips

  • Reading process.env outside src/config/index.js. This is the mistake that does the most long-term damage: you lose validation, defaults scatter, and nobody knows which variables the application actually uses. Ban it with lint.
  • Confusing "it is not in the code" with "it is safe". An environment variable is visible in /proc, in dumps and in any accidental spill. Treat it as a secret in transit, not as a safe.
  • Tip: generate every secret with openssl rand -hex 32 and never reuse the same value for JWT_ACCESS_SECRET, SESSION_SECRETS and CSRF_SECRET: if one leaks, all three leak. And update .env.example and the table in section 11 in the same commit where you add a variable.

Exercises

Exercise 1 — Configuration diagnostics

Write a scripts/check-configuration.js script that loads src/config/index.js and prints a table with every variable in the schema, indicating for each one whether it came from the environment or from a default, redacting the secrets. It must exit with code 1 if the configuration is invalid (leaning on the process.exit(1) the module already performs).

Exercise 2 — _FILE support

Extend src/config/index.js so that any variable can come from a file: if <NAME>_FILE exists, the contents of that path are read (trimming the trailing newline) and used as the value of <NAME>. Apply it before validating with zod.

Exercise 3 — Simulated rotation

Write an integration test verifying that a token signed with JWT_ACCESS_SECRET_PREVIOUS is still accepted by verifyAccessToken, and that one signed with a third, unknown key is rejected with AuthenticationError.

Solutions

Exercise 1. The key is not to read process.env again in the script except to find out the source, and to redact by name:

// scripts/check-configuration.js
'use strict';

// If the configuration were invalid, this require would already have exited with code 1.
const { configuration } = require('../src/config/index.js');

const SENSITIVE = /SECRET|TOKEN|PASSWORD|POSTGRES_URL|MONGO_URL/;
const NAMES = [
  'NODE_ENV', 'PORT', 'LOG_LEVEL', 'POSTGRES_URL', 'MONGO_URL',
  'REDIS_URL', 'JWT_ACCESS_SECRET', 'JWT_ACCESS_SECRET_PREVIOUS', 'SESSION_SECRETS',
  'CSRF_SECRET', 'ALLOWED_ORIGINS', 'TRUST_PROXY', 'WORKER_COUNT',
  'QUEUE_CONCURRENCY', 'METRICS_TOKEN',
];

const redact = (name, value) =>
  value === undefined ? '(unset)' : SENSITIVE.test(name) ? '********' : String(value);

console.table(NAMES.map((name) => ({
  variable: name,
  source: process.env[name] === undefined ? 'default' : 'environment',
  value: redact(name, process.env[name]),
})));

Exercise 2. You preprocess process.env before the safeParse:

const fs = require('node:fs');

function resolveFiles(environment) {
  const resolved = { ...environment };
  for (const [key, value] of Object.entries(environment)) {
    if (!key.endsWith('_FILE') || !value) continue;
    try {
      // Synchronous read on purpose: we are still starting up.
      resolved[key.slice(0, -'_FILE'.length)] = fs.readFileSync(value, 'utf8').trimEnd();
    } catch (error) {
      process.stderr.write(`Could not read ${key}=${value}: ${error.message}\n`);
      process.exit(1);
    }
  }
  return resolved;
}

const result = configurationSchema.safeParse(resolveFiles(process.env));

The read is synchronous on purpose: we are still in the startup path, before there is an event loop with real work to do, and we want to fail before going any further.

Exercise 3. With mocha and chai, signing by hand with each key:

const { expect } = require('chai');
const jwt = require('jsonwebtoken');
const { configuration } = require('../../src/config/index.js');
const { verifyAccessToken } = require('../../src/services/tokens.js');

describe('JWT_ACCESS_SECRET rotation', () => {
  const payload = { sub: 'usr-lucia', role: 'attendee' };

  it('accepts a token signed with the previous secret', () => {
    const previous = configuration.security.jwtAccessSecretPrevious;
    expect(previous, 'define JWT_ACCESS_SECRET_PREVIOUS in .env.test').to.be.a('string');
    const token = jwt.sign(payload, previous, { expiresIn: '15m' });
    expect(verifyAccessToken(token)).to.include({ sub: 'usr-lucia' });
  });
  it('rejects a token signed with an unknown key', () => {
    const token = jwt.sign(payload, 'x'.repeat(32), { expiresIn: '15m' });
    expect(() => verifyAccessToken(token)).to.throw(/invalid token/i);
  });
});

Conclusion

Escena Viva's configuration has stopped being a .env on your laptop and become an explicit contract: a zod schema that enumerates every variable, converts its type, applies defaults and kills the process with a comprehensible message if a secret is missing. A single read point for process.env, a .env.example that documents the contract, a reference table that will be the basis for the PM2 file, the Dockerfile, the docker-compose.yml, the PaaS dashboard and the CI secrets. And, on top of that, a rotation strategy that lets you change the JWT signing key without logging anybody out. With configuration settled, the next uncomfortable question appears: when something fails in production at three in the morning — and it will — how do you find out? In the next lesson, Logging and Monitoring in Production, we replace the console.log calls with structured JSON logging using pino, hang the child logger off the requestId we have been carrying since Module 6 so we can follow a failed purchase across every trace it leaves, expose metrics with prom-client — including the event loop lag we already know how to measure — and implement the /health/live and /health/ready probes the rest of the module will take for granted.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved