This is what happened.

I did not run out of GitHub Actions minutes once in a while. For several months in a row, I burned through the entire 2,000-minute allowance.

Eventually, I turned my own Mac into a self-hosted runner.

The problem did not go away.

Change Backend  → BE, FE, and App all run
Change Frontend → BE, FE, and App all run
Change docs     → everything still runs

Moving CI to my Mac only traded GitHub minutes for a different bill:

  • The Mac had to stay on.
  • Backend and Frontend competed for CPU.
  • VMs occasionally got stuck.
  • Caches and disks kept growing.
  • When a runner died, GitHub jobs sat in the queue.
  • Builds that served no purpose still ran.

The real issue was not the size of GitHub's allowance. The pipeline had no idea what had changed.

I measured before adding runners

My first ideas were predictable: add cache, CPU, or another VM, or simply run fewer tests.

Without knowing where the time went, all of those were guesses.

I split the pipeline into measurable parts:

Runner queue
Checkout
Dependency install
Static checks
Security checks
Tests
Build
Provider deployment
Production readback

That immediately showed me that slow CI and slow deployment were separate problems.

One full SpeakUp publisher run took 13 minutes 34 seconds. Railway API and worker publication accounted for about 79% of the measured time. No amount of Cloudflare tuning would fix the critical path.

Backend and Frontend can run in parallel

The original shape was close to this:

Backend → Frontend → Deploy

I changed it to this:

Backend checks  ─┐
                 ├→ Aggregate Gate → Deploy
Frontend checks ─┘

Backend and Frontend use separate runners, install dependencies, and execute tests at the same time.

In one recent PR exact-head run:

  • Backend: 5 minutes 21 seconds
  • Frontend: 4 minutes 16 seconds
  • GitGuardian: 15 seconds

Sequentially, Backend plus Frontend would have taken close to ten minutes. In parallel, the wait was set by the slower Backend lane: a little over five minutes.

I did not remove a required check. Deployment still requires:

backend-complete = success
frontend-complete = success
GitGuardian = success

If any lane fails, publication stops.

I split Backend again

Backend work did not need to be one long sequence either.

I kept one dependency installation, one owner for the full regression suite, and one owner for the PostgreSQL tests. Independent work then ran in parallel:

Dependencies install
   ├─ Source / static checks
   ├─ Readiness / security checks
   └─ Migration / full regression

All three lanes still converge on one backend-complete result. A failure in any lane fails the Backend gate.

In one main CI sample, Backend went from 6 minutes 14 seconds to 5 minutes 05 seconds, a reduction of 69 seconds. That is one real sample, not proof of a stable 18% improvement. I need repeated runs with the same workload before talking about P50 or P95.

Run the expensive checks only when their scope changed

After parallelizing the jobs, I went back to the largest waste. Why did a documentation-only change run more than 3,000 Backend tests and over 700 Frontend tests?

I added a changed-scope classifier:

Backend change    → Backend
Frontend change   → Frontend
Documentation     → skip expensive product tests
CI / Release      → Backend + Frontend
Shared contract   → Backend + Frontend
Unknown           → Backend + Frontend

Pull requests use a three-dot comparison:

merge-base...PR-head

Main pushes use the two commits that actually entered main:

before..head

If a SHA is invalid, Git history is incomplete, or classification fails, the pipeline does not treat that as “nothing changed.” It runs everything.

if diff_is_unknown:
    run_backend = True
    run_frontend = True

I would rather spend a few extra minutes than silently skip a required test because the classifier broke.

Tests do not need to literally sleep

Another easy-to-miss cost was tests waiting for real time to pass.

A keepalive test had to cross a five-second deadline, so it really waited for more than ten seconds. I needed to verify timeout behavior, not prove that a computer could wait five seconds.

I changed the test to advance its monotonic clock while keeping the real socket, TLS, authentication isolation, and connection reuse.

One run changed from:

12.44 seconds → 1.73 seconds

The full Linux test run went from 196.36 seconds to 174.72 seconds. Production timeouts did not change. Only the test stopped sleeping.

I used the same approach for retry tests. The test environment skips the backoff delay, while a separate production-policy test still confirms the one-second and two-second exponential backoff values.

Four retry tests changed from:

15.74 seconds → 1.92 seconds

A self-hosted runner is not free

The Mac runner fixed the GitHub-minute problem, but the cost moved to electricity, disk space, VM maintenance, runner recovery, cache cleanup, and my own time.

I now use the Mac as a controller and hand each CI lane to a separate Linux VM:

Mac
├─ Backend Linux runner
└─ Frontend Linux runner

I divide cached data into three groups:

Reproducible      → clean on a schedule
Short-term useful → set a retention window
Must not be lost  → do not touch

Dependency and Docker build caches can be rebuilt. VM images, production receipts, database volumes, and source code should not be deleted just because a tool calls them reclaimable.

What I kept from this work

I did not make CI/CD faster by endlessly adding cache or deleting tests.

My order was:

  1. Measure the time.
  2. Find the critical path.
  3. Run Backend and Frontend in parallel.
  4. Split independent Backend lanes.
  5. Remove fixed waits from tests.
  6. Run only the scopes that changed.
  7. Fail safe and run everything when classification is unknown.
  8. Tune runners and caches last.

The goal is not to run fewer useful tests. It is to stop paying in time, hardware, and maintenance for work that has nothing to verify.

Next in the series: Railway Said SUCCESS. Why Did I Still Stop the Release?

I also turned the evidence rules behind these articles into a reusable public resource: Evidence-first CI/CD writing skill