Measure the deployment pipeline as a critical path

If you are a backend or platform engineer who owns CI/CD and watches commits take half a day to reach production, start by defining the number you want to shrink. To reduce deployment time in a meaningful way, measure from merge to "running in production and healthy", not from "build started" to "build finished". Anything narrower hides where the hours actually go.

Record wall-clock start and end timestamps for every step, and split each step's elapsed time into three buckets:

Time bucket What it captures Typical cause Who can fix it
Queue time Job waiting for a runner or agent Undersized runner pool, concurrency limits Platform team
Execution time Job actually running Slow builds, tests, image pushes Service and platform teams
Approval/wait time Pipeline paused on a human or external signal Manual gates, change windows, soak timers Process owners

Execution time is the bucket people usually tune, but queue time and approval time often dominate the total. You can only see that if you record them separately.

Once you have the timings, draw the pipeline as a dependency graph and find the longest path through it. That path is the critical path, and only steps on it determine total duration. Suppose you cut a lint job from six minutes to one while it runs in parallel with a twenty-minute integration suite. The pipeline finishes no sooner. Optimize the critical path first, then recompute it, because shortening one step often promotes a different step to the critical path.

# Split each step into approval/queue/execution time, then find the critical path.
# Times are minutes after merge. Values are illustrative, not measured.
from functools import lru_cache

# name: (depends_on, ready_at, queued_at, started_at, finished_at)
# ready_at  = all dependencies finished
# queued_at = released by any gate and handed to the runner queue
steps = {
    "build":       ((),                                 0,  0,  4, 16),
    "lint":        (("build",),                        16, 16, 17, 23),
    "integration": (("build",),                        16, 16, 22, 42),
    "push_image":  (("build",),                        16, 16, 17, 21),
    "deploy_prod": (("lint", "integration", "push_image"), 42, 72, 75, 82),
}

for name, (_, ready, queued, started, finished) in steps.items():
    print(f"{name:12} approval={queued - ready:3}  queue={started - queued:3}  exec={finished - started:3}")

@lru_cache(maxsize=None)
def longest(name):
    # Longest elapsed path ending at this step: (total minutes, path).
    deps, ready, _, _, finished = steps[name]
    own = finished - ready  # approval + queue + execution for this step
    if not deps:
        return own, (name,)
    total, path = max(longest(d) for d in deps)
    return total + own, path + (name,)

total, path = max(longest(n) for n in steps)
print(f"critical path ({total} min): {' -> '.join(path)}")
# Cutting lint from 6 to 1 minute leaves the total unchanged because lint is off the path.
# The 30-minute approval gate on deploy_prod is the largest single bucket here.

A dependency-free Python script that splits each recorded pipeline step into approval, queue and execution time, then walks the dependency graph to report the critical path and its total duration from merge to production.

Build once, deploy many with artifact reuse

The single most common item on a slow pipeline's critical path is a build that runs more than once. If staging and production each compile, resolve dependencies and package from source, you pay for that work twice. You also deploy something to production that was never the exact thing you tested.

Produce one artifact per commit

Build once, early, and tag the output with the full commit SHA. The output can be a container image, a tarball or a language package. Push it to a registry and treat it as immutable: never overwrite a tag, and never push a second build under the same SHA. Each later stage pulls that artifact and deploys it. "Promotion" then means pointing the next environment at an existing artifact, not producing a new one. The deploy stages shrink to fetch-and-roll-out, and what passed in staging is byte-for-byte what reaches production.

Mutable tags such as latest or staging can exist as convenience pointers. Deployments should still reference the SHA, so a rollback is just redeploying an older, known artifact.

Move configuration out of the build

This only works if the artifact is environment-neutral. Database URLs, feature flags, API endpoints and credentials should arrive at runtime through environment variables, mounted config files or a secrets store read at startup.

The trade-off is real. Some apps bake configuration in at build time, and frontend bundles that inline API URLs are the classic case. Those apps need refactoring before artifact reuse is possible. One option is to load config from an endpoint or a file served alongside the bundle. That refactor is often the actual cost of this step, so scope it before promising faster deploys.

#!/usr/bin/env bash
# Build once per commit, then promote the same image to each environment.
set -euo pipefail

REGISTRY="registry.example.com/myteam/web-api"
SHA="$(git rev-parse HEAD)"          # full commit SHA, not a short hash
IMAGE="${REGISTRY}:${SHA}"

build_and_push() {
  # Refuse to overwrite: a SHA tag must always point at exactly one build.
  if docker manifest inspect "${IMAGE}" >/dev/null 2>&1; then
    echo "${IMAGE} already exists, skipping build"
    return
  fi
  docker build -t "${IMAGE}" .
  docker push "${IMAGE}"
}

deploy() {
  local env="$1"   # staging | production
  # No rebuild here: pull the exact artifact that was tested earlier.
  docker pull "${IMAGE}"
  # Environment-specific config arrives at runtime, so the image stays neutral.
  # Each env file holds values like DATABASE_URL and FEATURE_FLAGS.
  docker run -d --name "web-api-${env}" \
    --env-file "config/${env}.env" \
    "${IMAGE}"
}

