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
- What a PaaS is and what it saves you
- Heroku and its current alternatives
- Deploying Escena Viva step by step
- Managed add-ons and the connection problem
- Migrations during deployment and zero-downtime schema changes
- The ephemeral filesystem
- HTTPS, reverse proxy and
trust proxy - Horizontal and vertical scaling
- Domains, staging and deployment strategies
- How to roll a deployment back
- Costs and sizing with data
- 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.
- 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.
- 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):
Three lines and three very different meanings:
webis 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.workeris 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.releaseis 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 thereHere 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.
- 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:
- Size the pool according to the number of instances, which is exactly what
src/db/sequelize.jshas 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));-
Use an external connection pooler (PgBouncer, or the
pgbouncersome 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. -
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.
- 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.
- 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.
- HTTPS, reverse proxy and
trust proxy
trust proxyHere 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:
- 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. securecookies are not sent, because Express believes the connection is not secure. Sessions stop working with no useful error message.- 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.
- 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).
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.
- 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 |
| 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.
- 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-vivaEvery 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:
- 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.
- 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.
- 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.
- 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.PORTand listen on0.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 proxyset totrue. 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
READMEand 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
- 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
