Topic 58

Twelve-Factor, the Parts That Matter

Production

The twelve-factor list was written at Heroku in 2011, and fifteen years later half of it is what every framework does by default and the other half is still where services go wrong in their first week of production. Four factors carry the weight. The process is stateless and shares nothing with the process next to it. Configuration comes from the environment, not from a file with a production: section. Logs are a stream of lines to standard output, not a file the process rotates. And the process can be started and stopped at any moment without losing work, because a deploy is nothing but starting new processes and stopping old ones. Stagedoor's first production deploy broke three of the four in one evening.

This topic is those four applied to Stagedoor, with numbers, and the remaining eight in a line each. Chapter 4 already built the config object, Chapter 8 already moved the long-running work into the worker, and Chapter 13 will build the logger; what this topic adds is the reason they fit together, which is that a process built this way can be replaced by an identical one at any second, and the platform underneath it, whatever it is, does exactly that on every deploy.

Stateless Processes

Stateless does not mean the process holds nothing. It means it holds nothing that another instance would need to serve the next request. Between two requests, Stagedoor's api process holds its config object, the pool of 20 Postgres connections, the Redis client, the Payrail client with its keep-alive connections, the clock and id generator of Topic 20, the circuit breaker's failure counters, and an hour's cache of Google's public keys from Topic 27. Every one of those is either built from config at startup or rebuildable from a neighbour in milliseconds. None of it is the truth. The truth about a seat, a hold, an order or a session lives in Postgres or Redis, and api-02 can answer the request that api-01 was about to answer without having seen the one before it.

What the process holds between requests, and what it must never hold
Built from configpool, clients, clock, ids
Constructed once in build_service, closed at shutdown. Identical on every instance because the config is.
Rebuildable copiesbreaker counters, JWKS cache
Per-instance state that a fresh instance rebuilds in one call. Losing it costs a fetch, never a fact.
Never in the processsessions, holds, seat maps
The truth, or a shared copy of it. Redis or Postgres, where every instance sees the same value.
Never on local diskuploads, log files, sqlite
The second instance cannot see it and the next deploy deletes it. Object storage and stdout instead.

The three ways Stagedoor broke this rule are the three that every service breaks. The first version kept browser sessions in a dictionary inside the process, which worked until api-02 existed and then worked exactly as often as the load balancer happened to send both requests to the same host; Chapter 5 moved them to Redis, and Chapter 14 names the sticky-session workaround as a lie about scaling. The organizer's seat-map upload was written to /var/uploads on whichever instance received it, so the seat-map page served a broken image half the time and the first deploy deleted every file; the upload goes to object storage now, and Postgres holds the key. And the seat map itself was cached in a process-local dictionary for 30 seconds, which meant two instances showed two different maps and an invalidation on one never reached the other; Chapter 9 put that copy in Redis, where both instances read the same 2,000 rows.

Config in the Environment

Every difference between deployments is an environment variable, read once at startup into the typed object of Topic 23 of Chapter 4. Stagedoor's config has 10 fields, five of them required, and the same image runs in Marek's shell, on staging and on api-01 with different values for those 10 and nothing else different at all. The image contains no environment name. It cannot know it is staging, because the only thing that varies is PAYRAIL_URL pointing at the sandbox, and the code that handles a Payrail timeout runs in staging every time the sandbox is slow instead of running for the first time on the on-sale night.

The factor sounds like a style choice and is a correctness one. A service configured by a file inside the image is a service with one image per environment, three artifacts built from one commit with no guarantee they are the same, and a config drift between them that no diff will show. A service configured by the environment has one artifact, built once, and the deployment record is the list of variables, which is diffable, reviewable and short.

Logs to stdout

The process writes one JSON line per event to standard output and never opens a log file. The access log, the Payrail call's duration, the warning when a retry runs out of budget, the startup line with env=production: each is one line, one JSON object, with the request id from Topic 21 as a field, and Chapter 13 gives the format its full treatment. The process does not know or care where the stream goes. On api-01 the container runtime reads it; on Kubernetes the kubelet does; on Cloud Run the platform's logging agent does; in Marek's shell it is the terminal. Collection, retention, rotation and search are the platform's job, and every platform does them.