case "${1:-}" in
  build)  build_and_push ;;
  deploy) deploy "${2:?usage: $0 deploy <env>}" ;;
  *)      echo "usage: $0 build | deploy <env>"; exit 1 ;;
esac

A minimal script that builds and pushes one image tagged with the full commit SHA, then deploys that same image to staging and production with config injected through per-environment env files instead of rebuilding.

Restructure the pipeline as a dependency graph with parallel steps

Most pipelines run stages one after another because that is how they were first written, not because each stage needs the one before it. Lint does not need unit test results. A dependency vulnerability scan does not need lint to pass. The image build needs the source and the lockfile, and nothing else. Only the deploy step needs all of them to finish. If you write down what each job actually consumes, the real dependency graph is usually much wider than the pipeline that exists today.

Before and after

Durations below are an illustrative estimate with assumed inputs, not measurements:

  • Before (sequential): lint (2m) → unit tests (8m) → scan (4m) → build (6m) → deploy gives a critical path of 20m.
  • After (graph): [lint 2m | unit tests 8m | scan 4m | build 6m] → deploy gives a critical path of 8m.

Once the jobs run in parallel, the slowest branch sets the wall-clock time. That tells you where to spend effort next. In this example the unit tests are now the only branch worth optimizing.

Trade-offs

  • Higher total compute. Each parallel job takes its own runner, so it repeats checkout, setup and cache restore. Wall-clock time drops, but billed runner minutes usually go up.
  • Shared pool saturation. One push that starts four jobs at once can drain a shared runner pool. Other teams then wait in the queue, and that queue time can cancel out what you gained.
  • Harder to read and debug. When a graph fails, it is less obvious what failed and why than in a linear list. Name jobs clearly, make every dependency explicit, and avoid fan-in edges that hide the real reason a job is waiting.
BEFORE: sequential stages, critical path = 20m (illustrative estimate)

+--------+   +------------+   +--------+   +---------+   +--------+
| lint   |-->| unit tests |-->| scan   |-->| build   |-->| deploy |
| 2m     |   | 8m         |   | 4m     |   | 6m      |   |        |
+--------+   +------------+   +--------+   +---------+   +--------+

AFTER: dependency graph, critical path = 8m (illustrative estimate)

 +----------+      +---------------------+
 | checkout |--+-->| lint            2m  |--------+
 | source + |  |   +---------------------+        |
 | lockfile |  |   +=====================+        |
 +----------+  +-->| unit tests      8m  |========+  <== critical path 8m
               |   +=====================+        |
               |   +---------------------+        |    +--------+
               +-->| scan            4m  |--------+--->| deploy |
               |   +---------------------+        |    +--------+
               |   +---------------------+        |
               +-->| build           6m  |--------+
                   +---------------------+

Running lint, unit tests, the dependency scan and the image build as parallel branches that all fan in to deploy shortens the critical path from 20m to the 8m of the slowest branch, using illustrative durations that were not measured.

Caching dependencies and build layers without trusting stale results

Caching is often the cheapest way to reduce deployment time. It also lets a broken build look healthy. The goal is to reuse work only when the inputs really are identical.

Order Dockerfile layers by how often they change

Docker invalidates a layer and every layer after it when that layer's inputs change. Put the slow, stable steps first: the base image, system packages, then copying only the dependency manifest and lockfile and installing from them. Copy application source last. With this order, a one-line code change rebuilds only the final layers instead of reinstalling every dependency.

Key package caches on the lockfile hash

For package manager caches in CI, such as the npm, pip or Maven download directories, build the cache key from a hash of the lockfile plus the OS and runtime version. When the lockfile changes, the key changes and you get a fresh cache. Avoid broad fallback keys that restore an older cache on a miss. A restored cache that doesn't match the lockfile can leave extra or outdated packages in place.

Confirm the cache is hit

Don't assume. Docker's build output marks reused steps as cached. Most CI cache steps log whether they restored an exact key, a fallback, or nothing. If a step you expected to be cached runs in full, check that log first.

Guard against stale or poisoned caches

A cache keyed too loosely can produce an artifact that differs from a clean build. One example is a cached layer that still has a dependency the lockfile no longer pins. Another is a cache written by an untrusted branch. Limit write access to protected branches. Run a scheduled cache-free build and compare its test results and dependency list with those from the cached build.

# syntax=docker/dockerfile:1

# 1. Base image: changes rarely. Pin a specific tag so the base layer
#    doesn't change without anyone noticing.
FROM node:20-bookworm-slim

# 2. System packages: change rarely. If this layer changes, every later
#    layer is rebuilt.
RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# 3. Copy only the manifest and lockfile. This layer and the install
#    below are invalidated only when dependencies change, not when
#    application code changes.
COPY package.json package-lock.json ./

