What monolith continuous deployment is and who it suits
Monolith continuous deployment means every merge to main produces exactly one immutable artifact, and that same artifact moves through every environment until it reaches production. Usually the artifact is a container image tagged with the commit SHA. Nothing is rebuilt between staging and production. Configuration such as database URLs, credentials and flag defaults is injected at runtime through environment variables or mounted files. The bytes you tested are the bytes you ship, and any running instance traces back to a single commit.
Build once, promote many
Suppose commit a1b2c3d merges to main. CI builds the image once and pushes it as app:a1b2c3d. Staging runs that image with DATABASE_URL pointing at the staging database. Production later runs the identical app:a1b2c3d with production values. Promotion changes which environment references the tag. It never changes the image. Avoid mutable tags like latest for deploys, because they break that traceability.
Why teams do it
Batched releases combine dozens of changes. When one breaks, you have to bisect under pressure, and rolling back reverts good work along with the bad. Small, frequent deploys keep each change easy to read, cheap to revert and fresh in its author's mind.
Who it suits
It fits backend and platform teams that share a codebase, have test coverage they trust and run production telemetry good enough to spot regressions within minutes. The premise of this guide is that most of the safety comes from migration discipline, feature flags and canary analysis. Splitting the codebase into services is not what makes deployment safe.
#!/usr/bin/env bash
set -euo pipefail
SHA="$(git rev-parse --short HEAD)" # e.g. a1b2c3d
IMAGE="registry.example.com/app:${SHA}"
docker build -t "${IMAGE}" .
docker push "${IMAGE}"
docker run -d --name app-staging \
--env-file ./config/staging.env \
"${IMAGE}"
docker pull "${IMAGE}"
docker run -d --name app-production \
--env-file ./config/production.env \
"${IMAGE}"
docker inspect --format '{{.Image}}' app-staging app-production
The image is built and pushed once, tagged with the commit SHA. Staging and production then run that same image, and only the runtime environment file differs between them.
How the deployment pipeline works, stage by stage
Migration discipline, feature flags and canary analysis are what make continuous deployment safe. The pipeline is what turns that discipline into something you can run many times a day.
merge -> deploy queue -> build + publish -> staging + smoke -> canary 5% -> 25% -> 100%
Each stage gets a time budget. If one stage overruns, it limits how often you can ship. The figures below are an illustrative target with assumed values, not a measurement:
| Stage | Budget | Gate |
|---|---|---|
| Pre-merge: compile, unit tests, lint | ≤10 min | Required to merge |
| Post-merge build and artifact publish | ≤5 min | Immutable artifact, tagged by commit |
| Staging deploy and smoke tests | ≤10 min | Critical paths respond correctly |
| Canary, then staged rollout | ≤20 min | Automated metric comparison passes |
Build the artifact once and promote that same artifact through every stage. If staging and production run different builds, a green staging run tells you nothing about production.
Serialization
A shared production target needs one deploy at a time. A merge queue serializes changes into the mainline. A deploy queue serializes rollouts. Without these, two canaries can overlap, and neither result can be trusted.
Keep each deploy small, one commit or a few. If the canary's error rate rises, you want a short list of suspects, not forty commits from eight teams.
When the queue backs up, batch the waiting commits into one deploy. If that batch fails its canary, bisect it rather than reverting all of it. Persistent backlog is a capacity signal: shorten the slowest stage before you accept larger batches as normal.
+-------------+ +------------------------+ +---------------+
| Pull request|---->| Pre-merge checks |---->| Merge queue |
| | | compile, unit, lint | | serializes |
+-------------+ | budget <= 10 min | | mainline |
+------------------------+ +-------+-------+
v
+------------------------+ +---------------+ +-------+-------+
| Artifact registry |<----| Build and |<--| Deploy queue |
| immutable, tagged by | | publish | | 1 to few |
| commit SHA | | <= 5 min | | commits; batch|
+-----------+------------+ +---------------+ | if backed up |
| same artifact promoted +---------------+
v
+-----------+------------+
| Staging + smoke tests |
| <= 10 min, same build |
+-----------+------------+
v
+-----------+-----------+ +-----------+ +-----------+
| Canary 5% |-->| 25% |-->| 100% |
| metric gate vs. base | | gate | | done |
+-----------+-----------+ +-----+-----+ +-----------+
| | canary + rollout <= 20 min
+---------+-----------+
v
+---------+-----------+
| Halt, roll back, or |
| bisect the batch |
+---------------------+
One artifact, built once after the merge and deploy queues serialize changes, is promoted through staging and a gated canary rollout, with illustrative time budgets per stage and a halt-and-bisect path when a gate fails.
Keeping a large test suite fast and trustworthy
In a monolith, the slowest stage is almost always the test suite. Every commit shares one build, so a forty-minute suite caps how many changes you can ship per hour. The fix is to make the merge-blocking path prove only what a change could plausibly break.
Select tests by changed code. Build a map from source modules to the tests that exercise them, using the module dependency graph or per-test coverage data. Then run only the affected subset before merge. Selection can miss things: reflection, configuration, and shared fixtures hide dependencies. So run the full suite on the main branch after merge, and treat any test it catches that selection skipped as a bug in the map.
Shard and parallelise. Split the selected tests across workers and balance the shards by recorded duration, not by file count. Otherwise one slow shard sets the wall-clock time for the whole run. Isolate databases and ports per worker so parallelism doesn't introduce cross-test interference.
Move slow end-to-end suites off the blocking path. Run them continuously against the latest build or the canary environment, and alert on failures. The canary's automated analysis covers much of what they would catch.
Quarantine flaky tests. A test that fails nondeterministically is a defect. Automatic retries hide real race conditions and teach engineers to ignore red builds. Pull the flaky test out of the blocking set, give it an owner and a deadline, and track how large the quarantine grows.
All of this needs ongoing platform investment. When it's underfunded, the suite slows down, merges queue, and teams start batching changes. Batched changes mean riskier deploys, and you end up back at weekly releases.
Canary releases with automated analysis
Route a small share of real traffic to the new build, either by adjusting load balancer weights or by deploying it to a subset of instances. Compare the canary against a baseline of fresh instances running the current version, started at the same time and given the same traffic share. Comparing against last week's numbers mixes in time-of-day and cache effects. A concurrent baseline cancels them out.
Set thresholds before the deploy so nobody argues with a dashboard mid-incident. The table below is an illustrative threshold config. Every value in it is assumed for the sake of the example; none is a measured result or a recommended default. Derive your own from your service's normal variance and traffic volume.
| Signal (illustrative, assumed values) | Abort if canary vs. baseline |
|---|---|
| 5xx rate | > 0.5 percentage points higher |
| p99 latency | > 20% higher |
| Completed checkouts per session | > 5% lower |
| Minimum sample | 2,000 requests before any verdict |
| Analysis window | 15 minutes, after warm-up |
Illustrative thresholds with assumed values, shown to demonstrate the shape of a canary policy, not measured or benchmarked figures.
If no signal trips, promote automatically. If any signal trips, abort and drain the canary.
Limits
Low-traffic paths and noisy metrics cause false positives, which is why you need a minimum sample size. Rare features may get no canary traffic at all, so regressions in them pass as false negatives. Background jobs don't flow through the load balancer, and slow memory leaks outlast the window. A large monolith's startup and cache warm-up can add minutes, so exclude warm-up from analysis and expect longer canary windows.
Ownership
Map code areas to owning teams so a failed canary pages whoever owns the changed paths. Tag every metric, log line and trace with the deployed version. Keep a deploy log that records the version, commit range, timestamps and verdict.
MIN_REQUESTS = 2000 # no verdict on tiny samples (avoids false positives)
MAX_5XX_DELTA_PP = 0.5 # percentage points above baseline
MAX_P99_RATIO = 1.20 # canary p99 may be at most 20% higher
MIN_CHECKOUT_RATIO = 0.95 # checkouts/session may be at most 5% lower
def verdict(canary: dict, baseline: dict) -> tuple[str, list[str]]:
"""Compare canary and baseline metrics from the SAME post-warm-up window."""
if min(canary["requests"], baseline["requests"]) < MIN_REQUESTS:
return "wait", ["sample too small"]
failures = []
err_c = 100 * canary["errors_5xx"] / canary["requests"]
err_b = 100 * baseline["errors_5xx"] / baseline["requests"]
if err_c - err_b > MAX_5XX_DELTA_PP:
failures.append(f"5xx {err_c:.2f}% vs {err_b:.2f}%")
if canary["p99_ms"] > baseline["p99_ms"] * MAX_P99_RATIO:
failures.append(f"p99 {canary['p99_ms']}ms vs {baseline['p99_ms']}ms")
co_c = canary["checkouts"] / canary["sessions"]
co_b = baseline["checkouts"] / baseline["sessions"]
if co_c < co_b * MIN_CHECKOUT_RATIO:
failures.append(f"checkouts/session {co_c:.3f} vs {co_b:.3f}")
# Any tripped signal aborts; otherwise promote automatically.
return ("abort", failures) if failures else ("promote", [])
canary = {"version": "2026.10.29-1", "requests": 4100, "errors_5xx": 41,
"p99_ms": 910, "sessions": 800, "checkouts": 52}
baseline = {"version": "2026.10.28-3", "requests": 4050, "errors_5xx": 12,
"p99_ms": 870, "sessions": 790, "checkouts": 53}
result, reasons = verdict(canary, baseline)
print(result, reasons) # record this line in the deploy log with the commit range
A minimal canary analysis function that withholds a verdict until the minimum sample is reached, then compares 5xx rate, p99 latency and checkouts per session against a concurrent baseline using predefined, illustrative thresholds with assumed values to decide promote or abort.
Expand/contract migrations and feature flags
During a canary, the old and new versions run side by side against one database. A rollback also puts the old version back on a schema the new version may already have changed. So every migration must work with both the version currently running and the version that replaces it. Expand/contract makes this possible by splitting one breaking change into several compatible deploys.
Renaming users.fullname to display_name
- Expand. Run
ALTER TABLE users ADD COLUMN display_name text NULL;. Old code ignores the new column. A nullable column with no default is cheap on most engines. - Dual-write. Deploy code that writes both columns but still reads
fullname. - Backfill in throttled batches. Run
UPDATE users SET display_name = fullname WHERE id BETWEEN :lo AND :hi AND display_name IS NULL;, pausing between batches. Small batches keep locks short, limit replication lag, and let you stop the job if latency rises. - Switch reads. Read
display_nameand keep dual-writing so a rollback still findsfullnameup to date. - Contract. Once no running or rollback-eligible version reads
fullname, stop writing it. In a later deploy, runALTER TABLE users DROP COLUMN fullname;.
Separating deploy from release
Feature flags let you merge unfinished work to trunk and ship it dark. Turning it on becomes a runtime decision instead of a deploy. Roll out by cohort: internal users first, then a small percentage, then everyone. Watch the same metrics your canary uses at each step.
Flags also build up risk over time. Each flag doubles the number of possible code paths, and most combinations are never tested. Give every flag an owner and an expiry date when you create it. Fail CI or open a ticket when a flag passes its expiry. Remove the flag and its dead branch within a sprint of reaching 100%.
import os
import sys
import time
import psycopg2
BATCH = 5000 # small batches keep row locks and transactions short
PAUSE_S = 0.5 # breathing room for replicas and foreground traffic
MAX_BATCH_S = 2.0 # a slow batch signals DB pressure, so stop and investigate
conn = psycopg2.connect("dbname=app user=backfill host=db.internal")
with conn.cursor() as cur:
cur.execute("SELECT coalesce(min(id), 0), coalesce(max(id), 0) FROM users")
min_id, max_id = cur.fetchone()
lo = int(os.environ.get("START_ID", min_id))
while lo <= max_id:
hi = lo + BATCH - 1
started = time.monotonic()
with conn.cursor() as cur:
# IS NULL makes the job idempotent and safe to rerun.
cur.execute(
"UPDATE users SET display_name = fullname "
"WHERE id BETWEEN %s AND %s AND display_name IS NULL",
(lo, hi),
)
updated = cur.rowcount
conn.commit() # one transaction per batch, not one giant transaction
elapsed = time.monotonic() - started
print(f"ids {lo}-{hi}: {updated} rows in {elapsed:.2f}s")
if elapsed > MAX_BATCH_S:
sys.exit(f"batch too slow; rerun later with START_ID={hi + 1}")
lo = hi + 1
time.sleep(PAUSE_S)
conn.close()
A resumable, idempotent backfill that copies fullname into display_name in small committed batches, pauses between batches, and stops itself if a batch runs slow.
Rollback strategy: redeploy, flag off or roll forward
When a deploy goes wrong, there are three ways out. Each one depends on different conditions being true, so choose the path before the incident, not during it.
| Path | Speed | Scope | Preconditions | Risks |
|---|---|---|---|---|
| Flag off | Seconds | One feature | Change shipped behind a flag; off-path still works | Stale flag state across instances; untested flag combinations |
| Redeploy previous artifact | Minutes (one pipeline run or traffic switch) | Whole release | Old code reads current schema and data; artifact still retained | New-format rows or messages the old code can't parse |
| Roll forward | Pipeline duration plus fix time | Targeted | Cause understood; fast path to production | Writing a fix under pressure; skipping checks to save time |
Redeploying feels like it restores the previous state, but it only restores code. Rows already written in a new format stay in the database. Events already published sit in other teams' queues, and emails already sent are in customers' inboxes. If the new release wrote data the old code can't handle, rolling back the code can cause a second outage. In that case, roll forward with a fix, plus a compensating job or corrective message where one is needed.
Write the decision into your runbooks. Release notes should say whether a change is flag-guarded, whether it is safe to redeploy, or whether it can only be fixed by rolling forward. Mark migrations that break redeploy compatibility in review. Then rehearse rollback on a schedule by actually redeploying the previous artifact to production during a quiet period. A rollback path that nobody has exercised recently is the one most likely to fail during a real incident: retention policies may have deleted the old artifact, or a config change may have drifted underneath it.
When to avoid it, alternatives, and impact on cost and engineering time
When to avoid it
Skip it if the test suite can't catch regressions, if you can't compare error rates and latency per version in production, if a schema change can't be made backward compatible, if the app takes minutes to start (every canary and rollback inherits that delay), or if a regulator requires human sign-off per release.
The trade-offs are structural. One artifact means one blast radius: a billing bug can take down search. Deploys serialize through one queue, so throughput is capped by pipeline duration. When teams batch changes to keep up, they lose the ability to tell which change caused an incident. Expand/contract turns one schema change into several deploys and leaves dead columns until someone cleans them up.
| Approach | Release risk | Coordination overhead | Platform cost |
|---|---|---|---|
| Scheduled batched releases | High: large diffs, weak attribution | High: release trains, freezes | Low |
| Microservices | Smaller blast radius, new network failure modes | API versioning across teams | High: per-service pipelines, tracing |
| Modular monolith with module ownership | Same blast radius, clearer ownership | Moderate | Low to moderate |
The cost is ongoing work. Someone has to own pipeline speed, flaky-test triage and test infrastructure spend, and that job never finishes.
Illustrative estimate (assumed numbers, not a benchmark)
Net hours saved per month = (R × P × C) + (I × D) − M
The inputs are all assumed:
- R = 4 coordinated releases replaced per month
- P = 6 people per release
- C = 2 hours of coordination each
- I = 3 incidents per month
- D = 4 diagnosis hours saved per incident
- M = 30 hours of pipeline maintenance
The calculation is 48 + 12 − 30 = 30 hours per month. If M exceeds the savings, stay with batched releases.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith continuous deployment, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.