How we cut deploy times by 70% with GitHub Actions
A walkthrough of the specific optimisations that took one client's deploy pipeline from 22 minutes to under 7 — without sacrificing reliability.
Long deploy pipelines are a tax on every engineer on the team. When a deploy takes 22 minutes, you stop deploying often. When you stop deploying often, your batches get larger. Larger batches mean harder rollbacks and more painful incidents. The problem compounds.
Last year we inherited a GitHub Actions pipeline for a fintech client that was averaging 22 minutes per deploy. By the time we finished the optimisation pass, it was running in 6 minutes 40 seconds — a 70% reduction. Here's exactly what we changed.
1. Parallelise the test suite
The original pipeline ran tests in a single job, sequentially. The test suite had 1,400 tests covering unit, integration, and e2e layers. Splitting these into three parallel jobs — each running in its own matrix runner — immediately cut 8 minutes from the wall-clock time. The cost increase was negligible: GitHub Actions bills per minute per runner, and three 4-minute jobs cost the same as one 12-minute job.
2. Layer your Docker cache correctly
Docker layer caching was configured, but the Dockerfile had the application code copy before the dependency install step. This meant any code change invalidated the dependency cache — which was the expensive step. Reordering the Dockerfile so that `package.json` and `package-lock.json` were copied and dependencies installed before the application source saved 4–5 minutes on every non-dependency-changing commit.
- COPY package*.json ./ (then npm ci) — before COPY . .
- Use --mount=type=cache in BuildKit for the npm cache directory
- Pin base image digests to avoid unexpected cache misses
3. Cache node_modules across workflow runs
Using `actions/cache` with a key based on the hash of `package-lock.json` meant that on the ~80% of commits that don't touch dependencies, `npm ci` was replaced by a cache restore. This saved another 90 seconds.
4. Skip unnecessary steps on non-production branches
The pipeline ran the full security scan, SBOM generation, and Docker image signing on every PR. We moved these to `if: github.ref == 'refs/heads/main'` conditions. PR pipelines now skip these steps entirely — they still run on every main branch push before production deploy.
The fastest test is the one you don't run. Every step in your pipeline should earn its place on every branch it runs on.
The result
22 minutes → 6m 40s. The team went from deploying 3–4 times per day to deploying 15–20 times. Incident frequency dropped because smaller, more frequent deploys are easier to understand and roll back. And engineers reported feeling less friction deploying — which meant more small fixes shipped instead of being batched.
If your pipeline takes longer than 10 minutes, the ROI on optimising it is almost always positive. Start by measuring where the time actually goes — you'll usually find one or two steps consuming 60% of the runtime.