The app-minimal.js from the previous lesson fit in ten lines and started up with app.listen(3000). It works, but it cannot be tested, it cannot be shut down gracefully, it cannot be configured per environment and it has nowhere to grow. In this lesson we turn it into the real skeleton of Escena Viva: an application that is created in one place and started in another, with configuration centralized and validated, framework settings chosen deliberately, and a folder structure that can carry the six modules left in the course.

Contents

  1. The key separation: creating the application and starting it
  2. src/app.js: the createApplication() factory
  3. src/server.js: startup
  4. The Escena Viva folder structure
  5. Application settings with app.set and app.get
  6. Per-environment configuration: dotenv and src/config/index.js
  7. Mounting the application on your own http server
  8. Graceful shutdown with SIGTERM
  9. app.locals versus res.locals
  10. A note about view engines

  1. The key separation: creating the application and starting it

A rule we are not going to break for the rest of the course:

One module creates the application and returns it. A different module makes it listen.

That is: src/app.js exports createApplication(), which builds the Express object with all its routes and middleware and returns it without calling listen. And src/server.js imports that function, creates the HTTP server and makes it listen.

It looks like a matter of style. It is not:

Reason What would happen if you mixed both
Testing (M9) supertest needs the application not started: you hand it the app and it brings up an ephemeral server on a free port. If app.js calls listen, every test file would try to occupy port 3000 and the tests would fail in parallel.
Graceful shutdown (M11) To close properly you need the reference to the Server object, not to the app. You only have it if startup is explicit.
Reuse You can mount several instances (API and metrics on different ports) or nest it with app.use('/api/v1', createApplication()).

On top of that, createApplication() is a factory, not a directly exported object: it lets you pass options and guarantees that each test gets a clean application, with no shared state from the previous one.

  1. src/app.js: the createApplication() factory

This is the first version. It will keep growing in 06-03 (routers), 06-04 (our own middleware), 06-05 (third-party middleware) and 06-07 (errors).

// src/app.js
const express = require('express');
const { configuration } = require('./config/index.js');

/**
 * Builds the Escena Viva Express application.
 * It starts no server: it only returns the configured application.
 */
function createApplication(options = {}) {
  const { serveStaticFiles = true } = options;
  const app = express();

  // --- Framework settings (section 5) ---
  app.disable('x-powered-by');
  app.set('trust proxy', configuration.trustProxy);
  app.set('case sensitive routing', true);
  app.set('json spaces', configuration.isProduction ? 0 : 2);

  // --- Values shared by the whole application (section 9) ---
  app.locals.platformName = 'Escena Viva';
  app.locals.version = require('../package.json').version;

  // --- Built-in middleware: JSON body reading and static files ---
  app.use(express.json({ limit: configuration.bodyLimit }));
  if (serveStaticFiles) {
    app.use(express.static(configuration.publicDir, { index: 'index.html' }));
  }

  // --- Routes (to be filled in in 06-03) ---
  app.get('/health', (req, res) => {
    const { platformName, version } = app.locals;
    res.json({ status: 'ok', platform: platformName, version });
  });

  return app;
}

module.exports = { createApplication };

Points worth calling out: there is no listen anywhere (that is the contract); the function takes options with default values, so createApplication() with no arguments still works; the configuration comes from a module, not from process.env scattered across the file (section 6); and diagnostics are not printed here, because app.js does not talk to the console, it only builds.

  1. src/server.js: startup

// src/server.js
const http = require('node:http');
const { createApplication } = require('./app.js');
const { configuration } = require('./config/index.js');

/** Creates the HTTP server and makes it listen. */
function startServer() {
  // We create the server by hand instead of using app.listen(...):
  // that way we keep the Server reference so we can close it (sections 7 and 8).
  const server = http.createServer(createApplication());
  const { port, host, environment } = configuration;

  server.listen(port, host, () => {
    console.error(`[escena-viva] env=${environment} listening on http://${host}:${port}`);
  });

  registerGracefulShutdown(server);
  return server;
}

module.exports = { startServer };

// The file is both a module and a script.
if (require.main === module) startServer();

We will write the registerGracefulShutdown function in section 8. Update the package.json scripts too, because the entry point has changed since Module 4: "start": "node src/server.js" and "dev": "node --watch src/server.js".

  1. The Escena Viva folder structure

Express imposes no structure. This is ours, designed so that every file has a single reason to change.

escena-viva/
├── data/                     # events.json, sales.csv (already existed)
├── public/                   # index.html, styles.css, app.js (already existed)
├── src/
│   ├── app.js                # createApplication()
│   ├── server.js             # startup and shutdown
│   ├── config/               # index.js (NEW) + paths.js (already existed)
│   ├── routes/               # Express routers          (06-03)
│   ├── controllers/          # handlers (req, res)      (06-03)
│   ├── middleware/           # our own middleware       (06-04)
│   ├── schemas/              # validation with zod      (06-06)
│   ├── errors.js             # error hierarchy          (06-07)
│   ├── domain/               # Event, Session, SalesManager (already existed)
│   ├── services/             # currency-exchange.js and business logic
│   ├── catalog-data.js       # JSON access (already existed)
│   ├── reports/ streams/ utils/ and server/ (the hand-written M4 one, for reference)
└── package.json

Responsibilities, so nobody has any doubt about where each thing goes:

Folder Responsibility What it must not contain
routes/ Declare which URL calls which controller and which middleware protects it Business logic
controllers/ Translate HTTP into domain: read req, call a service, respond Capacity rules, file access
services/ Use cases: orchestrate domain and data req/res objects
domain/ Escena Viva rules: capacity, statuses, prices No HTTP, no Express
middleware/ Cross-cutting concerns: logging, identifier, caching Rules specific to one endpoint
schemas/ and config/ Expected shape of the input; reading and validating the environment Queries, responses or logic

The rule that sums it all up: the domain does not import Express, and Express does not import fs. If a file in domain/ needs require('express'), something is in the wrong place.

A warning against over-engineering

This structure makes sense for an API that is going to grow over six more modules. For a three-endpoint project it is overkill. Signs that you are overdoing it: controllers that only call a service that only calls a repository that only does one line (three files for a trivial operation), empty folders "just in case", and interfaces and abstractions for a single implementation that will never have another.

Start with routes/ + controllers/ and pull out services/ when the logic is repeated in two controllers, not before. In Escena Viva we will indeed reach that point, because ticket sales are used both from the API and from the reports.

  1. Application settings with app.set and app.get

Express keeps settings in an internal dictionary. app.set(name, value) writes, app.get(name) reads (careful: app.get with two arguments registers a GET route; with one, it reads a setting), and app.enable/app.disable are shortcuts for boolean values.

Setting Recommended value What it does and why
env process.env.NODE_ENV or development In production it enables the view cache and the error handler stops sending the stack trace (06-07)
x-powered-by disabled Express adds X-Powered-By: Express to every response: free information for anyone hunting for targets running a known version. It is not much of a defense, but it costs nothing
trust proxy 1 or the list of trusted proxies Behind Nginx or a load balancer, req.ip is the proxy's IP and req.protocol is http even if the client came over HTTPS. With this setting, Express reads X-Forwarded-For and X-Forwarded-Proto. Essential for the rate limiter in 06-05 and for the logging in Module 11
json spaces 2 in development, 0 in production Indents the JSON from res.json(). Handy when debugging with curl, wasted bandwidth in production
case sensitive routing true Without it, /Events and /events are the same route. Two different URLs returning the same thing hurts caching and SEO
strict routing false With false, /events and /events/ are the same route (the friendly behavior). With true, they are different. Our normalizePath from Module 4 did exactly this by hand
query parser 'simple' or 'extended' How nested query strings are interpreted (?filter[venue]=Bóveda). 'simple' uses the core querystring; 'extended' uses qs and supports nesting

From a handler you can read them with app.get('env') or app.get('trust proxy'); req.ip and req.protocol depend on the latter.

Careful with trust proxy. Turn it on only if there really is a proxy in front. Exposed directly to the internet, anyone can forge their IP by sending an X-Forwarded-For header, and your rate limiter becomes useless. That is why in our configuration it is an environment value, not a constant.

  1. Per-environment configuration: dotenv and src/config/index.js

dotenv has been a dependency since Module 5. Its job is to load a .env file into process.env. Nothing else: it does not validate or convert types. We do that ourselves.

# .env  (NOT committed to the repository; the repository holds .env.example)
NODE_ENV=development
PORT=3000
BODY_LIMIT=100kb
TRUST_PROXY=0
ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
// src/config/index.js
const path = require('node:path');
require('dotenv').config();

const { ROOT } = require('./paths.js');

// Typed readers: each one throws if the value does not meet expectations.
const optional = (name, fallback) => (process.env[name] ?? '').trim() || fallback;

function required(name) {
  const value = optional(name, '');
  if (value === '') throw new Error(`Missing required variable: ${name}`);
  return value;
}

function integer(name, fallback, { min, max }) {
  const number = Number.parseInt(optional(name, String(fallback)), 10);
  if (!Number.isInteger(number) || number < min || number > max) {
    throw new Error(`The ${name} variable must be an integer between ${min} and ${max}`);
  }
  return number;
}

const list = (n) => optional(n, '').split(',').map((e) => e.trim()).filter(Boolean);

const VALID_ENVIRONMENTS = ['development', 'test', 'production'];

function buildConfiguration() {
  const environment = optional('NODE_ENV', 'development');
  if (!VALID_ENVIRONMENTS.includes(environment)) {
    throw new Error(`NODE_ENV must be one of: ${VALID_ENVIRONMENTS.join(', ')}`);
  }

  return Object.freeze({
    environment,
    isProduction: environment === 'production',
    port: integer('PORT', 3000, { min: 1, max: 65535 }),
    host: optional('HOST', '127.0.0.1'),
    bodyLimit: optional('BODY_LIMIT', '100kb'),
    trustProxy: integer('TRUST_PROXY', 0, { min: 0, max: 10 }),
    allowedOrigins: list('ALLOWED_ORIGINS'),
    publicDir: path.join(ROOT, 'public'),
    // In production we demand a signing key; in development there is a filler value.
    gatewayKey: environment === 'production'
      ? required('GATEWAY_KEY')
      : optional('GATEWAY_KEY', 'development-key'),
  });
}

let configuration;
try {
  configuration = buildConfiguration();
} catch (error) {
  console.error(`[configuration] ${error.message}`); // fail fast
  process.exit(1);
}

module.exports = { configuration, VALID_ENVIRONMENTS };

The ideas behind this file:

  1. A single point where process.env is read. Searching for it in the project should give exactly one result: this file. That way you know at a glance what the application needs in order to start.
  2. Fail fast. If the gateway key is missing in production, the process dies at startup with a clear message. The alternative —starting up and failing three hours later, on the first sale— is far worse.
  3. Correct types. process.env.PORT is the string '3000'; the configuration exposes the number 3000 and avoids surprise comparisons.
  4. A frozen object with Object.freeze: nobody changes the configuration at runtime.
  5. The .env never goes into the repository. What you version is .env.example; add .env to .gitignore.

In Module 11 we will extend this with managed secrets; for now, this is enough.

  1. Mounting the application on your own http server

In 06-01 we saw that app is a handler function. Here we take advantage of it: we write http.createServer(app) instead of app.listen(...). What exactly do you gain?

Advantage What you will need it for
You hold the Server reference Closing it with server.close() during graceful shutdown (section 8)
You can attach other protocols to the same port Socket.IO in Module 12 does new Server(server)
You can tune timeouts server.keepAliveTimeout, server.headersTimeout in Module 11
You can listen to server events server.on('error', ...) to catch EADDRINUSE with a decent message
You can have two servers with the same app HTTP and HTTPS, or a separate metrics port
// The classic error: the port is already taken.
server.on('error', (error) => {
  if (error.code !== 'EADDRINUSE') throw error;
  console.error(`[escena-viva] port ${configuration.port} is already in use`);
  process.exit(1);
});

  1. Graceful shutdown with SIGTERM

When an orchestrator (PM2, Docker, Kubernetes) wants to stop your process, it sends it the SIGTERM signal. If you do nothing, Node dies immediately and in-flight requests are cut in half: a buyer could see their browser hang right after submitting an order. Graceful shutdown means stopping the acceptance of new connections, finishing the ones in flight, closing resources and exiting.

// src/server.js (continued)
const GRACE_PERIOD_MS = 10_000;

function registerGracefulShutdown(server) {
  let shuttingDown = false;

  function closeAndExit(signal) {
    if (shuttingDown) return; // a second signal does not duplicate the shutdown
    shuttingDown = true;
    console.error(`[escena-viva] received ${signal}, closing gracefully...`);

    // 1. We stop accepting new connections; the in-flight ones finish.
    server.close((error) => {
      if (error) console.error('[escena-viva] error while closing:', error.message);
      process.exit(error ? 1 : 0);
    });

    // 2. We close idle keep-alive connections so we don't wait for them to expire.
    server.closeIdleConnections();

    // 3. A safety net; unref keeps the timer from holding the process alive.
    setTimeout(() => {
      console.error('[escena-viva] forced shutdown after the grace period');
      process.exit(1);
    }, GRACE_PERIOD_MS).unref();
  }

  process.on('SIGTERM', () => closeAndExit('SIGTERM'));
  process.on('SIGINT', () => closeAndExit('SIGINT')); // Ctrl+C in development
}

Details people usually skip: the shuttingDown flag keeps pressing Ctrl+C twice from launching two simultaneous shutdowns; without closeIdleConnections() an idle keep-alive connection keeps the server alive until it expires and the shutdown drags on forever; and the timer with unref() guarantees that the process ends even if something gets stuck, without preventing it from ending sooner if all goes well. This is where, in Module 11, we will add closing the database and flushing pending logs.

  1. app.locals versus res.locals

Two stores with similar names and very different lifetimes.

app.locals res.locals
Scope The whole application A single request/response
Lifetime From startup to shutdown From when the request arrives until it is answered
Shared between users Yes No
Typical use Platform name, version, constants Request identifier, authenticated user, start timestamp
// Globals: set once, in createApplication().
app.locals.venues = ['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'];

// Per request: set by a middleware (06-04).
app.use((req, res, next) => {
  res.locals.requestId = crypto.randomUUID();
  next();
});

A serious and frequent mistake: storing the current user's data in app.locals. Since it is global, the second user would see the first user's data. Anything that depends on who is making the request goes in res.locals (or on req, as we will do with req.event and req.validatedData).

  1. A note about view engines

Express knows how to render templates on the server: app.set('view engine', 'ejs'), app.set('views', ...) and res.render('event', { event }). It is the classic way of generating HTML from Node and it is still valid for content-driven sites.

Escena Viva does not use it. Our front end is the public/ folder (HTML, CSS and an app.js that calls the API with fetch), and the server is a JSON API: the same API serves a web front end, a mobile app or an internal dashboard, and its responses are tested with supertest without parsing HTML. What we do keep is express.static, which serves those files and replaces your static.js from Module 4; we will take it apart in 06-04.

Common Mistakes and Tips

  • Calling listen() inside app.js. It breaks the Module 9 tests and the Module 11 shutdown. If you see a listen outside server.js, it is a bug.
  • Exporting the already-built app (module.exports = app) instead of a factory. It works until a test needs a different configuration; then everything has to be refactored.
  • process.env scattered across twenty files. Impossible to know what the application needs. Centralize it in config/index.js.
  • Confusing app.get('name') with app.get('/path', handler). One argument reads a setting; two register a route.
  • Enabling trust proxy with no proxy. You are giving away the ability to forge the IP to anyone.
  • Committing .env to the repository. Into .gitignore from day one; version .env.example.
  • Tip: run node -e "require('./src/config/index.js')" in the check script. If the configuration is invalid, you will find out in continuous integration and not in production.

Exercises

Exercise 1: the separation in practice

Create src/app.js and src/server.js with the structure from this lesson and prove that the separation works: write a third file try-app.js that imports createApplication(), mounts it on an http.createServer on port 0 (Node assigns a free one), makes a request to /health with fetch and closes the server. It must not touch src/server.js at any point.

Exercise 2: configuration that fails fast

Extend src/config/index.js with a DEFAULT_CURRENCY variable that only accepts EUR, USD or GBP, defaulting to EUR. Check that an invalid value prevents startup with a clear message and exit code 1.

Exercise 3: an observable shutdown

Add an in-flight request counter to the graceful shutdown: a middleware increments it on entry and decrements it on res.on('finish'). On receiving SIGTERM, the server must print to stderr how many requests were still alive. Test it with a slow endpoint (/slow, which takes 3 seconds) and by sending the signal while it is in flight.

Solutions

Solution 1

// try-app.js
const http = require('node:http');
const { createApplication } = require('./src/app.js');

async function main() {
  const server = http.createServer(createApplication({ serveStaticFiles: false }));
  // Port 0: the operating system assigns a free one. This is what supertest does.
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));

  const response = await fetch(`http://127.0.0.1:${server.address().port}/health`);
  console.log(JSON.stringify({ status: response.status, body: await response.json() }));

  await new Promise((resolve) => server.close(resolve));
}

main().catch((error) => {
  console.error('[try-app]', error.message);
  process.exitCode = 1;
});

That this is possible without starting src/server.js is exactly the proof that the separation is done right.

Solution 2

// Inside src/config/index.js
const VALID_CURRENCIES = ['EUR', 'USD', 'GBP'];

function currency(name, fallback) {
  const value = optional(name, fallback).toUpperCase();
  if (!VALID_CURRENCIES.includes(value)) {
    throw new Error(`${name} must be one of: ${VALID_CURRENCIES.join(', ')}`);
  }
  return value;
}
// ...inside buildConfiguration(): defaultCurrency: currency('DEFAULT_CURRENCY', 'EUR'),
DEFAULT_CURRENCY=YEN node src/server.js
# [configuration] DEFAULT_CURRENCY must be one of: EUR, USD, GBP   (exit: 1)

Solution 3

// src/server.js — fragments with the counter
let inFlight = 0;

// Registered FIRST so it counts absolutely everything.
app.use((req, res, next) => {
  inFlight += 1;
  res.on('finish', () => { inFlight -= 1; });
  next();
});

app.get('/slow', (req, res) => setTimeout(() => res.json({ status: 'ok' }), 3000));

process.on('SIGTERM', () => {
  console.error(`[escena-viva] SIGTERM with ${inFlight} requests in flight`);
  server.close(() => process.exit(0));
  server.closeIdleConnections();
});

An important detail: for the counter to count, it has to be registered before the routes. That is the central theme of 06-04.

Conclusion

You no longer have a toy script, but the skeleton of a real application. The central piece is the separation between createApplication() —which builds and returns the app without starting it— and src/server.js —which creates the http server, makes it listen and knows how to shut it down gracefully when SIGTERM arrives. That separation is not cosmetic: it is what will make it possible to test with supertest in Module 9, attach Socket.IO in Module 12 and deploy with PM2 and Docker in Module 11. You have also defined the Escena Viva folder structure with clear responsibilities (and you know when not to apply it), you have deliberately chosen the framework settings (x-powered-by off, trust proxy per environment, case sensitive routing on) and you have centralized configuration in a module that reads process.env exactly once, validates types and ranges and fails fast if something does not add up.

The app, however, has a single route: /health. In the next lesson, Routing in Express, we fill it up: route parameters compared with the compilePattern you wrote by hand, express.Router() to mount /api/events, /api/sessions and /api/orders as independent modules, router.param() to load the event only once, and the rule that prevents the most headaches: the order in which routes are declared.

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