Lesson 12 / 16
Twelve-Factor App Principles
The decisions made in this section are mapped to twelve principles named as a portability contract; a single build running unchanged across two environments is measured with a file hash, and fast startup and graceful shutdown are measured through the completion of an in-flight request.
Contents
The decisions made in this section look independent of one another: splitting directories into layers, reading configuration from the environment, masking secrets, writing the log to standard output, passing errors through a single layer. There is a connection between them, and it converges on a single goal: making the application independent of the machine it runs on.
There is a method that turns this goal into a named set of rules: the twelve-factor app principles. The principles are not a framework or a tool; they are a contract that defines the interface between the party that writes the application and the party that runs it. This lesson counts the principles, matches which ones this section satisfies against its measurements, and points out the ones that are not satisfied.
The Subject of the Contract
The principles share a common assumption: the party running the application has to be able to install it, configure it, replicate it, monitor it, and stop it without knowing its internals. Once that holds, the application is bound not to a machine but to a contract.
For the library loan service, this has a concrete counterpart. The developer who runs the service on a laptop and the operator who runs it on a server use the same file; the only difference between them is which environment variables they supply. The code does not know which machine it is on.
The twelve principles and their counterparts in this section are as follows.
| Principle | Summary | This section’s counterpart |
|---|---|---|
| Codebase | One codebase, many deploys | Introduction to Version Control course |
| Dependencies | Explicitly declared and isolated | Modules, Tooling and the Ecosystem course |
| Config | Stored in the environment | Configuration Management lesson |
| Backing services | Treated as an attached resource | The Local Development Environment lesson |
| Build, release, run | Stages strictly separated | Rendering Strategies and Infrastructure course |
| Processes | Stateless and share-nothing | The Application Runtime lesson |
| Port binding | Service binds its own port | The Web Server Concept lesson |
| Concurrency | Scales via the process model | The Application Runtime lesson |
| Disposability | Fast startup, graceful shutdown | This lesson |
| Dev/prod parity | Environments kept close to each other | This lesson |
| Logs | Treated as an event stream | Logging Setup lesson |
| Admin processes | Run as one-off tasks | Backend Development curriculum’s advanced courses |
Four of the principles fall outside this course; two are this lesson’s measurements.
Measurement: One Build, Two Environments
The dev/prod parity principle asks for the same build to run unchanged across different environments. Its testable form is this: the file’s hash stays unchanged while its behavior changes through environment variables.
// src/http/server.mjs — the same file in every environment; binds its own port, // writes the log to standard output, finishes the in-flight request and exits on SIGTERM. import { createServer } from "node:http"; import { setTimeout as wait } from "node:timers/promises"; const PORT = Number(process.env.LIBRARY_PORT ?? 8440); const ENV = process.env.LIBRARY_ENV ?? "local"; const LOAN_DAYS = Number(process.env.LIBRARY_LOAN_DAYS ?? 14); const log = (record) => console.log(JSON.stringify({ at: new Date().toISOString(), ...record })); let openRequests = 0; const server = createServer(async (req, res) => { res.sendDate = false; openRequests++; const path = new URL(req.url, "http://local").pathname; if (path === "/loans") await wait(1500); // a loan operation that takes time const text = JSON.stringify({ env: ENV, loanDays: LOAN_DAYS, path }); res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(text) }); res.end(text); openRequests--; }); server.listen(PORT, "127.0.0.1", () => log({ event: "started", port: PORT, env: ENV, loanDays: LOAN_DAYS, startupMs: Number(performance.now().toFixed(1)), // since process start })); process.on("SIGTERM", () => { log({ event: "sigterm_received", openRequests }); server.close(() => { // no new connections, open ones finish log({ event: "closed", openRequests }); process.exit(0); }); server.closeIdleConnections(); // idle connections close immediately setTimeout(() => { log({ event: "force_closed" }); process.exit(1); }, 5000).unref(); });
#!/usr/bin/env bash # Runs the same file with two environments; shows behavior changing while the file's hash stays unchanged. echo "file hash: $(shasum -a 256 src/http/server.mjs | cut -c1-16)…" try() { # $1 = env label, $2 = loan days, $3 = port LIBRARY_ENV="$1" LIBRARY_LOAN_DAYS="$2" LIBRARY_PORT="$3" \ node src/http/server.mjs > "log-$1.jsonl" 2>&1 & s=$! sleep 0.7 printf '%-11s -> ' "$1" curl -sS "http://127.0.0.1:$3/books"; echo kill -TERM "$s"; wait "$s" 2>/dev/null grep -o '"startupMs":[0-9.]*' "log-$1.jsonl" rm -f "log-$1.jsonl" } try local 14 8441 try production 21 8442
file hash: cc28586133fd7c6a…
local -> {"env":"local","loanDays":14,"path":"/books"}
"startupMs":28.5
production -> {"env":"production","loanDays":21,"path":"/books"}
"startupMs":28.2
The file hash and startup times change on every machine and after every edit; the hash is here to show that the file did not change between the two runs.
The same file produced two different responses in two environments: the loan period is fourteen
days in one, twenty-one in the other. There is no branch in the code; no condition carries the
environment’s name, no if (ENV === "production") line exists. The moment such a line is added,
the principle breaks, because the application now knows where it is running, and the path tested
locally diverges from the path that runs in production.
The same output confirms another principle too. The port binding principle asks the service to
open its own port without needing an external component. The curl call went straight to the
process; no component sat in between. The reverse proxy set up in an earlier section is an option,
not a requirement — and that is exactly why local development and production run the same code.
Measurement: Disposability
The disposability principle asks processes to start up fast and shut down cleanly. Both rest on the same reasoning: processes get started and stopped often. Releases, scaling changes, machine changes, and restarts after a crash all make this necessary.
The startup time is visible in the output above: under thirty milliseconds from process start to
listening. What actually needs testing is how the process comes down. The measurement below sends
SIGTERM to the process while a request is in flight.
#!/usr/bin/env bash # Sends SIGTERM while a request is in flight; measures whether it finishes and whether a new connection is rejected. node src/http/server.mjs > log.jsonl 2>&1 & pid=$! sleep 0.7 curl -sS -o /dev/null -w 'in-flight request : %{http_code} %{time_total} s\n' \ http://127.0.0.1:8440/loans > inflight.txt & req_pid=$! sleep 0.4 echo "SIGTERM sent" kill -TERM "$pid" sleep 0.4 printf 'new request during shutdown : ' if curl -s -o /dev/null --max-time 2 http://127.0.0.1:8440/books; then echo "response received" else echo "connection could not be established (curl exit code=$?)" fi wait "$req_pid"; cat inflight.txt wait "$pid"; echo "process exit code=$?" echo "--- log ---" cat log.jsonl rm -f log.jsonl inflight.txt
SIGTERM sent
new request during shutdown : connection could not be established (curl exit code=7)
in-flight request : 200 1.505712 s
process exit code=0
--- log ---
{"at":"2026-07-28T20:20:44.905Z","event":"started","port":8440,"env":"local","loanDays":14,"startupMs":25.9}
{"at":"2026-07-28T20:20:45.986Z","event":"sigterm_received","openRequests":1}
{"at":"2026-07-28T20:20:47.101Z","event":"closed","openRequests":0}
Timestamps and durations change on every run.
Three observations hold steady. The in-flight request finished: the signal arrived in the
middle of the request, and even so the response came back as 200 and the one-and-a-half-second
job was not cut short. The log’s sigterm_received line records that one request was open at that
moment, and the closed line records that zero requests were open once shutdown completed.
The new connection was rejected. The request made after the signal could not even establish a
connection; the server.close call closed the listening socket but did not cut off open
connections. This is the correct behavior for a process that is shutting down: take no new work,
finish the work you already took.
The exit code is zero. A supervisor seeing this code knows the process went down deliberately; a nonzero code would report an unexpected termination and call for a different response.
The force-shutdown timer is the unavoidable complement to this arrangement. A request that never
finishes could keep the process waiting forever; once the five-second limit is up, the process
goes down with a nonzero code. The unref call keeps this timer from extending the process’s life
once shutdown has already finished early.
What Is Not Satisfied
Reading the list of principles honestly means also counting the ones that are not satisfied.
The backing services principle has not been applied yet. This section’s stores lived in process memory; treating a database or an external service as a swappable attached resource identified by nothing more than an address is set up in this course’s The Local Development Environment lesson.
The admin processes principle is also open. One-off tasks like data migration, bulk fixes, and report generation have to run with the same code and the same environment as the application itself; done with a separate script, two different realities are created.
The build, release, run separation was not measured in this section; keeping the build output separate from environment variables and giving every release a revertible identity was covered in the Rendering Strategies and Infrastructure course, and its server-side counterpart sits in more advanced courses.
Not satisfying a principle is not a defect; not knowing that you have not satisfied it is. That is what the list is for — it ties each decision, whether made or deferred, to a name.
Summary
- The twelve-factor principles are not a tool but a contract between the party that writes the application and the party that runs it; their shared goal is making the application independent of the machine it runs on.
- Dev/prod parity was measured by the file’s hash staying unchanged while behavior changed through environment variables; the code has no branch that looks at the environment’s name.
- Under the port binding principle, the service opens its own socket; a reverse proxy is an option, not a precondition.
- In the disposability measurement, after
SIGTERMthe in-flight request finished with200, the new connection was rejected, and the process went down with a zero exit code; the force-shutdown timer guards the limit against a request that never finishes. - Backing services, admin processes, and the build-release-run separation were not satisfied in this section; the list’s job is to name the decisions that were deferred.
Next Step
The scaffold is now standing: configuration is read from the environment, secrets are kept separate, the log is written to standard output, errors turn into a shared response shape, and the process starts up and shuts down cleanly. Even so, the application still gives nothing real to the outside world; the only thing it hands back is its own state. The next section fills that gap starting from the simplest case: serving a file sitting on disk over the network. The next lesson sets up under what condition that file is allowed to be read, how the recipient will interpret its content, what happens when the same file is requested a second time, and whether that load stays on the server.
To keep your progress and take notes, Log in
My notes
Log in to take notes.