Aurora Libros runs on your machine, and it runs well. Production is a different animal: nobody there restarts the container by hand when it hangs, configuration changes from one environment to the next, and a request cut off halfway through is a lost order. This lesson turns your 1.3.0 image into the 2.0.0: the one an orchestrator can start, kill and start again a hundred times a day without a single customer noticing.

Contents

  1. What sets a production image apart
  2. The twelve factors applied to configuration
  3. Validating configuration at startup and failing fast
  4. PID 1 and the zombie problem
  5. One process per container
  6. The complete graceful shutdown
  7. The grace period and the longest request
  8. The three probes: liveness, readiness and startup
  9. /health/live and /health/ready in aurora-api
  10. Reproducibility: digests, lockfile and OCI labels
  11. Choosing resource limits from real data
  12. The memory limit and Node's heap
  13. Aurora Libros' definitive Dockerfile
  14. The compose.prod.yaml for version 2.0.0
  15. A fifteen-point checklist

Warning. The limits, timeouts and probe values in this lesson are starting points reasoned out from Aurora Libros' own data, not a policy. Resource thresholds, shutdown windows and the handling of secrets in production must be validated with the infrastructure, security or compliance officer in your organization.

  1. What sets a production image apart

It is not a difference of technology: it is a difference of assumptions. In development you assume somebody is watching; in production you assume nobody is.

Aspect Development image Production image
Base node:22 (~1.1 GB), with git and compilers node:22-alpine pinned by digest
Dependencies npm install, includes devDependencies npm ci --omit=dev with a lockfile
Code Bind mount from the host Copied into the image
Reloading nodemon, --watch No reloading: the process is replaced
Configuration .env in the repository Environment variables from the orchestrator
Secrets Convenient plain text Files in /run/secrets/, _FILE pattern
User root (who cares) node (UID 1000), unprivileged
Filesystem Writable read_only with minimal tmpfs
Logs Colored, debug level Single-line JSON, info level
Errors Stack trace sent to the client Generic message; the detail goes to the log
Shutdown Ctrl+C and that's it SIGTERM caught, orderly draining
Health None Three separate probes
Startup with invalid config Fails on the first request Fails at startup, at second 0
Reproducibility "It works today" The same digest today and in six months

The last four rows separate an image that works from one that can be operated; the first three you already solved back in module 5.

  1. The twelve factors applied to configuration

The third factor of the twelve-factor methodology is blunt: configuration lives in the environment, never in the image. The practical test is the open-source question: could you publish this image right now in a public registry without leaking anything? If the answer is no, you have configuration inside it.

From that follows the most valuable property in the whole module: one image, many environments. The same digest that passed the tests in CI is the one running in staging and the one running in production; the only thing that changes is the variables you inject into it. If you rebuilt the image for production, you would be deploying an artifact nobody has tested. Here is how each kind of data gets allocated.

Kind of data Where it goes Example in Aurora Libros
Application constant In the image View paths, API version
Per-environment configuration Environment variable DB_HOST, PORT, LOG_LEVEL
Secret Mounted file + _FILE pattern DB_PASSWORD_FILE=/run/secrets/db_password
State Volume or external service aurora-data, aurora-cache

  1. Validating configuration at startup and failing fast

The worst configuration failure is the silent one: the container starts, declares itself healthy, takes traffic and only then discovers that DB_PASSWORD was empty. By the time you find out, the load balancer has already sent it customers.

The answer is fail fast: validate everything at startup and exit with a non-zero code if anything is missing.

// api/src/config.js — the single entry point for configuration
const fs = require('node:fs');

function read(name, { required = false, fallback } = {}) {
  const path = process.env[`${name}_FILE`];   // _FILE pattern: wins over the direct variable
  if (path) {
    try { return fs.readFileSync(path, 'utf8').trim(); }
    catch (e) { throw new Error(`${name}_FILE points at ${path}, unreadable: ${e.code}`); }
  }
  const value = process.env[name];
  if (value) return value;
  if (fallback !== undefined) return fallback;
  if (required) throw new Error(`Missing required variable ${name} (or its _FILE variant)`);
}

function integer(name, fallback, { min = 1, max = 65535 } = {}) {
  const raw = read(name, { fallback: String(fallback) });
  const n = Number(raw);
  if (!Number.isInteger(n) || n < min || n > max) throw new Error(`${name}="${raw}" is not an integer between ${min} and ${max}`);
  return n;
}

