CI/CD Pipelines for Backend Systems

A CI/CD pipeline is the delivery system for your software. Its quality directly determines how fast your team can ship, how confidently they can deploy, and how quickly they can recover when something goes wrong. A poorly designed pipeline — one that is slow, flaky, or missing quality gates — destroys team velocity just as effectively as bad code.

This article covers the design of production-grade CI/CD pipelines for backend Java systems: pipeline stages, quality gates, Docker image build, artifact management, GitOps, and deployment strategies.

The Pipeline as a Quality Funnel

Think of a CI/CD pipeline as a funnel that filters out defects as early and cheaply as possible. The earlier a problem is caught, the lower the cost to fix it. A commit that fails a unit test costs minutes; the same defect found in production costs hours of incident response and potential customer impact.

Commit
→
Build & Unit Tests
→
Static Analysis
→
Integration Tests
→
Image Build
→
Deploy Staging
→
Deploy Prod
Each stage is a quality gate — failure stops the pipeline before the next stage runs

Stage 1: Build and Unit Tests

The first stage must be fast — ideally under 3 minutes. This is the feedback loop developers interact with on every commit. Slow feedback leads to context switching, batched commits, and harder debugging.

# Jenkinsfile — Stage 1: Build and unit tests stage('Build & Unit Tests') { agent { docker { image 'maven:3.9-eclipse-temurin-21' } } steps { sh 'mvn clean verify -Punit-tests --no-transfer-progress' junit 'target/surefire-reports/**/*.xml' recordCoverage(tools: [jacoco()]) } post { always { archiveArtifacts artifacts: 'target/*.jar', fingerprint: true } } }

Speed technique: Separate unit tests (no I/O, no Spring context) from integration tests at the Maven profile level. Unit tests run in Stage 1 in under 2 minutes. Integration tests run in Stage 3 against real infrastructure. Never mix them.

Stage 2: Static Analysis and Security Scanning

Static analysis is non-negotiable. It catches code smells, security vulnerabilities, and complexity issues before they reach a human reviewer.

