A Jenkins pipeline that successfully runs mvn package, builds a Docker image, and deploys it is not automatically a good CI/CD pipeline.
It is merely a pipeline that has not failed badly enough yet.
Production changes the standard. Builds must be reproducible. Credentials must stay protected. Tests must stop broken releases. Artifacts need traceable versions. Deployments must be recoverable. A failure halfway through the pipeline should leave enough evidence to understand exactly what happened.
The important shift is simple: stop treating Jenkins as a collection of shell commands and start treating the pipeline as a software delivery system.
That distinction separates a demo pipeline from one you can trust with production.
A pipeline is a chain of guarantees
Consider a typical Java application delivery process:
Git
↓
Checkout
↓
Compile
↓
Unit Tests
↓
Package WAR
↓
Archive Artifact
↓
Build Docker Image
↓
Push to Amazon ECR
↓
Deploy
Beginners often focus on making each command work individually. That is necessary, but insufficient.
A production pipeline needs stronger guarantees:
- The deployed code is the code that passed testing.
- Every release has a unique identity.
- Credentials are never embedded in source code.
- Failed tests prevent deployment.
- Previous releases remain identifiable.
- The pipeline produces useful evidence when something fails.
Without these properties, automation merely makes mistakes happen faster.
Build once, promote the same artifact
One of the most important CI/CD principles is build once, deploy many.
Suppose Jenkins performs this:
mvn package
during testing, but later rebuilds the application before production deployment.
That creates two artifacts.
Even if both came from the same Git commit, environmental differences, dependency resolution, build configuration, or external repositories can produce differences.
The safer model is:
Source → Build → Test → Artifact → Deploy same artifact
not:
Source → Build → Test
↓
Build again → Deploy
Once an artifact passes validation, it should become immutable.
For a Maven project:
stage('Build') {
steps {
sh 'mvn clean package -DskipTests'
}
}
stage('Unit Tests') {
steps {
sh 'mvn test'
}
}
stage('Archive') {
steps {
archiveArtifacts artifacts: '**/target/*.war',
fingerprint: true
}
}
Jenkins artifact fingerprinting helps associate an artifact with the build that produced it.
The artifact is no longer just something.war. It becomes part of the release history.
Version artifacts instead of overwriting them
A surprisingly common mistake is storing every build under the same filename:
cp target/app.war versions/app.war
Congratulations, your version history now contains exactly one version.
A better strategy assigns a unique identifier:
cp target/app.war "versions/app-${BUILD_NUMBER}.war"
A stronger release identifier can combine the build number and Git commit:
SHORT_SHA=$(git rev-parse --short HEAD)
cp target/app.war "versions/app-${BUILD_NUMBER}-${SHORT_SHA}.war"
You now get artifacts such as:
app-142-a18d93f.war
app-143-91f27ac.war
app-144-c65e010.war
That tiny naming decision has major operational consequences.
If production fails after build 144, you can identify exactly what was deployed and which commit created it.
Build numbers versus timestamps
Both approaches are useful, but they solve slightly different problems.
| Identifier | Advantage | Weakness |
|---|---|---|
| Build number | Simple and Jenkins-native | Meaningful mainly inside Jenkins |
| Timestamp | Human-readable chronology | Harder to associate with source |
| Git SHA | Exact source traceability | Less friendly for humans |
| Build + SHA | Strong traceability | Slightly longer identifier |
For most pipelines, build number plus short Git SHA is a practical compromise.
Treat container images as immutable releases
The same principle applies to Docker images.
This is convenient:
docker build -t myapp:latest .
It is also a terrible release record if latest is the only tag you keep.
Today:
latest → image A
Tomorrow:
latest → image B
Which image was running during yesterday's incident?
Without additional metadata, you have created an archaeological project for whoever is on call.
Tag every image uniquely:
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t "$ECR_REPO:${BUILD_NUMBER}-${SHORT_SHA}" .
You may additionally tag the validated image as latest for convenience:
docker tag \
"$ECR_REPO:${BUILD_NUMBER}-${SHORT_SHA}" \
"$ECR_REPO:latest"
But deployments should preferably reference the immutable version.
Deploy latest and hope everyone remembers what it contained.
Deploy a uniquely identifiable image and make rollback deterministic.
The Docker image documentation explains how image references and tags work, while Amazon ECR documentation covers storing and managing container images in AWS.
Credentials belong in Jenkins, not the Jenkinsfile
A pipeline often needs access to AWS, container registries, Git repositories, SSH servers, or external APIs.
Hardcoding credentials is never an acceptable solution:
environment {
AWS_ACCESS_KEY_ID = 'AKIA...'
AWS_SECRET_ACCESS_KEY = '...'
}
Once committed, that secret may exist in:
- Git history
- forks
- backups
- logs
- developer machines
- cached CI workspaces
Deleting the line later does not erase the exposure.
Use the Jenkins Credentials system instead.
For example:
withCredentials([
usernamePassword(
credentialsId: 'aws-creds',
usernameVariable: 'AWS_ACCESS_KEY_ID',
passwordVariable: 'AWS_SECRET_ACCESS_KEY'
)
]) {
sh '''
aws sts get-caller-identity
'''
}
Even better, when Jenkins runs inside AWS, consider IAM roles instead of long-lived access keys.
The hierarchy should generally be:
IAM role / workload identity
↓
Managed Jenkins credential
↓
Hardcoded secret
The last option belongs in security incident reports, not pipeline design.
Tests must control release flow
A pipeline containing a test stage is meaningless if failed tests do not block deployment.
This is not CI/CD:
Build → Test fails → Deploy anyway
The pipeline must enforce:
Build
↓
Tests
├── Fail → STOP
└── Pass
↓
Package
↓
Deploy
A basic Jenkins stage already provides this behavior:
stage('Unit Tests') {
steps {
sh 'mvn test'
}
}
If Maven returns a non-zero exit code, Jenkins marks the step as failed and later stages normally do not execute.
For better diagnostics, publish test reports:
post {
always {
junit '**/target/surefire-reports/*.xml'
}
}
This turns Jenkins from a command launcher into a source of engineering evidence.
When tests fail, you can inspect which test failed instead of staring at several thousand lines of console output, one of humanity's less inspiring debugging interfaces.
Separate pipeline stages by responsibility
Avoid giant stages containing dozens of unrelated commands.
This:
stage('Everything') {
steps {
sh '''
git pull
mvn package
mvn test
docker build .
docker push ...
kubectl apply ...
'''
}
}
technically works.
It is also miserable to troubleshoot.
Prefer explicit stages:
Checkout
Build
Unit Test
Static Analysis
Package
Container Build
Container Scan
Registry Push
Deploy
Verification
Why?
Because pipeline structure becomes operational telemetry.
If Jenkins reports:
Checkout SUCCESS
Build SUCCESS
Unit Test SUCCESS
Docker Build SUCCESS
ECR Push FAILURE
you immediately know the application compiled and tested successfully. The failure is probably related to registry authentication, authorization, connectivity, repository configuration, or image pushing.
Good pipeline structure reduces the search space during incidents.
That is not cosmetic organization. It is debugging architecture.
Deployment success is not application success
Another dangerous assumption is:
deployment command succeeded = application is healthy
Not necessarily.
A deployment can succeed while the application:
- crashes during startup
- cannot connect to its database
- has invalid configuration
- fails dependency initialization
- returns HTTP 500
- never becomes healthy behind the load balancer
Add post-deployment verification.
For a simple HTTP application:
curl --fail --retry 5 --retry-delay 5 \
https://app.example.com/health
For Kubernetes:
kubectl rollout status deployment/myapp --timeout=180s
Then verify the application endpoint rather than trusting only Kubernetes object state.
Deployment automation should answer two separate questions:
Did the deployment operation complete?
Did the application become healthy?
Production requires both.
Design rollback before deployment
Rollback should not be invented while customers are already seeing errors.
If every release has an immutable identifier such as:
myapp:144-c65e010
and the previous healthy release was:
myapp:143-91f27ac
rollback becomes a controlled operation rather than a rebuild.
That is another reason immutable artifacts matter.
A release pipeline should preserve enough information to answer:
- What version is currently deployed?
- Which Git commit produced it?
- Which Jenkins build created it?
- Did that build pass testing?
- What was the previous healthy version?
- Can we redeploy that exact version?
If your delivery system cannot answer those questions quickly, it is missing fundamental release metadata.
A practical production pipeline
The resulting architecture might look like this:
Developer
↓
Git Repository
↓
Jenkins
↓
Checkout
↓
Build
↓
Unit Tests
↓
Artifact Archive
↓
Docker Build
↓
Security Scan
↓
Amazon ECR
↓
Deployment
↓
Health Verification
↓
Release Recorded
Each stage establishes a guarantee for the next.
The pipeline is therefore not merely automating commands. It is progressively increasing confidence in a release.
What to verify before calling a pipeline production-ready
Before trusting a Jenkins pipeline with real deployments, verify:
- Source traceability: every release maps to a Git commit.
- Immutable artifacts: tested artifacts are not rebuilt before deployment.
- Unique versions: artifacts and images have durable identifiers.
- Credential isolation: secrets are managed outside source code.
- Least privilege: Jenkins receives only the permissions it needs.
- Test enforcement: failed tests stop releases.
- Artifact retention: useful historical builds remain available.
- Clear stages: failures identify the affected delivery phase.
- Deployment verification: application health is checked after release.
- Rollback capability: previous known-good versions can be restored.
- Useful logs: pipeline failures leave enough evidence for diagnosis.
The Jenkins Pipeline documentation is worth reading beyond the syntax examples. The real value is understanding how stages, credentials, post conditions, artifacts, and execution behavior combine into a reliable delivery process.
The lesson that matters
Learning Jenkins syntax is useful, but syntax is the easy part.
The harder skill is understanding what the pipeline must guarantee.
A production engineer does not ask only:
"Did Jenkins finish successfully?"
The better questions are:
What exactly did we build?
What source produced it?
What tests did it pass?
Where is that artifact stored?
What credentials were required?
What version was deployed?
How did we verify it?
How do we restore the previous version?
If those answers are explicit and automated, the pipeline is becoming trustworthy.
If the answers depend on someone's memory, a mutable latest tag, a forgotten shell command, or manually searching Jenkins logs at 2 a.m., the system is unfinished.
The goal of CI/CD is not to automate deployment. It is to make every release traceable, repeatable, verifiable, and reversible.
Enjoyed this? I write about what I'm learning.
DevOps, cloud, Linux, and automation - one honest post at a time. Follow along on my learning journey.