let config;
try {
  config = {
    port:    integer('PORT', 3000),
    version: read('APP_VERSION', { fallback: '0.0.0-dev' }),
    grace:   integer('SHUTDOWN_TIMEOUT_MS', 15000, { min: 1000, max: 120000 }),
    db: {
      host:     read('DB_HOST',     { required: true }),
      user:     read('DB_USER',     { required: true }),
      password: read('DB_PASSWORD', { required: true }),
      name:     read('DB_NAME',     { required: true }),
      maxPool:  integer('DB_POOL_MAX', 10, { min: 1, max: 200 }),
    },
    redis: { host: read('REDIS_HOST', { required: true }), ttl: integer('CACHE_TTL', 60, { min: 1, max: 86400 }) },
  };
} catch (e) {
  process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), level: 'error',
    service: 'aurora-api', message: 'invalid configuration', detail: e.message }) + '\n');
  process.exit(78);   // EX_CONFIG from sysexits.h: "configuration error"
}

module.exports = config;

Three important decisions here. First: no other part of the code reads process.env; everything imports config.js, so there is a single place to find out what configures the application. Second: _FILE wins over the direct variable, so the same code serves development (a variable) and production (a mounted secret). Third: exit code 78 is not decorative; distinguishing it from a generic 1 lets the orchestrator —and you, reading docker inspect— know that this is not a transient failure and that restarting a thousand times will not fix it. You will confirm this in exercise 1: the failure arrives at second 0, naming the exact variable that is missing, instead of a 502 at three in the morning.

  1. PID 1 and the zombie problem

Back in 03-02 you saw that the container's main process is PID 1. Production adds a nuance that does not bother anybody in development: on Linux, PID 1 carries two special responsibilities of the init process.

  1. Adopting orphans. When a process dies leaving children behind, those children get reparented to PID 1.
  2. Reaping zombies. A finished process stays in state Z until its parent calls wait() to read its exit code. If nobody does, the entry is never released.

Node.js does not do the second: it is not an init. If your API spawns subprocesses —an image conversion, a pg_dump—, each one leaves a zombie occupying a slot in the process table, and you count them with docker compose exec aurora-api ps -eo stat | grep -c Z. With pids_limit: 200, two hundred zombies and the container cannot create one more process. The fix is a minimal init in front:

Option How When
--init / init: true Docker injects docker-init (tini) as PID 1 By default: zero changes to the image
tini in the image ENTRYPOINT ["/sbin/tini","--"] When you don't control the runtime (Kubernetes)
dumb-init Same idea, the historical alternative Equivalent
None The process is PID 1 If it never spawns subprocesses and catches signals

One detail that costs dearly: PID 1 has the default actions for signals disabled. If your process does not install a SIGTERM handler, the signal is ignored, docker stop waits ten seconds and then kills it with SIGKILL. That is the real origin of most "my container takes ten seconds to stop" complaints.

  1. One process per container

The temptation to shove supervisord, systemd or a cron inside the container shows up the moment you need a second thing. Resist it, for concrete reasons:

  • State becomes opaque. Docker only sees the manager. If Node dies and supervisord keeps running, the container is "healthy" with the application down.
  • Restarts stop working. restart: always and the orchestrator react to the death of PID 1, which now never happens.
  • Logs get mixed together and lose their origin; docker logs stops being useful.
  • Scaling becomes coupled. If the API needs three replicas and the cron needs one, you cannot have it.
  • The image gets fatter and its attack surface grows.

Aurora Libros' periodic tasks —the database backup from 05-02— are a separate service with their own lifecycle, not a hidden cron. The one legitimate exception is the sidecar pattern: two distinct containers sharing a network or a volume, each with its own PID 1.

  1. The complete graceful shutdown

When the orchestrator retires a replica it sends SIGTERM and starts a stopwatch. What you do in those seconds decides whether the customers who were buying finish their purchase or see an error.

// api/src/server.js — graceful shutdown (closing fragment)
const server = app.listen(config.port, () =>
  log.info('listening', { port: config.port, version: config.version }));

server.keepAliveTimeout = 5000;          // without this, an idle connection keeps it open
server.headersTimeout   = 6000;

let shuttingDown = false;
state.ready = true;                      // from here on, /health/ready answers 200