A process that writes its own log file has taken on that job and does it worse. The file lives on the instance's disk, so it is gone with the instance and invisible to a collector that reads stdout. Rotation is a second mechanism to configure, and the one Stagedoor did not configure filled a 20 GB disk in nine days at on-sale logging rates. Two uvicorn processes writing the same file interleave their lines. And the person debugging at 8 p.m. has to know which of two hosts the request landed on before they can open the right file, which is exactly the question the request id in a central store answers in one query. Write to stdout; let the platform own the rest.

Disposability

A disposable process starts fast and stops clean, and assumes it will be stopped mid-request every day, because it will. A deploy is a rolling replacement: new instances start and become ready, old ones are told to stop, and for some seconds both versions serve. Autoscaling is the same operation without the new version. A node draining for maintenance is the same operation with no new version at all. Disposability is what makes every one of those invisible to a buyer, and it has two halves with different numbers.

Starting fast means the port is bound within seconds: Stagedoor's api parses its config, opens the pool, checks Redis and binds in about 2 seconds. The warm-up of the seat maps of the events on sale within the hour, from Topic 52 of Chapter 9, takes longer, and it happens before readiness rather than before binding, which Topic 60 lays out as the startup order. Stopping clean means that on SIGTERM the process stops taking new requests, finishes the ones it has, closes the pool and exits, inside the 30 seconds the platform gives it. Stagedoor's first deploy script did not send SIGTERM at all; it killed the old process the instant the new one was listening, and 340 checkouts that were between the Payrail call and the response were discarded with their connections. Topic 60 is the drain that prevents it, and this factor is why the drain is not optional.

A rolling deploy from the process's side: every arrow is a factor
new startsbind in 2 s
new is readytraffic arrives
both serveseconds, not zero
old drainsSIGTERM, 30 s

Backing Services as Attached Resources

Postgres, Redis and Payrail are URLs in config, and the code that uses them does not know whether the URL points at a container on Marek's laptop, a managed instance in a cloud region, or a fake on localhost in the test suite of Chapter 12. Swapping one for another is a change to a variable and a restart, never a change to code. The local developer's Postgres and the production one differ in one string. That string is the whole mechanism, and the demonstrated example is the two environments side by side.

The same variables, two deployments: only the values differ
# .env on Marek's laptop (gitignored)
DATABASE_URL=postgresql://stagedoor_app@localhost:5432/stagedoor
REPLICA_URL=postgresql://stagedoor_app@localhost:5432/stagedoor   # the same database; no replica locally
REDIS_URL=redis://localhost:6379/0
PAYRAIL_URL=https://sandbox.payrail.example
PAYRAIL_KEY=pr_test_...
POOL_SIZE=5

# the api deployment on api-01 (values from the platform, secrets from the store)
DATABASE_URL=postgresql://stagedoor_app:****@pg-primary:5432/stagedoor
REPLICA_URL=postgresql://stagedoor_app:****@pg-replica-a:5432/stagedoor
REDIS_URL=redis://:****@redis-01:6379/0
PAYRAIL_URL=https://api.payrail.example
PAYRAIL_KEY=pr_live_...
POOL_SIZE=20

The two blocks name the same variables and nothing in the code distinguishes them. On the laptop the replica URL points at the same local database as the primary, so the routing of Topic 36 runs unchanged and simply lands on one server; the pool is 5 because a laptop does not need 20. On api-01 the primary and replica are two hosts, Payrail is the live endpoint with a live key, and the pool is 20. A managed Postgres that replaces pg-primary next year is a new value for one variable. The point is not that the URLs are pretty; it is that every dependency is attached from outside, so the process can be pointed at anything that speaks the protocol, including the fakes that make the test suite fast.

The Rest in One Line Each

One codebase: the api and the worker are one repository and one image, started with different commands, and the domain code of Chapter 4 is shared rather than copied. Explicit dependencies: a lockfile pins every package to a version and a hash, so the image built on Tuesday installs what Monday's did. Build, release, run: the image is built once from a commit, tagged with that commit, and promoted through staging to production unchanged, with the config attached at release time rather than baked at build time. Port binding: the process serves HTTP itself, uvicorn on port 8000, with no web server inside the container; TLS ends at the load balancer as Topic 10 of Chapter 2 arranged. Concurrency by process count: one event loop per process, four processes on a four-core host, and more hosts when four are not enough, which is Topic 04's arithmetic and Chapter 14's scaling. Dev/prod parity: Postgres 18 and Redis 8 on the laptop, the same majors as production, because a query plan that differs between versions is a bug found on the wrong side. Admin processes: the migrator and the backfill of Topic 35 run as one-off processes from the same image, never inside the serving process's startup.

