CI/CD for Spring Boot with GitHub Actions
Master CI/CD pipelines for Spring Boot with GitHub Actions: multi-stage builds, Docker, AWS ECS/Kubernetes deploy, SonarQube, secrets management..
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Define a multi-stage workflow: build → test → docker build → push → deploy
- Cache Maven/Gradle dependencies with actions/cache keyed on lockfile hash
- Store secrets in GitHub Secrets and inject via env: in workflow steps
- Use matrix builds to test against multiple JDK versions simultaneously
- Integrate SonarQube with sonar-maven-plugin and SONAR_TOKEN secret
Think of GitHub Actions as a robotic assembly line in a factory. Every time a developer pushes code, the robots automatically compile the product, run quality checks, package it into a shipping container (Docker image), and deliver it to the warehouse (production servers) — all without human intervention. If any station fails, the line stops and alerts the team before a defective product ships.
In 2022, a major fintech team I consulted for was deploying Spring Boot services manually via SSH. A developer fat-fingered a JAR filename at 11 PM on a Friday and took down payments processing for 40 minutes. The incident cost $200K in chargebacks and led to a three-week post-mortem. The fix was a proper CI/CD pipeline — something that should have existed from day one.
GitHub Actions has become the default CI/CD platform for Spring Boot projects because it lives where your code lives, requires zero infrastructure to bootstrap, and has a rich marketplace of pre-built actions. But most tutorials show only the happy path: compile, test, done. Production pipelines are far more nuanced.
A production-grade GitHub Actions pipeline for Spring Boot needs to handle dependency caching aggressively — cold Maven builds pull 500MB+ of artifacts. It needs matrix builds to catch JDK version drift. It needs Docker layer caching so a 3-minute image build doesn't become your pipeline bottleneck. It needs gated deployments so that staging gets every commit but production requires a manual approval.
SonarQube integration is non-negotiable for enterprise teams. Static analysis catches security vulnerabilities (SQL injection, XXE, SSRF) that unit tests will never find. Wiring sonar:analyze into your pipeline with quality gates that break the build on new critical findings is the difference between a security-conscious team and a breach waiting to happen.
This guide walks through a battle-tested GitHub Actions workflow for Spring Boot: from the first push that compiles your code all the way to a zero-downtime rolling deployment on AWS ECS or Kubernetes, with every production gotcha documented.
Multi-Stage Workflow Architecture
A production GitHub Actions workflow for Spring Boot should be structured as a directed acyclic graph of jobs, not a single monolithic job. Each job runs on its own fresh runner, which means you need to explicitly pass artifacts between jobs using actions/upload-artifact and actions/download-artifact. This isolation is a feature, not a bug — it ensures your test environment doesn't leak state into your build environment.
The canonical job order is: build-and-test → sonarqube (runs in parallel with test if you have a separate test report upload) → docker-build-push → deploy-staging → integration-test-staging → deploy-production. The deploy-production job should require a manual approval using GitHub's environment protection rules with required reviewers.
One critical mistake teams make is running all steps in a single job for simplicity. This means a Docker build failure wastes 5 minutes of test time on a re-run. Split jobs properly and use needs: to express dependencies. Use if: github.ref == 'refs/heads/main' to restrict deployment jobs to the main branch, preventing feature branch pushes from triggering deploys.
For monorepos containing multiple Spring Boot services, use path filters with dorny/paths-filter to only build and deploy services that have changed. Running a full pipeline for every service on every commit is a waste of runner minutes and slows down developer feedback loops significantly.
env: block makes them available to all jobs including third-party actions. Always scope secrets to the specific step that needs them using the step-level env: block. This limits blast radius if a malicious action exfiltrates environment variables.Maven and Gradle Dependency Caching
Dependency caching is the single highest-ROI optimization in Spring Boot pipelines. A cold Maven build for a medium Spring Boot application (50+ dependencies) downloads 300-600MB of artifacts. On a GitHub-hosted runner with ~100 Mbps bandwidth, that's 30-60 seconds of pure network I/O per run. Multiply that by 50 builds per day across a team and you're burning 25-50 minutes of developer wait time daily on artifact downloads alone.
The correct cache key strategy is a two-level key: a primary key that is an exact hash of all POM files, and a restore-keys fallback that matches any cache from the same OS. When dependencies don't change (most commits), you get 100% cache hits and spend ~2 seconds on cache restore instead of 60 seconds downloading. When you add a dependency, the primary key misses, the fallback key retrieves the old cache, Maven downloads only the new artifacts, and the cache saves the updated state for future runs.
For Gradle, use actions/setup-java with cache: gradle which handles the Gradle wrapper cache, build cache, and dependency cache automatically. For Maven, use cache: maven in setup-java or manage it manually with actions/cache if you need fine-grained control.
Docker layer caching is equally important. Use GitHub Actions Cache backend (cache-from: type=gha, cache-to: type=gha,mode=max) with docker/build-push-action. Combine this with a properly layered Dockerfile (dependencies layer first, application layer last) to achieve near-instant Docker builds when only application code changes.
Matrix Builds and SonarQube Integration
Matrix builds allow you to test your Spring Boot application against multiple JDK versions, operating systems, or database backends in parallel. This is essential for library authors and teams that need to support multiple JDK LTS versions (17, 21) or validate that their service works with both PostgreSQL 14 and 15.
The matrix strategy generates a Cartesian product of all specified dimensions. A matrix of jdk: [17, 21] and database: [postgres:14, postgres:15] produces 4 parallel jobs. Each job gets the matrix variables via ${{ matrix.jdk }} and ${{ matrix.database }}. Use fail-fast: false in production matrix configs so a failure in one combination doesn't cancel all other combinations — you want to see the full failure surface.
SonarQube integration requires careful setup. The most common mistake is not passing fetch-depth: 0 to actions/checkout, which truncates Git history and breaks SonarQube's blame data, leading to incorrect 'new code' calculations. SonarQube uses Git blame to determine which code is 'new' since the last analysis and applies different quality gate thresholds to new vs existing code.
For pull request analysis, SonarQube needs to know the base branch and PR number to decorate the PR with inline comments. Pass sonar.pullrequest.key, sonar.pullrequest.branch, and sonar.pullrequest.base from the GitHub Actions context. The GITHUB_TOKEN secret (automatically provided) needs to be passed as sonar.pullrequest.github.token for PR decoration to work.
actions/checkout does a shallow clone with only the latest commit. SonarQube requires full Git history to calculate blame information for the 'new code' period. Without fetch-depth: 0, SonarQube treats all code as new and your quality gate thresholds won't work correctly.fail-fast: false in matrix builds to see the complete failure surface, and always pass fetch-depth: 0 for SonarQube.