async function shutdown(signal) {
  if (shuttingDown) return;              // idempotent: two SIGTERMs break nothing
  shuttingDown = true;
  state.ready = false;                   // 1) fail readiness NOW: no new traffic arrives
  log.info('shutdown started', { signal, timeout_ms: config.grace });

  await new Promise(r => setTimeout(r, 5000));   // 2) give the balancer time to notice

  await new Promise((resolve) => {              // 3) close the listener and drain what's in flight
    server.close(resolve);
    setTimeout(() => { log.warn('in-flight connections forced'); resolve(); }, config.grace - 7000);
  });

  // 4) close dependencies in the reverse order they were opened
  try { await redis.quit(); } catch (e) { log.warn('redis.quit failed', { detail: e.message }); }
  try { await pool.end(); }   catch (e) { log.warn('pool.end failed',   { detail: e.message }); }
  log.info('shutdown complete');
  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT',  () => shutdown('SIGINT'));
process.on('unhandledRejection', (e) => { log.error('uncaught promise', { detail: String(e) }); shutdown('rejection'); });

Step 2 is the one almost everybody skips and the one that prevents the most errors. Between your Pod ceasing to be ready and the balancer ceasing to send it traffic, a few seconds go by: updating the routing tables is not instantaneous. If you close the listener immediately, the requests already on their way slam into a closed port. Waiting a few seconds with the listener open but readiness in the red removes that window.

The order in step 4 matters too: Redis first and PostgreSQL after, the reverse of how they were opened, because an in-flight request may need the database after missing the cache.

  1. The grace period and the longest request

The rule is arithmetic:

grace_period  >  readiness_wait + longest_request + dependency_shutdown

For Aurora Libros, with the slowest request measured at 4 s (the full listing with no cache), a 5 s readiness wait and ~1 s to close connections: 5 + 4 + 1 = 10 s, and 15 s is configured to leave headroom.

Place Key Value in Aurora Libros
Application SHUTDOWN_TIMEOUT_MS 15000
Compose stop_grace_period 20s
Dockerfile STOPSIGNAL SIGTERM (the default)
Kubernetes terminationGracePeriodSeconds 30 (06-05)

Notice that the platform's timeout is always larger than the application's: the one that should decide to finish is your code, not SIGKILL. If Docker kills the process before it is done, all that graceful shutdown work was for nothing.

  1. The three probes: liveness, readiness and startup

The HEALTHCHECK from 02-04 answered a single question. In production there are three, and mixing them up causes spectacular outages.

Probe Question If it fails Should check Should NOT check
Liveness Is the process still alive and healthy? The container is restarted That the event loop responds External dependencies
Readiness Can it serve requests right now? Traffic is taken away, no restart The DB, the cache, the warm-up Anything expensive or slow
Startup Has it finished starting? Restarted (after many attempts) The same as liveness

The classic mistake is using the same endpoint for all three, and the consequence is a full cascading outage. Imagine /health checks PostgreSQL, like yours did in module 5, and the database goes away for thirty seconds during a failover:

  1. Readiness fails on all three replicas: correct, there is no point sending them traffic.
  2. Liveness fails too, because it is the same endpoint.
  3. The orchestrator restarts all three API replicas.
  4. The database comes back, but the replicas are starting from scratch.
  5. All three reconnect at once, saturate the pool and liveness fails again.

You have turned a 30-second incident into a restart loop. The rule that prevents it is simple: liveness does not check dependencies. Restarting your process does not fix a downed database; all it does is make things worse.

The startup probe solves a different problem: if starting takes 40 s (migrations, cache warm-up) and liveness has a 10 s threshold, the container restarts forever without ever managing to start. Startup suspends the other two until it passes for the first time.

  1. /health/live and /health/ready in aurora-api

// api/src/health.js — three endpoints, three semantics
const state = { ready: false, startedAt: Date.now() };

function mount(app, { pool, redis, config }) {
  // LIVENESS: touches nothing external. If Node answers, Node is alive.
  app.get('/health/live', (req, res) =>
    res.json({ status: 'alive', version: config.version, uptime_s: Math.round(process.uptime()) }));

  // READINESS: this one does check dependencies, with a short timeout and no side effects
  app.get('/health/ready', async (req, res) => {
    if (!state.ready) return res.status(503).json({ status: 'shutting down' });
    const withTimeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(new Error('timeout')), ms))]);
    const checks = {};
    try { await withTimeout(pool.query('SELECT 1'), 2000); checks.db = 'ok'; } catch (e) { checks.db = `error: ${e.message}`; }
    try { await withTimeout(redis.ping(), 1000); checks.cache = 'ok'; }        catch (e) { checks.cache = `error: ${e.message}`; }
    // A degraded cache does NOT stop us serving: we serve from the DB, slower but correct
    const healthy = checks.db === 'ok';
    res.status(healthy ? 200 : 503).json({ status: healthy ? 'ready' : 'degraded', checks });
  });

  // STARTUP: ready once startup has finished; the orchestrator stops waiting
  app.get('/health/started', (req, res) =>
    res.status(state.ready ? 200 : 503).json({ status: state.ready ? 'started' : 'starting' }));
}
module.exports = { state, mount };

