The previous lesson ended with an uncomfortable sentence: PM2 solves the process, not the environment. Escena Viva lives under supervision, but on a machine somebody configured by hand months ago, with a Node version that may not be the one engines demands, with whatever system libraries were needed to compile bcrypt and with the fonts that got installed when the ticket PDFs started coming out wrong. Today we make the environment travel with the application. The result will be a single artifact — an image — containing Node 24, the production dependencies already installed, the code and nothing else; one that runs identically on your laptop, in continuous integration and in production; and that is deployed by copying an identifier instead of repeating installation steps. Scope warning: the catalog has a complete Docker course. We are not going to study Docker here: we are going to package a Node application properly, with just enough theory to be self-sufficient and all the detail in the decisions that affect an application like ours.
Contents
- "It works on my machine" and what a container is
- Image, container, layers and registry
- The Escena Viva
Dockerfile, line by line .dockerignore: the file nobody writes and everybody needsHEALTHCHECKwith/health/ready- Build, measure, tag and publish
docker compose: the complete development environment- Migrations in a container
- Logs, memory limits and CPU
- What does not go into the image and how to scan it
- "It works on my machine" and what a container is
The problem has a name and it is old: the application depends on far more than its code. It depends on the exact interpreter version, on the operating system's shared libraries, on the environment variables, on the binaries that happen to be on the PATH, on the installed fonts and on the contents of /etc. Your laptop, the CI server and the production VPS differ in all of that, and the differences only show up at the worst possible moment. A container is an ordinary process on the host operating system that the kernel has lied to about the world. Through namespaces it sees its own filesystem, its own process list (where it is PID 1), its own network and its own users; through cgroups it is limited in how much CPU and memory it can use. But it is just another host process, running the same Linux kernel. If you run ps aux on the server, there is your node. That is the key difference from a virtual machine, which emulates complete hardware and runs an entire operating system with its own kernel.
| Container | Virtual machine | |
|---|---|---|
| What it isolates | Processes, filesystem, network | Complete hardware |
| Kernel | The host's, shared | Its own, one per machine |
| Boot | Milliseconds | Tens of seconds |
| Typical size | 50-300 MB | 1-20 GB |
| Overhead | Practically none | 5-15% of CPU and memory |
| Isolation | Good, but the kernel is shared | Very strong |
| Operating system | Linux only, on a Linux kernel | Any |
The practical consequence: you can run twenty containers on a laptop, start them in a second and throw them away without a second thought. And the security consequence: because the kernel is shared, a container is not as strong a security boundary as a VM. That is why the non-root user in section 3 matters.
- Image, container, layers and registry
Four concepts and we move on:
- Image: an immutable, read-only template. The complete filesystem the process will see, plus metadata (which command to run, which port it exposes, which variables it carries). It is built from a
Dockerfile. - Container: a running instance of an image, with an ephemeral writable layer on top. When the container dies, that layer disappears. Everything you write inside is lost, and that detail will have enormous consequences in lesson 11-05.
- Layers: every
Dockerfileinstruction that modifies the filesystem creates a layer. Layers are cached and shared between images: if two of your images start fromnode:24-alpine, that layer is stored and transferred only once. This governs both build time and deployment time. - Registry: an image store (Docker Hub, GitHub Container Registry, ECR, Artifact Registry). You publish there and the server pulls from there, and that is what turns "deploy" into "pull an image and start it". That is enough theory: let's get to work.
- The Escena Viva
Dockerfile, line by line
Dockerfile, line by lineHere is the complete file; then we take it apart piece by piece.
# syntax=docker/dockerfile:1
# ---------- STAGE 1: production dependencies ----------
FROM node:24-alpine AS dependencies
# Build tools for the native modules (bcrypt).
# Installed ONLY in this stage: they never reach the final image.
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy ONLY the manifests: they change far less often than the code.
COPY package.json package-lock.json ./
# npm ci: reproducible install from package-lock.json (M5).
# --omit=dev: no development dependencies (mocha, chai, eslint...).
RUN npm ci --omit=dev && npm cache clean --force
# ---------- STAGE 2: final image ----------
FROM node:24-alpine AS production
# tini as PID 1: forwards signals and reaps zombie processes.
RUN apk add --no-cache tini
ENV NODE_ENV=production
ENV NODE_OPTIONS="--max-old-space-size=768"
WORKDIR /app
# Already-compiled node_modules, with the right owner from the start.
COPY --from=dependencies --chown=node:node /app/node_modules ./node_modules
# Application code. It goes last: it is what changes most.
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src
COPY --chown=node:node migrations ./migrations
COPY --chown=node:node scripts ./scripts
USER node # Unprivileged: the node image already ships 'node' (uid 1000).
EXPOSE 3000 # Documentation only: it publishes nothing, but it informs tooling.
# Health check built on the 11-02 probe.
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node ./scripts/check-health.js
# Exec form (JSON array): NO intermediate shell, the signal reaches the process.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "src/server.js"]FROM: which base image
The first decision is the one most often copied without thinking. The real options:
| Base | Size | For | Against |
|---|---|---|---|
node:24 (full Debian) |
~1.1 GB | Everything compiles painlessly, good debugging | Enormous; large attack surface |
node:24-slim (minimal Debian) |
~250 MB | glibc, compatible with almost everything | Bigger than Alpine |
node:24-alpine |
~150 MB | Small, few vulnerabilities | musl instead of glibc |
distroless/nodejs24 |
~180 MB | No shell, no package manager: minimal surface | Impossible to debug inside |
An important warning about Alpine and native modules. Alpine uses musl libc instead of glibc. The practical consequence: the precompiled binaries published by packages like bcrypt are built for glibc, so on Alpine they have to be compiled, and that needs python3, make and g++. That is why our first stage installs them. On top of that, musl has a different memory allocator that, under highly concurrent workloads, can perform slightly worse than glibc. Escena Viva uses alpine because the size and the attack surface make up for it, and because the multi-stage build keeps the build tools out of the final image. If you hit odd problems with bcrypt, sharp or canvas, node:24-slim is a perfectly honorable retreat — and another alternative worth considering is replacing bcrypt with @node-rs/argon2, which needs no compilation. And one non-negotiable rule: never FROM node:latest. A build that uses Node 24 today and Node 25 tomorrow is not reproducible, and engines says <25 for a reason. For maximum reproducibility you can pin the digest: FROM node:24-alpine@sha256:....
Multi-stage builds
FROM ... AS dependencies followed by a second FROM creates two images during the build. Only the last one is kept; from the first one we copy what we care about with COPY --from=dependencies.
The benefit is enormous: python3, make and g++ take about 200 MB and never reach the final image. Neither do the compilation headers nor the npm cache. The final image has the already-compiled node_modules, with nothing that was needed to produce it.
Why package*.json before the code
This is what saves the most day to day, and it deserves to be understood well. Docker caches layers: if an instruction's input files have not changed, it reuses the layer and jumps to the next step. But as soon as one layer is invalidated, all the following ones are rebuilt. Let's compare:
COPY . . # BAD: any change reinstalls EVERYTHING
RUN npm ci --omit=dev
COPY package.json package-lock.json ./ # GOOD: only if the manifests change
RUN npm ci --omit=dev
COPY src ./src| Scenario | With COPY . . first |
With the manifests first |
|---|---|---|
| First build | 95 s | 95 s |
| Change in one controller | 92 s | 6 s |
Change in package.json |
94 s | 94 s |
Since the code changes dozens of times a day and the dependencies once a fortnight, the real saving is around 90% of the build time; in a CI pipeline that runs on every push (11-06), that is hours of waiting a month.
npm ci --omit=dev: the Module 5 payoff
npm ci installs exactly what package-lock.json says, without resolving version ranges: it is reproducible by definition, and the same build today and in six months yields the same dependency tree. npm install, by contrast, can resolve a new version of a transitive dependency and slip in a change nobody asked for. On top of that, npm ci fails if package.json and the lock are inconsistent, which catches the mistake of editing one without the other.
--omit=dev excludes devDependencies: mocha, chai, sinon, supertest, c8, eslint, prettier, husky, pino-pretty. That is about 200 MB and dozens of packages that contribute nothing in production and do contribute attack surface. This is where the discipline we imposed in 11-01 pays off: if something the application needs at runtime sits in devDependencies, the image starts and blows up with Cannot find module. And only in production.
USER node and why
By default, the process inside the container runs as root. Container root is not host root, but if somebody manages to escape the isolation — or if you mount a host volume — the difference between root and a normal user is the difference between an incident and a catastrophe. The official Node images already ship a node user with uid 1000. All you need is USER node after copying the files, plus --chown=node:node on each COPY so the permissions are right from the start (if you run chown -R afterwards, you double the size: it creates a new layer with every file in it). A side effect that confuses many people: as a non-root user, the process cannot listen on ports below 1024. It is exactly the Module 4 restriction, and the answer is the same: listen on 3000 and let the reverse proxy or the port mapping (-p 80:3000) do the rest.
WORKDIR, EXPOSE, ENV
WORKDIR /app sets the working directory and creates it if it does not exist; always use it instead of RUN cd, because every RUN is a fresh shell and the cd does not persist. EXPOSE 3000 is purely documentary: it opens no port — real publishing is -p 3000:3000 at run time — and its value is that docker compose and other tools read it. And ENV NODE_ENV=production turns on everything we saw in 11-01: Express in production mode, libraries without development checks and our own configuration.isProduction.
CMD in exec form: making SIGTERM actually arrive
This is the classic mistake that ruins all the graceful-shutdown work we have been carrying since Module 6.
CMD node src/server.js # BAD: shell. Docker runs /bin/sh -c "..."
CMD ["npm", "start"] # BAD: npm is a middleman that does not forward signals
CMD ["node", "src/server.js"] # GOOD: exec form, node receives the signalsWith the shell form, the container's PID 1 is /bin/sh and Node is its child. When you run docker stop, Docker sends SIGTERM to PID 1. The shell receives it, does not forward it to its children and does nothing. Node never finds out it is supposed to shut down. Ten seconds later, Docker loses patience and sends SIGKILL, which kills the process instantly: connections cut mid-response, transactions left open, none of the in-flight purchases finished. With the exec form (the JSON array), Node is PID 1 and receives SIGTERM directly. Our handler in src/server.js fires, /health/ready starts returning 503, idle connections are closed and whatever remains is drained. Exactly what we built five modules ago. The same applies to ENTRYPOINT. And never npm start as the command: npm adds an intermediate process with the same problem, and it also rewrites exit codes.
The PID 1 problem and tini
Being PID 1 comes with responsibilities Node was not written to take on:
- Reaping zombie processes. When a process dies, its parent must collect its exit code. If the parent is gone, PID 1 inherits the orphan. Node does not do this, and zombies pile up in the process table.
- Handling signals with no default behavior. PID 1 does not get the kernel's default handlers: if you do not handle a signal explicitly, it is ignored.
Escena Viva really does fork processes (the PDF pool's worker threads and, potentially, child processes), so the problem is real. The solution is a minimal init: tini, which takes up a few kilobytes. It is solved by the three lines already in the Dockerfile: RUN apk add --no-cache tini and ENTRYPOINT ["/sbin/tini", "--"] ahead of the CMD. Now tini is PID 1, it reaps zombies and forwards every signal to Node. The alternative without touching the Dockerfile is docker run --init, which injects its own init; it works just as well, but it depends on whoever runs the container remembering the flag. I prefer the image to be correct on its own.
.dockerignore: the file nobody writes and everybody needs
.dockerignore: the file nobody writes and everybody needsBefore building, Docker sends the build context — the whole directory — to the engine. Without a .dockerignore, that includes your local node_modules, .git with the entire history and, watch out, your .env. The three disasters, in order of severity:
- The
.envends up inside the image. WithCOPY . ., your secrets are baked into a layer, and layers get published to a registry: anybody with access to the image extracts them withdocker history. This has leaked credentials from large companies more than once. - Your local
node_modulesgets copied in. Besides being slow, if your laptop is macOS or Windows, the compiledbcryptbinaries are for another platform and do not work on Linux. Incomprehensible errors guaranteed. .gitbloats the context and may contain branches, old credentials and deleted files that are still in the history.
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.github
.env
.env.*
!.env.example
test
coverage
.nyc_output
*.md
!README.md
.vscode
.idea
.DS_Store
Dockerfile
docker-compose*.yml
reportsA note on reports: it is the directory where Module 3 wrote the generated PDFs. It must not go anywhere near the image, and in the next lesson we will see that it should not exist in production at all.
HEALTHCHECK with /health/ready
HEALTHCHECK with /health/readyHEALTHCHECK tells Docker how to check whether the container is healthy. Docker marks the container as healthy or unhealthy, and orchestrators use that status to withdraw traffic or replace it.
Here we reuse the readiness probe from 11-02. Since the Alpine image ships neither curl nor a full wget — and we do not want to add them just for this — the check is done with Node, which is already inside:
// scripts/check-health.js — no dependencies, no config: it must always work.
'use strict';
const http = require('node:http');
const port = process.env.PORT || 3000;
const options = { host: '127.0.0.1', port, path: '/health/ready', timeout: 2000 };
// Exit code 0 = healthy; anything else = unhealthy.
const request = http.request(options, (r) => process.exit(r.statusCode === 200 ? 0 : 1));
request.on('error', () => process.exit(1));
request.on('timeout', () => { request.destroy(); process.exit(1); });
request.end();The HEALTHCHECK parameters deserve attention:
| Parameter | Why |
|---|---|
--interval=30s |
Enough to detect problems; does not overload anything |
--timeout=3s |
Longer than the probe's internal limit (1 s per dependency) |
--start-period=20s |
Migrations and the DB connection take time; failures here do not count |
--retries=3 |
An isolated failure must not mark the container unhealthy |
--start-period is the most frequently forgotten one: without it, a slow startup counts as a failure and the container is marked unhealthy before it ever had a chance to be healthy.
- Build, measure, tag and publish
# Two tags: the version (for humans) and the commit hash (the truth).
docker build -t escena-viva/api:1.4.0 -t escena-viva/api:$(git rev-parse --short HEAD) .
docker images escena-viva/api # Real size
docker run --rm -p 3000:3000 --env-file .env escena-viva/api:1.4.0 # Test locally
docker push escena-viva/api:1.4.0 # PublishOn tagging: latest is not a version, it is a mutable alias pointing at whatever you published last. Deploying latest means not knowing what you are deploying and not being able to roll back. Always tag with the semantic version and with the short commit hash; the first is for humans and the second is the truth.
The size, measured
These are the real Escena Viva numbers at each optimization step:
| Version | Size | What changed |
|---|---|---|
node:24 + COPY . . + npm install |
1,410 MB | The naive starting point |
node:24-slim / node:24-alpine |
546 / 412 MB | Minimal Debian base; Alpine base |
+ npm ci --omit=dev |
268 MB | No development dependencies |
| + multi-stage (no compilers) | 97 MB | No python3, make, g++ or npm cache |
From 1.4 GB to under 100 MB: fourteen times smaller, and it is not just aesthetics. Less size means faster deployments — each instance pulls the image — less storage and transfer cost in the registry, and a vastly smaller attack surface: every binary that is not in the image is a binary that cannot have a vulnerability or serve an attacker.
docker compose: the complete development environment
docker compose: the complete development environmentHere comes what you have been wanting since Module 7. Escena Viva needs PostgreSQL, MongoDB and Redis; installing them by hand on every team laptop is a lost afternoon per person and an inexhaustible source of version differences.
docker compose brings the whole environment up with one command.
# docker-compose.yml
name: escena-viva
# YAML anchors: the API and the consumer share the image and the connections.
x-common: &common
build: { context: ., target: production }
env_file: ['.env.docker'] # Secrets only; NOT versioned.
restart: unless-stopped
environment: &env
NODE_ENV: development
LOG_LEVEL: debug
# Service names are DNS names inside the compose network.
POSTGRES_URL: postgres://escena:escena@postgres:5432/escena_viva
MONGO_URL: mongodb://mongo:27017/escena_viva
REDIS_URL: redis://redis:6379
x-healthy: &healthy { condition: service_healthy }
services:
api:
<<: *common
ports: ['3000:3000']
init: true # Docker's init; redundant with tini, harmless.
depends_on: { postgres: *healthy, mongo: *healthy, redis: *healthy }
consumer: # The M10 queue consumer.
<<: *common
command: ['node', 'src/processes/ticket-consumer.js']
environment:
<<: *env
QUEUE_CONCURRENCY: 2
depends_on: { postgres: *healthy, redis: *healthy }
postgres:
image: postgres:17-alpine
environment: { POSTGRES_USER: escena, POSTGRES_PASSWORD: escena, POSTGRES_DB: escena_viva }
ports: ['5432:5432'] # Exposed only for a local client.
volumes: ['postgres-data:/var/lib/postgresql/data']
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U escena -d escena_viva']
interval: 5s
retries: 10
mongo:
image: mongo:8
ports: ['27017:27017']
volumes: ['mongo-data:/data/db']
healthcheck:
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
interval: 5s
retries: 10
redis:
image: redis:7-alpine
command: ['redis-server', '--appendonly', 'yes']
ports: ['6379:6379']
volumes: ['redis-data:/data']
healthcheck: { test: ['CMD', 'redis-cli', 'ping'], interval: 5s, retries: 10 }
volumes: { postgres-data: , mongo-data: , redis-data: }What you need to understand about this file:
- Network and DNS. Compose creates its own network where every service is resolvable by name: that is why the URL is
postgres://escena:escena@postgres:5432/..., with the service name and the internal port (5432), not the mapped one. depends_onwithcondition: service_healthy. A plaindepends_ononly waits for the container to start, not for the service to be ready; PostgreSQL takes a few seconds to accept connections, and the API would start before that and fail. Even so, the application must tolerate the database disappearing at runtime:depends_ononly covers startup.- Named volumes. The data lives in Docker-managed volumes and survives
docker compose down. To start from scratch,docker compose down -v. - A separate
env_file. Non-secret variables go inenvironment(visible and versioned); the secrets go in.env.docker, which is in.gitignore. It is the same separation we applied to the PM2 ecosystem file in 11-03. - One
Dockerfilefor two services.apiandconsumershare the image and differ only incommand: the two processes PM2 managed as two applications are here two containers of the same artifact.
And the daily workflow, which is the real gift of this lesson:
docker compose up -d # Brings up the five services
docker compose logs -f api # Follows the API logs
docker compose exec api sh # Shell inside the container
docker compose down # Stops everything, keeping the data (-v deletes it)Somebody joining the team clones the repository, copies .env.example to .env.docker, runs docker compose up and has the whole environment in two minutes. Without installing PostgreSQL, or MongoDB, or Redis, or even Node.
- Migrations in a container
An obvious temptation: putting npm run migrate && node src/server.js in the CMD. It is a mistake, for three reasons.
- They would run N times in parallel. With four instances starting at once, four processes migrate the same schema simultaneously, and race conditions in a concurrent
ALTER TABLEproduce unpredictable results. - It breaks the exec form. That
&&forces a shell, and we already saw what happens toSIGTERMwith a shell in the way. - A failed migration leaves the container in a restart loop instead of failing visibly and stopping the deployment.
Migrations are a separate, one-off step:
# docker-compose.yml, additional service
migrate:
<<: *common
command: ['npm', 'run', 'migrate']
depends_on: { postgres: *healthy }
restart: 'no' # It is a one-off task, not a service: it does not restart.It is invoked separately, before bringing up the processes: docker compose run --rm migrate and then docker compose up -d api consumer. Notice the restart: 'no': it is a task that finishes, not a service. Here npm run is acceptable because the process is ephemeral and signals do not matter. In production this has a name and a lesson of its own: it is the release phase we will talk about in 11-05, and its ordering relative to the deployment is a topic in itself.
- Logs, memory limits and CPU
Logs to stdout, now mandatory
In 11-02 we said writing to stdout is recommended. In containers it is the only sane option: the container filesystem is ephemeral, so a log written to a file disappears with the container — exactly when you need it most, because the container died for a reason. Docker captures stdout and stderr and hands them to its logging driver, which can be the local JSON file, journald, or a remote service. The application neither knows nor cares. With pino writing JSON to stdout, the chain is complete.
It is also worth adding logging: { driver: json-file, options: { max-size: '20m', max-file: '5' } } to each service, which avoids the classic problem of logs filling the host's disk: by default, the json-file driver has no limit.
Limits and their relationship with Node
Containers can be constrained with cgroups:
And now the part that fools an enormous number of people. Node does not always respect the container's memory limit. V8 computes the maximum heap size from the memory it can see, and in versions and configurations where it sees the host's, a container limited to 1 GB can end up with a target heap of several GB. The consequence? The process grows, the cgroup kills it without ceremony (OOMKilled, exit code 137) and garbage collection never even considered that there was memory pressure. There is no stack trace, there is no error: the container simply disappears. The solution is to tell it explicitly, leaving room for what is not heap (buffers, the stack, Node itself). That is why the Dockerfile includes ENV NODE_OPTIONS="--max-old-space-size=768". With a 1 GB container limit, a 768 MB heap leaves about 256 MB of headroom. The practical rule: --max-old-space-size at roughly 75% of the container limit. The same arithmetic applies to CPU and to Module 10. If you limit the container to 2 CPUs but start the cluster inside with instances: 0 (as many as there are cores), Node will see the host's 16 cores and start 16 workers that will fight over 2 CPUs. Worse performance than with a single one, more memory and more database connections. In containers the doctrine differs from PM2's: one process per container, and scaling is done with more containers, not with more processes inside. The orchestrator is the one that distributes. That is why the Dockerfile points at src/server.js and not at src/cluster.js.
- What does not go into the image and how to scan it
Never in the image:
- Secrets. Not with
ENV, not withARG, not by copying a.env. Everything stays in the layer history and can be recovered withdocker history --no-trunc. Secrets are injected at run time, not at build time. If you need a secret during the build (for example, a token for a private npm registry), useRUN --mount=type=secret, which leaves no trace in any layer. - Data: databases, uploaded files, generated PDFs. They go to volumes or to external storage, because the image is code, not state.
- Cloud credentials, SSH keys, private certificates and development tools (compilers,
git, editors): every extra binary is attack surface.
And scanning the image is a cheap and very profitable step. Tools like Trivy, Grype or docker scout compare the installed packages against public vulnerability databases: trivy image --severity HIGH,CRITICAL escena-viva/api:1.4.0. It catches both base-system vulnerabilities (OpenSSL, musl) and npm dependency ones, complementing the npm audit from Module 5. This step joins the CI pipeline in lesson 11-06, and it is one more reason to keep the image small: fewer packages, fewer findings to review.
Common Mistakes and Tips
CMDin shell form ornpm start.SIGTERMnever reaches Node,SIGKILLcuts mid-response and the graceful shutdown from Module 6 counts for nothing. AlwaysCMD ["node", "src/server.js"].- Forgetting
.dockerignore. You copy.envandnode_modulesinside. Published secrets and binaries for the wrong platform. COPY . .beforenpm ci, which makes every one-line change reinstall every dependency, orFROM node:latest, which makes reproducible builds impossible.- Running as root, which is the default and has to be changed deliberately.
- Not setting
--max-old-space-sizewith a memory limit. The container dies with exit code 137 and not a single clue. - Starting the cluster inside a constrained container: Node sees the host's cores and starts far too many workers.
- Migrations in the
CMD, which run in parallel once per instance, or logs to a file inside the container, which are lost precisely when you need them. - Tip: run
docker run --rm -it escena-viva/api:1.4.0 shand look inside: check thatnode_moduleshas no mocha, that there is no.envand that the user isnode. You will discover surprising things. And always build the image in CI, never on the production server: the server only pulls and runs.
Exercises
Exercise 1 — Demonstrate the signal problem
Build two variants of the image: one with CMD node src/server.js (shell form) and one with CMD ["node", "src/server.js"]. Start each one, run docker stop and measure how long it takes to stop. Explain the result from the shutdown logs.
Exercise 2 — Shrink the image
Start from a naive Dockerfile (FROM node:24, COPY . ., npm install) and apply the four optimizations from this lesson one at a time, measuring the size with docker images after each step. Also measure the rebuild time after changing one line of a controller.
Exercise 3 — Complete environment and seed
Bring the environment up with docker compose up -d, run the migrations in a separate container, run scripts/seed.js to load the three venues and the three events, and verify that /health/ready returns 200 with all three dependencies true.
Solutions
Exercise 1.
Building both variants and timing docker stop, the shell variant takes about 10 seconds (Docker's grace period before SIGKILL) and docker logs shows not a single shutdown line: the SIGTERM handler never ran. The exec variant stops in under a second and leaves the expected logs:
{"level":30,"msg":"SIGTERM received, starting graceful shutdown"}
{"level":30,"msg":"server closed; connections drained"}Those ten seconds of difference are, in production, ten seconds of cut-off requests on every deployment.
Exercise 2. The expected sizes are the ones in the table in section 6. As for rebuild time, with COPY . . first, changing one line of a controller costs about 90 seconds; with the manifests copied first, about 6, and Docker's output confirms it with a => CACHED [dependencies 4/4] RUN npm ci --omit=dev: the word CACHED on the install step is the proof that the ordering works.
Exercise 3.
docker compose up -d postgres mongo redis
docker compose run --rm migrate
docker compose run --rm api node scripts/seed.js
docker compose up -d api consumer
curl -s http://localhost:3000/health/ready
# {"status":"ready","detail":{"postgres":true,"mongo":true,"redis":true}}The order is deliberate: first the databases, then the schema, then the data and finally the application processes. It is exactly the order the deployment pipeline in 11-06 will reproduce. If redis came back as false, check that the URL uses the service name (redis://redis:6379) and not localhost: inside a container, localhost is the container itself.
Conclusion
Escena Viva is no longer code running on an unknown machine: it is a 97 MB artifact carrying its own environment inside. A multi-stage Dockerfile that compiles the native modules in a throwaway stage, installs only the production dependencies with npm ci --omit=dev, copies the manifests before the code to exploit the layer cache, runs as the unprivileged node user and starts with CMD in exec form under tini, so that SIGTERM genuinely reaches the process and the graceful shutdown from Module 6 finally works in a container too. With a HEALTHCHECK built on /health/ready, a .dockerignore that keeps secrets out of a published layer, and a docker compose setup that brings up the API, the consumer, PostgreSQL, MongoDB and Redis with a single command: the complete development environment that had been missing since Module 7. What is left is answering where that container runs when somebody really wants to buy a ticket. In the next lesson, Deploying to Heroku and Other PaaS, we put Escena Viva on the internet: the deployment models compared, the Procfile with its web and worker processes, the port the platform assigns in process.env.PORT, managed PostgreSQL and Redis add-ons with their connection limits, migrations in the release phase and zero-downtime schema changes, the ephemeral filesystem and what to do then with the Module 3 PDFs, HTTPS and trust proxy — the promise outstanding since Module 6 — scaling, blue-green and canary deployments, and how to roll a deployment back, which is the first thing you need to know how to do.
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
