What is CI/CD โ and Why Does It Exist?
Before CI/CD, shipping a Java change meant building it on your own machine, and then manually copying the artifact to a server โ SCP, FTP, or a shared drive โ and restarting the process by hand. Every deploy depended on whoever's laptop built the jar: their exact JDK version, their exact dependency cache, whatever uncommitted local change happened to be sitting in their working directory. "It works on my machine" wasn't a joke; it was the actual deployment process.
// BEFORE โ manual build and deploy, different every time
$ mvn package # on YOUR laptop, with YOUR local Maven cache
$ scp target/order-service.jar deploy@prod:/opt/app/
$ ssh deploy@prod "systemctl restart order-service"
# Did staging get tested with this exact jar? Unknown.
# Did anyone else's uncommitted change sneak into this build? Unknown.
// AFTER โ one pipeline builds it once, the same artifact moves through every stage
// git push triggers: compile โ test โ package โ the SAME jar is
// promoted through staging โ production. No laptop involved, ever.
- Continuous Integration (CI) โ every push automatically triggers a build and the full test suite. This is the part almost every team has.
- Continuous Delivery โ every change that passes CI is automatically packaged into a release-ready artifact, but a human still triggers the actual production deploy.
- Continuous Deployment โ every change that passes all checks deploys to production automatically, with no manual gate at all.
CI/CD from a Developer's Seat โ What's Actually Your Job
It's easy to hear "CI/CD" in standups and file it under "that's a
DevOps thing." In practice, on most teams building services like
order-service or customer-service, the
split looks like this โ and the boundary shifts with team size,
but the categories stay useful:
| Typically yours | Typically platform/DevOps |
|---|---|
| The workflow/Jenkinsfile for your own service's build and test stages | The runners/agents themselves, and the cluster they execute on |
| Keeping your tests fast and non-flaky โ a flaky test is a bug in your test, not "a pipeline problem" | Secrets management infrastructure (Vault, the encrypted-secrets backend itself) |
| Writing database migrations that don't break the version of your code still running during a rolling deploy (Section 8) | The GitOps controller that reconciles manifest changes into the cluster (Section 10) |
| Reading a failed pipeline's actual error, not just re-running it and hoping | Provisioning the environments and networking the pipeline deploys into |
The instinct to say "the pipeline is broken" is usually
backwards. A failing test, a build that doesn't reproduce
locally, a migration that locks a table for ten minutes โ
these are your code's problems surfacing earlier and cheaper
than they would in production. Treating pipeline failures as
an infrastructure annoyance instead of direct feedback on
your own change is the single biggest mindset gap between
developers who trust CI/CD and developers who route around
it with -DskipTests.
The CI/CD Pipeline โ Stage by Stage
Source → Build → Unit Tests → Static Analysis → Package → Integration Tests → Deploy Staging → Deploy Production
| Stage | What runs | A failure here usually means |
|---|---|---|
| Build | mvn compile | A type error or missing dependency โ caught before anyone else pulls your branch |
| Unit Tests | mvn test | Your change broke a documented behavior, or the test itself is flaky and needs fixing |
| Static Analysis | SonarQube, Checkstyle โ see Code Quality Tools | A real code smell or a quality gate threshold your change pushed past |
| Package | mvn package | Rare if compile/test passed โ usually a resource or manifest misconfiguration |
| Integration Tests | Tests against a real database/queue, often via Testcontainers | Your change works in isolation but breaks against the real schema or a downstream service |
| Deploy Staging | Automated, no approval | Environment configuration drift, not usually your code |
| Deploy Production | Manual gate (Delivery) or automatic (Deployment) | The one place a mistake has real customer impact โ this is why the earlier stages exist |
GitHub Actions for Java
Basic build and test workflow
# .github/workflows/build.yml
name: Java CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
# Least-privilege by default โ grant only what this workflow actually needs
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build and test
run: mvn -B verify
Multi-version testing
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [17, 21]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: 'temurin'
cache: maven
- run: mvn -B verify
An Action referenced by @main or even by a
version tag like @v4 can change underneath you
the moment the maintainer pushes new code to that ref โ and
several real supply-chain compromises of popular GitHub
Actions have worked exactly this way: a widely used
third-party action gets compromised, and every workflow
referencing it by a mutable ref starts running the
attacker's code with access to your repository's secrets on
the very next run. Pinning to a specific commit SHA makes
the code that actually executes immutable and auditable:
# Fragile โ the code that runs can change without your workflow file changing
- uses: some-org/some-action@main
- uses: some-org/some-action@v4
# Pinned โ this exact commit's code is what runs, always, until you
# deliberately bump it
- uses: some-org/some-action@8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f # v4.2.1
Official actions maintained directly by GitHub
(actions/checkout, actions/setup-java)
carry materially lower risk than a random third-party action
with a handful of stars โ reserve strict SHA-pinning
discipline especially for anything outside that trusted set.
Docker build and push
name: Build and Push Image
on:
push:
tags: [ 'v*' ]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn -B package -DskipTests
- name: Log in to registry
uses: docker/login-action@v3
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/shop/order-service:${{ github.ref_name }}
pull_request_target is not a drop-in replacement for pull_requestBoth `main` example workflows above trigger on
pull_request, which runs with a read-only token
and no access to repository secrets for PRs from forks โ
deliberately, because the code being tested came from
someone you may not trust. pull_request_target
runs with full access to secrets and checks out the
base branch by default, which is safe โ but if that workflow
is modified to also check out and execute the PR's own
untrusted code, it hands a stranger's code your secrets.
Never combine pull_request_target with a
checkout of the incoming PR's head ref.
Jenkins Pipeline
Jenkins is the most widely used self-hosted CI/CD server โ maximum flexibility and control, at the cost of you (or a platform team) maintaining the server itself.
// Jenkinsfile in project root
pipeline {
agent any
tools {
maven 'Maven-3.9'
jdk 'JDK-21'
}
stages {
stage('Build') {
steps { sh 'mvn clean compile' }
}
stage('Unit Tests') {
steps { sh 'mvn test' }
post { always { junit 'target/surefire-reports/*.xml' } }
}
stage('Package') {
steps {
sh 'mvn package -DskipTests'
archiveArtifacts artifacts: 'target/*.jar'
}
}
stage('Deploy to Production') {
when { branch 'main' }
steps {
input message: 'Deploy to production?' // manual gate โ Continuous Delivery, not Deployment
sh './deploy.sh production'
}
}
}
}
Parallel stages โ independent work runs concurrently
stage('Tests') {
parallel {
stage('Unit Tests') { steps { sh 'mvn test -Dtest=*UnitTest' } }
stage('Integration Tests') { steps { sh 'mvn verify -Dtest=*IntegrationTest' } }
stage('Coverage') { steps { sh 'mvn jacoco:report' } }
}
}
GitLab CI/CD
# .gitlab-ci.yml
image: maven:3.9-eclipse-temurin-21
cache:
paths:
- .m2/repository/
stages:
- build
- test
- package
- deploy
unit-tests:
stage: test
script:
- mvn test
artifacts:
reports:
junit: target/surefire-reports/TEST-*.xml
package:
stage: package
script:
- mvn package -DskipTests
artifacts:
paths:
- target/*.jar
deploy-production:
stage: deploy
script:
- ./deploy.sh production
when: manual # Continuous Delivery gate
only:
- main
Maven Configuration for CI/CD
A multi-module reactor (like order-service,
customer-service, and shared-domain as
separate modules) needs every module's version to move
together. Hardcoding the version in every pom.xml
means a release requires editing N files; Maven's CI
Friendly Versions feature lets CI inject one version
into a single placeholder instead.
<!-- pom.xml -->
<version>${revision}</version>
<properties>
<revision>1.0.0-SNAPSHOT</revision>
</properties>
<build>
<plugins>
<!-- Resolves ${revision} to a real value before the artifact is
installed/deployed โ downstream consumers never see the placeholder -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>flatten-maven-plugin</artifactId>
<version>1.5.0</version>
<executions>
<execution><goals><goal>flatten</goal></goals></execution>
</executions>
</plugin>
</plugins>
</build>
# CI sets the real version at build time โ every module gets it consistently
mvn -B -Drevision=1.4.0 clean package
Docker in CI/CD
Multi-stage Dockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
# Non-root user โ never run the JVM as root inside the container
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Docker Compose for integration tests
services:
order-service:
build: .
environment:
- SPRING_PROFILES_ACTIVE=test
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/orders_test
depends_on:
- db
db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=orders_test
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
Database Migrations in the Pipeline โ the Part That Actually Breaks Production
Rolling and canary deployments (Section 9) mean two versions of your code run against the same database at the same time, for minutes or longer. If a migration in the new version isn't compatible with the old version's queries, the old instances start failing the moment the migration runs โ before they're even the ones being replaced.
// V12__rename_customer_full_name.sql โ Flyway migration
-- WRONG: renaming a column the old version still SELECTs by name
ALTER TABLE customers RENAME COLUMN full_name TO display_name;
-- The moment this runs, every OLD instance still executing
-- "SELECT full_name FROM customers" starts throwing SQL errors โ
-- and during a rolling deploy, old instances are still serving traffic.
-- CORRECT: expand/contract pattern โ add the new column first,
-- backfill, deploy code that writes to both, THEN drop the old column
-- in a LATER migration once every instance is on the new version
ALTER TABLE customers ADD COLUMN display_name VARCHAR(255);
UPDATE customers SET display_name = full_name;
-- full_name is dropped only in a future release, after this one is fully rolled out
# Running Flyway as an explicit pipeline stage โ a separate step from
# the application deploy, so a failed migration blocks the rollout
# instead of half-applying during app startup
mvn flyway:migrate -Dflyway.url=$DB_URL -Dflyway.user=$DB_USER
Every additive-only, backward-compatible migration in this pattern is something you write when you design the change โ the pipeline just runs it. Getting this wrong isn't a CI/CD failure to blame on tooling; it's the same category of mistake as the cascade-on-delete trap covered in Entity Relationships โ a correct-looking change with a consequence that only shows up under a specific runtime condition the author didn't consider.
Deployment Strategies โ and What Each One Requires From Your Code
Blue-Green
Blue (current) ← production traffic
Green (new) ← deploy and test the new version here first
// Switch traffic once Green is verified. Rollback = switch back to Blue.
Requires: the new version must be able to run health checks
(Spring Boot Actuator's /actuator/health) before
receiving any real traffic โ the switch is only safe if
"healthy" actually means healthy.
Canary
v1.0 (current) ← 95% of traffic
v1.1 (canary) ← 5% of traffic, monitored, gradually increased
Requires: your metrics and error rates must be observable per version โ a canary you can't measure isn't a canary, it's just an unmonitored partial rollout.
Rolling
Instance 1: v1.0 -> v1.1 (updating)
Instance 2: v1.0
Instance 3: v1.0
// One instance at a time, until all are updated
Requires: the exact backward-compatibility guarantee from Section 8 โ old and new instances serve real traffic against the same schema simultaneously throughout the rollout.
All three strategies get considerably safer when risky
logic ships behind a feature flag rather than a straight
code path โ the code deploys and runs everywhere
immediately, but the new behavior only activates for a
controlled subset, independent of the rollout mechanism
itself. This is also what makes trunk-based development
(see Version Control) practical: you
can merge unfinished work to main safely as
long as it's flagged off.
GitOps โ the Modern "Deploy" Step
Increasingly, the final deploy step isn't your pipeline running
kubectl apply directly against a cluster. Instead, a
controller running inside the cluster (ArgoCD, Flux)
continuously watches a Git repository of Kubernetes manifests
and reconciles the cluster to match whatever is committed
there.
// What actually changes for you:
// Your CI pipeline builds the image and pushes it to a registry, then
// opens a PR (or commits directly) bumping the image tag in a manifests
// repo โ it never touches the cluster itself.
// manifests-repo/order-service/deployment.yaml
image: ghcr.io/shop/order-service:1.4.0 # โ this line is what your pipeline updates
// ArgoCD notices the commit, diffs it against the live cluster state,
// and applies the change itself โ with a full audit trail of every
// change as ordinary Git history.
For you as a developer, this mostly means: the artifact you build and the state of the cluster are connected entirely through Git commits, so "what's running in production right now" is always answerable by reading a file in a repo, not by asking someone with cluster access.
Security Scanning in the Pipeline
name: Security Scan
on: [push, pull_request]
permissions:
contents: read
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: OWASP Dependency Check
uses: dependency-check/Dependency-Check_Action@<pinned-commit-sha> # pin to a specific release commit, not @main
with:
project: 'order-service'
path: '.'
format: 'HTML'
- uses: actions/upload-artifact@v4
with:
name: dependency-check-report
path: reports/
This scans your dependencies for known CVEs. It does not protect you from Section 3's supply-chain risk โ an Action itself being malicious is a different threat than a vulnerable library, and needs the SHA-pinning discipline covered there.
Best Practices and Common Pitfalls
โ Do
- Fail fast โ run compile and lint before the slower test suites, not after
- Cache Maven/Gradle dependencies to keep pipeline feedback under ~10 minutes
- Build the artifact once and promote that exact artifact through every environment โ never rebuild per environment
- Pin third-party GitHub Actions to a commit SHA; declare least-privilege
permissionsexplicitly - Write migrations as additive/expand-contract so old and new code can run against the same schema during rollout
- Treat a red pipeline as your code telling you something, not an infrastructure inconvenience to route around
โ Don't
- Don't use
-DskipTestsas a habit to get a red pipeline green โ it hides the signal, it doesn't fix it - Don't reference third-party Actions by
@mainor a floating tag โ that's the exact vector of real supply-chain compromises - Don't combine
pull_request_targetwith checking out the incoming PR's own code โ it hands a stranger's code your secrets - Don't rename or drop a column in the same migration that removes the old code path โ use expand/contract across two releases
- Don't treat Canary/Blue-Green as purely an ops concern โ they only work if your code exposes real health checks and tolerates two versions running concurrently
Interview Questions
Q: What's the difference between Continuous Delivery and Continuous Deployment?
Both automatically build, test, and package every change that
passes CI into a release-ready artifact. Continuous Delivery
stops there and requires a human to trigger the actual
production deploy. Continuous Deployment removes that manual
gate โ a passing pipeline deploys to production automatically.
Q: Why should a pipeline build the artifact once and reuse it, instead of rebuilding for each environment?
If staging and production are built from separate compile
steps, you're no longer testing the actual bytes that will run
in production โ a different dependency resolution, a different
compiler flag, or a flaky build could silently produce a
different artifact. Building once and promoting that exact jar
through every environment guarantees what you tested in staging
is byte-for-byte what reaches production.
Q: Why is a rolling deployment risky if a database migration in the same release renames a column?
During a rolling deployment, old and new instances of the
application serve real traffic against the same database
simultaneously. If the migration renames a column the old
version's queries still reference by its old name, every
remaining old instance starts failing immediately โ before the
rollout has even finished replacing them.
Q: A workflow references a third-party GitHub Action via @v3. Explain precisely why this is a supply-chain risk even though the version looks pinned, and what actually mitigates it.
A version tag like v3 is a mutable Git ref โ the
maintainer (or an attacker who compromises the maintainer's
account) can force-push a different commit to that same tag at
any time, and every workflow referencing @v3 will
silently start executing that new code on its very next run,
with whatever secrets and repository access that workflow
already has. This is exactly the mechanism behind real
supply-chain compromises of popular Actions in recent years.
The only mitigation that actually closes this gap is pinning to
an immutable commit SHA โ a SHA cannot be reassigned to
different content, so the code that executes is guaranteed
identical to what you last reviewed, until you deliberately
change the pin yourself.
Q: Your team wants to migrate a NOT NULL column with no default on a large orders table, deployed via rolling update. Walk through why a single-migration approach breaks, and design the safe sequence.
A single migration that adds the column as NOT NULL locks the
entire table while it back-fills a value for every existing row
โ on a large table this can hold a write lock for minutes,
during which the old application instances, still receiving
traffic, will have their writes blocked or time out. The safe
sequence follows expand/contract across at least two releases:
first add the column as nullable with no default (a fast,
metadata-only operation on most engines); ship application code
that writes the new column going forward while tolerating nulls
on read; run an online, batched backfill of historical rows
outside of peak traffic; only once every instance is confirmed
on the version that populates the column, add the NOT NULL
constraint in a later migration, and only then remove any
fallback logic that tolerated nulls. Each step is independently
safe to roll out and roll back; the single-migration version is
not.
Q: Why does adopting GitOps change what "who deployed this" means, and what real problem does that solve?
In a traditional pipeline, the deploy step is a script or job
that authenticates directly against the cluster and pushes a
change โ the audit trail for "what's actually running" lives
scattered across CI job logs and whoever had cluster
credentials at the time, and the live cluster state can drift
from what any manifest says it should be if someone applies a
manual change out-of-band. Under GitOps, the cluster's desired
state is defined entirely by what's committed in a manifests
repository, and a controller continuously reconciles the live
cluster to match it โ including reverting manual out-of-band
changes back to whatever Git says. "What's running in
production" becomes answerable by reading Git history, and "who
deployed this" becomes "who merged this commit," which is the
same audit trail your team already trusts for code review.