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
    ↓
Kubernetes

Why CI/CD Matters

Without CI/CD, a deployment can involve several manual steps.

A developer may need to:

  1. Pull the latest code.

  2. Build the application.

  3. Run tests.

  4. Create a Docker image.

  5. Push the image to a registry.

  6. Connect to the target environment.

  7. Update the Kubernetes deployment.

  8. 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:

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.

/health

provides 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: 20

Multiple Replicas

replicas: 2

This 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: ClusterIP

The 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 Deployment

I 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:latest

the pipeline generates images such as:

cloud-native-api:21
cloud-native-api:22
cloud-native-api:23

Every 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:

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=120s

Jenkins 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 Stops

No Docker image is deployed.

If the tests pass:

Source Code
   ↓
Build
   ↓
Test
   ↓
Docker Build
   ↓
Registry
   ↓
Kubernetes Deployment
   ↓
Health Verification

Only 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
     ↓
Production

The 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
  ↓
Production

This 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 Scan

If 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-api

and:

kubectl rollout undo deployment/cloud-native-api

However, 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:

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
                      API

Conclusion

In this article, I created a basic but reliable CI/CD flow using Jenkins, Docker and Kubernetes.

The pipeline automatically:

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.