Every time a developer pushes code, you need to check: does it compile, do the tests pass, are there any obvious vulnerabilities? Doing this by hand is slow and unreliable. CI (Continuous Integration) automates that check.
This article is about what a typical CI pipeline looks like for a Java/Spring Boot project and why it is built the way it is.
Why you need CI at all
Developers used to work independently for weeks, then try to merge their code. This was called "integration hell": changes conflicted, tests failed, and figuring out the cause was hard.
Continuous Integration solves this like so: every commit gets an automatic check. A mistake is visible right away, while the context is still fresh. The more often you integrate, the fewer conflicts you get.
What steps the pipeline consists of
A typical CI setup for a Spring service looks like this:
1. compile + unit tests ~2–4 min first PR check
2. static analysis ~2–3 min in parallel with step 1
3. integration tests ~5–10 min Testcontainers: PG, WireMock
4. image build and publish ~2 min after steps 1–3 go green
5. deploy to staging automatic from the main branch
The order is not accidental: the fastest and cheapest checks come first. If the code doesn't compile, there's no point running ten-minute integration tests.
Why a Gradle build can be slow and how to fix it
A cold build of a Spring Boot project can take several minutes just to download dependencies. If CI does this on every commit, the pipeline burns time for nothing.
The fix is caching. CI saves the ~/.gradle/caches folder between runs and restores it on the next one. Dependencies are downloaded once and then taken from the cache.
A Docker image can cache too: if you move dependencies into a separate layer, that layer is reused on the next build. Dependencies change rarely, so the layer is almost always cached.
Without these optimizations a pipeline easily hits 20+ minutes. With caches, it's 8–12.
How to run tests correctly in CI
Tests are split into levels, and in CI they run in this order:
Unit tests — the fastest. They test a single class without the Spring context, without a database, without the network. They run in seconds. They go first on every PR.
Integration tests — they bring up the Spring context and real dependencies via Testcontainers. For example, PostgreSQL, or WireMock to mock external HTTP services.
@SpringBootTest
@Testcontainers
class OrderServiceIT {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void shouldSaveOrder() { ... }
}
Testcontainers starts a real PostgreSQL in Docker straight from the test. The CI runner must have access to the Docker daemon — most GitHub Actions/GitLab CI runners support this.
To avoid bringing up a container for each test class separately, the container is declared static — it starts once for the whole run.
Two rules that keep CI alive
Flaky tests must be fixed, not re-run. If a test sometimes passes and sometimes doesn't, that's not randomness — it's a bug: a race in the code or incorrect handling of asynchrony. An automatic re-run hides the problem and destroys trust in CI.
Tests don't go to the internet. An integration test that reaches a real staging server or a public API will fail because of someone else's problems — a neighboring service went down, or the runner lost its internet connection. Everything external must be mocked through WireMock or brought up in a container.
Quality gates: what blocks a merge
A quality gate is a check that won't let code merge if something is wrong. The point is that a gate either blocks or doesn't exist. A report that no one reads isn't a gate — it's noise.
| Check | Tool | Blocks |
|---|---|---|
| Compilation with warning analysis | Error Prone | Yes |
| Static analysis for bugs | SpotBugs + FindSecBugs | Yes, by severity level |
| Vulnerable dependencies | OWASP Dependency-Check | Yes, by CVSS threshold |
| Secrets in code | Gitleaks | Yes, always |
| Vulnerabilities in the Docker image | Trivy | Yes, by severity level |
Error Prone — a compiler extension from Google that catches error patterns right during javac. For example, swapped parameters or incorrect use of collections.
SpotBugs analyzes bytecode and finds potential bugs: NullPointerException, concurrency problems, unsafe use of an API.
Gitleaks scans the commit history for secrets that slipped in by accident: tokens, passwords, API keys. It blocks unconditionally — a secret that made it into the repository must be considered compromised.
OWASP Dependency-Check checks dependencies against the CVE database. If the project uses a library with a known vulnerability above the set CVSS threshold, the build fails.
What you get as output
The result of a successful CI run is a Docker image. The image tag is the commit hash, so you can always tell exactly what is deployed.
Spring Boot can add build metadata directly into the image:
// build.gradle.kts
springBoot {
buildInfo()
}
After that, /actuator/info returns the version, commit hash, and build time. Handy for debugging.
The image is published to a registry (Docker Hub, GitHub Container Registry, ECR). From there the CD zone begins — what to do with the image across different environments.
How to stay within the time budget
A good pipeline fits in 15 minutes. If it spills over, you break it down step by step:
- Caches — the first thing to check. If there are no caches, everything else is pointless.
- Parallelism — steps that don't depend on each other (compilation and static analysis) run at the same time.
- Parallel tests — Gradle can run test classes in parallel via
maxParallelForks. - Test review —
Thread.sleep(10_000)in a test is 10 seconds on every run, forever. Wait with Awaitility, not withsleep. - Beefier runners — only if everything above is exhausted and you've hit a CPU/memory wall.
Common mistakes
Tests are re-run automatically on failure — instability reaches production, and developers stop trusting CI.
Tests reach real services — they fail for someone else's reasons, and trust in CI falls with them.
A SAST report with no blocking — vulnerabilities pile up because "there's no time to deal with it." A gate with a severity threshold solves this structurally.
No caches — the pipeline is slow from the very start, and people just put up with it.
A code formatter as a gate — every PR turns into an argument about braces. Style is better aligned by an auto-formatter on file save, not by blocking the merge.
In short
- CI automatically checks every commit: compilation, tests, security analysis.
- Step order goes from fast to slow: unit → static analysis → integration tests → image build.
- The Gradle cache and Docker layer cache cut build time by several times.
- Testcontainers brings up a real database and other dependencies right in the test — no schema-level mocks.
- Quality gates block a merge: Error Prone, SpotBugs, OWASP, Gitleaks, Trivy.
- Flaky tests must be fixed, not re-run automatically.
- Tests don't go to the internet — everything external is mocked with WireMock or brought up in a container.
- The output of CI is a Docker image tagged with the commit.
What to read next
- Pipeline principles — general rules that work for any stack.
- Release strategies — what happens to the image after CI: rolling, blue-green, canary.