What actually limits how often a monolith can deploy
To deploy a monolith frequently means shipping the main branch to production at least once per working day, with each deploy carrying a small, reviewable set of changes. The single deployable unit is rarely what stops this. Release pain comes from batch size. When two weeks of work from six teams lands at once, every deploy becomes a high-stakes event: failures are hard to attribute, rollbacks discard unrelated work, and people respond by adding more sign-offs. Long-lived branches make batches larger, and manual gates make them slower, which pushes teams toward even bigger releases.
This approach suits teams whose monolith is stable but slow to ship, and who want faster feedback without taking on the operational cost of distributed services.
Find your real constraint
Before changing tooling, measure lead time for a sample of recent changes, from first commit to running in production. Split it into stages and record how long each one took. In most cases one stage accounts for most of the delay, and that stage is where to start.
| Stage | What to measure | Common cause of delay |
|---|---|---|
| Wait for review | PR opened → approved | Large diffs, few reviewers |
| Build | Merge → artifact ready | No caching, full rebuilds |
| Test | Artifact → suite passes | Flaky or serial tests |
| Approval | Tests pass → release sign-off | Manual change boards |
| Deploy | Release start → serving traffic | Hand-run steps, slow rollout |
Optimizing a stage that isn't the bottleneck will not change how often you can ship.
Trunk based development and feature flags on a shared codebase
Long-lived branches are where a monolith's deploy cadence goes to die. Every day a branch stays open, it drifts further from main, and the eventual merge turns into a multi-team conflict resolution session that nobody wants to ship on a Friday. Trunk based development removes that cost by keeping branches short: hours or a day or two, merged to main at least daily, behind a required CI run and review. The rule that makes it work is that main is always releasable. If a commit breaks the build, reverting it takes priority over new work.
Incomplete features still land on main; they just ship dark. A feature flag separates deploy (code is running in production) from release (users see the behavior). The check itself should be boring: a single call such as flags.isEnabled("new_checkout", user) at the decision point, with the old path as the default.
Flags need the same hygiene as any other production config:
- Owner: a named team, not "whoever added it."
- Expiry: a date after which CI or a linter flags it as stale.
- Removal: deleting the flag and the dead branch of code is part of finishing the feature, not a follow-up ticket.
The trade-offs are real. Ten independent boolean flags produce 1,024 possible combinations, so test the default state and each flag's target state, not every permutation. Teams with thin test coverage and loose review will also break main more often at first. Treat those early breakages as feedback on test gaps rather than as a reason to return to long branches.
// Lifecycle metadata lives next to the flag, so owner and expiry are reviewed with the code.
type Flag = {
owner: string; // team accountable for rollout and removal
expires: string; // ISO date; after this, CI fails until the flag is deleted
rolloutPercent: number; // 0 = dark (deployed, not released), 100 = fully released
};
export const FLAGS: Record<string, Flag> = {
new_checkout: { owner: "payments-team", expires: "2026-12-31", rolloutPercent: 10 },
};
// Stable hash so a given user always gets the same answer for a given flag.
function bucket(key: string): number {
let h = 0;
for (const c of key) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return h % 100;
}
export function isEnabled(name: string, user: { id: string }): boolean {
const flag = FLAGS[name];
if (!flag) return false; // unknown or removed flag: fall back to the old path
return bucket(`${name}:${user.id}`) < flag.rolloutPercent;
}
// Run in CI: a non-empty result fails the build and forces cleanup.
export function staleFlags(today: Date = new Date()): string[] {
return Object.entries(FLAGS)
.filter(([, f]) => new Date(f.expires) < today)
.map(([name, f]) => `${name} (owner: ${f.owner}, expired ${f.expires})`);
}
// Call site: the old path stays the default until the flag is removed.
// if (isEnabled("new_checkout", user)) return newCheckout(cart);
// return legacyCheckout(cart);
This is a minimal in-code feature flag registry. Each flag records an owner and an expiry date and supports a percentage rollout. The isEnabled check falls back to the old code path for unknown flags, and a CI check reports expired flags so they get removed.
Making the build and test pipeline fast enough for continuous delivery
Frequent deploys only work if the pipeline answers "is this commit safe?" in minutes, not hours. When feedback is slow, engineers batch changes to avoid waiting, and batching is exactly what makes a monolith release risky.
Layer the tests by cost
Put fast, isolated unit tests first. They catch most mistakes at the lowest cost. Run integration tests against real databases and queues next. Keep end-to-end tests to a small set of critical user paths. A failure in an early layer stops the pipeline before the expensive layers consume runners.
Run only what changed, where tooling allows
Build systems that model dependencies between modules can work out which targets a commit affects and skip the rest. On a large codebase this is often the biggest single saving. It depends on accurate dependency declarations, though. A hidden coupling, such as shared database tables or reflection-based wiring, can let a cross-module regression through. Keep a full-suite run on a schedule and before each release to catch what selective runs miss.
Parallelize
Split independent stages so they run at the same time, and shard large suites across runners. The example config that follows shows lint, unit, and integration jobs fanning out after a shared build step.
Treat flaky tests as defects
A test that fails at random teaches people to rerun until the build is green, and then real failures get ignored the same way. Fix each flaky test promptly. If you can't, quarantine it with a named owner and a deadline.
Pipeline time grows along with the codebase. Budget ongoing engineering time to keep it fast.
# Generic CI pipeline (vendor-neutral schema; map keys to your CI system).
# One shared build, then independent checks fan out in parallel.
pipeline:
triggers:
- on: pull_request # fast path: affected tests only
mode: affected
- on: schedule # nightly safety net for cross-module regressions
cron: "0 2 * * *"
mode: full
- on: release_tag # never ship on a selective run alone
mode: full
stages:
build:
run: ./ci/build.sh # compile once and publish artifacts
artifacts: [dist/]
lint:
needs: [build] # these three start together after build
run: ./ci/lint.sh
unit:
needs: [build]
shards: 4 # split a large suite across 4 runners
run: ./ci/test.sh --layer unit --mode $MODE --shard $SHARD_INDEX/$SHARD_TOTAL
integration:
needs: [build]
services: [postgres, rabbitmq] # real dependencies, not mocks
run: ./ci/test.sh --layer integration --mode $MODE
e2e:
needs: [unit, integration] # expensive layer runs only if cheaper ones pass
run: ./ci/test.sh --layer e2e --suite critical-paths
quarantined:
needs: [build]
allow_failure: true # flaky tests still run for data, but do not block
run: ./ci/test.sh --tag quarantined # each tag entry lists owner + fix-by date
A vendor-neutral CI pipeline in which lint, sharded unit tests, and integration tests run in parallel after a single build. Pull requests run only affected tests, while scheduled and release runs execute the full suite, and quarantined flaky tests run without blocking the pipeline.
Database schema changes that tolerate daily deploys
During a rolling deploy, old and new versions of the monolith run against the same database at the same time. A rollback brings the old version back. Every schema change therefore has to work with both the code currently in production and the code about to replace it. The expand-and-contract pattern meets that requirement by splitting one breaking change into several compatible ones.
Take renaming users.email to users.email_address:
- Expand. Run
ALTER TABLE users ADD COLUMN email_address text NULL. A nullable column without constraints is invisible to existing code. - Dual-write. Deploy code that writes to both columns but still reads
email. - Backfill. Copy existing rows with
UPDATE ... SET email_address = email WHERE id BETWEEN ...in small batches. One large update can hold locks and fall behind replicas. - Switch reads. Deploy code that reads
email_addressand keeps writing both columns, so a rollback still findsemailup to date. - Contract. Stop writing
email. Once no running version references it, runALTER TABLE users DROP COLUMN email.
Run migrations as their own pipeline step before the code rollout. Don't run them at application startup, where many instances can race to apply them and a failure couples schema state to a code rollback.
The cost is several deploys spread across days, plus tracking so the contract step isn't forgotten. Skipping steps is a common cause of outages. Renaming in place, or dropping a column while old instances still read it, breaks production partway through the rollout.
-- Expand-and-contract for renaming users.email -> users.email_address (PostgreSQL).
-- Each block ships in a separate deploy. Migrations run as their own pipeline step.
-- Deploy 1 (EXPAND): nullable, no default, no constraints.
-- Old code ignores the column, so old and new versions can run side by side.
ALTER TABLE users ADD COLUMN email_address text NULL;
-- Deploy 2 (DUAL-WRITE): no schema change. App code now writes both columns:
-- INSERT INTO users (name, email, email_address) VALUES ($1, $2, $2);
-- UPDATE users SET email = $1, email_address = $1 WHERE id = $2;
-- BACKFILL: copy existing rows in small batches, one transaction per batch.
-- Short batches keep locks brief and let replicas keep up.
-- The IS NULL guard makes re-running a batch safe.
UPDATE users
SET email_address = email
WHERE id BETWEEN 1 AND 10000
AND email_address IS NULL;
-- ...repeat with the next id range (10001-20000, and so on) until done.
-- Gate before switching reads: this must return 0.
SELECT count(*) AS mismatched
FROM users
WHERE email_address IS DISTINCT FROM email;
-- Deploy 3 (SWITCH READS): app reads email_address but still writes both,
-- so a rollback to Deploy 2 code still finds email up to date.
-- Deploy 4: app stops writing email. Ship this only after no running
-- version reads email, including versions you might roll back to.
-- Deploy 5 (CONTRACT): no running version references email any more.
ALTER TABLE users DROP COLUMN email;
The PostgreSQL statements for each expand-and-contract step when renaming users.email to users.email_address, with a batched, re-runnable backfill and a consistency check before reads switch to the new column.
How release safety works for a single artifact
A monolith ships one artifact, so release safety rests on controlling how much traffic the new version receives and how quickly you can withdraw it.
┌──────────────── Load balancer ────────────────┐
│ 95% ──► v41 (current) │
│ 5% ──► v42 (candidate) │
└───────────────┬───────────────────────────────┘
│ health checks + metrics tagged version=v42
▼
error rate / p99 latency vs v41 baseline
│ │
within budget breached
▼ ▼
promote to 25→100% shift 100% to v41
| Strategy | How traffic moves | Rollback speed | Cost |
|---|---|---|---|
| Rolling | Instances replaced in batches | Slow, re-rolls old version | Low |
| Blue-green | Full switch between two fleets | Instant LB flip | Double capacity during cutover |
| Canary | Percentage shifts over time | Fast, small blast radius | Moderate, needs routing |
Automated gates only work if every metric and log line carries a version tag. Without that tag, a 5% canary's errors get averaged into the fleet and disappear. Compare per-release error rate and latency against the running baseline. Alert in the minutes after a deploy, before users report the problem.
Use two exits. Rolling back to the previous artifact restores service. A git revert on trunk is still needed so the next deploy doesn't ship the bug again.
There are trade-offs. Blue-green doubles capacity during the switch. At low traffic, a canary sees too few requests to show a significant difference, so it needs a longer soak or synthetic load. The biggest cost is shared fate: one team's regression blocks every other team's changes until it is reverted, which is why fast reverts matter more than heroic debugging.
┌─────────────────────────────────────────────┐
│ Incoming user traffic │
└──────────────────────┬──────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Load balancer (weighted routing) │
│ step v42: 5% → 25% → 50% → 100% │
└──────────┬───────────────────────┬──────────┘
95% │ │ 5%
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ v41 fleet (stable) │ │ v42 fleet (canary) │
└──────────┬─────────┘ └─────────┬──────────┘
│ health + version=v41 │ health + version=v42
▼ ▼
┌─────────────────────────────────────────────┐
│ Metrics + logs, tagged by version │
│ Per-release error rate, p99: v42 vs v41 │
└──────────────────────┬──────────────────────┘
▼
┌─────────────────────────────────────────────┐
│ Gate: alert window right after deploy │
└──────────┬───────────────────────┬──────────┘
within │ │ breached
budget ▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ Promote next step │ │ LB: 100% to v41 │
│ (loop back to LB) │ │ git revert on trunk│
└────────────────────┘ └────────────────────┘
The load balancer sends a small share of traffic to the v42 canary, and version-tagged health checks and metrics compare it against v41 to decide whether to promote it to the next traffic step or shift all traffic back to v41 and revert the change on trunk.
Module boundaries and deploy ownership for many teams
Shared fate also explains why boundaries inside the codebase matter. When thirty engineers can import anything from anywhere, a change to billing can break search in ways nobody predicted, and every deploy carries the risk of the whole system.
Start with a modular directory layout where each top-level module, such as modules/billing, modules/search or modules/accounts, exposes a small public interface (for example, a public or api subpackage) and keeps everything else internal. Then assign owners with an ownership file at the repository root. A typical entry maps a path pattern to a team, for example modules/billing/ to @payments-team. The review tool then requires that team's approval for changes under that path. This routes review to the people who understand the code, and it records who is accountable for each area.
Ownership alone does not stop coupling. Add a dependency check to CI that parses imports and fails the build when one module reaches into another module's internals or when a dependency appears that is not on an allowed list. Boundaries erode one convenient import at a time. Without an automated check, the module structure stops matching the real dependency graph within a few quarters.
Deploy ownership needs the same clarity. One rotating role or team owns the deploy pipeline, and each module team owns its regressions and reverts. Replace change-advisory boards with these automated checks plus an audit trail that links every deploy to its commits, approvals and test results. Auditors get better evidence, and deploys no longer wait for a weekly meeting.
Impact on cost and engineering time, and when to split a service instead
Faster approvals only pay off if the batch size drops as well, because smaller batches are what reduce the cost of a defect.
Illustrative estimate (assumed numbers, not a benchmark). Assume 200 commits per week, 4 production defects per week, and investigation time = 1 hour + (suspect commits × 3 minutes).
- Weekly release: 1 + (200 × 3 ÷ 60) = 11 hours per defect, or 44 hours per week.
- Daily deploys (40 commits each): 1 + (40 × 3 ÷ 60) = 3 hours per defect, or 12 hours per week.
You can substitute your own commit volume, defect count and triage time into the same formula. Smaller batches also shrink the blast radius, because a rollback reverts one day of work instead of a week.
Daily deploys still have a running cost. Someone has to keep the pipeline fast, remove stale flags and run expand-and-contract migrations. That work is shared by everyone who commits. Extracting a service costs more and keeps costing more: network failure handling, versioned contracts, a separate pipeline, cross-service tracing and split data ownership.
Extraction is justified when a component needs its own runtime. Examples include very different scaling profiles (a CPU-heavy batch job alongside latency-sensitive request handling), hard isolation requirements for security or compliance, or a different language or release cadence.
| Approach | Batch size | Ongoing cost | Fits when |
|---|---|---|---|
| Scheduled release train | Large | Low tooling, high coordination | Deploys are externally gated |
| Modular monolith, frequent deploys | Small | Pipeline, flag and migration discipline | Shared runtime is acceptable |
| Service extraction | Small, per service | Distributed-systems overhead | Scaling, isolation or runtime needs diverge |
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 deploy monolith frequently, 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.