Escena Viva is on the internet. But deploying is still a ritual: somebody runs the tests on their laptop (if they remember), builds the image, publishes it, runs the migrations, scales the processes and checks by hand that selling works. Six steps in the right order, with one specific person who knows how to do them. If that person is on holiday the day the Festival de Jazz de Primavera opens, the team has a problem.
This lesson closes the module by automating the whole chain. The goal is not technical elegance: it is that shipping a version stops being an event. That it becomes boring. That it happens five times a day without anybody holding their breath. A boring deployment happens often, and deploying often is what makes each deployment small and therefore safe.
Contents
- Continuous integration, delivery and deployment: three different things
- Anatomy of the Escena Viva pipeline
- GitHub Actions: the CI file, block by block
- Secrets in CI
- What makes a pipeline useful
- Build once and promote the artifact
- Migrations, smoke tests and automatic rollback
- Versioning and release notes
- What must not be in the pipeline
- DORA: knowing whether you are improving
- Continuous integration, delivery and deployment: three different things
The initials CI/CD get used as a single word and hide three distinct concepts. Continuous integration (CI) means integrating every change into the main branch frequently — at least daily — and, on every integration, letting an automated system build the project and run the tests. It solves integration hell: two people working separately for two weeks and discovering at the end that their changes are incompatible. With CI, the incompatibility surfaces within hours, when fixing it costs minutes.
Continuous delivery (CD) goes one step further: every change that passes CI is ready to deploy, with the artifact built, tested and stored. Deploying is pressing a button. What is automated is not the deployment but the ability to deploy at any moment. And continuous deployment removes the button: every change that passes all the stages reaches production on its own.
| Continuous integration | Continuous delivery | Continuous deployment | |
|---|---|---|---|
| What is automated | Build and test | Everything up to staging | Everything, up to production |
| Production is reached | By hand | By pressing a button | On its own |
| Requires | Reliable tests | The above + environments | The above + smoke tests and rollback |
| Risk per deployment | The usual | Lower | The minimum, because they are tiny |
Escena Viva will implement full continuous integration and continuous delivery, with automatic deployment to staging and manual approval for production. It is the sensible choice for a small team in a business where an outage during a sale costs real money. Pure continuous deployment is a legitimate goal, but it demands a confidence in the tests that is earned over time, not decreed.
- Anatomy of the Escena Viva pipeline
flowchart TD
A[Push or PR] --> B[npm ci with cache] --> C[Linter and formatting]
B --> D[Unit tests]
C --> E[Integration: Postgres, Mongo, Redis]
D --> E
E --> F[Coverage with threshold] --> G[Audit] --> H{Main branch?}
H -->|No| I[End: report on the PR]
H -->|Yes| J[Build image and publish] --> K[Migrate and deploy to staging]
K --> L[Smoke tests]
L -->|Fail| M[Automatic rollback]
L -->|Pass| N{Manual approval} -->|Approved| O[Promote the SAME image]
O --> P[Smoke tests in production] -->|Fail| Q[Automatic rollback]
P -->|Pass| R[Release notes]
Two properties of the design, before we write any code. The first: the stages go from fast to slow and from cheap to expensive; the linter takes 15 seconds, and if it fails there is no point spending four minutes on integration tests — it is the same fail fast we applied to configuration validation in 11-01. The second: the image is built exactly once and then promoted, so that the artifact that passed the tests and was deployed to staging is precisely the one that reaches production. Section 6 explains why that is non-negotiable.
- GitHub Actions: the CI file, block by block
# .github/workflows/ci.yml
name: Continuous integration
on:
push:
branches: [master]
pull_request:
branches: [master]
# Cancels earlier runs on the same branch: do not spend minutes
# testing a commit that has already been superseded.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# 1. Static quality: fast and cheap, so it goes first.
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '24.x', cache: 'npm' }
- run: npm ci
- run: npm run lint
- run: npm run format -- --check
# Bans console in src: the pino logger must be used instead (11-02).
- run: '! grep -rn "console\." src/ --include="*.js"'
# 2. Unit tests at both ends of the engines range.
unit:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { node: ['24.5', '24.x'] }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '${{ matrix.node }}', cache: 'npm' }
- run: npm ci
- run: npm run test:unit
# 3. Integration: it needs the three M9 databases.
integration:
runs-on: ubuntu-latest
needs: [quality, unit]
services:
postgres:
image: postgres:17-alpine
env: { POSTGRES_USER: escena, POSTGRES_PASSWORD: escena, POSTGRES_DB: escena_viva_test }
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U escena -d escena_viva_test"
--health-interval 5s --health-timeout 3s --health-retries 10
mongo:
image: mongo:8
ports: ['27017:27017']
options: >-
--health-cmd "mongosh --quiet --eval 'db.adminCommand(\"ping\")'"
--health-interval 5s --health-retries 10
redis:
image: redis:7-alpine
ports: ['6379:6379']
options: '--health-cmd "redis-cli ping" --health-interval 5s --health-retries 10'
env:
NODE_ENV: test
LOG_LEVEL: error
POSTGRES_URL: postgres://escena:escena@localhost:5432/escena_viva_test
MONGO_URL: mongodb://localhost:27017/escena_viva_test
REDIS_URL: redis://localhost:6379
# TEST secrets: worth nothing, but they satisfy the 11-01 schema.
JWT_ACCESS_SECRET: '0123456789abcdef0123456789abcdef'
SESSION_SECRETS: 'fedcba9876543210fedcba9876543210'
CSRF_SECRET: 'aaaabbbbccccddddeeeeffff00001111'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '24.x', cache: 'npm' }
- run: npm ci
- run: npm run migrate
- name: Integration with coverage and threshold
run: npm run test:ci
# 4. Supply chain (M5): checkout + setup-node + npm ci and, at the end:
# - run: npm audit --audit-level=high --omit=devon, concurrency, jobs and runs-on. The two triggers serve different purposes: on a pull request it runs before merging, as a barrier that keeps broken code out; on a push to master it runs afterwards, because the result of merging two branches that passed separately can still fail — the broken semantic merge, more common than it sounds. concurrency cancels the previous run on the same branch when a new commit arrives, so if you push three commits in a row only the last one is tested. Each job runs on a clean, isolated virtual machine, by default in parallel; needs: [quality, unit] establishes the order and thereby implements the diagram's "fast and cheap first". And runs-on: ubuntu-latest should match production: testing on Windows and deploying on Alpine is asking for surprises with paths, permissions and native modules.
checkout, setup-node and the cache. actions/checkout@v4 clones the repository; note the @v4, which pins the action's major version, because using an action with no version — or from an unknown third party — means running somebody else's code with access to your repository, and for unofficial actions it is wise to pin the commit hash. actions/setup-node@v4 installs Node, and cache: 'npm' is the single line that saves the most time in the whole file: it stores the npm cache between runs using the hash of package-lock.json as the key, so npm ci goes from about 45 seconds on a cold cache to about 8 on a warm one.
npm ci: the definitive Module 5 payoff. It installs exactly what package-lock.json says without resolving ranges, so what is tested in CI is identical version by version to what runs in production; it deletes node_modules before installing, so there is no state inherited from another run; it fails if package.json and the lock are inconsistent, catching the mistake of editing one without the other; and it is faster, because it skips resolution. npm install in CI is an anti-pattern: it can resolve a new version of a transitive dependency and cause the worst case — CI passing and production failing, or the other way round — and it also modifies the lock without anybody reviewing it.
The version matrix. engines says >=24.5.0 <25, so we test both ends of the supported range: the minimum declared version and the latest of the major. If something uses an API that only exists from 24.8 onwards, 24.5 catches it — and that is exactly the version the PaaS from 11-05 might install. fail-fast: false keeps one failing combination from cancelling the others, because knowing whether it fails on both or only on one changes the diagnosis.
Service containers. The services block brings the Module 9 integration tests to life. Three important things:
localhost, not the service name. Unlikedocker compose(11-04), the job runs on the host machine and the containers expose ports on it. It is the number one confusion when migrating from compose to Actions.- Health checks are not optional. Without
--health-cmd, the job starts as soon as the container exists, not when the service accepts connections. PostgreSQL takes a few seconds, and the result is a test that fails one time in ten: a flaky test created by the CI configuration itself. - The versions match production. Testing against PostgreSQL 15 and deploying on 17 is testing something else.
The env block reproduces .env.test from Module 9, with fake secrets that meet the 32-character minimum the 11-01 schema imposes. Another dividend of validating the configuration: if the schema and the CI environment drift apart, the failure is immediate and explicit. On coverage and auditing, npm run test:ci runs the tests under c8, and the threshold is declared in the c8 configuration, not in the YAML:
{
"c8": {
"all": true, "include": ["src/**/*.js"],
"lines": 80, "functions": 80, "branches": 70,
"check-coverage": true
}
}With check-coverage, c8 exits with a non-zero code if the threshold is not met and the job fails. On the numbers: set the threshold at your current level and raise it gradually, because setting 90% when you are at 62% guarantees somebody disables the check within two weeks; and remember from Module 9 that coverage measures which lines run, not whether the tests check anything useful. For its part, npm audit --audit-level=high --omit=dev closes out Module 5: it only fails on high or critical vulnerabilities, so the pipeline does not break every week over a minor advisory, and it ignores development dependencies, which never reach production. As a complement, scanning the image with Trivy also catches the base-system ones.
- Secrets in CI
Secrets are stored in the repository's or the organization's settings and injected as variables (env: { TOKEN: '${{ secrets.REGISTRY_TOKEN }}' }). The rules to respect:
- Least privilege. The token that publishes images should only be able to write to the registry. No personal tokens with full account access.
- Never in the
rundirectly.docker login -p ${{ secrets.TOKEN }}leaves the value on the command line, visible in the process table. Pass it throughenvand read it with--password-stdin. - Secrets are not exposed to fork PRs. GitHub does this by design, and it is essential: without it, anybody could open a PR with a modified workflow that prints your credentials. The practical consequence is that the jobs that need secrets — build and deploy — only run on pushes to
master. - Rotate them regularly, with an expiry date if the provider offers one.
- If one leaks, apply the 11-01 procedure: revoke first, investigate afterwards. CI logs are public in public repositories and, although GitHub masks known values, a secret assembled from pieces or base64-encoded slips past that filter.
A modern pattern worth knowing is OIDC: instead of storing long-lived credentials, the workflow obtains a temporary token from the cloud provider by proving its identity, eliminating the persistent secret entirely.
- What makes a pipeline useful
A pipeline that exists but nobody looks at is worse than none at all: it gives a false sense of security. Three properties make it useful. Fast. Target: under 10 minutes from push to result. Above that, people switch tasks, lose context and stop waiting. The techniques that pay off most are caching npm (30-40 s per job), parallelizing independent jobs (you pay for the longest, not the sum), putting the cheap before the expensive with needs, cancelling stale runs with concurrency and caching the Docker layers (1-3 min per build).
Reliable. Here is the number one reason a team abandons its CI: flaky tests. A flaky test fails sometimes with no change to the code. The damage is not the failure, it is what it does to people: the first time it gets investigated; the third, somebody says "just rerun it, it's that flaky one". From then on any red failure gets blamed on flakiness, including the real ones, and CI has stopped providing information and now only provides delay. In Module 9 we saw the causes, and here they come back aggravated because CI machines are slower and more variable than your laptop:
| Cause | Symptom | Fix |
|---|---|---|
| Order dependency | Fails when run alone or in a different order | Clean state in every beforeEach |
| Fixed waits | Fails on slow machines | Wait for conditions, not for clocks |
| Dates and time zones | Fails at midnight or in another region | Fake clock with sinon; UTC in CI |
| Service not ready or fixed port | The first test fails, or it fails when parallelized | Health checks; port 0 |
Recommended policy: a flaky test gets fixed or deleted within 48 hours. Marking it as skipped is acceptable as a temporary measure, with a ticket attached; leaving it failing is not.
With the main branch always deployable. The pipeline is useless if red code can be merged. On master you configure branch protection: no direct pushes, required checks (quality, unit, integration, audit), at least one approving review and the branch up to date before merging, which prevents the broken semantic merge. With that, the state of master is always "tested and deployable", and that invariant is what lets you deploy without fear during the Festival de Jazz opening.
- Build once and promote the artifact
Build once, promote the artifact. Never rebuild per environment.
If you build one image for staging and another for production, they are not the same image: between the two builds a transitive dependency, a base image or a system package can change. You would have tested one thing and deployed another, and all the previous work would count for nothing.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [master]
workflow_dispatch: # Allows launching it by hand from the interface.
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: meta
name: Tag = version + commit hash
run: |
VERSION=$(node -p "require('./package.json').version")
echo "tag=${VERSION}-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/escena-viva/api:${{ steps.meta.outputs.tag }}
cache-from: type=gha # Reuses the 11-04 layers.
cache-to: type=gha,mode=max
# staging: identical to the job below, with environment: staging,
# its own _STAGING secrets and smoke tests against https://staging.escenaviva.test
production:
needs: [build, staging]
runs-on: ubuntu-latest
environment: production # With required reviewers configured.
steps:
- uses: actions/checkout@v4
- name: Migrate the schema
env: { POSTGRES_URL: '${{ secrets.POSTGRES_URL_PROD }}' }
run: npm ci --omit=dev && npm run migrate
- name: Promote THE SAME image, without rebuilding
env: { PLATFORM_TOKEN: '${{ secrets.PLATFORM_TOKEN_PROD }}' }
run: ./scripts/deploy.sh production ${{ needs.build.outputs.tag }}
- name: Smoke tests
id: smoke
run: node scripts/smoke.js https://escenaviva.test
- name: Roll back if the smoke tests fail
if: failure() && steps.smoke.outcome == 'failure'
env: { PLATFORM_TOKEN: '${{ secrets.PLATFORM_TOKEN_PROD }}' }
run: ./scripts/rollback.sh productionThe keys to this workflow: outputs propagates the tag computed during the build to the deployment jobs, so staging and production receive the same string and deploy the same image. Tagging with version and hash (1.4.0-8f3a1c2) combines what a human understands with the exact truth of the commit, and it avoids latest, which is not a version but a mutable alias. environment: production activates GitHub environments, where required reviewers are configured: the job stops and waits for human approval, and there lies the boundary between continuous delivery and continuous deployment — moving it means changing one line. workflow_dispatch lets you launch the workflow by hand to redeploy without a new commit. And cache-from/cache-to store the Docker layers, taking advantage of the layer ordering from 11-04: if package-lock.json did not change, the npm ci layer is reused.
- Migrations, smoke tests and automatic rollback
The migrations run before the code is deployed, in a step of their own and exactly once. It is the same decision as in 11-04 (out of the CMD) and in 11-05 (the release phase). And it works without downtime only because they follow the expand → migrate → contract strategy: during the rolling replacement of instances, the old and the new code coexist over the already-migrated schema, so the migration has to be compatible with both.
| Step | What happens | If it fails |
|---|---|---|
| 1 | Backward-compatible migration | It aborts; the old code keeps the old schema |
| 2 | Rolling deployment of the new image | The platform stops the replacement |
| 3 | Smoke tests | Automatic rollback to the previous artifact |
| 4 | (A later deployment) contract migration | Only once nothing uses what is being removed |
A warning about timing: a migration that locks a large table for minutes turns a zero-downtime deployment into an outage, so measure its duration against a copy of the real volume before merging it. As for smoke tests, they check that what was deployed responds and does the essentials; they do not replace the integration tests, they verify that the deployment itself went well.
// scripts/smoke.js
'use strict';
const base = process.argv[2];
const checks = [
{
name: 'readiness probe',
async run() {
const response = await fetch(`${base}/health/ready`);
if (response.status !== 200) throw new Error(`status ${response.status}`);
// All three dependencies must answer: the value of the 11-02 probe.
const { detail } = await response.json();
const down = Object.entries(detail).filter(([, ok]) => !ok).map(([n]) => n);
if (down.length > 0) throw new Error(`dependencies down: ${down.join(', ')}`);
},
},
{
name: 'public catalog',
async run() {
const { data } = await (await fetch(`${base}/api/events`)).json();
if (!Array.isArray(data) || data.length === 0) throw new Error('empty catalog');
},
},
{
// With no token it must answer 401. If it answers 200, something serious changed.
name: 'authentication required',
async run() {
const response = await fetch(`${base}/api/purchases`, { method: 'POST' });
if (response.status !== 401) throw new Error(`expected 401, got ${response.status}`);
},
},
];
// Growing wait: the rolling deployment takes time to complete.
async function withRetries(check, attempts = 5) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await check.run();
} catch (error) {
if (attempt === attempts) throw error;
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
}
}
(async () => {
let failures = 0;
for (const check of checks) {
try {
await withRetries(check);
process.stdout.write(`OK ${check.name}\n`);
} catch (error) {
process.stderr.write(`FAILED ${check.name}: ${error.message}\n`);
failures += 1;
}
}
process.exit(failures > 0 ? 1 : 0);
})();Three deliberate decisions: the retries with a growing wait are essential because the rolling deployment takes time to complete and the first requests may hit an instance that is still starting; all the checks run before exiting, so you see the full picture; and checking that /api/purchases returns 401 is a security sentinel that would catch, in seconds, a deployment that broke authenticate.js from Module 8. The script exiting with a non-zero code is what triggers the rollback step with if: failure(), and there the 11-05 work closes the circle: rolling back means returning to the previous artifact, a matter of seconds because the images are tagged and the migrations are backward compatible.
- Versioning and release notes
Module 5 gave us npm version, which updates package.json, creates a commit and a Git tag: patch for fixes, minor for compatible functionality, major for breaking changes. Automating the notes requires machine-readable commit messages, and conventional commits are the standard format: feat(purchases): allow seat selection at the Auditorio Ribera, fix(capacity): correct the count when a provisional reservation is cancelled, chore(deps): update pino to 9.5.0.
| Prefix | Meaning | Effect on the version |
|---|---|---|
fix: |
Bug fix | patch |
feat: |
New functionality | minor |
BREAKING CHANGE: in the body |
Breaking change | major |
chore:, docs:, test:, refactor: |
No user-facing impact | none |
With that, tools like semantic-release close the loop: they analyze the commits since the last tag, decide the version, generate the CHANGELOG.md, tag and publish. The team stops arguing about whether a change is minor or patch, because the commit format decides it. Starting simple — conventional commits plus npm version by hand — is perfectly valid; full automation gets added when the release cadence justifies it.
- What must not be in the pipeline
| Anti-pattern | Why it is a problem |
|---|---|
| Plain-text secrets in the YAML | They are readable in the repository and in every run's log |
npm install instead of npm ci |
It installs different versions from the tested ones and modifies the lock |
| Undocumented manual steps | "Before deploying you have to run X" gets forgotten, and whoever knew it leaves |
| Rebuilding the image per environment | You deploy something different from what you tested |
Tagging with latest |
You do not know what is deployed or what to roll back to |
| Testing against production | Real data contaminated; emails sent to real people |
The case of manual steps deserves emphasis. A pipeline with a hole — "and then somebody clears the Redis cache by hand" — is not an automated pipeline: it is a list of commands with hidden parts. If a step is necessary, it goes inside; if it does not fit inside, it goes into a written runbook anybody can follow.
- DORA: knowing whether you are improving
The DORA research program identified four metrics that correlate with the performance of teams that deliver software. They are for knowing whether your process is improving, not for comparing teams or evaluating people.
| Metric | What it measures | High performance | Low performance |
|---|---|---|---|
| Deployment frequency | How often a change reaches production | Several times a day | Less than once a month |
| Lead time | From commit to production | Under a day | Over a month |
| Change failure rate | What percentage causes an incident | 0-15% | 46-60% |
| Time to restore | How long it takes to restore service | Under an hour | Days or weeks |
The most counterintuitive finding: speed and stability are not in conflict. Teams that deploy more often also fail less and recover faster, because deploying often forces each deployment to be small, and a small change is easy to review, test and roll back. Fear of deploying produces large deployments, and large ones are what break things. What we built in this module moves all four: the pipeline lets you deploy whenever you want, minutes pass from commit to staging, the linter, tests and smoke tests catch failures before users do, and the automatic rollback restores service in seconds. A final piece of advice: measure them, but do not turn them into anybody's target, because as soon as deployment frequency is a number somebody has to hit, empty deployments appear. They are a thermometer, not a grade.
Common Mistakes and Tips
- Living with flaky tests. It is the main reason a team stops looking at CI. Fix them or delete them within 48 hours.
- Services with no health check. They produce random failures that look like code bugs and are not.
- Using the service name instead of
localhostin Actions. In compose yes, in Actions no. npm installin CI. It breaks the reproducibilitypackage-lock.jsonguarantees.- Rebuilding per environment. The artifact you tested is not the one you deploy.
- An unrealistic coverage threshold. Somebody will disable it. Set it at the current level and raise it slowly.
- A pipeline with no branch protection. Red code can be merged, and then it is worth nothing.
- Tip: make a CI failure visible where the team already looks. A broken pipeline nobody sees is useless.
- Tip: measure your pipeline's duration and treat it as a product metric. Every minute saved is multiplied by every run of the year.
Exercises
Exercise 1 — Diagnosing a flaky test
An integration test fails in CI roughly one run in five, always with ECONNREFUSED against PostgreSQL, and never fails locally. List the three most likely causes and the fix for each one.
Exercise 2 — Smoke test of the purchase flow
Extend scripts/smoke.js with a check that verifies the purchase path in staging: authenticate with a test user, query the capacity of evt-003 and check that the purchase endpoint answers with a controlled 400 to an empty body, instead of a 500.
Solutions
Exercise 1. First and most likely: the service health check is missing, or the job starts before PostgreSQL accepts connections; it explains why it never fails locally, where the database has been up for hours, and it is fixed with --health-cmd "pg_isready ..." and enough retries. Second: connections are not closed between tests and the container's max_connections is exhausted; it is fixed by closing the pool in mocha's global after and checking that test/helpers/ does not create one connection per file. Third: the CI machine is slower and some fixed timeout in the connection code expires; it is fixed by raising the pool's acquire timeout in the test environment and waiting for conditions, never for clocks.
Exercise 2.
{
name: 'purchase flow reachable',
async run() {
const json = { 'content-type': 'application/json' };
// 1. User seeded ONLY in staging; password from secrets.
const body = { email: '[email protected]', password: process.env.SMOKE_PASSWORD };
const login = await fetch(`${base}/auth/login`, {
method: 'POST', headers: json, body: JSON.stringify(body),
});
if (login.status !== 200) throw new Error(`login: status ${login.status}`);
const auth = { authorization: `Bearer ${(await login.json()).token}` };
// 2. Capacity for the Festival de Jazz de Primavera.
const capacity = await fetch(`${base}/api/events/evt-003/capacity`, { headers: auth });
if (capacity.status !== 200) throw new Error(`capacity: status ${capacity.status}`);
// 3. Validation: an empty body must give 400, NEVER 500.
const purchase = await fetch(`${base}/api/purchases`, {
method: 'POST', headers: { ...auth, ...json }, body: '{}',
});
if (purchase.status !== 400) throw new Error(`expected 400, got ${purchase.status}`);
},
}The important detail is the last one: checking that an invalid body produces 400 and not 500 verifies in one stroke that the zod validation, the Module 6 error handler and the STATUS_BY_CODE map are still wired together. A 500 there would mean something has come loose in the application's assembly, and it is exactly the kind of breakage a smoke test should catch before a user does.
Conclusion
Escena Viva began this module as a project that worked on a laptop, with its secrets in a local .env, no centralized logs, no supervisor, no container and no automated deployment. It ends as something else. Its configuration is an explicit contract that is validated at startup and kills the process with a clear message if a secret is missing, with key rotation that logs nobody out. It says what it is doing: structured JSON logs where every line carries the request identifier that lets you reconstruct a failed purchase in full, metrics for latency, tickets sold, queue size and event loop lag, and liveness and readiness probes a supervisor can interrogate. It is supervised by an ecosystem file that declares its two processes and reloads them without cutting a single sale. It travels packaged in a 97 MB image that runs as an unprivileged user and genuinely receives SIGTERM, alongside a docker compose setup that brings up the complete environment with one command. It is deployed on a platform that assigns it a port, injects its configuration, terminates TLS at the edge and scales it horizontally because every piece of state was moved out of the process at the right time. And now it is automated: every push goes through the linter, the unit tests, the integration tests against three real databases, a coverage threshold and a dependency audit; the image is built exactly once and promoted between environments; the migrations run in their own step, backward compatible; and smoke tests against /health/ready decide whether the version stays or rolls itself back.
Escena Viva is a product in production, not a project on a laptop. And the difference is not in the code — the purchase code is the same one from Module 7 — but in everything around it: configuration, observability, supervision, packaging, deployment and automation. That is the distance between knowing how to program in Node and knowing how to deliver software in Node, and you have just covered all of it.
One last part of the journey remains. In Module 12, Real-World Projects, we stop building a single application and build four complete ones, each with its own challenge: a real-time chat application with Socket.IO, an e-commerce API, a blogging platform and a task management tool. Each project will apply what you learned in the previous eleven modules — modules, streams, Express, databases, authentication, testing, performance and everything you have just seen about deployment — and the module will close the course with the step you now know how to take: from project to production.
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
