Escena Viva is configured, observed, supervised and packaged. What it still is not is on the internet. Lucía cannot buy her tickets for the Festival de Jazz de Primavera because the application is still running on localhost:3000. This lesson puts it into real production. And it does so by the route that makes the most sense for a project of this size: a platform as a service, where the provider takes care of the machines, the operating system, the load balancer, the TLS certificates and the scaling, and you take care of your application. We are going to walk through the complete process with Heroku as the canonical case, understanding that its concepts are identical on Railway, Render, Fly.io or Cloud Run.

Contents

  1. What a PaaS is and what it saves you
  2. Heroku and its current alternatives
  3. Deploying Escena Viva step by step
  4. Managed add-ons and the connection problem
  5. Migrations during deployment and zero-downtime schema changes
  6. The ephemeral filesystem
  7. HTTPS, reverse proxy and trust proxy
  8. Horizontal and vertical scaling
  9. Domains, staging and deployment strategies
  10. How to roll a deployment back
  11. Costs and sizing with data

  1. What a PaaS is and what it saves you

A platform as a service takes your code or your image, runs it and takes care of everything underneath: provisioning machines, patching the operating system, configuring the load balancer, issuing and renewing TLS certificates, collecting logs, restarting crashed processes and scaling when you ask it to. In exchange it charges you money — quite a bit more per unit of compute than a VPS — and control: you do not choose the kernel version, you do not install arbitrary system packages, you do not control where each instance runs and you are subject to the provider's limits.

Model What you manage What the provider manages Cost When to pick it
VPS / your own server Everything: OS, Node, proxy, TLS, PM2, backups, security Only the hardware The lowest per CPU Tight budget and somebody with time to operate it
PaaS Code and configuration OS, TLS, load balancer, scaling, logs Medium-high Small teams that want to ship product
Managed containers (Cloud Run, ECS, App Runner) Image and configuration Execution, scaling, networking Medium, often pay per actual use You already have the 11-04 image and want control with little work
Serverless (Lambda, Functions) Functions only Absolutely everything Very low with little traffic Sporadic workloads or extreme spikes
Orchestrator (Kubernetes) An awful lot Very little High in people Many services, several teams

For Escena Viva — three venues, a small team, specific spikes on opening nights — the honest choice is between PaaS and managed containers. Serverless is ruled out: we have a long-lived queue consumer, persistent PostgreSQL connections and cold starts that would ruin the p99 we cared so much about in Module 10. Kubernetes is artillery for shooting flies.

  1. Heroku and its current alternatives

Heroku invented this model in 2007 and defined the vocabulary everybody uses: Procfile, dynos, buildpacks, add-ons, release phase. It is worth studying for that reason, although today it pays to be honest about two things: its free tier disappeared in November 2022 — the "deploy for free in five minutes" of the old tutorials no longer exists — and there are now equivalent alternatives, some with a free or very cheap tier.

Provider Model Note
Heroku Buildpacks or container The original; the reference vocabulary
Railway Detects the project or uses a Dockerfile Very simple; a good first step
Render Buildpacks or Dockerfile The closest thing to Heroku today
Fly.io Containers at the edge Deployment close to the user; good pricing
DigitalOcean App Platform Buildpacks or Dockerfile Integrated with the rest of their ecosystem
Google Cloud Run Containers only Scales to zero; pay per request
AWS App Runner Container or code Good if you already live in AWS

They all share the same concepts, and it is those concepts — not any provider's interface — that you need to learn: declaring process types (one web, others in the background); listening on the port the platform assigns; receiving configuration through environment variables; consuming managed services through an injected URL; running migrations in a release phase; accepting an ephemeral filesystem; and having TLS terminated at the edge, speaking HTTP internally. Once you have learned that, switching providers is a matter of hours.

  1. Deploying Escena Viva step by step

The Procfile and the two process types

The Procfile declares which processes make up your application. Escena Viva has the same two we declared in PM2 (11-03) and in compose (11-04):

web: node src/server.js
worker: node src/processes/ticket-consumer.js
release: npm run migrate