The business decision lives in the second-to-last line: Redis being down does not pull the replica out of the load balancer, because Aurora Libros' cache-aside degrades to source: db and keeps selling books, just more slowly. PostgreSQL being down does, because with no catalog there is nothing to serve. That distinction between a hard and a soft dependency is yours, not Docker's, and it is worth writing down in the code right next to the probe.

  1. Reproducibility: digests, lockfile and OCI labels

Today's image and the one from six months' time being the same is not purism: it is the only way for a rollback to be worth anything.

Source of drift Fix Verification
The base changes FROM node:22-alpine@sha256:... docker buildx imagetools inspect
An npm package moves version npm ci (never npm install) package-lock.json in the repository
System packages Version pinned in apk add Rebuild and compare digests
Not knowing which commit is running OCI labels with the SHA docker inspect --format '{{json .Config.Labels}}'
Timestamps SOURCE_DATE_EPOCH Two builds, same digest
docker buildx imagetools inspect node:22-alpine --format '{{.Manifest.Digest}}'   # goes into the FROM
docker inspect auroralibros/aurora-api:2.0.0 \
  --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'          # which commit is running

  1. Choosing resource limits from real data

Setting memory: 2G "just in case" has two costs: the orchestrator reserves memory nobody uses, and less fits on each node. Setting too little has another: an OOM kill with code 137 at peak hour. You measure it with fifteen minutes of sampling under representative load, dumping docker stats --no-stream --format '{{.Name}};{{.MemUsage}};{{.CPUPerc}}' in a loop into a CSV.

Service Idle memory Peak memory Average CPU Peak CPU Limit chosen
aurora-api 78 MiB 214 MiB 4 % 96 % 512M / 1.0 CPU
aurora-db 96 MiB 380 MiB 6 % 140 % 1G / 2.0 CPU
aurora-cache 12 MiB 208 MiB 1 % 15 % 256M / 0.5 CPU
aurora-web 6 MiB 22 MiB 1 % 30 % 64M / 0.5 CPU

The rules behind those numbers:

  • Memory: roughly the observed peak × 2, rounded up to a comfortable power. Memory is not compressible: going over the limit means death, not slowness.
  • CPU: don't choke the peaks. CPU is compressible; a low limit only produces latency. aurora-db gets 2 CPUs because a VACUUM or a pg_dump has to be able to run.
  • aurora-cache with --maxmemory 200mb needs a container limit above those 200 MB: Redis uses memory beyond that of the data itself. 256M is the smallest defensible figure.
  • Always keep requests below limits in Kubernetes (06-05): requests reserve and decide where the Pod fits; limits cut you off.

  1. The memory limit and Node's heap

There is a specific trap here. V8's heap has its own maximum, independent of the cgroup. If Node believes it can use 4 GB and the container cuts off at 512 MB, the process dies from OOM without throwing any exception: the kernel kills it before the garbage collector decides there is any pressure. Check it with docker run --rm --memory 512m node:22-alpine node -p 'v8.getHeapStatistics().heap_size_limit/1048576'.

Node 22 reads the cgroup and adjusts the limit reasonably, but it is worth being explicit, above all because the heap is not the whole memory of the process: buffers, native code and the stack all live outside it. The practical rule is to give the heap 75 % of the container limit, which is where the Dockerfile's ENV NODE_OPTIONS="--max-old-space-size=384" comes from. With that setting, when the application approaches the ceiling, V8 collects aggressively and, if it genuinely does not fit, throws a heap out of memory you can log instead of a mute 137.

  1. Aurora Libros' definitive Dockerfile

