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
- The twelve factors applied to configuration
process.envfor realdotenvand its limits- Validate at startup and fail fast
- Per-environment configuration without duplicating files
- What never belongs in an environment variable
- Secret management done properly
- Rotating secrets without downtime
- When a secret leaks
- Hot reload versus restart
- Reference table of the Escena Viva variables
- 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.
process.env for real
process.env for realNode 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.
dotenv and its limits
dotenv and its limitsIn 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:
dotenvdoes not overwrite what already exists inprocess.env. If the real environment already definesPORT, the.envis ignored for that variable. That is the correct behavior: the real environment outranks the convenience file.dotenvis 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.
- 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 writeNumber(process.env.SOMETHING)scattered around the code.asBooleanaccepts only'true'or'false': if somebody writesTRUST_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|| 4disappears, 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.freezeat 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, notprocess.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.envis read exclusively insrc/config/index.js. Anywhere else it is forbidden. You can police it with an ESLint rule (no-restricted-properties) or with agrepin the CI pipeline from lesson 11-06.
- 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_ENVundefined 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=devinstalls 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 independencies, never indevDependencies. Arequireof a development package blows up in production and nowhere else. - Our own code:
configuration.isProductiondecides whetherpino-prettyis active (11-02), whether cookies carrysecure, 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.
- 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.
- 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 yourJWT_ACCESS_SECRETends 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 theDockerfileis 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.
- 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.
- 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:
- Revoke and rotate first. A leaked secret is valid until it stops being valid; everything else can wait.
- Review access afterwards, using the Module 8 audit log: were there connections from unknown IPs? were any odd tokens issued?
- 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. - 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).
- 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.
- 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.envoutsidesrc/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 32and never reuse the same value forJWT_ACCESS_SECRET,SESSION_SECRETSandCSRF_SECRET: if one leaks, all three leak. And update.env.exampleand 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
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
