$ emrebener

12. Admin processes

author: emre bener read time: 8 min about: twelve-factor app, schema migration, devops
published: updated: mentions: kubernetes, docker, ruby on rails, django, repl, amazon ecs

1. The rule: admin tasks as one-off processes

Factor 12 is one sentence: run admin and management tasks as one-off processes. The one-off part is the whole rule. A database migration, a console session, a script you run once, executes in a process you start, let finish, and throw away. That process runs in an environment identical to the app’s regular long-running ones. Same release, same code, same config, same isolated dependencies. Nothing about the task is special except that it’s transient.

The contrast is with the app’s normal process formation. A running app is one or more long-lived processes: a web process serving requests, a worker process draining a queue, each meant to stay up. An admin process is the opposite shape. You run rake db:migrate once against the database and it exits; you open a console, poke at a few records, and close it. The factor’s claim is that this transient process is not a second-class citizen of the deploy. It runs wherever and however the long-running ones run, on the same release, so that inspecting or mutating the live system happens in the environment that serves it, not an approximation of it. This is the series introduction‘s bias toward operability applied to the odd job: the methodology cares how you run a one-time task, not just how the steady state stays up.

2. What counts as an admin process

Three kinds of task show up as admin processes, and they cover most of what you’ll ever run this way.

The first is database migrations: schema changes run against the live database, rake db:migrate in Rails, python manage.py migrate in Django, or your framework’s equivalent. You run it once per deploy that carries a schema change and it exits.

The second is a REPL or console: an interactive shell that boots the app’s code and connects to its backing services, so you can run arbitrary code and inspect the app’s models against the real database. rails console, python manage.py shell, node with your app loaded. It’s the tool you reach for when a question about production state has no dashboard to answer it, or when you need to hand-fix a record.

The third is one-time maintenance scripts: a script written for a specific job, say a scripts/fix_bad_records that corrects data mangled by a bug, run once and in principle never again. That the script is committed to the repo matters, and it’s the subject of the next section.

All three share the transient shape, and all three touch live state, which is exactly why the environment they run in has to match. A migration alters the schema the running app depends on; a console can execute any code path the app can; a fix-up script rewrites real rows. Run any of them against the wrong version of the code and you don’t get an error, you get corruption.

3. The crux: same release, same code, config, and dependencies

The rule that makes an admin process safe is that it runs against a release. Not against your laptop’s checkout pointed at the production database, not against a copy of one file scp’d onto a box, but against the same Build, release, run release the app’s regular processes run against. That single constraint pulls in the three factors underneath it.

Same code, from the same Codebase: the migration and the console boot from the identical commit the web process booted from, so the models the console sees and the schema the migration writes are the ones this release actually expects. Same config, so the admin process talks to the same database and backing services the app does, resolved the same way. And same dependency isolation, from Dependencies: if the web process runs under bundle exec, the migration runs under bundle exec too, against the same locked versions. An admin process that resolves a different set of libraries is a different program wearing the app’s name.

This is why admin code ships committed with the application code, part of the same release, rather than off to the side. The failure the factor guards against is drift. Write a maintenance script against your mental model of the schema, run it a week later against a release whose migrations have since moved the schema, and the script does precisely the wrong thing to real data. A script that assumes a column the release has already dropped, or a shape the models no longer produce, is how you corrupt a database with a tool you wrote to repair one. Shipping the admin code inside the release locks the script and the schema it assumes to the same version, so they can’t desynchronize.

4. How you ran them in 2011: a shell in the checkout

In the original formulation, running an admin process meant getting a shell where the app’s code lived and typing the command. Locally that’s trivial: you’re already in the app’s checkout, so you run rake db:migrate or open the console directly in your working directory, in the same environment your dev server uses.

Production is where 2011 shows its age. There the essay’s answer was to ssh into a running deploy, or use the execution environment’s remote-command mechanism, and run the one-off against the release checked out on that machine, with the same dependencies and config the long-running processes already use. Twelve-factor adds a preference here: it favors languages that ship a good REPL and make one-off scripts easy, because a language where a console session or a quick script is natural makes this whole factor cheaper to live with.

That ssh-into-the-box mechanism is the part that dated. The principle underneath it, run the one-off in the same release environment, did not; but the picture of an engineer opening a remote shell on a production server is one that modern operations practice has spent a decade moving away from. The next two sections are what replaced it.

5. Containers made it “same image, different command”