# 4. Install exactly what the lockfile pins. npm ci fails if the
#    lockfile and package.json disagree, and it removes any existing
#    node_modules first. The BuildKit cache mount reuses downloaded
#    tarballs between builds but is not saved in the image.
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

# 5. Application source changes most often, so it goes last. A code-only
#    change rebuilds from here down.
COPY . .

CMD ["node", "server.js"]

# Check that the cache is being hit: build with plain progress output and
# look for CACHED next to steps 1-4 after a code-only change.
#   docker build --progress=plain -t myapp:dev .
# Scheduled clean build, used to compare against cached builds:
#   docker build --no-cache --pull -t myapp:clean .

A Dockerfile ordered from least to most frequently changing layers, with a BuildKit cache mount for npm downloads, and the commands to confirm cache hits and to run a cache-free build for comparison.

Speeding up tests, the deploy step and runner queues

Tests

Split the suite across runners by historical duration, not by file count. One slow integration file can leave a shard running long after the others finish. The longest shard sets the wall-clock time for the whole stage. Most test runners and CI systems can save per-test timings and use them to rebalance shards.

Run only affected tests when your build tool has an accurate dependency graph. If the graph misses a link, such as runtime config, reflection or a shared fixture, a change can skip the tests that would have caught it. Keep a full run on the main branch as a backstop.

Quarantine flaky tests rather than retrying them silently. Each quarantined test needs an owner and a review date, so the quarantine list cannot hide real regressions.

The deploy step

Targets should pull the artifact you already built, not rebuild or reinstall dependencies on the host. Replace fixed sleeps with polling a health endpoint until it reports ready or a timeout expires. A sleep is always either too long or too short.

Larger rollout batches and shorter readiness checks finish faster. They also shrink the window in which a bad release shows errors before it reaches every instance. Raise batch sizes only after your metrics and automated rollback can react within one batch.

Runner queues

Queue time often exceeds run time. Size runner pools to peak demand, which you can find in your queue-wait metrics. Keep a small set of warm runners so jobs don't wait for a machine to boot.

When to replace manual gates, when to avoid these optimizations, and alternatives

Replacing manual gates

A manual approval often costs more wall-clock time than every build step combined, because it waits on a person's attention rather than on compute. You can replace it with automated smoke tests and metric-based promotion when three conditions hold. Your tests exercise the paths that actually break in production. Your rollout watches error rate and latency against a baseline. Rollback is automatic. If any of these is missing, the human is doing real verification work, and removing them removes that check.

Keep the gate when compliance or change-management rules require a named approver, when test coverage is weak in areas reviewers know to watch, or when your dependency graph is inaccurate. An inaccurate graph lets parallel steps deploy services in the wrong order, and a reviewer may be the only thing catching that.

When it isn't worth it

A ten-minute pipeline that runs twice a week has little time left to save, and graph restructuring adds maintenance. The same applies to rarely deployed services. Spend the effort where deploy frequency multiplies the savings.

Alternatives

Option Benefit Trade-off
Accept slower deploys No engineering effort Slower fixes and longer feedback loops
Batch releases Fewer pipeline runs Larger changes, harder to bisect failures
Merge queues Main stays green under heavy merge traffic Adds queue latency per change
Larger runners Fast to adopt, no pipeline redesign Higher cost per minute; doesn't shorten serial waits or queues

Impact on cost and engineering time: an illustrative estimate

Illustrative estimate (assumed numbers, not a benchmark). Assume a serial pipeline: build 10 min, lint 4, unit tests 12, integration tests 15, a second build for production 10, deploy 6. Every job in the optimized version pays an assumed 3 min of setup (checkout, artifact download), and integration tests are split into three shards.

Stage Serial: wall / compute (min) Parallel + artifact reuse: wall / compute (min)
Build 10 / 10 10 / 10
Lint, unit, integration 31 / 31 15 / 46 (7 + 15 + 3×8)
Production rebuild 10 / 10 0 / 0
Deploy 6 / 6 6 / 6
Total 57 / 57 31 / 62

Wall-clock time drops by 26 minutes, while compute rises by 5 minutes. The critical path is now build, then the slowest parallel branch (unit tests), then deploy. Setup overhead repeated across parallel jobs is why the total goes up even though the rebuild is gone.

To decide whether the trade pays off, compare recovered engineer time with added cost:

  • Engineer hours saved per month = deploys × minutes saved × recovery factor ÷ 60
  • Added runner minutes per month = deploys × extra compute minutes

The recovery factor is the share of saved waiting time that turns into useful work. With 200 deploys and a factor of 0.5, that is 200 × 26 × 0.5 ÷ 60 ≈ 43 hours. Over the same deploys, compute rises by 200 × 5 = 1,000 runner-minutes. Reruns improve too, because a flaky shard retries in 8 minutes instead of rerunning the whole 57-minute pipeline.

Subtract the ongoing costs before calling it a win. A dependency graph has more jobs to maintain, the artifact contract has to stay versioned, and new engineers need to learn why a step skipped or reused an artifact.

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 reduce deployment time, 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.