# syntax=docker/dockerfile:1.7
# api/Dockerfile — Aurora Libros 2.0.0
ARG NODE_DIGEST=sha256:9c8f1b1e0c9d2a3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c

FROM node:22-alpine@${NODE_DIGEST} AS deps            # 1. production dependencies only
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci --omit=dev

FROM node:22-alpine@${NODE_DIGEST} AS deps-dev        # 2. full dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci

FROM deps-dev AS tests                                # 3. the pipeline's target stage (06-02)
COPY . .
RUN npm run lint && npm test -- --run

FROM node:22-alpine@${NODE_DIGEST} AS runtime         # 4. final image
ARG VERSION=2.0.0
ARG REVISION=unknown
ARG BUILD_DATE
LABEL org.opencontainers.image.title="aurora-api" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${REVISION}" \
      org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.source="https://github.com/auroralibros/aurora-libros" \
      org.opencontainers.image.base.name="docker.io/library/node:22-alpine"
ENV NODE_ENV=production APP_VERSION=${VERSION} PORT=3000 \
    NODE_OPTIONS="--max-old-space-size=384"
WORKDIR /app
# --chown on the COPY avoids an extra RUN chown layer that would duplicate the files
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node package.json ./
COPY --chown=node:node src ./src

USER node
EXPOSE 3000
STOPSIGNAL SIGTERM

# Liveness inside the image as well: useful outside an orchestrator
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
  CMD node -e "require('http').get('http://127.0.0.1:3000/health/live',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"

# No shell: node is PID 1 and receives SIGTERM directly
CMD ["node", "src/server.js"]
docker build -t auroralibros/aurora-api:2.0.0 --build-arg REVISION=$(git rev-parse --short HEAD) \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) api/
docker image inspect auroralibros/aurora-api:2.0.0 --format '{{.Size}}' | numfmt --to=iec
# 104M   (2 MB more than 1.3.0: the price of three probes and the validation)

  1. The compose.prod.yaml for version 2.0.0

# compose.prod.yaml — the API service only; the rest stays as it was in 05-03
services:
  aurora-api:
    image: auroralibros/aurora-api:2.0.0
    init: true                          # tini as PID 1: it reaps zombies
    restart: unless-stopped
    stop_grace_period: 20s              # > SHUTDOWN_TIMEOUT_MS (15 s)
    read_only: true
    tmpfs: [/tmp:size=32m,noexec,nosuid]
    user: "1000:1000"
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    pids_limit: 200
    environment:
      DB_HOST: aurora-db
      DB_USER: aurora
      DB_NAME: aurora_books
      DB_PASSWORD_FILE: /run/secrets/db_password
      DB_POOL_MAX: "10"
      REDIS_HOST: aurora-cache
      CACHE_TTL: "60"
      SHUTDOWN_TIMEOUT_MS: "15000"
      LOG_LEVEL: info
    secrets: [db_password]
    healthcheck:
      test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/health/ready',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:       { memory: 512M, cpus: "1.0" }
        reservations: { memory: 128M, cpus: "0.1" }
    networks: [frontend, backend]
    depends_on:
      aurora-db:    { condition: service_healthy }
      aurora-cache: { condition: service_healthy }

Compose's healthcheck uses /health/ready while the image's HEALTHCHECK uses /health/live, and that is deliberate: here health governs depends_on, that is, "can I send you traffic?", which is exactly readiness.

  1. A fifteen-point checklist

# Control Check
1 Base pinned by digest grep 'FROM.*@sha256' api/Dockerfile
2 npm ci with the lockfile in the repository git ls-files package-lock.json
3 No devDependencies in the final image docker run --rm IMG ls node_modules | wc -l
4 Runs as an unprivileged user docker run --rm IMG id -u1000
5 Zero configuration inside the image docker history --no-trunc IMG | grep -i pass
6 Secrets by file with the _FILE pattern DB_PASSWORD_FILE in the environment
7 Fails at startup if configuration is missing Start without DB_PASSWORD → code 78
8 Correct PID 1 (init: true or tini) docker exec IMG ps -eo pid,comm | head -2
9 A single main process No supervisord or cron in the image
10 SIGTERM caught and graceful shutdown time docker stop C → under 15 s
11 Three separate probes, liveness with no dependencies curl /health/live with the DB stopped → 200
12 stop_grace_period > the application's timeout 20 s against 15 s
13 Limits measured, not invented A documented docker stats table
14 Node's heap consistent with the limit NODE_OPTIONS at 75 % of memory
15 OCI labels with version and commit docker inspect --format '{{json .Config.Labels}}'

