The Problem
I was working at a company with a small engineering team, eight of us, nine including our engineering lead. Six months earlier, the company had cut ties with the contracting firm that originally built and maintained the entire platform. We were still discovering “bombs” the contractors had left behind: tangled library dependencies, badly constructed applications, and infrastructure decisions that no one on the internal team had been consulted on.
The platform was a Micronaut 1 monolith comprising more than 30 Gradle projects, split roughly evenly between 17 deployable applications and the libraries they shared. The CI pipeline was a single GitLab build job running a shell loop: one Dockerfile, 17 docker build invocations one after another, each pushing its image to the container registry before the next one started. Change a single line in one application and the loop rebuilt all 17 images anyway. On the branch where I was doing the upgrade work, a full build and deploy of all 17 services ran a median of 45.8 minutes across eight runs, and that one serial build job accounted for roughly 36 of them. There was no parallelism, no change detection, and no way to build or test images locally.
The Approach
I had not set out to fix the pipeline. I had been tasked with upgrading the monolith from Micronaut 1 to Micronaut 4, a major migration that also meant moving from Hibernate 5 to 6, Java 11 to 17, and Gradle 7 to 8. The slow CI was painfully impeding my dev cycle on that upgrade work, so I started solving the build problem as a prerequisite.
First, I replaced the Dockerfile-based builds with the Gradle JIB plugin. JIB builds container images directly from Gradle without needing a Docker daemon, which meant no Docker-in-Docker service in CI and, just as importantly, that I could build and push images locally during development instead of waiting for a pipeline. This alone transformed my feedback loop on the Micronaut 4 migration.
Second, I rewrote the Gradle build structure to be modern and modular, untangling the library dependencies that the contractors had left intertwined. This was necessary groundwork; without clean dependency boundaries, change detection would have been impossible.
Third, I created child gitlab-ci files for each application, so the one monolithic build job became 17 per-app JIB builds that run in parallel. Each app declares its own paths plus the paths of every shared library it depends on, so a change inside a shared library fans out to exactly the applications that consume it and no further. A FULL_REBUILD marker in a commit message overrides the rules and rebuilds everything, which is the escape hatch for the cases where a diff does not tell the whole story, and each build job caches its Gradle state between runs.
The work had a long proving ground before it had a merge. JIB ran on my upgrade branch from late September 2024, so I was living on the new pipeline daily for months. An attempt to land it on the main branch that October was reverted the same day. It reached the main branch the following June, and the boundary in the pipeline history is a clean one: every run that contains the change is fast, every run before it is slow, on the same day.
The tipping point for that merge came when a member of engineering leadership hit the same wall the rest of us had been hitting, while trying to get logging and metrics instrumentation deployed. He started voicing what the whole team had been quietly enduring. I took the opportunity to migrate the JIB and CI changes I had already built and proven on my upgrade branch into the main branch.
Results
Full 17 Service Build and Deploy, Median Minutes
The unit of work here is a full build and deploy of all 17 services, measured on the branch where the redesign was proven. On that branch the median went from 45.8 minutes across 8 pre-change runs to 10.8 minutes across 89 post-change runs, a 4.2x reduction. The fastest post-change run was 7.2 minutes; a quarter came in under 8.9 and a quarter took longer than 13.2. The mechanism is visible in the job breakdown: one serial build job of 36.6 minutes became 17 parallel JIB builds of roughly 4.7 to 5.6 minutes each.
Not all of that delta is the build redesign, and I would rather say so than let the number carry weight it has not earned. About 8.7 minutes of the roughly 39 minute improvement came from elsewhere: database migrations went from 8.2 minutes to 1.0 via a separate change to the migrator, and a 1.5 minute dependency-scanning job was dropped from the pipeline along the way. The build work is responsible for the other 30 or so.
The obvious objection to any change-detection number is that the pipeline simply got better at skipping work, and the records answer it. Full-rebuild pipelines build all 17 services unconditionally, no skipping, and they finish around 12 minutes against roughly 27 before. One of them does 69 minutes of cumulative build work in 11.3 minutes of wall clock, which is what parallelism looks like when you measure it honestly: the work did not disappear, it stopped being serial.
On the main branch, where the change landed nine months later, the median fell about 65 percent, from roughly 38 minutes to roughly 11. That number spans two changes rather than one: moving the pipeline onto self-hosted runners in October 2024, worth about 12 minutes on its own, and then this work. I mention it because it is the number a colleague would have seen day to day, but the 45.8 to 10.8 comparison is the one where the unit of work is actually held constant.
What I will not claim is “under 8 minutes.” I used to, and the figure came from one real run. It sits in the fastest 3 percent of the post-change distribution, paired against a pre-change run that was the second slowest of its eight. Both endpoints are genuine pipelines; neither is typical, and putting them next to each other overstates the result by a wide margin. There is a second trap pulling the same direction: after the change, build jobs on feature branches default to manual, so most feature-branch pipelines build nothing at all and finish in well under a minute. That is a real improvement in day-to-day experience and a completely meaningless build time, and quoting it would be worse than quoting the outlier.
Every figure above comes from the project’s pipeline records, pulled read-only through the GitLab API across a sample of 457 individually fetched pipelines and cross-checked against the commit history to confirm which runs contained the change. They are not recollections, and the correction on this page is what happened when I went and looked instead of trusting the version I had been repeating. What the records cannot tell me is how much engineering time the change actually gave back, because I never instrumented that: the pipeline durations are measured, the human benefit is inferred.
I identified, pitched, and executed this entirely on my own over about three weeks of build effort, while simultaneously making progress on the Micronaut 4 upgrade that had motivated the work in the first place. The optimized pipeline removed what had been the single biggest source of friction for the entire eight-person engineering team, a team that was already stretched thin recovering from the contractor transition.
A speed-up is worth what its distribution says, not what its best run says.