Containers turned the factor’s hardest requirement into its easiest. “Identical environment to the regular processes” used to take discipline; with a container image it’s free, because the admin process and the long-running processes are literally the same image run with a different command. The image is the release: it carries the code, the resolved dependencies and a default command. Point it at your migration instead of your server and everything else is already identical, because it’s the same build.

In practice that’s a handful of shapes, all “same image, different command.” A docker run <app-image> rake db:migrate runs the migration in the exact environment docker run <app-image> runs the server in. On Kubernetes, a Job runs the app’s own image with the migration command, once to completion, which is what a schema change wants: one run regardless of how many replicas the web deployment has. kubectl run with the app image gives you that ad hoc, and kubectl exec drops you into a console inside a running pod when you need the live process’s environment specifically.

Init containers are another face of the pattern, with one caveat. An init container runs the app image with a setup command before the main container starts, same image and a different command again, but it runs once per replica: five pods, five runs. That’s right for a per-pod setup step and wrong for a migration, which must run once for the whole deploy. Keep a schema change in a Job or a pipeline step, not an init container. Either way the through-line holds: same image, different entrypoint is the container-era statement of Factor 12.

Same image, different commandApp image(same build — code · deps · default cmd)web processcmd: server(long-running)worker processcmd: worker(long-running)admin Jobcmd: rake db:migrate(one-off — runs once, exits)launched as a platform job:K8s Job · docker run · ECS RunTaskSame image, different commandApp image(same build — code · deps · default cmd)web processcmd: server(long-running)worker processcmd: worker(long-running)admin Jobcmd: rake db:migrate(one-off — runs once, exits)launched as a platform job:K8s Job · docker run · ECS RunTask

6. The hard part the factor left: sequencing migrations against a rolling deploy

Two honest updates separate the durable part of this factor from the part that aged, and both leave the principle standing.

The first is the mechanism. The 2011 picture, a human ssh-ing into production to type a command, is now something most operations practice actively discourages. Immutable infrastructure treats running machines as disposable and unmodified, and audit and security practice treats a person at a production shell, especially a REPL wired to the production database, as a risk to be audited rather than a routine tool. The modern answer isn’t a person at a shell; it’s a platform-launched one-off: a Kubernetes Job, an ECS RunTask, or a migration step baked into the CI/CD pipeline so the deploy runs it instead of an engineer. The principle Factor 12 stated, run the one-off in the same release environment as everything else, is exactly as true as it was. The mechanism moved from an interactive session to a declarative job.

The second update is the one the factor never addressed, and it’s the genuinely hard part. Running a migration is easy. Sequencing it safely against a rolling deploy is not. Because the app scales out as stateless replicas rolled out gradually (Concurrency and Disposability at work), a deploy has a window where old code and new code run at once, against one database. A schema migration has to stay compatible with both versions across that window, which is why teams reach for the expand/contract pattern, also called parallel change. Expand first: add the new column, backfill it, and ship code that reads the new column but keeps writing both, so instances still on the old release read the old column and see what they expect. Contract comes later, and in two steps. Once every running instance reads the new column, a further release stops writing the old one; only after nothing reads or writes it does a final migration drop it. Sequenced that way, no single migration ever breaks the code running beside it. The one-off-process framing hands you a clean way to run the migration. It says nothing about when to run it relative to the rollout, and that timing is where the real work now lives.

Expand / contract: one schema change, three releasessuccessive releases over time1. Expand release2. Stop-writing release3. Drop releaseold_colnew_colcodewritten (dual-write)+ read by old codeno longer written— dormantDROPPED(final migration)added + backfilled,read & writtensole column:read + writtenthe only columnnew: reads new, writes bothold: still reads oldwrites new onlyreads/writes new onlyOld and new code overlap during each rollout —that overlap is why one schema change becomes three releases.Expand / contract: one schema change, three releasessuccessive releases over time1. Expand release2. Stop-writing release3. Drop releaseold_colnew_colcodewritten (dual-write)+ read by old codeno longer written— dormantDROPPED(final migration)added + backfilled,read & writtensole column:read + writtenthe only columnnew: reads new, writes bothold: still reads oldwrites new onlyreads/writes new onlyOld and new code overlap during each rollout —that overlap is why one schema change becomes three releases.

None of that unseats the idea worth keeping. An admin process is just a one-off run of the same release as your regular processes: same code, same config, same dependencies, never a snowflake environment assembled by hand. Get that right and a migration or a console session is as reproducible and disposable as the app itself; get it wrong and you’re back to editing production by feel. Containers made the “same environment” half almost automatic and left sequencing as the part still worth your attention, a fair place for the twelfth and last factor to leave you.