Deploy Is Not Release
LaunchDarkly posted about the tension between deployment velocity and managed risk, and I replied with three words: deploy != release. Most of that tension comes from treating the two as one event. Split them, and you can deploy more often while taking less risk.
Two events with one name
A deploy puts a new build on your production servers. A release turns on new behavior for users. Deploys are an engineering event, and the risk is technical: the people who feel it are the ones on call. Releases are a product event, and the risk shows up in support tickets and revenue.
When the two are welded together, every deploy carries product risk. Teams respond the reasonable way, by deploying less often. Fewer deploys mean bigger ones, with more changes in each, and a bigger deploy is harder to debug and harder to roll back. Deploying less to lower risk ends up raising it.
Feature flags pull them apart
A feature flag is a conditional you can change without shipping code.
ruby
if Flags.enabled?(:new_checkout, user)
NewCheckout.call(cart)
else
LegacyCheckout.call(cart)
end
The new checkout deploys with the flag off. It’s in production, and nobody sees it. The deploy is boring, which is exactly what you want from a deploy.
This changes how you merge, too. Half-finished work can land on main behind a flag that’s off, so there’s no long-lived branch drifting away from everyone else’s changes. Merge daily, deploy daily, and release when the feature is ready.
Dark launches and progressive rollouts
Once deploy and release are separate, you get options in between.
A dark launch runs the new code path in production without showing users the result. Call both the old and new pricing logic, return the old answer, and log every mismatch. You learn how the new code behaves on real traffic and real data before anyone depends on it.
A progressive rollout releases to a growing slice of users: employees first, then 1%, 10%, 50%, and finally everyone. At each step, watch error rates, latency, and the business metric the feature is supposed to move. Bucket users with a stable hash so the same person gets the same experience on every request:
```ruby require “zlib”
def in_rollout?(flag, user, percent) Zlib.crc32(“#{flag}:#{user.id}”) % 100 < percent end ```
Including the flag name in the hash keeps the same 1% of users from being the test group for every rollout you ever run.
Kill switches beat rollbacks
When a release goes wrong, turning off a flag takes seconds. Rolling back a deploy takes a full pipeline run, and it reverts every other change that went out with it. Sometimes a rollback can’t happen at all, because a migration already ran.
A flag limits the blast radius to one feature. Turn off the new checkout, and the bug fix someone else shipped in the same deploy stays live.
Kill switches earn their keep beyond new features. A flag that turns off an expensive recommendations widget during a traffic spike lets you shed load on purpose, before the database starts choosing what to drop for you.
Flags are debt
Every flag is a branch in your code. Two flags give you four combinations, and ten give you over a thousand, most of which nobody has tested.
Old flags turn into landmines. In 2012, Knight Capital reused an old flag for new trading code. One server never received the new deploy, the flag woke up long-dormant code on that server, and the firm lost hundreds of millions of dollars in under an hour.
So treat flags as temporary. Give each one an owner and an expiration date. Once a release reaches 100% and stays there for a week, delete the flag and the old code path with it. Permanent kill switches are the exception, and they should be named and documented as permanent.
Splitting deploy from release is how you ship as fast as possible, but not faster. The deploy pipeline can run all day, and the riskiest decision becomes a flag flip you can undo in seconds.
Deploy constantly. Release on purpose.