stage('Static Analysis') { steps { // SonarQube: code quality, coverage thresholds, duplication withSonarQubeEnv('sonarqube') { sh 'mvn sonar:sonar -Dsonar.qualitygate.wait=true' } // OWASP Dependency-Check: known CVEs in dependencies dependencyCheck( additionalArguments: '--format HTML --format JSON', odcInstallation: 'OWASP-DC' ) // Fail pipeline if Quality Gate fails timeout(time: 5, unit: 'MINUTES') { waitForQualityGate abortPipeline: true } } }

Configure SonarQube quality gates with hard thresholds: coverage must not drop below 80%, no new critical or blocker issues, duplication under 3%. The pipeline fails if any threshold is violated — not warns, fails.

Stage 3: Integration Tests with Testcontainers

Integration tests verify that your application works correctly with real infrastructure: real database, real message broker, real cache. Testcontainers spins up Docker containers as part of the test run — no shared test databases, no environment-specific configuration, fully reproducible.

// Spring Boot integration test with Testcontainers @SpringBootTest @Testcontainers class OrderRepositoryIT { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Container static RabbitMQContainer rabbitMQ = new RabbitMQContainer("rabbitmq:3.13-management"); @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.rabbitmq.host", rabbitMQ::getHost); registry.add("spring.rabbitmq.port", rabbitMQ::getAmqpPort); } @Test void shouldPersistAndRetrieveOrder() { // Test against a real PostgreSQL container } }

Stage 4: Docker Image Build

Build the image only after all quality gates pass. Use multi-stage builds to minimize the final image size and reduce the attack surface.

# Multi-stage Dockerfile — builds in CI, runs lean in production FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -q # cache dependencies layer COPY src ./src RUN mvn package -DskipTests -q FROM eclipse-temurin:21-jre-alpine # ~200MB vs ~600MB full JDK WORKDIR /app # Non-root user for security RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser COPY --from=builder /build/target/app.jar app.jar ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseContainerSupport" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
stage('Build & Push Image') { steps { script { def image = docker.build("registry/order-service:${GIT_COMMIT}") docker.withRegistry('https://registry.example.com', 'registry-creds') { image.push() // Also tag as latest for non-prod environments image.push('latest') } } } }

Secrets Management in Pipelines

A pipeline that builds Docker images, deploys to Kubernetes, and calls external APIs needs credentials: registry passwords, database URLs, API keys, signing certificates. How those secrets reach the pipeline is one of the most commonly neglected security decisions in CI/CD.

Hard rule: Secrets must never appear in source code, Dockerfiles, build scripts, or commit history — not even in private repositories. Leaked secrets in git history account for a significant share of production security incidents. Use git-secrets or pre-commit hooks to catch accidental commits before they reach the remote.

CI Platform Encrypted Secrets (Baseline)

GitHub Actions, GitLab CI, and Jenkins all offer encrypted secret stores. Secrets are injected as environment variables at runtime — never printed in logs, never written to disk. This is the minimum acceptable baseline for any pipeline.

# GitHub Actions — secrets injected at runtime, never visible in logs jobs: build: steps: - name: Build and push Docker image env: REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | echo "$REGISTRY_PASSWORD" | docker login registry.example.com -u ci --password-stdin mvn sonar:sonar -Dsonar.token=$SONAR_TOKEN

HashiCorp Vault (Production Grade)

For production systems, use a dedicated secrets manager. Vault provides dynamic secrets — database credentials that are generated for a specific pipeline run and expire automatically when the stage finishes. The pipeline authenticates to Vault using a short-lived token (AppRole or Kubernetes auth) and retrieves only the secrets it needs for that run.

stage('Build') { steps { withVault( vaultSecrets: [[ path: 'secret/ci/order-service', secretValues: [ [envVar: 'DB_PASSWORD', vaultKey: 'db_password'], [envVar: 'STRIPE_API_KEY', vaultKey: 'stripe_key'] ] ]] ) { sh 'mvn clean verify' // DB_PASSWORD and STRIPE_API_KEY available as env vars // credentials are revoked when the stage completes } } }

Kubernetes Secrets at Runtime

Secrets that the running application needs (not the build pipeline) should live as Kubernetes Secrets — ideally as Sealed Secrets (Bitnami) or synced from Vault via the Vault Agent Injector. Mount them as files rather than environment variables: processes that crash dump their environment to logs, which can inadvertently expose secrets.

Audit trail: Vault logs every secret access with a timestamp, pipeline identity, and the exact secret path. When a credential is compromised, you know immediately which pipeline accessed it and when — without that audit trail, incident response is archaeology.

GitOps: Separating Application and Infrastructure Repos

In a GitOps model, the desired state of the infrastructure is stored in a Git repository (the "config repo"), separate from the application code repo. The pipeline's job is to update the config repo with the new image tag — not to apply Kubernetes manifests directly.

# Pipeline updates the config repo (GitOps model) stage('Update Config Repo') { steps { withCredentials([sshUserPrivateKey(credentialsId: 'config-repo-key')]) { sh """ git clone git@github.com:org/k8s-config.git cd k8s-config # Update image tag in the staging overlay sed -i 's|order-service:.*|order-service:${GIT_COMMIT}|' \\ overlays/staging/kustomization.yaml git add -A git commit -m "chore: bump order-service to ${GIT_COMMIT}" git push origin main """ } } } # ArgoCD or Flux detects the change and applies it to the cluster

ArgoCD (or Flux) watches the config repo and applies changes to the cluster automatically. This means the cluster state is always auditable — every deployment is a commit with a timestamp, author, and diff. Rollback is a git revert.

Deployment Strategies

Blue/Green

Run two identical environments. The load balancer points at blue (production). Deploy the new version to green, run smoke tests, then switch the load balancer. Rollback is instant — flip back to blue. Best for high-risk releases where rollback speed is critical.

Rolling Update

Kubernetes default: gradually replace old pods with new ones. Zero downtime, no extra infrastructure cost. Rollback is slower than blue/green. Requires the new version to be backward-compatible with the database schema during the transition window.

Canary Release

Route a small percentage of traffic to the new version. Monitor error rate, latency, and business metrics. Gradually increase the percentage. Abort and roll back if metrics degrade. Best for high-traffic systems where catching regressions on real traffic before full rollout is worth the complexity.

Rule of thumb: Use rolling updates for routine releases. Use blue/green for major version changes or database migrations. Use canary for critical user-facing services where a regression at 5% traffic is far preferable to a regression at 100%.

Pipeline Health Metrics

Treat the pipeline itself as a production system. Track these DORA metrics to measure and improve delivery performance:

Anti-pattern: A pipeline that nobody trusts. If developers bypass the pipeline ("I'll just deploy manually this once"), the pipeline has failed at its job — either it's too slow, too flaky, or it's blocking legitimate changes with false positives. Fix the pipeline, not the bypass.

Key Takeaways

Back to all articles