The last of those is the one Stagedoor got wrong twice. Running migrations at startup means two instances starting at once run the migration twice, and the second one blocks on the first one's lock for as long as the first takes; on the on-sale deploy that was the readiness check timing out on both instances while a migration nobody meant to run twice held an exclusive lock on seats. Topic 35 made migrate a separate step of the deploy, run once, before the rollout, and Topic 62 shows what that step looks like as a job. The same logic removes the in-process scheduler: the hold-expiry sweep as a timer inside the api ran on every instance, so two instances swept twice, and after a scale-out, three times; Topic 46 of Chapter 8 gave the worker one scheduler and the api none.

Common Mistakes
  • Uploaded seat-map images written to the instance's disk — api-02 cannot see what api-01 stored, the seat-map page shows a broken image on half the requests, and the next deploy replaces the instance and deletes every file with it.
  • A log file inside the container — rotated by nobody, filling a 20 GB disk in nine days at on-sale rates, lost when the instance is replaced, and invisible to the collector that only reads stdout.
  • Migrations run at startup — two instances starting together run the migration twice, the second blocks on the first one's lock, and readiness times out on both while an exclusive lock sits on seats; migrate is a separate step of the deploy, run once.
  • The environment name baked into the image — a staging build that "knows" it is staging skips the payment path, and the code that handles a Payrail timeout runs for the first time in production.
  • An in-process scheduler in the api — three instances, three hold-expiry sweeps a minute, and a fourth after every scale-out; the schedule belongs to one worker process.
  • Sessions or a shared cache in a process-local dictionary — login works only when the balancer sends both requests to the same host, and an invalidation on one instance never reaches the other.
Best Practices
  • Keep nothing on local disk and nothing in memory between requests that another instance would need: uploads in object storage, sessions and shared copies in Redis, the truth in Postgres.
  • Build one image per commit, promote it through staging to production unchanged, and configure it with environment variables read once at startup.
  • Write one JSON line per event to stdout and let the platform collect, retain and search the stream.
  • Bind the port within seconds, drain on SIGTERM inside the 30-second grace, and put every long-running concern in the worker or a one-off process.
  • Attach every backing service as a URL in config, so the test suite's fake and the production host differ by one variable.
Comparable toolsThe Twelve-Factor App the document itself, and Heroku the platform it was written forCloud Run, Fly.io and Kubernetes platforms that assume every factor and enforce most of themDocker Deep Dive the image the factors are packaged intoKubernetes Deep Dive the rollout that disposability makes invisible

Knowledge Check

Which of these may Stagedoor's api process hold in memory between two requests without breaking the stateless rule?

  • The breaker's failure counters for Payrail
  • The browser sessions created by requests it served
  • A 30-second copy of the seat map for the hot event
  • The seat holds placed during the last 10 minutes

Why does the process write its log lines to stdout rather than to a file it manages itself?

  • Because writing to a file blocks the event loop, and stdout is asynchronous
  • Because JSON lines can only be parsed by a collector when they arrive on stdout
  • Because the platform collects the stream, and a file on the instance is lost with it
  • Because two uvicorn processes cannot open the same file, while both can write stdout

A deploy starts two new api instances at once. What goes wrong if each runs the migrations as part of its startup?

  • Both apply every migration file, so the schema ends up one version ahead of the code
  • The second run queues behind the first's lock, and neither becomes ready
  • Each instance migrates its own copy of the schema and the two drift
  • The migration rolls back on the second instance and leaves the first's half applied

What does "disposable" require of the process, concretely?

  • That it keeps serving through any outage of Postgres or Redis without ever restarting or degrading
  • That it saves its in-memory state to disk before exiting so the replacement instance can load it back
  • That it starts within seconds, and that it may be killed instantly once a replacement is listening
  • That it binds its port within seconds, drains on SIGTERM, and expects to be stopped mid-request daily

You got correct