Common Mistakes and Tips

  • Rebuilding the image for each environment. If staging and production have different images, you have not tested what you deploy. One artifact, one digest, many environments.
  • Liveness checking the database. This is the most expensive mistake in the lesson: it turns a DB outage into a restart loop across the whole fleet. Liveness only answers "the process responds".
  • Closing the listener the instant SIGTERM arrives. Without the prior wait with readiness in the red, requests already routed are lost. Five seconds of margin remove almost every 502 in a deployment.
  • CMD npm start. npm becomes PID 1, does not forward SIGTERM to Node and adds a useless layer. Always use CMD ["node", "src/server.js"].
  • Trusting configuration defaults. A DB_HOST defaulting to localhost does not fail: it connects to the wrong place. Anything mandatory has no default.
  • stop_grace_period shorter than the internal timeout. Docker sends SIGKILL halfway through the draining and all the graceful shutdown work goes in the bin. And latest in production makes it impossible to know what is running and impossible to roll back: tag with SemVer and, better still, deploy by digest.
  • Tip: test the shutdown. Run docker stop with hey pushing load and count how many requests fail. If it is not zero, your graceful shutdown is not graceful.
  • Tip: log the effective configuration at startup, with secrets hidden. A startup log with db_host, pool_max and version saves hours of diagnosis.

Exercises

Exercise 1. Demonstrate fail-fast behavior: start aurora-api:2.0.0 without DB_PASSWORD, check that it exits with code 78 in under a second with a message naming the variable, and verify that it does start with DB_PASSWORD_FILE pointing at a secret.

Exercise 2. Check that the graceful shutdown works: generate load against /books, run docker stop in the middle of that load and measure how many requests failed. Then disable the SIGTERM handler and repeat, comparing the stop time and the errors.

Exercise 3. Demonstrate why liveness must not touch the database: stop aurora-db and check what /health/live and /health/ready answer. Explain what an orchestrator would do with each answer.

Solutions

Solution 1.

time docker run --rm -e DB_HOST=aurora-db -e DB_USER=aurora -e DB_NAME=aurora_books \
  -e REDIS_HOST=aurora-cache auroralibros/aurora-api:2.0.0
echo "code: $?"
{"ts":"2026-08-05T09:12:44.118Z","level":"error","service":"aurora-api",
 "message":"invalid configuration",
 "detail":"Missing required variable DB_PASSWORD (or its _FILE variant)"}
real  0m0.421s
code: 78
printf 'aurora-dummy-secret' > /tmp/db_password
docker run --rm -d --name config-test -v /tmp/db_password:/run/secrets/db_password:ro \
  -e DB_HOST=aurora-db -e DB_USER=aurora -e DB_NAME=aurora_books -e REDIS_HOST=aurora-cache \
  -e DB_PASSWORD_FILE=/run/secrets/db_password --network aurora-libros_backend \
  auroralibros/aurora-api:2.0.0 && docker logs config-test | head -1
# {"ts":"...","level":"info","message":"listening","port":3000,"version":"2.0.0"}

Three things are demonstrated. The failure takes 0.4 seconds: the container never gets to exist as a service, so no load balancer can send it traffic. The message names the exact variable, which makes diagnosis immediate even for somebody who does not know the code. And code 78 is distinguishable: in an orchestrator with automatic restarts, seeing exit 78 over and over says "stop insisting, fix the configuration", whereas an exit 1 could be anything.

In the second command the secret comes in as a read-only file: it never appears in docker inspect or in the shell history, and the code reads it exactly once at startup.

Solution 2.

docker run --rm --network aurora-libros_frontend ghcr.io/rakyll/hey \
  -z 30s -c 20 http://aurora-api:3000/books > /tmp/with-handler.txt &
sleep 8; time docker compose -f compose.prod.yaml stop aurora-api
wait; grep -E 'responses|error' /tmp/with-handler.txt
# real  0m6.104s
# Status code distribution:
#   [200] 4127 responses

Now the opposite scenario, overriding the startup command so the handler does not exist:

docker run -d --name no-handler --network aurora-libros_frontend \
  auroralibros/aurora-api:2.0.0 \
  node -e "require('http').createServer((q,s)=>setTimeout(()=>s.end('ok'),300)).listen(3000)"
# ... same load with hey ...
time docker stop no-handler
real  0m10.213s
Status code distribution:
  [200] 3811 responses
  [error] 152 connection reset by peer
Scenario stop time Failed requests
With graceful shutdown ~6 s 0
Without a SIGTERM handler 10.2 s 152

The two numbers tell the same story. 10.2 seconds is the unmistakable signature of the problem: the process ignored SIGTERM —remember, PID 1 has no default action—, Docker waited out the full 10 seconds of --time and then killed it with SIGKILL. A SIGKILL cannot be caught: the 152 open connections were cut off mid-flight, and every one of them is a customer looking at an error.

With the handler, stopping takes less time (6 s: the 5 of readiness margin plus the actual draining) and no request fails. The operational lesson is that a twenty-replica deployment without graceful shutdown means thousands of errors per released version, and not one of them will show up in your application logs, because the process was already dead when they happened.

Solution 3.

docker compose -f compose.prod.yaml stop aurora-db
sleep 3
curl -s -o /dev/null -w 'live:  %{http_code}\n' http://localhost:8080/health/live
curl -s -w '\nready: %{http_code}\n' http://localhost:8080/health/ready
live:  200
{"status":"degraded","checks":{"db":"error: timeout","cache":"ok"}}
ready: 503
Probe Answer What the orchestrator would do Correct?
/health/live 200 Nothing: the replica is still alive Yes
/health/ready 503 Takes traffic away, without restarting Yes

That pair of answers is exactly the desired behavior. The Node process is perfectly healthy —its event loop responds in milliseconds—, so restarting it would fix nothing; what is happening is that it cannot serve, and that is why it stops receiving traffic and nothing more.

If /health were both liveness and readiness, as in version 1.3.0, that same 503 would have produced: a restart of all three replicas → 40 s with no service while they start → three pools reconnecting simultaneously against a database that has only just come back → a possible new failure → a second restart. A 30-second database incident turned into several minutes of total outage, caused entirely by a badly designed probe.

And look at the third line of the response: cache: ok with db: error returns 503, but the reverse case —db: ok with cache: error— returns 200, because cache-aside degrades to source: db. That asymmetry is a product decision encoded in the probe: Aurora Libros would rather sell books slowly than not sell them at all.

Conclusion

You have turned an image that works into an image that can be operated. Configuration left the image entirely and came in through the environment, with a single config.js that validates it at startup and fails in 0.4 seconds with code 78 if something mandatory is missing, instead of blowing up on the first real request; the _FILE pattern lets the same code serve both your laptop and a cluster with mounted secrets. You have solved PID 1 with init: true so zombies do not exhaust your pids_limit, and you have understood why putting a supervisord inside breaks restarts, logs and scaling all at once.

The graceful shutdown is now complete and, above all, measured: zero lost requests against 152, with the five-second wait with readiness in the red before closing the listener —the step almost nobody implements— and the closing of the Redis client and the PostgreSQL pool in the reverse order to how they were opened. You know how to calculate the grace period from the longest request and why stop_grace_period has to be larger than the internal timeout. You have separated the three probes with their three semantics and seen the reason live: with PostgreSQL stopped, /health/live answers 200 and /health/ready answers 503, so the replica loses its traffic but does not restart; a single endpoint would have turned thirty seconds of incident into several minutes of total outage. And you have pinned the base by digest, used npm ci with a lockfile, added OCI labels carrying the commit, chosen limits with fifteen minutes of docker stats instead of by eye, and set a V8 heap at 75 % of the cgroup limit so an overflow is a loggable exception and not a mute 137. All of it crystallized in the Dockerfile and the compose.prod.yaml of aurora-api:2.0.0 and in a checklist of fifteen individually verifiable controls.

You now have the right artifact, but you are still building it yourself, by hand, on your laptop. In the next lesson, CI/CD with Docker, a machine takes that work over: you will set up the pipeline that, on every git push, runs the lint and the tests inside containers, builds the multi-architecture image reusing BuildKit's remote cache, scans it with Trivy breaking the build on critical vulnerabilities, signs it with Cosign, generates its SBOM and publishes it to ghcr.io tagged automatically from your Git tag.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved