Deploys need a release path
A safe deploy has a scope, a rollout plan, health signals, rollback criteria, and database changes that work across versions.
A deploy is a production change. That is the whole of it, and it is enough.
Every deploy carries the same few things: a version, a path into service, a set of users who can reach it, health signals to watch, and a condition that tells the rollout when to stop. Once you can see those parts, the change stops being a moment of suspense and becomes something you can hold.
There is one question that organizes all of it:
What has to be true before the next slice of traffic sees this version?
Asked early, that question keeps the deploy small enough to reason about. It gives the system a way to move forward, and it gives the person at the controls a clear place to stop. Nothing more is required of you than to answer it honestly, one slice at a time.
Start with the release path
Three words do a lot of quiet work here, and it helps to let them mean different things.
A deploy places a version in production.
A release exposes behavior to users.
A rollout changes that exposure over time.
They help because each one has its own control points, and naming them separately gives you somewhere precise to act:
| Word | What it names | Control point |
|---|---|---|
| Deploy | Code and configuration available in production | Build, artifact, environment, version |
| Release | Behavior reachable by users | Feature flag, permission, tenant, cohort |
| Rollout | The amount of exposure | Percentage, region, account set, traffic route |
Once those controls are separate, you gain room to breathe. A version can sit in production at zero traffic. A feature can be enabled for a single tenant. A rollout can rest at a small percentage for as long as you need. The change is present, and nothing has happened yet that you cannot undo.
The release object
Give the release one durable record, in one place, that everyone can return to.
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Release:
version: str
artifact_digest: str
environment: str
rollout_percentage: int
status: str
started_at: datetime
owner: str
That record becomes the single thing automation reads and updates, so the truth lives somewhere other than memory.
The useful release states are few, and they stay simple on purpose:
| State | Meaning |
|---|---|
prepared | Artifact built, verified, and available |
deployed | Version present in production capacity |
canary | Small traffic slice reaches the version |
expanding | Rollout percentage is increasing |
paused | Version remains deployed and rollout is stopped |
rolled_back | Traffic returned to the previous stable version |
completed | Full intended scope reaches the version |
With this record, humans and automation share one view of the same world. There is no private version of the story.
Blue-green deployment
Blue-green deployment keeps two production-ready environments side by side.
One environment serves traffic. The other quietly receives the new version, the migrations that are safe for the current system, the configuration, the warmup, and the smoke checks. When the prepared environment is healthy, routing moves traffic to it. The switch happens only after the new side has earned it.
A few things hold this together:
- Both environments can run the production workload.
- Routing can move traffic with a clear command.
- The previous environment stays available for a defined window.
- Shared dependencies can handle both versions during the transition.
- State changes remain compatible across the switch.
Blue-green works well when the serving layer can be duplicated cleanly. The shared parts, databases, queues, caches, and object storage, still ask for release discipline, because they are touched by both sides. When the shared state is already compatible, the traffic switch becomes the easy step it was meant to be.
Rolling deployment
A rolling deployment replaces capacity gradually, a little at a time.
One instance leaves service. The new version starts. Readiness checks pass. The instance returns to service. Then the same small step repeats, instance by instance, until the whole fleet is running the version you intended.
The shape is ordinary, and that is its strength:
drain old instance
start new version
wait for readiness
serve traffic
continue with the next instance
A rolling deployment lives with version overlap, and there is no avoiding it. During the rollout, old and new instances may handle the same user, read the same database rows, consume the same queues, and write the same cache keys. They coexist for a while.
So compatibility is the requirement. If the two versions can hold the same data without confusion, the overlap is nothing to fear.
Canary deployment
A canary sends a small slice of production traffic to the new version, and then watches.
The slice should be stable. A user, tenant, or request key should hash into the same bucket each time, so that one user sees one consistent behavior throughout the measurement window. Stability is a kindness to your future self reading the metrics.
import hashlib
def stable_bucket(key: str, buckets: int = 100) -> int:
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
return int(digest[:8], 16) % buckets
def receives_canary(user_id: str, rollout_percentage: int) -> bool:
return stable_bucket(user_id) < rollout_percentage
That is enough for a percentage rollout. The same user lands in the same bucket while the percentage moves underneath them, and their experience stays steady.
Canary traffic asks for the same monitoring as normal traffic, only broken down by version so you can compare like with like:
| Signal | Question it answers |
|---|---|
| Request rate | Is the canary receiving the intended scope? |
| Error rate | Is the version failing requests? |
| p95 and p99 latency | Is the version changing tail behavior? |
| Saturation | Are CPU, memory, pools, or queues near limits? |
| Business success rate | Are important workflows completing? |
| Unknown or pending states | Is the version creating repair work? |
| Log event shape | Are important events still emitted with expected fields? |
The rollout advances when the health gate is satisfied, and not before. There is no rush in it.
Feature flags
A feature flag controls behavior at runtime, which lets you separate shipping code from turning it on.
A flag worth keeping has a name, an owner, a default value, a rollout rule, and a removal date. The removal date matters as much as the rest.
def use_new_ranking(request, flags):
enabled = flags.enabled(
"feed.new_ranking",
actor_id=request.user.id,
tenant_id=request.tenant.id,
default=False,
)
if enabled:
return rank_feed_v2(request)
return rank_feed_v1(request)
The flag lookup should be fast, local where it can be, and observable. When the behavior change is meaningful, let the request log carry the flag state alongside it, so the record explains itself later.
logger.info(
"feed.ranked",
extra={
"event": "feed.ranked",
"user_id": request.user.id,
"tenant_id": request.tenant.id,
"ranking_version": "v2" if enabled else "v1",
"flag": "feed.new_ranking",
"flag_enabled": enabled,
"duration_ms": duration_ms,
"result": "success",
},
)
Flags are production code, not notes in the margin. They deserve tests, ownership, cleanup, and a known default, the same care you give anything else that runs.
Rollout gates
A rollout gate is a small decision function, and its smallness is the point.
def rollout_gate(version, baseline, current):
checks = {
"error_rate": current.error_rate <= baseline.error_rate * 1.25,
"p99_latency": current.p99_latency_ms <= baseline.p99_latency_ms * 1.20,
"success_rate": current.success_rate >= baseline.success_rate * 0.995,
"saturation": current.max_saturation <= 0.80,
"unknown_states": current.unknown_state_rate <= baseline.unknown_state_rate,
}
return {
"version": version,
"passed": all(checks.values()),
"checks": checks,
}
The numbers themselves are policy, and you can tune them. What matters is that the decision is explicit and written down by the gate rather than carried in someone's head. A deploy rests easiest when the choice to advance or stop is something you can point to.
Good rollout gates tend to share these qualities:
- They use metrics tagged by version.
- They wait long enough to collect useful data.
- They include user-visible success signals.
- They include saturation signals.
- They produce a decision record.
- They page a human when the decision needs human judgment.
From there the gate can advance, pause, or roll back. Three calm options, each of them safe.
Automatic rollback
Rollback is a routing action first, before it is anything else.
The simplest automatic rollback just moves traffic away from the version that failed the gate, back to the version that was already serving well.
def evaluate_release(release):
baseline = metrics.for_version(release.previous_version)
current = metrics.for_version(release.version)
decision = rollout_gate(release.version, baseline, current)
audit_log.write(
{
"event": "release.gate_evaluated",
"version": release.version,
"rollout_percentage": release.rollout_percentage,
"passed": decision["passed"],
"checks": decision["checks"],
}
)
if decision["passed"]:
return advance_rollout(release)
route_traffic(version=release.previous_version, percentage=100)
mark_release(release.version, status="rolled_back")
alert_release_owner(release, decision)
return release
Rollback works cleanly when the release kept its state compatible all along. Code can move back the moment the data still makes sense to the previous version. The exit was prepared before it was ever needed.
That is why database migrations matter so much.
Database changes
Database changes have to work across the entire rollout window, while both versions are alive.
A common and dependable sequence is:
- Expand the schema.
- Deploy code that can read and write both shapes.
- Backfill existing rows.
- Move reads to the new shape.
- Stop writing the old shape.
- Contract the schema after the old version is gone.
As an example, take splitting users.name into first_name and last_name. Each step below leaves the system whole.
First, add nullable columns:
ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name text;
Then write both shapes:
def update_name(user_id, full_name):
first_name, last_name = split_name(full_name)
db.execute(
"""
UPDATE users
SET name = %(full_name)s,
first_name = %(first_name)s,
last_name = %(last_name)s
WHERE id = %(user_id)s
""",
{
"user_id": user_id,
"full_name": full_name,
"first_name": first_name,
"last_name": last_name,
},
)
Then read with a fallback:
def display_name(user):
if user.first_name is not None and user.last_name is not None:
return f"{user.first_name} {user.last_name}"
return user.name
Backfill in batches:
def backfill_names(batch_size=500):
rows = db.query(
"""
SELECT id, name
FROM users
WHERE first_name IS NULL
ORDER BY id
LIMIT %(batch_size)s
""",
{"batch_size": batch_size},
)
for row in rows:
first_name, last_name = split_name(row.name)
db.execute(
"""
UPDATE users
SET first_name = %(first_name)s,
last_name = %(last_name)s
WHERE id = %(user_id)s
AND first_name IS NULL
""",
{
"user_id": row.id,
"first_name": first_name,
"last_name": last_name,
},
)
Once the rollout and backfill are both complete, a later migration can remove the old column. The removal belongs to its own release, given its own time. There is no need to crowd everything into one risky moment.
Queues and workers
Deploys move through queues too, in their own time, not just through request traffic.
A producer may publish a new event field before every consumer has the new code. A worker may read a job created by the previous version. A retry may replay a payload from hours ago, long after the deploy you thought was finished. None of this is a problem if you expect it.
So queue payloads carry a few compatibility rules:
- Add fields as optional first.
- Keep old fields during the rollout.
- Give events a schema version.
- Make consumers tolerant of unknown fields.
- Keep handlers idempotent.
- Hold old handler code until queued work drains or expires.
Example:
def handle_user_updated(event):
version = event.get("schema_version", 1)
if version == 1:
email = event["email"]
display_name = event.get("name", "")
else:
email = event["email"]
display_name = " ".join(
part for part in [event.get("first_name"), event.get("last_name")] if part
)
search_index.update_user(
user_id=event["user_id"],
email=email,
display_name=display_name,
event_id=event["event_id"],
)
The worker treats old payloads as ordinary input, not as exceptions. Nothing breaks, because nothing was surprised.
Configuration
Configuration is part of the release, every bit as much as code.
Configuration worth trusting has:
| Property | Reason |
|---|---|
| Typed values | Prevents accidental strings, units, and missing fields |
| Defaults | Keeps startup behavior explicit |
| Validation | Fails before serving traffic |
| Versioning | Connects behavior to a deployed config |
| Audit trail | Shows who changed production behavior |
| Rollback path | Restores a known prior value |
Let configuration changes show up in the release record and in the logs for affected requests. A silent config change can wear the mask of a code regression during an incident, and at three in the morning that disguise costs real time. Visibility is mercy.
Smoke tests and readiness
Smoke tests check the version in its environment.
Readiness checks decide whether an instance is ready to receive traffic.
A small smoke test suite walks the production path at low volume:
- Start the process.
- Load configuration.
- Connect to required dependencies.
- Serve a health endpoint.
- Execute one read path.
- Execute one write path in a safe test scope.
- Emit a structured log event.
- Export metrics with the version tag.
Readiness should cover the dependency state the request path truly needs, stay cheap to run, and keep unavailable instances out of rotation until they are genuinely ready. It is a small gate that prevents a lot of grief.
What I notice
Calm deploy systems have a name for each stage.
The version is named. The artifact is named. The rollout scope is named. The flag is named. The health gate is named. The rollback condition is named. The database state is named. Naming a thing is how you stop being afraid of it.
When all of that is named, the team can point to one release record and simply see what is happening, together.
The systems that feel anxious are the ones that scatter those facts across chat messages, dashboards, a terminal history on someone's laptop, and memory. Nothing is wrong with the people; the path is just hidden from them.
So the work, gentle and unglamorous, is to make the path visible.
Metrics that help
The deployment metrics that earn their keep are tagged by version, environment, region, and rollout_percentage, so you can always ask which slice of the world a number belongs to.
| Metric | Why it matters |
|---|---|
| Request rate by version | Confirms traffic is reaching the intended code |
| Error rate by version | Shows failures tied to the release |
| p95 and p99 latency by version | Shows tail behavior during rollout |
| Saturation by version | Shows CPU, memory, pool, thread, and queue pressure |
| Business success rate | Shows product workflow completion |
| Rollback count | Shows release quality and gate behavior |
| Time in each rollout stage | Shows stalled releases |
| Migration batch rate | Shows backfill progress |
| Migration lock time | Shows schema change risk |
| Queue age by payload version | Shows old work still in flight |
| Flag evaluation rate | Shows which behavior users are receiving |
The release process leaves logs of its own, too:
release.prepared
release.deployed
release.canary_started
release.gate_evaluated
release.rollout_advanced
release.paused
release.rolled_back
release.completed
With those events, deployments become queryable in the same system as application behavior. The deploy stops being a separate, mysterious thing and joins the rest of what you can see.
What I think
The practical sequence, in order, is this:
- Build one artifact and give it a digest.
- Deploy the artifact to production capacity.
- Keep release behavior behind a flag when the behavior is meaningful.
- Start with a small stable cohort.
- Tag logs, metrics, and traces with the version and flag state.
- Evaluate the health gate after enough traffic arrives.
- Advance the rollout in clear steps.
- Pause when the signal is incomplete.
- Route traffic back when the gate fails.
- Design database and queue changes for overlapping versions.
- Remove old flags, columns, and compatibility paths in later releases.
The under-engineering move, the one that quietly saves you, is to make the deploy path explicit and mechanical. Let the steps be boring. Boring is what lets you stay calm.
A small release system with clear gates is genuinely enough for most teams. You do not need more than this to be safe.
Tutorial checklist
For any production deploy, it helps to fill this out before you begin:
| Question | Example answer |
|---|---|
| Version | 2026.05.29.3 |
| Artifact | Image digest or build hash |
| Environment | production |
| Owner | Release engineer or service team |
| Rollout scope | 5 percent of users, stable hash by user_id |
| Feature flag | feed.new_ranking, default false |
| Health window | 20 minutes or 100,000 requests |
| Success signals | Error rate, p99 latency, feed render success |
| Saturation signals | CPU, memory, DB pool, queue age |
| Rollback condition | Gate failure on error, latency, or workflow success |
| Database state | Expanded schema deployed, old and new code compatible |
| Queue state | Payload v1 and v2 both accepted |
| Observability | Logs and metrics tagged by version and flag |
| Cleanup | Remove flag and old code after full rollout and one stable window |
Then ask:
Can this release pause or move back while keeping data in a shape the old code can read?
A complete release path can answer yes to that question without hesitation. When it can, the deploy is no longer something to brace against. It is just a change you can make, and unmake, in the open.
Summary
- A deploy places a version in production.
- A release exposes behavior to users.
- A rollout changes exposure over time.
- Blue-green deployment keeps two production-ready environments.
- Rolling deployment replaces capacity gradually.
- Canary deployment sends a small stable slice of traffic to the new version.
- Feature flags control runtime behavior and need ownership.
- Rollout gates turn health metrics into advance, pause, or rollback decisions.
- Automatic rollback works cleanly when state remains compatible.
- Database migrations need an expand, overlap, backfill, and contract sequence.
- Queue payloads need compatibility across producer and consumer versions.
- A release record makes the deploy path visible and auditable.
Pop quiz
Interactive quiz
Deployment and release safety
A gentle, randomized review of release paths, canaries, feature flags, rollout gates, rollback, and compatible migrations.