$ emrebener

10. Dev/prod parity

author: emre bener read time: 7 min about: twelve-factor app, continuous deployment, devops
published: updated: mentions: docker, postgresql, sqlite, devcontainers, amazon dynamodb, amazon s3, aws lambda

1. The rule: keep the gap between dev and prod small

The factor is one sentence: keep development, staging, and production as similar as possible. Behind it is a purpose. A twelve-factor app is built for continuous deployment, and continuous deployment only works when the place you write code and the place you run it behave the same way. The wider the gap between them, the more often code that worked on a laptop breaks in production, and every one of those breaks is friction that makes you deploy less often. Parity exists to remove that friction before it accumulates.

That makes this factor the safety rail under the release model. Build, release, run is where continuous deployment actually happens: one build, promoted unchanged from a developer’s machine through staging to production. Promoting the same build only pays off if the environments it lands in are close enough that passing in one predicts passing in the next. As the series introduction put it, minimal divergence between development and production is the precondition for continuous deployment. This is the factor that keeps that divergence small.

2. Three gaps: time, personnel, tools

The factor splits the dev-to-prod distance into three gaps and names the traditional habit that widens each.

GapTraditional appTwelve-factor app
TimeWeeks or months between writing code and deploying itHours, sometimes minutes
PersonnelDevelopers write the code; a separate ops team deploys itThe people who wrote the code deploy it and watch it run
ToolsDev and prod differ across the stack: OS, runtime, web server, database (SQLite on a laptop, Postgres on a server)Dev and prod stay as similar as possible

The time gap is the headline one, and the other two feed it. When a deploy is a hand-off to another team scheduled weeks out, the code under review has drifted far from the code that finally ships, and nobody who wrote it is still holding the context. Closing the gap to hours means the author deploys their own change while it’s still fresh in their head.

The personnel gap is the one the industry gave a name to. Traditionally the split was organizational: developers threw code over a wall to operations, who owned production and had never seen the code before. Putting the authors of the code on the hook for running it, so the person who wrote a change is involved in shipping it and watching it in production, is a core part of what DevOps came to mean. The tools gap is the subtlest of the three, and it’s where this factor gets broken most often.

3. The backing-services trap

The sharpest way to widen the tools gap is the backing service, and it’s the classic violation of this factor: run a lightweight service in development and a heavier one in production. SQLite on the laptop because it’s zero-setup, Postgres on the server because it’s what production actually runs.

The usual defense is that an adapter or ORM sits between the app and the database and hides the difference, so the same code runs against both. It mostly does, and then it doesn’t. The abstraction leaks in small ways: a query the ORM generates that SQLite accepts and Postgres rejects, a column type that rounds differently between the two, a constraint one enforces while the other silently ignores it. Code that ran clean and passed its tests on the laptop fails against the real database in production.

That failure is exactly the friction this factor exists to remove. Every deploy that breaks on a difference the developer had no way to see makes the next deploy scarier, and continuous deployment quietly grinds to a halt. So the rule is to erase the difference: every deploy, every developer’s laptop, staging, and production, runs the same type of backing service at the same major version. Exact-version parity is often impractical, so major-version parity is what you actually aim for: the same Postgres, everywhere.

None of this is an argument against adapters. As Backing services argued, a backing service is an attached resource the app reaches through a config handle, and an adapter that abstracts that resource is genuinely useful; it’s what makes porting to a different service later a small job instead of a rewrite. Keep the adapter. It just isn’t a license to run a different service behind it in development than you run in production. Parity says the thing behind the handle should be the same kind of thing, at the same version, in every deploy.

4. Containers closed the tools gap

Running the same backing services everywhere used to be the expensive part of this factor, and it isn’t anymore.

When the factor was written around 2011, keeping the stacks aligned took real work. The advice of the day was to install the real services on your machine with a package manager like Homebrew or apt, and to approximate the production server with a local VM, managed by Vagrant and provisioned by tools like Chef or Puppet to resemble the real thing. It was doable but it was enough friction that the lightweight-local shortcut stayed tempting, which is why the factor had to argue against it at all.

Containers removed the friction. With Docker and docker compose you declare the exact services your app depends on, the same Postgres image and the same Redis image pinned to the same versions as production, and bring the whole set up with a single command. Devcontainers take the last step and put your entire toolchain in that same declared, reproducible, container-based environment, so a new developer gets a production-shaped setup on checkout instead of a day of installing things. The devcontainer spec is exactly this idea worked out in full.

The result is that the temptation the 2011 text warned about, reaching for a lighter service because the real one was a hassle to run locally, is basically dead for anything you can put in a container. The real Postgres is now one line in a compose file.

5. What containers can’t close: push the rest to staging

“As similar as possible” is not “identical,” and the difference is carrying real weight. Some of production genuinely will not fit on a laptop, and pretending otherwise is its own way to get burned.

Three things resist local parity. Scale is the obvious one: you can’t run a hundred-node cluster or production-level traffic on your machine, so behavior that only appears under load stays invisible in development. Production data is the second: it’s usually too large to copy and too sensitive to be allowed to, so you develop against a small, clean sample and meet the messy real distribution only in production. The third has grown the most since 2011: managed cloud services. DynamoDB, S3 and Lambda aren’t images you can pull and run next to your app; they’re services a vendor operates, with no local copy to bring up in a container. A managed relational database like RDS is a softer case: it runs standard Postgres or MySQL, so the engine is the same one you already containerize locally, and only its managed layer (backups, failover, IAM auth, parameter groups) resists parity.

That last category partly reopened the gap containers had closed. The 2011 answer to parity was “run the real service locally,” and for a self-hosted service in a container that answer still holds perfectly. But a large share of a modern app’s dependencies are now managed services you cannot run locally at all. Emulators soften the edge where they exist: LocalStack stands in for a slice of AWS, DynamoDB Local mimics DynamoDB. They approximate imperfectly, though, and an emulator that’s almost right becomes its own quiet source of “worked in dev, broke in prod.”

So the modern parity strategy splits by service type, and part of it goes beyond anything the original text described. The self-hosted services you can containerize, run identically across every deploy, which containers made cheap. For the managed services you can’t run locally, test against the real thing in a production-like environment: a staging environment wired to actual cloud services, or an ephemeral preview environment stood up per pull request against real infrastructure and torn down when the PR merges. Neither production-like staging nor per-PR previews were in the 2011 write-up; they’re the industry’s answer to a gap the managed-cloud era opened after it, and they keep the spirit of the factor by moving parity to wherever the real service actually lives.

Parity by service type, across environmentsLaptop (dev)Staging / previewProductionSelf-hosted(containerized)Managed cloud(vendor-run)Postgres · RediscontainersPostgres · RediscontainersPostgres · Rediscontainers==No local copy(emulators only)DynamoDB · S3 ·Lambda (real)DynamoDB · S3 ·Lambda (real)parity moves to staging / previewParity by service type, across environmentsLaptop (dev)Staging / previewProductionSelf-hosted(containerized)Managed cloud(vendor-run)Postgres · RediscontainersPostgres · RediscontainersPostgres · Rediscontainers==No local copy(emulators only)DynamoDB · S3 ·Lambda (real)DynamoDB · S3 ·Lambda (real)parity moves to staging / preview

The reason to chase any of this is the reason the factor exists at all: parity is what makes continuous deployment safe. Run production-identical services locally wherever you can, hand the parts you can’t to a staging or preview environment wired to the real services, and keep the distance between a laptop and production small enough that shipping continuously stays a safe, ordinary thing to do.