Three lines and three very different meanings:

  • web is the only type with a reserved name: it is the one that receives HTTP traffic from the platform's router, and the only one assigned a port.
  • worker is a free-form name for background processes. It is our BullMQ queue consumer from Module 10: it listens on no port, connects to Redis and processes jobs. It scales independently of the web process, which is exactly what we want during the Festival de Jazz opening.
  • release is special: it runs exactly once per deployment, before the new version receives any traffic. If it fails, the deployment is aborted. Section 5 lives off this.

Notice there is no npm start: for the same reason as in Docker, an intermediate npm process complicates signal forwarding. We invoke node directly.

The port: process.env.PORT

This is the point where most deployments fail the first time, and it links straight back to Module 4. The platform runs several containers on each machine and assigns each one an arbitrary port, which it communicates through the PORT variable. Your process must listen on that port. If you hard-code 3000, the platform's router will call the port it assigned, nobody will be listening and the deployment will fail with a timeout. Our schema from 11-01 already reads PORT, exactly the name the platform injects, so there is nothing to translate here — the value simply arrives from the environment and the schema validates it. The only rule is never to hard-code it anywhere in the code:

// src/config/index.js — the platform injects PORT, which is precisely the
// name our schema expects: no renaming needed, the value flows straight through.
const result = configurationSchema.safeParse(process.env);

// src/server.js — listen on 0.0.0.0, NOT on 127.0.0.1: inside a
// container, 127.0.0.1 is only reachable from the container itself.
server.listen(configuration.port, '0.0.0.0');

The general rule: in production you never fix a port. In development you pick 3000 for convenience; in production the environment dictates it. It is the same twelve-factor principle that governs all of the configuration.

engines and the build

package.json already declares the Node version, and PaaS platforms read it to choose the interpreter:

{
  "engines": { "node": ">=24.5.0 <25", "npm": ">=10" },
  "scripts": { "start": "node src/server.js", "migrate": "sequelize-cli db:migrate" }
}

Without engines, the platform picks whichever version it likes — usually the LTS of the moment — and you can end up in production on a different version from the one you tested. A range like this one allows security patches without jumping a major. During the build, the platform detects Node and runs npm ci --omit=dev if package-lock.json exists (which is why it has been versioned since Module 5) and then npm run build if that exists. If you prefer total control, nearly all of them accept your Dockerfile from 11-04, and then the build is exactly the one you wrote. For Escena Viva that is the recommended path: the image tested in CI is the one that runs in production, with no intermediate translation.

Configuration: the dashboard, not the .env

On a PaaS there is no .env (and there must not be: the .dockerignore from 11-04 takes care of that). The variables are defined on the dashboard or through the CLI:

heroku config:set NODE_ENV=production LOG_LEVEL=info \
  TRUST_PROXY=true ALLOWED_ORIGINS=https://escenaviva.test --app escena-viva
heroku config:set JWT_ACCESS_SECRET=$(openssl rand -hex 32) \
  SESSION_SECRETS=$(openssl rand -hex 32) \
  CSRF_SECRET=$(openssl rand -hex 32) --app escena-viva
heroku config --app escena-viva   # Review what is there

Here another 11-01 dividend gets paid: if you forget a required variable, the zod validation kills the process at startup with a clear message, the platform detects that the new version never started and keeps the previous one in service. Failing fast turns a configuration error into an aborted deployment instead of an outage. Two warnings: changing a variable restarts the application on most platforms (consistent with the 11-01 decision to restart rather than hot reload), and anybody with dashboard access sees every secret in the clear, so the project's access control is part of your security.

  1. Managed add-ons and the connection problem

Add-ons are services the platform provisions and connects to your application by injecting their URL as an environment variable:

heroku addons:create heroku-postgresql:standard-0 --app escena-viva
heroku addons:create heroku-redis:premium-0 --app escena-viva
# MongoDB usually comes from a third party: MongoDB Atlas.

That creates DATABASE_URL and REDIS_URL. Since our schema expects POSTGRES_URL, we normalize in the same single place:

const normalizedEnvironment = {
  ...process.env,
  // The add-on injects DATABASE_URL; our schema calls it POSTGRES_URL.
  POSTGRES_URL: process.env.DATABASE_URL ?? process.env.POSTGRES_URL,
  // REDIS_URL already matches the name the schema expects: it flows straight through.
};
const result = configurationSchema.safeParse(normalizedEnvironment);

One single file absorbs every provider-specific quirk. That is exactly the benefit of having one place where process.env is read. A practical detail: the database URL can change without warning (maintenance, failover). Never copy it elsewhere and never cache it: always read it from the environment.

The connection problem, which is serious

Here is the mistake that takes applications down on opening night. Managed plans limit the number of simultaneous connections. A small PostgreSQL plan may allow 20. And the arithmetic is merciless: it is web instances × pool + worker instances × (pool + BullMQ connections). A realistic scenario for the Festival de Jazz opening:

Item Count Pool Connections
Web instances 4 10 40
Worker instances 3 5 15
Release phase (migrations) 1 2 2
Total 57

With a limit of 20, half the processes never manage to connect. And the worst part: the application starts just fine (the pool creates connections on demand) and fails under load, which is when it costs the most. Three remedies, in order:

  1. Size the pool according to the number of instances, which is exactly what src/db/sequelize.js has done since Module 7 with the worker count. Here the variable is the platform's instance count:
// src/db/sequelize.js (excerpt)
const CONNECTION_LIMIT = Number(process.env.DB_CONNECTION_LIMIT ?? 20);
const INSTANCES = Number(process.env.EXPECTED_INSTANCES ?? 4);
// We reserve 4 connections for migrations and for connecting to debug.
const maxPool = Math.max(2, Math.floor((CONNECTION_LIMIT - 4) / INSTANCES));
  1. Use an external connection pooler (PgBouncer, or the pgbouncer some plans offer). It multiplexes many application connections onto a few server ones. Careful: in transaction mode you cannot use session-level prepared statements, and Sequelize has to be told so.

  2. Move up a plan. Sometimes that is simply the right answer, and it costs less than a redesign.

The same applies to Redis, made worse by the fact that BullMQ opens several connections per worker (one to listen for blocking events, another for commands). With QUEUE_CONCURRENCY=5 and 3 instances, count on 30-40 Redis connections, not 3.

  1. Migrations during deployment and zero-downtime schema changes

The release phase

The release: npm run migrate line in the Procfile runs once per deployment, after the build and before the new version receives any traffic. If it fails, the deployment is cancelled and the old version keeps serving. It is the solution to the problem we raised in 11-04: migrations do not belong in the application's startup path. If they were in the CMD, with four instances starting at once you would have four processes migrating the same schema in parallel. In the release phase they run exactly once, in an ephemeral container, with the output visible in the deployment logs.

Zero-downtime schema changes: expand → migrate → contract

And here comes the most valuable concept in the lesson. During a deployment, the old and the new version of the code coexist over the same database, even if only for a few seconds. With blue-green or canary deployments, that coexistence lasts minutes or hours. Therefore: every migration must be compatible with both the old code and the new. An ALTER TABLE ... RENAME COLUMN instantly breaks every old instance, which keeps querying the old name. The technique is to split the change across three deployments. A real example: in Escena Viva we want to rename price (a decimal number, inherited from an old model) to priceCents (an integer), respecting our convention of money in cents. Deployment 1 — Expand. The migration adds without removing anything:

// migrations/20260815100000-add-price-cents.js
'use strict';

module.exports = {
  async up(queryInterface, DataTypes) {
    // New, NULLABLE column: the old code ignores it without trouble.
    await queryInterface.addColumn('sessions', 'priceCents', {
      type: DataTypes.INTEGER, allowNull: true,
    });
    // And it is filled in from the existing data.
    await queryInterface.sequelize.query(
      'UPDATE sessions SET "priceCents" = ROUND(price * 100) WHERE "priceCents" IS NULL'
    );
  },
  async down(queryInterface) {
    await queryInterface.removeColumn('sessions', 'priceCents');
  },
};

The code in this deployment writes to both columns and reads from the old one. Both versions work. Deployment 2 — Migrate. The code switches to reading from priceCents and keeps writing to both. If you have to roll back to version 1, everything still works because both columns are up to date. Deployment 3 — Contract. The code stops touching price, and only then does the migration remove it with await queryInterface.removeColumn('sessions', 'price').

Deployment Migration The code writes The code reads Can it be rolled back?
1. Expand Adds priceCents and fills it Both price Yes
2. Migrate None Both priceCents Yes
3. Contract Removes price priceCents priceCents Only to deployment 2

It is slower and it is three steps instead of one. In exchange, none of the three requires stopping the application or prevents a rollback. On a ticket-selling platform, where a two-minute outage during an opening means hundreds of lost sales, that slowness is exactly what you want. A practical rule for large migrations: beware of ALTER TABLE statements that lock the whole table. Adding a nullable column is instant on modern PostgreSQL; backfilling two million rows is not, and it is best done in batches.

  1. The ephemeral filesystem

An instance's disk is ephemeral. When the instance restarts — every deployment, every configuration change, every platform recycle — everything written disappears. And on top of that each instance has its own disk: what one writes, another never sees. This completely invalidates what we did in Module 3 with the reports/ directory, where we stored the ticket PDFs generated by the worker thread pool. In production on a PaaS: the EV-2026-004182.pdf the worker generates does not exist for the web instance that will serve Lucía's download, and even if it did it would vanish on the next deployment. The solution is object storage — S3, Cloud Storage, R2, Spaces — with this flow:

// src/services/report-store.js
'use strict';

const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { configuration } = require('../config/index.js');

const client = new S3Client({ region: configuration.storage.region });
const Bucket = configuration.storage.bucket;

async function saveTicketPdf({ ticketCode, content }) {
  const key = `tickets/${ticketCode}.pdf`;
  await client.send(
    new PutObjectCommand({
      Bucket, Key: key, Body: content, ContentType: 'application/pdf',
      ACL: 'private',   // Never public: tickets are personal.
    })
  );
  return key;
}

// The PDF never goes through our server: we sign a temporary URL.
async function temporaryDownloadUrl(key, seconds = 300) {
  return getSignedUrl(client, new GetObjectCommand({ Bucket, Key: key }), {
    expiresIn: seconds,
  });
}

module.exports = { saveTicketPdf, temporaryDownloadUrl };

Note the signed URL pattern: the worker uploads the PDF to the store and saves the key; when Lucía asks for her ticket, the API checks that it is hers (require-ownership.js from Module 8) and returns a signed URL that expires in five minutes. The file never crosses our server, so it consumes neither memory nor bandwidth from the instance. The same rule applies to any local state: user uploads, on-disk caches, session files, log files. Nothing persistent on the instance disk. Sessions and rate limiting have been in Redis since Module 10 precisely because of this.

  1. HTTPS, reverse proxy and trust proxy

