Modern application teams need to release changes frequently without making every deployment a manual activity. As applications move towards containers and Kubernetes, CI/CD becomes an important part of making deployments repeatable, consistent and easier to manage.
In this article, I will build a simple CI/CD pipeline for an ASP.NET Core API using Jenkins, Docker and Kubernetes.
The idea is straightforward. Whenever a developer pushes a change, Jenkins should validate the application, build it, create a Docker image and deploy the new version to a Kubernetes cluster.
The flow we are building is
Developer
↓
Git Repository
↓
Jenkins
↓
Build and Test
↓
Docker Image
↓
Container Registry
↓
KubernetesWhy CI/CD Matters
Without CI/CD, a deployment can involve several manual steps.
A developer may need to:
Pull the latest code.
Build the application.
Run tests.
Create a Docker image.
Push the image to a registry.
Connect to the target environment.
Update the Kubernetes deployment.
Confirm whether the application started correctly.
The problem is not just the amount of work involved.
Manual deployments can also introduce inconsistencies.
For example, one engineer may build an application using a slightly different command or environment configuration from another engineer.
A CI/CD pipeline creates a repeatable process. The same steps are executed whenever a new version of the application is deployed.
Technology Stack
For this example I am using:
ASP.NET Core Web API
Git
Jenkins
Docker
Container Registry
Kubernetes
kubectl
The same overall approach can also be used for applications written in Java, Python, Node.js or other languages.
Creating a Simple API
For demonstration purposes, we can create a basic ASP.NET Core API.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks();
var app = builder.Build();
app.MapGet("/", () =>
{
return Results.Ok(new
{
message = "Cloud Native API is running",
version = "1.0"
});
});
app.MapHealthChecks("/health");
app.Run();This gives us two endpoints.
/returns a simple API response.
/healthprovides a health endpoint that Kubernetes can use to determine whether the application is healthy.
Health endpoints become particularly useful when applications are deployed into container platforms.
Containerising the API
The next step is creating a Docker image.
A simple multi-stage Dockerfile could look like this:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "CloudNativeApi.dll"]Multi-stage builds are useful because the final container does not need the complete .NET SDK.
The SDK is required during compilation, while the final image only needs the runtime.
This helps keep the runtime image cleaner.
Kubernetes Deployment
Once the API has been containerised, Kubernetes can run the application.
A basic deployment might look like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloud-native-api
spec:
replicas: 2
selector:
matchLabels:
app: cloud-native-api
template:
metadata:
labels:
app: cloud-native-api
spec:
containers:
- name: cloud-native-api
image: myregistry/cloud-native-api:latest
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20Multiple Replicas
replicas: 2This runs two instances of the API.
If one pod stops working, Kubernetes can continue serving the application using the remaining healthy pod while replacing the failed one.
Readiness Probe
The readiness probe tells Kubernetes whether the application is ready to receive traffic.
An application process may be running while the application itself is still starting.
Readiness checks help prevent traffic being sent too early.
Liveness Probe
The liveness probe determines whether the application is still healthy.
If the application becomes unresponsive, Kubernetes can restart the container.
Kubernetes Service
We also need a service to expose the application internally.
apiVersion: v1
kind: Service
metadata:
name: cloud-native-api
spec:
selector:
app: cloud-native-api
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIPThe service provides a stable endpoint for the API even when individual pods are recreated.
Setting Up Jenkins
Now we can start building the CI/CD pipeline.
Jenkins will perform the following stages:
Checkout
↓
Restore Dependencies
↓
Run Tests
↓
Build Application
↓
Build Docker Image
↓
Push Docker Image
↓
Deploy to Kubernetes
↓
Verify DeploymentI prefer breaking pipelines into clear stages because troubleshooting becomes much easier.
If the pipeline fails, we immediately know whether the problem happened during testing, image creation, registry communication or Kubernetes deployment.
Jenkinsfile
The pipeline can be defined using a Jenkinsfile stored with the application source code.
pipeline {
agent any
environment {
APP_NAME = "cloud-native-api"
REGISTRY = "myregistry"
IMAGE_NAME = "${REGISTRY}/${APP_NAME}"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Restore') {
steps {
sh 'dotnet restore'
}
}
stage('Build') {
steps {
sh 'dotnet build --configuration Release --no-restore'
}
}
stage('Test') {
steps {
sh 'dotnet test --configuration Release --no-build'
}
}
stage('Build Docker Image') {
steps {
sh 'docker build -t $IMAGE_NAME:$BUILD_NUMBER .'
}
}
stage('Push Docker Image') {
steps {
withCredentials([
usernamePassword(
credentialsId: 'docker-registry-credentials',
usernameVariable: 'REGISTRY_USER',
passwordVariable: 'REGISTRY_PASSWORD'
)
]) {
sh '''
echo "$REGISTRY_PASSWORD" | docker login \
-u "$REGISTRY_USER" \
--password-stdin "$REGISTRY"
docker push "$IMAGE_NAME:$BUILD_NUMBER"
'''
}
}
}
stage('Deploy to Kubernetes') {
steps {
withCredentials([
file(
credentialsId: 'kubeconfig-prod',
variable: 'KUBECONFIG'
)
]) {
sh '''
kubectl set image \
deployment/cloud-native-api \
cloud-native-api="$IMAGE_NAME:$BUILD_NUMBER"
'''
}
}
}
stage('Verify Deployment') {
steps {
withCredentials([
file(
credentialsId: 'kubeconfig-prod',
variable: 'KUBECONFIG'
)
]) {
sh '''
kubectl rollout status \
deployment/cloud-native-api \
--timeout=120s
'''
}
}
}
}
post {
success {
echo 'Deployment completed successfully.'
}
failure {
echo 'Pipeline failed. Review the failed stage before deployment.'
}
}
}Why I Use the Jenkins Build Number
One detail I consider important is avoiding the exclusive use of the latest Docker tag.
Instead of:
cloud-native-api:latestthe pipeline generates images such as:
cloud-native-api:21
cloud-native-api:22
cloud-native-api:23Every Jenkins build therefore produces a uniquely identifiable image.
This makes troubleshooting easier because we can identify exactly which application build is running.
It also makes rollback easier.
For example, if build 23 introduced a problem, we could restore build 22 rather than trying to determine which application version was previously tagged as latest.
In a larger environment I would normally use a combination of semantic application versions, Git commit identifiers or release identifiers rather than relying only on the Jenkins build number.
Keeping Credentials Outside the Pipeline
One thing I would never recommend is storing passwords directly inside a Jenkinsfile.
For example, this should be avoided:
REGISTRY_PASSWORD = "mypassword123"Source code repositories should not contain infrastructure credentials.
Instead, Jenkins credentials can hold information such as:
Container registry credentials
Kubernetes configuration
API tokens
SSH keys
Certificates
The pipeline retrieves those credentials only when they are required.
This separation becomes especially important when multiple developers have access to the application's source repository.
Deployment Verification
A pipeline should not consider a deployment successful simply because the kubectl command returned successfully.
The application still needs to start.
That is why the pipeline contains:
kubectl rollout status deployment/cloud-native-api --timeout=120sJenkins waits for Kubernetes to confirm that the deployment has successfully rolled out.
If the new pods fail readiness checks or cannot start, the pipeline will fail rather than incorrectly reporting a successful deployment.
What Happens When a Developer Pushes Code?
Once everything is configured, the workflow becomes much simpler.
A developer pushes a code change.
Jenkins detects the change and starts the pipeline.
The application is restored and tested.
If the tests fail:
Pipeline StopsNo Docker image is deployed.
If the tests pass:
Source Code
↓
Build
↓
Test
↓
Docker Build
↓
Registry
↓
Kubernetes Deployment
↓
Health VerificationOnly validated code reaches the Kubernetes environment.
What I Would Improve for Production
Separate Environments
Instead of deploying directly to production, I would normally create stages such as:
Development
↓
Testing
↓
Staging
↓
ProductionThe same application image should ideally progress between environments rather than rebuilding different images for each environment.
Approval Before Production
Production deployment can require an approval step.
For example:
Build
↓
Test
↓
Deploy to Test
↓
Integration Testing
↓
Approval
↓
ProductionThis provides an additional control before high-impact changes are deployed.
Security Scanning
Container images can also be scanned before deployment.
The pipeline could eventually include:
Code Analysis
Dependency Scan
Container Image Scan
Configuration ScanIf a critical vulnerability is detected, the pipeline can prevent the release.
Better Rollback
A stronger pipeline should also have a defined rollback strategy.
Kubernetes itself provides rollout history and rollback capabilities.
For example:
kubectl rollout history deployment/cloud-native-apiand:
kubectl rollout undo deployment/cloud-native-apiHowever, production rollback should be designed and tested before it is actually needed.
A CI/CD Pipeline Is More Than Automation
When I started looking deeper into CI/CD, one thing became clear to me.
A pipeline is not simply a collection of shell commands.
A well-designed pipeline represents the delivery process of an application.
It answers questions such as:
What needs to happen before code can be released?
Which tests must pass?
Which version is being deployed?
Where are credentials stored?
How do we know the application is healthy?
What happens when a deployment fails?
How can we restore the previous version?
Thinking about these questions makes CI/CD much more valuable than simply automating a deployment command.
Final Architecture
Developer
|
v
Git Repository
|
v
Jenkins
|
+---------------+---------------+
| | |
v v v
Build Test Validation
| |
+---------------+---------------+
|
v
Docker Build
|
v
Container Registry
|
v
Kubernetes Cluster
|
+---------+---------+
| |
v v
Pod 1 Pod 2
| |
+---------+---------+
|
v
Kubernetes Service
|
v
APIConclusion
In this article, I created a basic but reliable CI/CD flow using Jenkins, Docker and Kubernetes.
The pipeline automatically:
Retrieves source code
Restores application dependencies
Runs tests
Builds the application
Creates a versioned Docker image
Pushes the image to a registry
Deploys the application to Kubernetes
Verifies the Kubernetes rollout
This type of automation creates a strong foundation for cloud-native application delivery.
As systems grow, the same pipeline can evolve further with security scanning, automated testing, environment promotion, deployment approvals, GitOps, observability and advanced deployment strategies.
For me, understanding the complete path from source code to a running application is one of the most important parts of DevOps engineering. It creates the foundation for many of the automation and platform engineering concepts that I will explore further in this series.

Join the conversation! Your thoughts help the community grow.