Thoughts

Twelve factors when you do not own a process

The twelve-factor app was written for long-running processes on a platform that no longer describes how most things ship. Which factors survive a static front end and an edge function, which invert, and which quietly still bite.

The twelve-factor app is from 2011 and it was written by people running long-lived processes on Heroku. Read it today against a static front end and a handful of edge functions and several of its factors describe a world you are no longer in: you do not bind a port, you do not manage concurrency, and nothing you wrote decides when a process dies.

It would be easy to conclude the document has aged out. That is the wrong conclusion. Six of the twelve are as load-bearing as they ever were, two invert, and the rest belong to the platform now. Sorting them is worth doing once, deliberately, because the ones that still apply are the ones that hurt quietly rather than loudly.

Hangpolc, the audiobook shelf, is the useful specimen, because it is genuinely split: a Vite single-page app, a few serverless functions, and the Hungarian Electronic Library as an upstream nobody here controls.

The ones that still hold

III, config in the environment. The upstream origin is not a constant in the source:

const env = typeof process !== 'undefined' ? process.env.MEK_ORIGIN : undefined

with a default behind it. This is a small thing that pays twice. It let me point the whole catalogue at a local fixture server while developing, without touching a line of parsing code, and it means the day MEK moves is a variable, not a deploy of changed source. The test for this factor is not “do you use env vars”. It is whether you could run the same build against a different upstream with no edit.

IV, backing services as attached resources. MEK is an attached resource, and the attachment is literal: a rewrite maps /audio/:path on my own origin to mek.oszk.hu. Swap the target and nothing downstream knows.

That rewrite also had a consequence I did not plan, and it is a good example of a deployment decision reaching into the client. Because the audio arrives same-origin rather than cross-origin, a MediaElementAudioSourceNode over it is not tainted, so the browser would let me process that audio with Web Audio. I did not, in the end, for reasons about lock-screen playback, but the option existed only because of how the resource was attached. Infrastructure is not downstream of application design. It decides what the application is allowed to do.

II, explicitly declared dependencies. pnpm install --frozen-lockfile in the build command. Not a preference. The lockfile is the difference between a build and a coincidence.

V, strictly separate build and run. The platform enforces this whether you like it or not, which is one of the genuine gifts of the model: there is no stage at which you can edit the running thing.

X, dev/prod parity. The factor most often nodded at and least often held. A memory from the other project on this site: for months every touch test passed, because the test driver synthesised a stationary mouse sequence and a real finger does not. Dev and prod agreed perfectly and both were wrong about the same thing. Parity is not “same database engine”. It is whether the thing you test against can produce the inputs production produces.

XI, logs as event streams. Inherited entirely, and better than most of us would have built.

The ones that invert

VI, processes, and IX, disposability. The original advice is to make your process stateless and quick to start so the platform can kill it freely. With serverless functions you do not have the choice. Statelessness is imposed and disposability is the whole model. What used to be discipline is now physics.

The inversion has a cost worth naming. Because the functions are disposable by construction, the temptation is to put state in the client instead and stop thinking about it. Hangpolc keeps its reading positions in localStorage, which is right for a private shelf, and would be wrong the moment anyone wanted the same book on two devices. The factor did not stop applying. It moved.

VII, port binding. Gone. The platform routes. vercel.json is the contract. This is the one factor I would now call obsolete rather than transformed.

The one that is a trap

I, one codebase, many deploys. Both of the apps I have written this month live in one repository beside each other, along with a Rust player. That is a monorepo, and twelve-factor’s “one codebase tracked in revision control, many deploys” is satisfied in the letter and endangered in spirit: a push to the repository is a push to every deploy attached to it.

The guard is a single line:

"ignoreCommand": "[ \"$VERCEL_ENV\" != production ] && git diff --quiet ${VERCEL_GIT_PREVIOUS_SHA:-HEAD^} HEAD -- ."

Do not build unless this directory actually changed. Without it, every project in the repository rebuilds on every commit to any of them, which is not just waste but a real risk, because it redeploys code nobody looked at today, under a commit message about something else.

What I take from it

The factors that survived are the ones about boundaries: what is configuration, what is someone else’s service, what is a dependency, what is a build. The factors that fell away are the ones about running a process, which is precisely the part the platform took over.

That is a reasonable trade, but it has a direction worth watching. Every factor the platform absorbs is a decision you stop making and, shortly afterwards, stop understanding. The ignoreCommand line above is one I only wrote because a deploy surprised me. The twelve-factor document’s real value, fifteen years on, is not its list. It is that it makes you enumerate the decisions at all, including the ones now being made on your behalf.