Here we keep the promise from Module 8: HTTPS and certificates. The good news is that on a PaaS you do not manage certificates. The platform puts a reverse proxy at the edge that terminates TLS: it receives HTTPS from the browser, validates the certificate (issued and renewed automatically, usually via Let's Encrypt) and speaks plain HTTP to your instance over the internal network.

flowchart LR
    N["Lucia's browser"] -->|HTTPS 443| B[Edge proxy: TLS terminates here]
    B -->|HTTP + X-Forwarded-* headers| W1[Web instance 1]
    B -->|HTTP| W2[Web instance 2]
    W1 --> R[(Redis)]
    W1 --> P[(PostgreSQL)]

Your application does not need https.createServer or certificates. Node serves HTTP and that is correct. But it has an important consequence, and it is the promise we left outstanding in Module 6. From Express's point of view, every request comes from the proxy: req.ip is the load balancer's internal IP, req.protocol is http and req.secure is false. The real information travels in headers:

Header Contents
X-Forwarded-For A chain of IPs: the client's first
X-Forwarded-Proto The original protocol (https)
X-Forwarded-Host The host the client requested

Express only uses them if you tell it to, in src/app.js:

// Number of trusted proxies in front. The PaaS puts one.
// NEVER 'true' in production: trusting any header lets a client
// forge its IP by setting X-Forwarded-For by hand.
if (configuration.trustProxy) app.set('trust proxy', 1);

Without trust proxy, three things break at once:

  1. Per-IP rate limiting (express-rate-limit, Module 6, with the Redis store from Module 10) sees the same IP — the proxy's — for every user. Result: either the global limit is exhausted in seconds and blocks everybody, or you set it so high that it protects against nothing. It is the most serious of the three.
  2. secure cookies are not sent, because Express believes the connection is not secure. Sessions stop working with no useful error message.
  3. The 11-02 logs record the load balancer's IP instead of the user's, and any abuse investigation becomes impossible.

The number matters: app.set('trust proxy', 1) means "trust the last hop". If you set true, Express trusts the entire X-Forwarded-For chain, and since anybody can send that header, anybody can fake their IP and bypass rate limiting. Count the real proxies and put that number. Two additions that are genuinely up to you: redirecting HTTP to HTTPS (many platforms do it, but it is worth checking) and enabling HSTS, which has come with helmet since Module 6 and tells the browser never to try HTTP again.

  1. Horizontal and vertical scaling

Vertical Horizontal
What you do Bigger instances More instances
Limit The largest size on offer Practically none
Fault tolerance If it falls, everything falls If one falls, the rest remain
Requirement None The application cannot hold state
In Node Not much use: one process uses one core The natural path

Vertical scaling is especially weak in Node: one process uses one core, so moving to an 8-CPU machine speeds up nothing on its own. That is why the Module 10 cluster existed. On a PaaS, the unit of scaling is already the instance, so you scale horizontally and run one process per instance (the same doctrine as with containers, 11-04).

heroku ps:scale web=4 worker=3 --app escena-viva

And now the important part: Escena Viva is already prepared for this, and not by accident. Every decision in the course was pointing here:

Horizontal scaling requirement Where we solved it
No state in the process's memory Design since Module 6
Shared sessions connect-redis (Module 10)
Shared rate limiting rate-limit-redis (Module 10)
Shared cache src/cache/catalog.js in Redis (Module 10)
Background work outside the web process BullMQ and the consumer (Module 10)
Files off the local disk Object storage (section 6)
Graceful shutdown when scaling down SIGTERM (Module 6)
A probe to withdraw traffic /health/ready (11-02)

If any of this were missing, scaling to 4 instances would produce intermittent errors impossible to reproduce: sessions lost when hopping instances, limits that do not limit, PDFs that never appear. The fact that the list is complete is why ps:scale web=4 simply works.

  1. Domains, staging and deployment strategies

Domains. You add the domain, point a CNAME at whatever the provider tells you, and it issues the certificate automatically. Remember to update ALLOWED_ORIGINS with the real domain: it is the most frequent configuration mistake when launching a domain. Staging. A second application with the same codebase and its own configuration:

Production Staging
Application escena-viva escena-viva-staging
NODE_ENV production staging
Database Large plan, with backups Small plan, anonymized data
Instances web=4, worker=3 web=1, worker=1
Payment gateway Real Test mode
Email Real To a .test catch-all mailbox

Two warnings that are worth money: staging data must be anonymized (a raw copy of production is a personal-data breach waiting to happen), and the gateway in test mode keeps you from actually charging anybody during a demo. Deployment strategies:

Strategy How it works For Against
Recreate Stops everything and starts the new version Simple Service outage
Rolling (the default almost everywhere) Replaces instances one at a time No outage Two versions coexist
Blue-green The complete new environment is brought up and traffic is switched at once Instant rollback Double cost during the window
Canary 5% of the traffic goes to the new version, you watch, then you raise it Limits the damage of a failure Requires metrics and weighted routing

Rolling is the usual one and it is what imposes the expand → migrate → contract discipline from section 5: during the replacement, both versions coexist over the same schema. Canary shines in exactly a case like ours: if 5% of traffic goes to the new version and the error rate of /api/purchases rises (the 11-02 metric), you roll back before the remaining 95% notice. To do it properly you need per-version metrics, which means tagging logs and metrics with the deployed version — one more reason to log the version at startup, as we recommended in 11-02.

  1. How to roll a deployment back

This is the first thing you need to know how to do. Before deploying for the first time, make sure you know how to roll back. A team that can roll back in thirty seconds deploys calmly; one that cannot deploys on Tuesday mornings in fear.

$ heroku releases --app escena-viva
v52  Deploy 8f3a1c2         [email protected]  2026/08/15 18:02
v51  Deploy 4b9e0d7         [email protected]   2026/08/15 11:40
v50  Set JWT_ACCESS_SECRET  [email protected]   2026/08/14 09:15

$ heroku releases:rollback v51 --app escena-viva

Every release is immutable and contains code and configuration, so rolling back restores the exact combination that worked. In seconds. Three things to be clear about when rolling back:

  1. Rolling back the code does not roll back the database. If version v52 ran a destructive migration, going back to v51 leaves old code on top of a new schema. That is why migrations are designed to be backward compatible. The three-step strategy is not purism: it is what makes rolling back safe.
  2. Roll back first, investigate afterwards. Just like with a leaked secret in 11-01: first you restore the service, then you look for the cause. The urge to "let me just look for five minutes" has stretched out many an incident.
  3. Rehearse the rollback. Do it once in staging, with a stopwatch. You will discover details — permissions that still need granting, who has access, how long it takes — that you do not want to discover with sales down.

A very useful complementary pattern: decouple deployment from activation with feature flags. You deploy the new early-sales code switched off, turn it on when the time comes and, if it goes badly, turn it off without deploying anything. Remember from 11-01 that those flags belong in the database, not in environment variables.

  1. Costs and sizing with data

A PaaS charges per instance-hour, plus the add-ons. A realistic example for Escena Viva in normal operation:

Item Quantity Approximate monthly cost
Web instances 2 medium 100 EUR
Worker instances 1 medium 50 EUR
Managed PostgreSQL Standard plan 50 EUR
Managed Redis Small plan 15 EUR
Object storage 50 GB + traffic 5 EUR
Logs and metrics 14-day retention 20 EUR
Total ~240 EUR/month

An equivalent VPS would cost 40-60 EUR, but you would have to add somebody's time maintaining it, updating it, managing backups and answering the phone at 3 a.m. With one person, the PaaS usually works out cheaper overall. And the advice that closes Module 10 and this lesson: size with data, not with intuition. You already know how to measure. Before deciding how many instances you need for the Festival de Jazz opening, fire autocannon at staging with a realistic load profile, look at the p99, the event loop lag and the CPU usage, and do the math. "Let's put eight instances up just in case" costs money every month and often does not help: if the bottleneck is the database, eight instances just consume more connections and make things worse. Cost tips worth having: scale up before an expected peak (the opening has a date) and scale down afterwards; watch the logging spend, which grows with traffic and surprises everybody; and enable billing alerts, because a badly written retry loop can multiply the bill over a weekend.

Common Mistakes and Tips

  • Hard-coding the port in production. The deployment fails with a timeout. Use process.env.PORT and listen on 0.0.0.0.
  • Forgetting trust proxy. Per-IP limiting breaks, secure cookies do not travel and the logs record the load balancer's IP.
  • trust proxy set to true. It lets clients forge their source IP. Set the number of real proxies.
  • Writing files to the instance disk. They are lost on every deployment and the other instances never see them.
  • Migrations at startup instead of in the release phase. They run in parallel once per instance.
  • Ignoring the plan's connection limit. Instances × pool exhausts the plan and fails exactly under load.
  • Deploying without knowing how to roll back. The most expensive mistake of all.
  • Tip: document the rollback procedure in the README and rehearse it. Anybody on the team must be able to run it at three in the morning.
  • Tip: deploy often and small. A deployment carrying two days of work is a low-risk deployment; one carrying two months is a sleepless night.

Exercises

Exercise 1 — Readiness audit

Go through Escena Viva and list everything that would break on moving to 4 instances without the Module 10 and 11 work: in-memory state, local files, timers, cache. For each item, state the mechanism that solves it and which module introduced it.

Exercise 2 — Zero-downtime migration

Escena Viva needs to add the attendeeName field to the tickets table, required from now on. Design the complete expand → migrate → contract sequence, stating for each of the three deployments what the migration does and what the code does.

Exercise 3 — Connection budget

With a PostgreSQL plan of 20 connections and a Redis one of 30, work out the maximum web and worker instance configuration for Escena Viva, knowing that each web instance uses a PostgreSQL pool and that each worker uses a pool plus 2 BullMQ connections per unit of concurrency. Propose concrete values.

Solutions

Exercise 1.

What would break Why Solution Module
In-memory sessions Each instance has its own; on hopping, the user appears logged out connect-redis M10
In-memory rate limiting The limit gets multiplied by the number of instances rate-limit-redis M10
In-memory catalog cache Inconsistent invalidation: one instance serves stale data Redis with a TTL M10
PDFs in reports/ The worker writes where the web process does not read, and they are lost on restart Object storage 11-05
setInterval jobs in the web process They run N times, once per instance BullMQ queue with a scheduled job M10
In-memory idempotency A purchase resent to another instance would be duplicated Keys in Redis M10
Local metric counters Each instance exposes its own Aggregation in Prometheus 11-02

Exercise 2.

Deployment 1 — Expand. Migration: addColumn('tickets', 'attendeeName', { type: STRING, allowNull: true }) plus a backfill of the existing rows with the buyer's name. Code: it writes the field if it receives it, does not require it and never reads it. The endpoint's zod schema marks it as optional. Deployment 2 — Migrate. No migration. Code: the zod schema starts requiring attendeeName on new purchases and the interface shows it. You can roll back to deployment 1 without trouble, because the column is still nullable in the database. Deployment 3 — Contract. Migration: changeColumn to set allowNull: false, once a query has verified that no null rows remain. Code: unchanged. This order is essential: if you set NOT NULL in deployment 1, every old instance — which does not send the field — would start failing on insert.

Exercise 3.

PostgreSQL, 20 connections: we reserve 4 for migrations and manual access, leaving 16. With web=3 and worker=2, and a pool of 3 for web and 2 for worker, that comes to 3×3 + 2×2 = 13 connections and fits with room to spare; with web=4 and a pool of 4 it would be 16 for the web alone, leaving nothing for the worker. Redis, 30 connections. BullMQ opens about 2 per unit of concurrency, plus about 2 per web instance for sessions, limits and cache. Web: 3 × 2 = 6. Worker: 2 instances × (5 concurrency × 2) = 20, total 26: tight, but it fits. With QUEUE_CONCURRENCY=3 instead of 5 it would be 2 × 6 = 12, total 18, far more comfortable.

Proposed configuration: web=3 with a pool of 3, worker=2 with a pool of 2 and QUEUE_CONCURRENCY=3. And the important conclusion: you have to do this arithmetic before scaling, because the symptom of exhausted connections (intermittent errors under load) is one of the hardest to diagnose live.

Conclusion

Escena Viva is on the internet. A Procfile declares its three pieces — the web process, the worker that consumes the Module 10 queue and the release phase that runs the migrations exactly once — it listens on the port the platform assigns, it reads its configuration from the provider's variables instead of a .env, it consumes managed PostgreSQL and Redis with the pool sized so as not to exhaust the plan, it stores the ticket PDFs in object storage because the instance disk is ephemeral, and it serves HTTPS with a certificate managed at the edge while Express trusts a single proxy and thereby recovers each user's real IP: the Module 6 promise fulfilled. It scales horizontally because every piece of state was moved out of the process at the right time, it changes schema without downtime using expand → migrate → contract, and it rolls back in seconds, which is the first thing we learned to do. One last link remains, and it is the one that turns all of this into routine. Right now deploying is still a sequence of commands somebody runs by hand, in the right order, hoping not to forget any of them. In the module's last lesson, Continuous Integration and Deployment, we automate the entire chain: a GitHub Actions file that installs with npm ci, runs the linter, runs the unit and integration tests against PostgreSQL, MongoDB and Redis brought up as services, enforces a coverage threshold, audits the dependencies, builds the image exactly once and promotes it between environments, runs smoke tests against /health/ready and rolls itself back if they fail. Until shipping a version becomes boring.

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