“It works” and “it’s ready for production” are two very different claims, and the gap between them is where a lot of software projects quietly go wrong. A demo that runs on a developer’s machine has cleared a low bar. Software that real users and real data depend on, day after day, has to clear a much higher one. Here is what production-ready actually means — so you can tell whether what you’re getting is finished or just demonstrable.
It Handles the Unhappy Path
Demo software handles the case where everything goes right. Production software handles everything else: empty inputs, malformed data, network failures, the third-party service that’s down, the user who does something no one anticipated. Most of the real engineering in a robust system is in these edges — the errors caught gracefully, the retries, the sensible message instead of a crash. If a build has only ever been tested on the happy path, it is not ready; it just hasn’t met reality yet.
The Marks of Production-Ready
- Tested. Automated tests that prove it works and catch regressions when it changes — not just a manual click-through before release.
- Observable. Logging, monitoring, and alerting so you find out something is wrong before your users tell you.
- Secure. Input validated, access controlled, secrets protected, and the common vulnerability classes designed out — a discipline our BulletproofSoft practice treats as foundational.
- Scalable enough. Proven to handle realistic load, with a known plan for growth — not just fast with one test user.
- Maintainable. Clear code, documentation, and a repeatable deployment, so the next change — or the next engineer — doesn’t start from archaeology.
- Recoverable. Backups, and a tested way to restore. Data you can’t recover is data you’re one bad day from losing.
Why the Gap Gets Hidden
The unhappy-path work, the tests, the monitoring, the deployment pipeline — none of it shows up in a demo. So it is the easiest work to skip when someone is racing to look done, and the hidden corner-cutting only surfaces later, as outages and emergencies. When AI is involved, the gap is wider still: a model that answered well in a few tries can fail in ways that only systematic evaluation reveals. “It worked when we tried it” is the most expensive sentence in software.
Insist on the Standard
Production-ready is not gold-plating — it is the actual definition of done for software that matters. When you commission a build, make these expectations explicit up front, and treat testing, security, observability, and documentation as part of the deliverable, not optional extras. The teams worth hiring build to this standard by default, because they’ve seen what happens when it’s skipped.
SoftwareMile builds custom software to a production standard — tested, observable, secure, and maintainable — because software your business depends on deserves nothing less. Tell us what you need built and we’ll build it to last.
The Environment Is Part of the Software
The most common reason working software stops working is that it moved. A development machine and a production host differ in ways nobody wrote down: the operating system and the system libraries under it, the patch level of the language runtime, and the set of environment variables that happen to be present in one shell and absent in the other. A container image pins the first two, which is most of the reason containers won, but it does not pin the third. The code is identical; the ground under it is not.
These differences rarely announce themselves as environment problems. They surface as an intermittent bug in an unrelated feature, so the first hours of debugging go into application logic that was never wrong. The drift is also one-directional in practice: production is the constrained side, with less memory, stricter permissions, and more neighbors, so a difference almost always shows up as production failing at something the laptop tolerated.
The test that proves parity is boring and decisive: take a clean checkout on a machine that has never run the project, follow only what is written down, and see whether it builds, migrates, and starts. Every step that requires someone to remember something is a step that will be missing at 2 a.m. during an incident, and if the answer lives in one person's shell history, "maintainable" is not yet true no matter how clean the code reads. A reproducible build still does not equalize everything, and the differences that survive it are the ones worth checking deliberately.
- Time. Servers usually run UTC and laptops run local time, so date arithmetic that looks correct in one place is off by hours in the other. Daylight saving makes it worse: one local hour does not exist in spring and one occurs twice in fall, and code that stores local time has no way to tell the repeated hour apart.
- Case sensitivity. Linux filesystems are case-sensitive; the default macOS and Windows filesystems are not. An import of ./Utils that resolves on a laptop fails in the container with a file-not-found error naming a file you can plainly see in the repository.
- Resource ceilings. A container that exceeds its memory limit does not slow down the way a laptop with swap does. The kernel kills the process outright, and the visible evidence is exit code 137 and a restart, with no stack trace and nothing useful in the application log.
- Concurrency. One developer generates one request at a time. Production generates many, which is where connection pool limits, race conditions, and lock contention live. Multiply pool size by the number of application instances before assuming the database will accept the connections: PostgreSQL ships with max_connections set to 100.
- Network distance. On a laptop the dependency is on loopback: no TLS handshake, no DNS, no packet loss, latency in microseconds. In production all of those exist, and a call that never needed a timeout because it always returned instantly now hangs until something upstream gives up.
How Much of Each Mark You Need
Every one of the six applies to any system a business depends on; what varies is the level. Each one is a dial, and what sets the dial is what breaks when the software does. How many people are blocked, how much money moves through it, whether the data can be reconstructed from somewhere else, and whether a wrong answer is embarrassing or dangerous. An internal scheduling tool used by six people who can fall back to a spreadsheet does not need the same availability engineering as a system that takes payments, and pretending otherwise spends the budget in the wrong place.
Two numbers do most of the work of setting those dials, and only the business can supply them: the recovery point objective (RPO), how much data you can afford to lose, measured in time, and the recovery time objective (RTO), how long you can be down. An RPO of twenty-four hours means a nightly backup is enough. An RPO of five minutes means continuous replication or log shipping, with a standing cost to match. Almost every architecture argument downstream, from replication to hosting region, is settled by those two numbers, which is why letting engineers guess them in a design meeting produces a system nobody actually asked for.
What Makes a Test Suite a Gate
A test suite has exactly one job: to fail when the software is wrong. Most of what gets measured instead is a proxy, and line coverage is the weakest of them, because coverage counts lines executed, not claims verified. A test that calls a function and asserts nothing raises coverage as much as one that checks the result. The useful question is not what percentage is covered but which specific past failure would be caught if someone reintroduced it, which is why the regression test written the day a bug is fixed is usually the most valuable test in the file.
The second way a suite stops being a gate is flakiness, and the arithmetic is unforgiving. Three hundred tests that each fail spuriously one run in a hundred will produce a red build about nineteen runs in twenty with nothing actually broken. At that point the team learns to re-run rather than read, and a genuine regression becomes indistinguishable from noise. Deleting or quarantining a flaky test is a better outcome than tolerating it: two hundred tests people trust catch more than three hundred people ignore.
The third failure is a suite that tests the code's imagination of its dependencies. If the HTTP client is mocked, the test proves the parser handles the response you invented, not the one the service sends, including its error shapes, its rate-limit headers, and its redirects. Recording real responses once and replaying them, or running the dependency in a container for integration tests, closes most of that gap. What separates a suite that gates a release from one that decorates it comes down to a few properties.
- Determinism. No wall clock, no live network, no dependence on test order, no shared database state carried between tests. Seed the random source and freeze time explicitly rather than hoping.
- Speed. A suite that takes forty minutes gets run once at the end, so it finds problems after the code they belong to has been forgotten. Anything a developer will not run before pushing is documentation, not a gate.
- Failure messages that name the cause. "Expected 3, got 2" costs an hour that "order total excluded the discounted line" does not.
- Coverage of the boundary, not just the interior. Serialization, migrations, authorization checks, and third-party response handling are where changes break quietly, and they are the parts most often left to manual testing.
Scalable Enough Is a Measurement
Of the six marks, this is the one that is a number rather than a practice, and the number has to be a percentile. Averages conceal exactly the behavior that matters, because slow responses are rare by definition and rare events dominate anything assembled from many parts. If a page is built from twenty backend calls and one call in a hundred is slow, roughly one page load in five contains a slow call. The p99 of a dependency is close to the p80 of the thing built on top of it.
Response time also does not degrade in a straight line. For a single resource taking work from a queue, a request arriving when that resource is busy half the time waits about as long as it takes to serve; at ninety percent busy the wait is roughly nine times the service time. That is why a system can run at sixty percent utilization for a year, absorb a twenty percent traffic increase, and fall over, with nothing changed except the distance to the ceiling. Headroom is the number to watch, because average latency stays flat right up until it does not.
Finally, a load test run against a small dataset measures a different system. A query that scans a thousand rows in under a millisecond scans ten million in seconds, and the planner may choose an entirely different plan once the table statistics change, so the fast path in the test is not the path production takes. The same applies to the N+1 query: one query per row is invisible at fifty rows and fatal at fifty thousand, because the cost is round trips rather than work. Adding application servers fixes none of this, since the bottleneck is almost always the one component that is not duplicated, and more instances mean more connections to it. The cheapest capacity is usually work removed, not hardware added.
The Changes You Cannot Take Back
Deployments are reversible and data changes are not, and treating them as one thing is how a routine release becomes an incident. Rolling back an application means redeploying the previous image. A migration that dropped a column cannot be undone by rolling back the code that ran it, because the values are gone; the old build simply starts up and fails against a schema it no longer understands. "We can always roll back" is half true, and the half that is not true is the expensive half.
Migrations also fail in a way that has nothing to do with reversibility: they block. PostgreSQL's ALTER TABLE takes an ACCESS EXCLUSIVE lock, and a waiting lock request queues ahead of every query that arrives after it, so a change that executes in milliseconds can still stall all reads on the table for as long as whatever long-running query it is waiting behind. Adding a column with a constant default has been a metadata-only operation since PostgreSQL 11, so the risk there is the lock queue rather than a rewrite; setting lock_timeout to a few seconds and retrying converts that risk into a short, loud failure instead of an outage. MySQL performs many alterations in place, but the ones it cannot do in place copy the whole table, costing hours on a large table and roughly as much free disk as the table occupies.
The remedy for the irreversible half is to separate the change from the removal. Expand first: add the new column, write to both old and new, and deploy code that reads whichever is populated. Backfill in batches that can be stopped and resumed. Only after the new path has run long enough that you would have noticed it being wrong, contract by removing the old column in a later release. That sequence costs three deployments instead of one and buys a working rollback at every step in between. The changes worth routing through it are the ones no rollback can repair, because the old values no longer exist.
- Dropping a column or table. Restoring the schema is trivial; the contents are not in the schema, and the only route back is a restore from backup.
- Narrowing a type or length. Shrinking a column, or converting a timestamp to a date, discards whatever did not fit, and whether the engine refuses or silently truncates depends on the engine and its configuration.
- In-place backfills. An UPDATE that sets a column from its own current value overwrites its input, so it is not safe to run twice and there is nothing left to compare against. Write the result to a new column instead.
- Deletes, especially cascading ones. A foreign key declared ON DELETE CASCADE removes rows in tables nobody mentioned in the ticket, and the count comes back as a number, not a list.
- Renames. The schema change reverses cleanly, but every deployed instance still using the old name breaks the moment the rename commits, so it is only safe as the contract step after both names have been supported.
Frequently Asked Questions
Is production-ready the same as bug-free?
No, and treating them as the same is how the standard gets dismissed as unrealistic. Production-ready describes how the system behaves when something is wrong: the failure is contained, someone finds out promptly, and there is a way back to a known good state. Bugs will still ship. What separates a production system from a demo is that a defect surfaces as a caught error and an alert rather than as a customer's phone call three days later, which is why observability and recoverability are on the list.
Does the standard change for an internal tool or a small pilot?
Most of the items move, and one does not. Observability for a low-stakes internal tool can be structured logs plus an email when an unhandled exception fires, where a system customers depend on needs request tracing, dashboards, and an alert that reaches a person expected to answer it. Recoverability is the item that does not scale down: a pilot's database is still the only copy of whatever the pilot collected, and "we will add backups once it proves itself" is how a pilot loses the evidence that would have justified it.
What has to exist before software can be deployed to production at all?
An environment that is not a developer's machine, and the accounts needed to create it: hosting, a DNS name, a TLS certificate, and a managed database or a host to run one. Beyond that, an identity source if users log in, credentials and network access for every system the software integrates with, and a non-production environment close enough to the real one that testing against it means something. The integration credentials are usually the long pole, because they often come from a third party on their own schedule rather than yours.
How does the first production deployment actually proceed?
In the order that keeps each step reversible: provision the environment, get the build and test pipeline running end to end from a tagged commit, apply schema migrations, deploy the application, then admit traffic. The first deployment should happen long before launch day and be repeated many times against an empty production environment, so that by the time there are users the pipeline is boring. Where the shape of the system allows it, cut traffic over gradually by feature flag, limited user group, or percentage of requests, and watch error rate and latency for a defined period before opening it further. A restore drill belongs in the same sequence, because a backup that has never been restored is an assumption rather than a capability.
What do we have to decide, as opposed to provide?
Four decisions, and none of them are technical questions. RPO and RTO, which nobody inside the build can set on your behalf. How long each class of data is kept and when it is deleted, which is a legal and contractual matter before it is a storage one. Which jurisdictions the data may be stored and processed in. And who answers an alert outside working hours, including what happens when that person is unreachable.
Can any of this be added after launch?
Yes, but the items differ sharply in what the delay costs. Tests and observability retrofit well and are cheapest to add right after an incident, when everyone knows exactly which failure to instrument. Recoverability and security retrofit badly: backups configured today do not cover the data lost yesterday, and a schema that kept no history cannot be made to remember who changed what last month. The other cost is timing, since the retrofit happens on a live system and competes for the same attention the incidents are already consuming.