TL:DR
A full-stack GitLab CI/CD pipeline running 8 minutes was cut to 5 with five configuration-only changes: enabling Gradle parallelism and build caching, fixing a cold-start loop in the OWASP security scan with cache: when: always, switching downstream jobs to pull-only cache policy, replacing branch-based cache keys with source file hashes, and restructuring the pipeline from sequential stages into a DAG using needs: declarations. No application code was touched.
Introduction
Eight minutes. That’s how long every push to my GitLab project took before anything reached production. Not catastrophically broken, but slow enough to kill my flow — too long to wait at the screen, too short to context-switch to something else. So I decided to do something about it.
I built a full-stack Reddit clone with Spring Boot (Kotlin) on the backend and React on the frontend, hosted on GitLab with a CI/CD pipeline that handles linting, building, security scanning, testing, and deployment. Over the course of a few weeks, I applied five targeted changes to .gitlab-ci.yml — without touching a single line of application code — and brought the pipeline from 8 minutes down to 5. Here’s how I did it.
All pipelines ran on GitLab.com’s shared SaaS runners (saas-linux-small-amd64). A fresh VM runs per job, no local disk is shared between them. Cache travels exclusively through GitLab’s object storage: uploaded at the end of one job, downloaded at the start of the next. If you’re on self-hosted runners where jobs share local disk, some of these optimizations matter less — but if you’re on GitLab.com or any autoscaled setup, this is exactly your environment.
Feel free to check the full source code.
The 8-Minute Pipeline
Before any optimization, my pipeline ran 8 jobs across 6 stages in strict sequential order: lint, build, security-scan, test, deploy. Every job used the same branch-name-based cache key ($CI_COMMIT_REF_SLUG), which meant every single commit — even a README change — invalidated and rebuilt the entire cache. Every job downloaded the cache at the start and re-uploaded it at the end, even when nothing changed. And every stage had to fully complete before the next one could begin.
The pipeline duration changed as I applied each optimization:
#108 was a perfect run — warm caches, fast runners. For pipeline #111 I touched a frontend file on purpose to test cache invalidation. Frontend hash changed, backend didn’t.
Optimization 1: Enable Gradle parallelism and build caching
The first change took one line. My initial config disabled the Gradle daemon, a pattern in CI guides that avoids daemon lifecycle issues but also throws away Gradle’s built-in caching and parallelism.
Before:
variables:
GRADLE_OPTS: "-Dorg.gradle.daemon=false"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
After:
variables:
GRADLE_OPTS: "-Dorg.gradle.parallel=true -Dorg.gradle.caching=true"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
This enables Gradle’s task-level parallelism and its build cache, which skips tasks whose inputs have not changed. I also upgraded from Gradle 8.7 to 9.0.0 to take advantage of the latest caching improvements.
Optimization 2: Taming the Security Scan
This one deserves the most attention because it was the most painful to debug. My pipeline runs OWASP dependency-check, which downloads the entire NVD (National Vulnerability Database) on its first run. Without an API key, the NVD throttles requests to 5 per 30 seconds. A cold download takes 20–30 minutes.
Here’s the trap: GitLab’s default cache behavior is when: on_success — it only saves the cache if the job succeeds. But the security scan kept timing out before the download could finish. So the cache was never saved, and every run started from scratch. A classic cold-start death spiral.
I applied four fixes:
- Passed the NVD_API_KEY as a Gradle system property — the environment variable alone was not reaching the plugin due to Gradle’s configuration-cache evaluation.
- Set cache: when: always to persist the NVD data directory even when the job fails or times out. This single keyword breaks the cold-start loop.
- Gave the security scan its own cache key (gradle-owasp-…) so its lifecycle stays independent from the main Gradle build cache.
- Upgraded the OWASP plugin from 10.0.3 to 12.2.0 to handle new CVSS v4 enum values that were causing parse crashes.
The final security scan job:
backend-security-scan:
stage: security-scan
image: gradle:9.0.0-jdk17
needs:
- job: compute-cache-keys
artifacts: true
- job: backend-lint
variables:
GRADLE_OPTS: "-Dorg.gradle.parallel=false -Dorg.gradle.caching=true"
script:
- ./gradlew dependencyCheckAnalyze --info \
-Dorg.owasp.dependencycheck.data.directory=...
-Dorg.owasp.dependencycheck.nvd.apikey=$NVD_API_KEY
allow_failure: true
cache:
key: "gradle-owasp-${BACKEND_CACHE_HASH}"
when: always
paths:
- .gradle/
The result: security scan went from 25+ minutes (cold) down to 2 minutes with a warm cache.
Optimization 3: Stop Uploading What You Already Have
By default, every GitLab CI job both downloads and re-uploads its cache archive. When five downstream jobs all push identical cache archives, that is five redundant uploads adding ~30 seconds each in my runner setup.
The fix is simple: let the lint jobs (the first to run in the pipeline) push the cache, and set every downstream job to policy: pull — consume the cache, but never re-upload it:
backend-build:
stage: build
cache:
key: "gradle-${BACKEND_CACHE_HASH}"
fallback_keys:
- "gradle-${CI_COMMIT_REF_SLUG}"
policy: pull # <-- the only addition
paths:
- .gradle/
Pipeline dropped from 8m 16s to 6m 34s with this change alone.
Optimization 4: Hash the Source, Not the Branch
My original cache key used $CI_COMMIT_REF_SLUG (the branch name), which means every commit invalidates the cache — even if I only changed the CI config or updated a README. The cache should only invalidate when actual source files change.
I added a .pre stage job called compute-cache-keys that runs in 12 seconds on a lightweight Alpine image. For the backend cache, it means that any time Kotlin, .kts, .yml or .sql suffixed files changed, the cache was rebuilt. For frontend code, .tsx, .js, .ts and .json. It hashes all source files into SHA-256 digests and exports them as dotenv artifacts:
compute-cache-keys:
stage: .pre
image: alpine:3.19
script:
- BACKEND_HASH=$(find . \( -name '*.kt' -o -name '*.kts' ... \)
| sort | xargs sha256sum | sha256sum | cut -d' ' -f1)
- FRONTEND_HASH=$(find frontend \( -name '*.ts' ... \)
| sort | xargs sha256sum | sha256sum | cut -d' ' -f1)
- echo "BACKEND_CACHE_HASH=${BACKEND_HASH}" > cache_keys.env
- echo "FRONTEND_CACHE_HASH=${FRONTEND_HASH}" >> cache_keys.env
artifacts:
reports:
dotenv: cache_keys.env
Every downstream job then uses the key: “gradle-${BACKEND_CACHE_HASH}” as its cache key, with fallback_keys pointing to the old branch-slug key for a smooth migration. Now the cache only invalidates when source files actually change. CI-only commits reuse the existing cache entirely.
Optimization 5: Break the Stage Barrier
GitLab’s default execution model requires every job in a stage to finish before any job in the next stage starts. That means frontend-build waits for backend-lint even though they have zero dependency on each other.
I replaced this implicit ordering with explicit needs: declarations. Each job now starts as soon as its specific dependencies complete:
backend-build:
stage: build
needs:
- job: compute-cache-keys
artifacts: true
- job: backend-lint
Backend-build starts the moment compute-cache-keys and backend-lint finish — even if frontend-lint is still running. This turns the pipeline from a sequential chain into a DAG (Directed Acyclic Graph) where the backend and frontend branches run truly in parallel.
The Numbers
- The biggest single win was backend-test — down from 2m 39s to 1m 06s, largely due to better caching and DAG scheduling.
- Backend-build shed 34 seconds, the security scan dropped 15 seconds.
- Frontend jobs barely moved, which makes sense; they were never the bottleneck.
- The new compute-cache-keys job adds 12 seconds upfront but saves multiples of that downstream.
Total wall time: 8m 16s to 4m 04s.
Key Takeaways & Conclusion
None of this required touching application code. The pipeline was slow because of how it was configured — bad cache keys, redundant uploads, artificial stage barriers. Once I could see where the time actually went, the fixes were obvious. Most of them were one-line changes. The OWASP issue was the only one that genuinely took time to debug, mostly because the failure mode was silent — the job timed out, the cache never saved, and nothing in the logs told me why. when: always was the fix. Took me longer to find it than to write it. One thing to mention, not every change stuck. I also tried enabling Gradle’s configuration cache — a feature that skips re-evaluating build scripts by caching the configuration phase. On paper, I thought it was a free win. But in practice, Gradle 9 and the Kotlin plugin do not play well with it, and jobs started failing with serialization errors. I decided to pull the flag and moved on.
