GitOps CI/CD Pipeline with ArgoCD
Build a full GitOps pipeline with Jenkins, Terraform, and ArgoCD on Kubernetes.
Introduction
30 Second Summary
Every time you use an app, there is a pipeline behind the scenes turning code changes into live features. Developers push code, it gets tested and packaged, then somehow lands on a server without anyone manually copying files around.
In this project, you will build a complete CI/CD pipeline that automatically builds, containerizes, and deploys a database-backed application to Kubernetes every time you push code to GitHub.
What You'll Build
You will push a code change to a Flask API, watch Jenkins automatically build and push a Docker image, then see ArgoCD detect the updated manifests and deploy the new version to your cluster with zero manual intervention.
By the end of this project, you'll have:
- A fully automated deployment where pushing code triggers a build, packages your app into a container, and rolls it out to a live Kubernetes cluster without a single manual command.
- Your entire cluster infrastructure defined as code with Terraform, so you can create namespaces, a database, and ArgoCD by running one command.
- A GitOps workflow where ArgoCD watches a Git repository and automatically syncs your cluster to match whatever is committed there.
- Secret Mission: Add production health checks and demonstrate ArgoCD self-healing by intentionally breaking the deployment and watching it recover automatically.
Are there any prerequisites?
This project assumes you already have Docker Desktop with Kubernetes enabled, kubectl, Terraform, Jenkins running locally, and the ArgoCD CLI binary installed. You also need a GitHub account and a Docker Hub account.
Step 1 will verify all of these tools are working before you start building.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Taking a moment to articulate the goal keeps you focused as you move through multiple tools and configuration files.
Set Up Repositories and Project Structure
In a production DevOps workflow, application source code and deployment configuration live in separate repositories. This separation ensures your CI system (Jenkins) can build and push images without ever needing direct access to the cluster.
In this step, you'll verify your tools are installed, create two GitHub repositories following the GitOps two-repo pattern, and scaffold both projects with the directory structure they need for later steps.
In this step, get ready to:
- Verify Docker, kubectl, Terraform, and ArgoCD CLI all respond to commands.
- Create and clone two GitHub repositories for the GitOps pattern.
- Set up the project directory structure with placeholder files.
Verify your local environment is ready
Before creating anything, you need to confirm that every tool the pipeline depends on is reachable from your terminal.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Run these four commands to check your tools are installed:
docker --version
kubectl cluster-info
terraform --version
argocd version --client
What do these commands check?
- `docker --version` confirms Docker Desktop is installed and the daemon is running.
- `kubectl cluster-info` confirms your local Kubernetes cluster (Docker Desktop) is reachable.
- `terraform --version` confirms the Terraform CLI is on your PATH.
- `argocd version --client` confirms the ArgoCD CLI binary is installed (it only checks the local binary, not a server).
Each command should print a version number or cluster details without errors.
Seeing command not found?
- If docker fails, make sure Docker Desktop is running. Open it from your applications and wait for the whale icon to appear in your system tray.
- If kubectl fails, confirm Kubernetes is enabled in Docker Desktop under Settings > Kubernetes > Enable Kubernetes.
- If terraform or argocd fails, verify the binary is on your system PATH.
Help me troubleshoot a command not found error for Docker, kubectl, Terraform, or ArgoCD on my system.
Create and clone your GitHub repositories
The GitOps pattern separates concerns into two repositories: one holds the application source code and CI pipeline definition, the other holds the Kubernetes manifests that define the desired cluster state.
- Open github.com in your browser and sign in.
- Click the + icon in the upper-right corner of any page.
- Select New repository.
- Enter devops-pipeline-app in the Repository name field.
- Select Public for visibility.
- Check Add a README file.
- Click Create repository.
Now create the second repository using the same process.
- Click the + icon again and select New repository.
- Enter devops-pipeline-manifests in the Repository name field.
- Select Public, check Add a README file, and click Create repository.
Why two repositories?
Jenkins (CI) watches the app repo and builds Docker images. ArgoCD (CD) watches the manifests repo and deploys to Kubernetes. Neither system needs access to the other's domain. This is the production standard for GitOps.
Next, clone both repositories to your local machine.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone both repositories by running these commands:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline-app.git
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline-manifests.git
- Confirm both folders exist by running:
ls
You should see both devops-pipeline-app and devops-pipeline-manifests listed in the output.
Clone failing with authentication error?
GitHub requires a Personal Access Token (PAT) for HTTPS clone operations. If prompted for a password, enter your PAT instead. You can generate one under Settings > Developer settings > Personal access tokens on GitHub.
Help me fix a GitHub authentication error when cloning a repository over HTTPS.
Set up the project directory structure
Each repository needs a specific folder layout. The app repo holds the Flask source code, Dockerfile, and Jenkinsfile. The manifests repo holds Kubernetes YAML files.
- Move into the app repository and create its structure by running these commands:
cd ~/Desktop/devops-pipeline-app
mkdir app
touch Dockerfile
touch Jenkinsfile
What does this structure do?
- The app/ directory will hold your Flask application code and requirements file.
- The Dockerfile at the root defines how to build the container image.
- The Jenkinsfile at the root defines the CI pipeline stages.
- Confirm the files were created by running:
ls
You should see app, Dockerfile, Jenkinsfile, and README.md in the output.
- Now set up the manifests repository by running these commands:
cd ~/Desktop/devops-pipeline-manifests
mkdir k8s
- Confirm the directory was created by running:
ls
You should see k8s and README.md listed.
What is the k8s directory for?
The k8s/ directory will hold your Kubernetes deployment and service YAML files. ArgoCD watches this directory and automatically syncs any changes to your cluster.
Before you run the final check, what do you expect kubectl cluster-info to show if your Docker Desktop Kubernetes cluster is healthy?
- Verify everything is ready by running:
kubectl cluster-info
You should see output showing the Kubernetes control plane running at a local address. This confirms Docker Desktop's built-in cluster is active and reachable.
Your environment is verified and both repositories are structured for the GitOps workflow. Next up, you'll build the Flask API and Docker image that this pipeline will deploy.
Build the Flask App and Docker Image
Your repositories are set up, but right now they're empty shells. Before you can test your CI/CD pipeline, you need something real to push through it.
In this step, you'll build a Flask REST API that connects to PostgreSQL, containerize it with a production-grade multi-stage Docker build, and prove the whole thing works locally with docker compose.
In this step, get ready to:
- Write a Flask REST API with health check and CRUD endpoints backed by PostgreSQL.
- Build a multi-stage Dockerfile that separates build-time from runtime dependencies.
- Run the app and database locally with Docker Compose and test the API.
Create the Flask application code
The app needs three files: a Python dependencies list, the application code itself, and an SQL initialization script.
- In the devops-pipeline-app repo, create app/requirements.txt with the following content:
Flask==3.1.3
psycopg2-binary==2.9.12
gunicorn==26.0.0
What do these dependencies do?
- Flask is the web framework for your REST API.
- psycopg2-binary is the PostgreSQL database adapter for Python.
- gunicorn is a production WSGI server that replaces Flask's built-in dev server.
- Create app/main.py and paste the following code to set up the app configuration and database connection:
import os
import time
from flask import Flask, request, jsonify
import psycopg2
app = Flask(__name__)
# Database connection settings from environment variables
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = os.environ.get("DB_PORT", "5432")
DB_NAME = os.environ.get("DB_NAME", "appdb")
DB_USER = os.environ.get("DB_USER", "postgres")
DB_PASS = os.environ.get("DB_PASS", "postgres")
def get_db_connection():
return psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASS
)
What does this code do?
The environment variables let the app connect to different databases depending on where it runs. Locally, it defaults to localhost. Inside Kubernetes later, those variables will point to the cluster's database service.
- Confirm both files exist by running:
ls app/
You should see main.py and requirements.txt listed.
- Add the database initialization function below the existing code in app/main.py:
def init_db():
"""Create tables if they do not exist. Retries on connection failure."""
retries = 5
for i in range(retries):
try:
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
conn.commit()
cur.close()
conn.close()
print("Database initialized successfully.")
return
except Exception as e:
print(f"DB connection attempt {i+1}/{retries} failed: {e}")
time.sleep(3)
print("WARNING: Could not initialize database after retries.")
Why retry the database connection?
When containers start together, the app often boots faster than the database. The retry loop gives PostgreSQL time to become ready before the app gives up.
- Add the health check and list endpoints below init_db in app/main.py:
@app.route("/health", methods=["GET"])
def health():
try:
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT 1")
cur.close()
conn.close()
return jsonify({"status": "healthy", "database": "connected"}), 200
except Exception as e:
return jsonify({"status": "unhealthy", "database": str(e)}), 500
@app.route("/api/tasks", methods=["GET"])
def get_tasks():
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, title, description, created_at FROM tasks ORDER BY created_at DESC;")
tasks = cur.fetchall()
cur.close()
conn.close()
return jsonify([
{"id": t[0], "title": t[1], "description": t[2], "created_at": str(t[3])}
for t in tasks
])
What do these endpoints do?
- /health pings the database with SELECT 1 and reports whether the connection is alive. Kubernetes will use this to decide if the pod is healthy.
- /api/tasks (GET) returns all tasks from the database, sorted by newest first.
- Check that your functions are accumulating correctly by running:
grep "def " app/main.py
You should see get_db_connection, init_db, health, and get_tasks listed.
- Add the create endpoint below get_tasks in app/main.py:
@app.route("/api/tasks", methods=["POST"])
def create_task():
data = request.get_json()
title = data.get("title", "")
description = data.get("description", "")
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO tasks (title, description) VALUES (%s, %s) RETURNING id;",
(title, description)
)
task_id = cur.fetchone()[0]
conn.commit()
cur.close()
conn.close()
return jsonify({"id": task_id, "title": title, "description": description}), 201
- Add the delete endpoint, startup call, and main block to finish app/main.py:
@app.route("/api/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("DELETE FROM tasks WHERE id = %s;", (task_id,))
conn.commit()
cur.close()
conn.close()
return jsonify({"message": "Task deleted"}), 200
init_db()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
Why call init_db() at module level?
Placing init_db() at the bottom of the module means it runs when the app first loads. This ensures the tasks table exists before any request handler tries to query it.
- Save app/main.py.
- Confirm the complete file has all six functions by running:
grep "def " app/main.py
You should see get_db_connection, init_db, health, get_tasks, create_task, and delete_task listed.
✔️ Awesome, I've got everything!
Great. Double check you've saved app/main.py and confirm the grep output shows all six function definitions.
ⓧ I'd like to double check the full code
Scroll up and confirm each chunk is present in order:
- Chunk 1: imports, config variables, and get_db_connection function.
- Chunk 2: init_db function with retry loop.
- Chunk 3: health and get_tasks route handlers.
- Chunk 4: create_task route handler.
- Chunk 5: delete_task route handler, init_db() call, and if __name__ block.
Grep showing fewer than six functions?
- Make sure you pasted each chunk below the previous one, not replacing it.
- Check that each chunk starts with a blank line separating it from the code above.
Help me figure out why my app/main.py is missing functions
- Create init.sql in the repository root (next to the app/ directory) with this content:
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Why a separate init.sql?
Docker Compose mounts this file into PostgreSQL's /docker-entrypoint-initdb.d/ directory. Postgres automatically runs any SQL files in that directory when the container starts for the first time.
- Save init.sql and confirm your file structure by running:
ls app/ && ls init.sql
You should see main.py and requirements.txt inside app/, and init.sql at the root.
Write the multi-stage Dockerfile
A multi-stage build separates the heavy dependency-installation step from the lightweight runtime image. This keeps your final image small and avoids shipping build tools to production.
- Replace the placeholder Dockerfile in the repository root with the following content:
# Stage 1: Install Python dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
# Stage 2: Lightweight runtime image
FROM python:3.11-slim
WORKDIR /app
RUN useradd --system --no-create-home appuser
COPY --from=builder /install /usr/local
COPY app/ .
USER appuser
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "main:app"]
What does this Dockerfile do?
- The builder stage installs packages into /install using --prefix. Only that folder is copied to the final image, leaving behind pip caches and build artifacts.
- The runtime stage creates a non-root appuser so the container never runs as root. This is a production security best practice.
- Gunicorn serves the app with 2 worker processes, which is more robust than Flask's built-in server.
- Save the Dockerfile.
- Verify the file looks correct by running:
head -5 Dockerfile
You should see FROM python:3.11-slim AS builder as the first instruction line.
Don't see the FROM line?
- Make sure you replaced the placeholder content entirely, not appended below it.
- Confirm the file is named exactly Dockerfile (capital D, no extension) in the repository root.
Help me fix my Dockerfile
Run and test with Docker Compose
Docker Compose lets you spin up the Flask app and PostgreSQL together with one command. This mirrors how the services will talk to each other inside Kubernetes.
- Create docker-compose.yml in the repository root with the following content:
services:
web:
build: .
ports:
- "5000:5000"
depends_on:
- db
environment:
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=appdb
- DB_USER=postgres
- DB_PASS=postgres
db:
image: postgres:15
restart: always
environment:
POSTGRES_DB: appdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
volumes:
pgdata:
What does this compose file do?
- The web service builds your Dockerfile and passes database connection details as environment variables.
- The db service runs PostgreSQL 15, mounts init.sql for automatic table creation, and uses a named volume (pgdata) so data persists across restarts.
- DB_HOST=db works because Docker Compose creates a network where services reference each other by name.
- Save docker-compose.yml.
- Before you run this, what do you think will happen? Will the Flask app connect to PostgreSQL successfully on the first attempt, or will it need retries?
- Build and start both services by running:
docker compose up --build
What should I see?
Docker builds the multi-stage image, pulls the PostgreSQL image, and starts both containers. You'll see log output from both services. Look for Database initialized successfully in the output, which confirms the Flask app connected to PostgreSQL.
This command takes over the terminal. You'll need a second terminal window for the next commands.
- Open a new terminal tab or window.
- Test the health endpoint by running:
curl http://localhost:5000/health
You should see a JSON response like:
{"database":"connected","status":"healthy"}
- Test the tasks endpoint by running:
curl http://localhost:5000/api/tasks
You should see an empty list: []. That confirms the app is connected to PostgreSQL and the tasks table exists but has no data yet.
- Verify the full CRUD cycle works by creating a task:
curl -X POST http://localhost:5000/api/tasks -H "Content-Type: application/json" -d '{"title":"Test task","description":"Pipeline test"}'
You should see a response with "id": 1 confirming the task was created in PostgreSQL.
Seeing "Connection refused" from curl?
- Check the docker compose terminal for errors. If you see DB connection attempt messages followed by success, the app just needed time to connect.
- If the container exited, look for Python syntax errors in the build output. A missing comma or colon in app/main.py will crash the container.
- Make sure port 5000 isn't already in use by another process on your machine.
Help me debug my docker compose setup
- Once testing is complete, stop the running containers by pressing Ctrl+C in the terminal where docker compose is running.
Your Flask app is running, tested, and containerized. Next up, you'll provision the Kubernetes infrastructure where this app will live in production.
Provision Infrastructure with Terraform
Your Flask app works locally, but deploying it to Kubernetes means creating namespaces, deploying a database, and setting up services.
Doing this manually with kubectl commands is error-prone and leaves no record of what you built. Terraform lets you define your entire cluster infrastructure as code that is version-controlled, repeatable, and destroyable in one command.
In this step, get ready to:
- Configure the Terraform Kubernetes provider to manage your local cluster.
- Define three namespaces and a PostgreSQL deployment as code.
- Apply the configuration and install ArgoCD into Kubernetes.
Create the Terraform directory and configure the provider
Terraform needs a dedicated directory to store its configuration files, state, and plugin cache.
- In your terminal, make sure you are in the devops-pipeline-app repo root.
- Create a terraform/ directory and a main.tf file inside it by running these commands:
mkdir terraform
touch terraform/main.tf
- Confirm the file exists by running:
ls terraform/
You should see main.tf in the output.
- Open terraform/main.tf in your editor and add the Terraform provider configuration:
terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 3.0"
}
}
}
provider "kubernetes" {
config_path = "~/.kube/config"
}
What does this code do?
This block tells Terraform which provider plugin to download and how to connect to your cluster.
- source = "hashicorp/kubernetes" points Terraform to the official Kubernetes provider from HashiCorp's registry.
- version = "~> 3.0" pins to the 3.x line so future minor updates won't break your config.
- config_path = "~/.kube/config" tells the provider to authenticate using the same kubeconfig your kubectl commands use.
- Below the provider block, add the three namespace resources:
resource "kubernetes_namespace_v1" "production" {
metadata {
name = "production"
labels = {
environment = "production"
}
}
}
resource "kubernetes_namespace_v1" "database" {
metadata {
name = "database"
labels = {
environment = "production"
}
}
}
resource "kubernetes_namespace_v1" "argocd" {
metadata {
name = "argocd"
}
}
What does this code do?
Each kubernetes_namespace_v1 resource creates a namespace in your cluster.
- production will host your Flask app pods.
- database isolates PostgreSQL from app workloads.
- argocd holds the ArgoCD system that will manage deployments later.
- Save terraform/main.tf.
- Initialize Terraform by running these commands:
cd terraform
terraform init
You should see Terraform has been successfully initialized! in the output.
Seeing a provider error?
- Make sure the source line reads exactly hashicorp/kubernetes with no typos.
- Check that your Terraform version is 1.0+ by running terraform --version.
- If you see a network error, make sure you have an active internet connection for the provider download.
Still stuck? Help me troubleshoot terraform init failing.
Define the PostgreSQL deployment and service
Your Flask app needs a database running inside Kubernetes. You will define a PostgreSQL deployment and a service to expose it within the cluster.
- In terraform/main.tf, add the PostgreSQL deployment resource below the namespace blocks. Start with the deployment metadata and container declaration:
resource "kubernetes_deployment_v1" "postgres" {
metadata {
name = "postgres"
namespace = kubernetes_namespace_v1.database.metadata[0].name
labels = {
app = "postgres"
}
}
spec {
replicas = "1"
selector {
match_labels = {
app = "postgres"
}
}
template {
metadata {
labels = {
app = "postgres"
}
}
spec {
container {
name = "postgres"
image = "postgres:15"
- Immediately below (still inside the container block), add the port and environment configuration along with all closing braces:
port {
container_port = 5432
}
env {
name = "POSTGRES_DB"
value = "appdb"
}
env {
name = "POSTGRES_USER"
value = "postgres"
}
env {
name = "POSTGRES_PASSWORD"
value = "postgres"
}
}
}
}
}
}
What does this deployment do?
This resource creates a single PostgreSQL pod in the database namespace.
- namespace = kubernetes_namespace_v1.database.metadata[0].name references the namespace you defined above, so Terraform knows the dependency order.
- The env blocks set the same credentials your Flask app uses (appdb/postgres/postgres).
- replicas = "1" keeps it to a single instance since this is a local development cluster.
- Below the deployment, add the ClusterIP service that exposes PostgreSQL to other pods:
resource "kubernetes_service_v1" "postgres" {
metadata {
name = "postgres-service"
namespace = kubernetes_namespace_v1.database.metadata[0].name
}
spec {
selector = {
app = "postgres"
}
port {
port = 5432
target_port = 5432
}
type = "ClusterIP"
}
}
What does this service do?
A Kubernetes Service gives pods a stable DNS name to reach each other. The name postgres-service becomes reachable at postgres-service.database.svc.cluster.local from any namespace in the cluster.
Your Flask app will use this DNS name to connect to PostgreSQL when running inside Kubernetes.
- Save terraform/main.tf.
- Validate the full configuration by running:
terraform validate
You should see Success! The configuration is valid. in the output.
Seeing a validation error?
- Check for missing closing braces. The deployment resource has deeply nested blocks. Each opening { needs a matching }.
- Confirm kubernetes_namespace_v1.database.metadata[0].name is spelled exactly this way in both the deployment and the service.
- Make sure the provider and namespace blocks from the first chunk are still at the top of the file.
Still stuck? Help me fix my Terraform validation error.
✔️ Awesome, I've got everything!
Great. Confirm you have saved terraform/main.tf and validation passed.
ⓧ I'd like to double check the full code
Your terraform/main.tf file is long, so confirm these sections appear in order from top to bottom:
- terraform block with hashicorp/kubernetes provider requirement (version = "~> 3.0").
- provider "kubernetes" block with config_path = "~/.kube/config".
- kubernetes_namespace_v1 resources for production, database, and argocd.
- kubernetes_deployment_v1 for postgres with image postgres:15, port 5432, and three env blocks.
- kubernetes_service_v1 for postgres-service with ClusterIP type and port 5432.
Apply the infrastructure and install ArgoCD
Your configuration is validated. Now Terraform will create all the resources in your Docker Desktop Kubernetes cluster.
- Before you run this, what do you expect Terraform to create? How many resources total?
- Apply the configuration by running:
terraform apply
What happens during terraform apply?
Terraform shows a plan of every resource it will create and asks you to confirm. Type yes when prompted.
You should see 5 resources created: 3 namespaces, 1 deployment, and 1 service. The output ends with Apply complete! Resources: 5 added, 0 changed, 0 destroyed. in the terminal.
- Verify the namespaces exist by running:
kubectl get namespaces
You should see production, database, and argocd listed alongside the default Kubernetes namespaces.
Why not install ArgoCD with Terraform too?
ArgoCD's install manifest includes Custom Resource Definitions (CRDs) that exceed Terraform's default annotation size limits. The official ArgoCD docs recommend installing with kubectl apply using --server-side --force-conflicts flags to handle these large objects.
Terraform handled the standard resources cleanly. ArgoCD install is a one-time command.
- Install ArgoCD into the argocd namespace by running:
kubectl apply -n argocd --server-side --force-conflicts -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
This downloads and applies the full ArgoCD system. You will see a long list of resources being created (services, deployments, configmaps, roles).
- Confirm the PostgreSQL pod is running in the database namespace:
kubectl get pods -n database
You should see a pod named postgres- followed by a random suffix, with status Running and 1/1 in the READY column.
- Verify ArgoCD pods are also coming up by running:
kubectl get pods -n argocd
You should see several ArgoCD pods (around 7) transitioning to Running status. Some may take a minute to fully start.
Pods stuck in Pending or CrashLoopBackOff?
- Make sure Docker Desktop Kubernetes is enabled and running. Check Docker Desktop's status icon.
- If PostgreSQL is in CrashLoopBackOff, check its logs with kubectl logs -n database <pod-name> for clues.
- ArgoCD pods need time to pull images on first install. Wait 2-3 minutes and re-run the kubectl get pods command.
Still stuck? Help me troubleshoot my Kubernetes pods.
Your entire Kubernetes infrastructure is now defined as code and running. Next up, you will configure ArgoCD to watch your manifests repository and automatically deploy changes to the cluster.
Configure ArgoCD and Set Up GitOps
Your Kubernetes infrastructure is provisioned and PostgreSQL is running in the cluster. The next challenge is deploying your Flask app to Kubernetes without manually running kubectl commands every time you make a change.
ArgoCD solves this by watching a Git repository for Kubernetes manifests and automatically syncing the cluster to match. This makes Git the single source of truth for what is deployed. You will create the manifests that describe your Flask deployment, then connect ArgoCD to watch them.
In this step, get ready to:
- Create Kubernetes deployment and service manifests for the Flask app.
- Log into ArgoCD and connect it to your manifests repository.
- Verify ArgoCD detects your manifests and reports the application status.
Create the Kubernetes deployment manifest
Your Flask app needs two Kubernetes resources to run in the cluster: a Deployment (which creates and manages pods running your container) and a Service (which exposes those pods to network traffic).
- Switch to your devops-pipeline-manifests repository by running this command:
cd ~/Desktop/devops-pipeline-manifests
- Create k8s/deployment.yaml and add the deployment metadata and strategy configuration:
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops-app
namespace: production
labels:
app: devops-app
spec:
replicas: 2
selector:
matchLabels:
app: devops-app
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
What does this configuration do?
This top section defines the deployment identity and update strategy.
- replicas: 2 tells Kubernetes to always maintain two running copies of your app for redundancy.
- RollingUpdate strategy means Kubernetes replaces pods one at a time during deploys, so there is zero downtime.
- The selector links the deployment to pods with the label app: devops-app.
- Now add the pod template below the strategy section in the same file:
template:
metadata:
labels:
app: devops-app
spec:
containers:
- name: devops-app
image: [[DOCKERHUB_USERNAME="your-dockerhub-username"]]/devops-pipeline-app:latest
ports:
- containerPort: 5000
env:
- name: DB_HOST
value: "postgres-service.database.svc.cluster.local"
- name: DB_PORT
value: "5432"
- name: DB_NAME
value: "appdb"
- name: DB_USER
value: "postgres"
- name: DB_PASS
value: "postgres"
What does the pod template do?
The template section defines what runs inside each pod.
- The image field points to your Docker Hub repository. Jenkins will update this tag automatically later.
- The DB_HOST value postgres-service.database.svc.cluster.local is the Kubernetes DNS name for your PostgreSQL service. It follows the format <service-name>.<namespace>.svc.cluster.local.
- The remaining environment variables match the credentials you configured in Terraform for the PostgreSQL deployment.
- Save k8s/deployment.yaml.
Create the Kubernetes service manifest
The Deployment creates pods, but they are not reachable from outside the cluster yet. A Service exposes them on a stable port.
- Create k8s/service.yaml with the following content:
apiVersion: v1
kind: Service
metadata:
name: devops-app-service
namespace: production
spec:
type: NodePort
selector:
app: devops-app
ports:
- port: 5000
targetPort: 5000
nodePort: 30050
What does this service do?
A NodePort service exposes your app on a fixed port (30050) on every node in the cluster. Once the pods are running, you will be able to reach the Flask API at http://localhost:30050.
The selector field matches the app: devops-app label from your deployment template, connecting the service to the correct pods.
- Save k8s/service.yaml.
- Confirm both files exist by running:
ls k8s/
You should see deployment.yaml and service.yaml listed.
- Commit and push both manifests to GitHub by running these commands:
git add .
git commit -m "Add Flask app deployment and service manifests"
git push origin main
Push failed or permission denied?
Make sure you are inside the devops-pipeline-manifests directory, not the devops-pipeline-app directory. Also confirm you set up the remote origin when you created this repo in Step 1.
help me push my manifests to GitHub
✔️ Awesome, I've got everything!
Great. Make sure both files are saved and pushed to GitHub before continuing.
ⓧ I'd like to double check the full code
Your k8s/deployment.yaml is built from the two chunks above. Scroll up and confirm you have both parts (the metadata/strategy section and the pod template section) combined into one continuous file with correct indentation.
Your k8s/service.yaml should look exactly like this:
apiVersion: v1
kind: Service
metadata:
name: devops-app-service
namespace: production
spec:
type: NodePort
selector:
app: devops-app
ports:
- port: 5000
targetPort: 5000
nodePort: 30050
Log into ArgoCD
ArgoCD is installed in your cluster but you have not connected to it yet. You need to expose the ArgoCD server, retrieve the admin credentials, and authenticate via the CLI.
- Start port-forwarding to the ArgoCD server by running this command:
kubectl port-forward svc/argocd-server -n argocd 8080:443
What does port-forward do here?
This command tunnels traffic from localhost:8080 on your machine to the ArgoCD server running inside the cluster on port 443. It keeps running in the foreground, so you will need a new terminal for the next commands.
- Open a new terminal tab or window. The port-forward command occupies the current terminal.
- Retrieve the ArgoCD admin password by running this command in the new terminal:
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d
You should see a random string printed to your terminal. Copy this password.
- Log into ArgoCD via the CLI by running this command, replacing your-password-here with the password you just copied:
argocd login localhost:8080 --insecure --username admin --password your-password-here
Why the --insecure flag?
ArgoCD uses a self-signed TLS certificate by default. The --insecure flag skips certificate verification, which is safe for local development. In production, you would configure a proper TLS certificate.
You should see a message confirming you are logged in successfully.
Connection refused on localhost:8080?
Make sure the kubectl port-forward command is still running in your other terminal tab. If it stopped, re-run it before trying the login again.
help me connect to ArgoCD on localhost
Create the ArgoCD Application and verify
Now you will tell ArgoCD which Git repository to watch and where to deploy the manifests. ArgoCD calls this an Application.
- Create the ArgoCD Application by running this command:
argocd app create devops-app --repo https://github.com/[[GITHUB_USERNAME="your-github-username"]]/devops-pipeline-manifests.git --path k8s --dest-server https://kubernetes.default.svc --dest-namespace production --sync-policy automated
What does this command configure?
- --repo points ArgoCD at your manifests repository on GitHub.
- --path k8s tells it to look inside the k8s/ directory for YAML files.
- --dest-server https://kubernetes.default.svc deploys to the same cluster ArgoCD runs in.
- --dest-namespace production targets the namespace Terraform created earlier.
- --sync-policy automated means ArgoCD will automatically apply changes when it detects new commits in the repo.
Before you check the result, what status do you think ArgoCD will report for this application? The image in your deployment manifest points to a Docker Hub repository that does not have any images yet.
- Check the application status by running:
argocd app get devops-app
You should see the application status reported as OutOfSync. This is expected. The image your-dockerhub-username/devops-pipeline-app:latest does not exist on Docker Hub yet, so Kubernetes cannot pull it.
- Confirm the ArgoCD UI is also accessible by opening http://localhost:8080 in your browser.
- Log in with username admin and the password you retrieved earlier.
You should see the devops-app application in the dashboard with an OutOfSync status. This gap is exactly what the next step closes.
Why is OutOfSync expected here?
ArgoCD read your manifests from Git and tried to apply them to the cluster. The Deployment references a Docker image that has not been pushed yet, so the pods cannot start. In the next step, Jenkins will build and push that image, then update the manifest with the correct tag. ArgoCD will detect the change and deploy automatically.
Application not showing in the UI?
Verify the app was created by running argocd app list in your terminal. If it is not listed, re-run the argocd app create command and check for typos in the repository URL.
help me see my application in the ArgoCD dashboard
Your GitOps deployment pipeline is wired up. ArgoCD is watching your manifests repo and waiting for a valid Docker image to deploy. Next up, you will build the Jenkins CI pipeline that builds the image, pushes it to Docker Hub, and updates the manifests to trigger a full automated deployment.
Build the Jenkins CI Pipeline
Right now, your ArgoCD Application is stuck showing OutOfSync. The reason is straightforward: the deployment.yaml in your manifests repo references a Docker image that does not exist on Docker Hub yet.
Jenkins solves this by automating the entire CI process. It builds your Docker image, pushes it to Docker Hub, and updates the manifests repo with the new image tag. Once that commit lands, ArgoCD detects the change and deploys the new version to your cluster automatically.
In this step, get ready to:
- Create Jenkins credentials for Docker Hub and GitHub authentication.
- Write a multi-stage Jenkinsfile that builds, pushes, and updates manifests.
- Trigger the first build and watch ArgoCD auto-deploy to the cluster.
Create Jenkins credentials
Your pipeline needs to authenticate with two external services: Docker Hub (to push images) and GitHub (to push manifest updates). Jenkins stores these securely as credentials that the Jenkinsfile references by ID.
- Open your Jenkins dashboard in the browser (typically at http://localhost:8080).
- Click Manage Jenkins in the left sidebar.
- Click Credentials.
- Under Stores scoped to Jenkins, click (global).
- Click Add Credentials.
Fill in the first credential for Docker Hub:
- Set Kind to Username with password.
- Enter your Docker Hub username in the Username field.
- Enter your Docker Hub password or access token in the Password field.
- Set ID to dockerhub-creds.
- Click Create.
Now add the second credential for GitHub:
- Click Add Credentials again.
- Set Kind to Username with password.
- Enter your GitHub username in the Username field.
- Enter a GitHub personal access token in the Password field.
- Set ID to github-creds.
- Click Create.
You should now see both dockerhub-creds and github-creds listed in the Global credentials table.
Why store credentials in Jenkins?
Jenkins encrypts credentials at rest and injects them into pipeline runs as environment variables. Your Jenkinsfile references them by ID (dockerhub-creds, github-creds) so secrets never appear in source code.
GitHub token permissions
Your personal access token needs the repo scope so Jenkins can push commits to devops-pipeline-manifests. Generate one from GitHub under Settings > Developer settings > Personal access tokens.
Don't see the Add Credentials button?
Make sure you clicked into the (global) domain under the Jenkins store. If you are on the top-level credentials page, you need to drill into a specific domain first.
help me find where to add credentials in Jenkins
Write the Jenkinsfile
The Jenkinsfile defines your entire CI pipeline as code. It lives in the devops-pipeline-app repo root and tells Jenkins what to do when a build is triggered.
- Open the Jenkinsfile in the devops-pipeline-app repo and replace its contents with the following pipeline skeleton:
pipeline {
agent any
environment {
DOCKER_IMAGE = "[[DOCKERHUB_USERNAME=\"your-dockerhub-username\"]]/devops-pipeline-app"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build Docker Image') {
steps {
sh "docker build -t ${DOCKER_IMAGE}:${BUILD_NUMBER} ."
sh "docker tag ${DOCKER_IMAGE}:${BUILD_NUMBER} ${DOCKER_IMAGE}:latest"
}
}
stage('Push to Docker Hub') {
steps {
withCredentials([usernamePassword(credentialsId: 'dockerhub-creds', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
sh "echo \$DOCKER_PASS | docker login -u \$DOCKER_USER --password-stdin"
sh "docker push ${DOCKER_IMAGE}:${BUILD_NUMBER}"
sh "docker push ${DOCKER_IMAGE}:latest"
}
}
}
What does this code do?
- environment defines a reusable DOCKER_IMAGE variable with your Docker Hub username and image name.
- Checkout stage uses checkout scm to pull the source code that triggered this build.
- Build Docker Image runs your multi-stage Dockerfile and tags the image with both the build number and latest.
- Push to Docker Hub uses withCredentials to inject your Docker Hub credentials securely, then pushes both tags.
- Now add the Update Manifests stage, post block, and closing braces below the Push stage:
stage('Update Manifests') {
steps {
withCredentials([usernamePassword(credentialsId: 'github-creds', usernameVariable: 'GIT_USER', passwordVariable: 'GIT_TOKEN')]) {
sh """
rm -rf devops-pipeline-manifests
git clone https://\${GIT_USER}:\${GIT_TOKEN}@github.com/\${GIT_USER}/devops-pipeline-manifests.git
cd devops-pipeline-manifests
sed -i 's|image: ${DOCKER_IMAGE}:.*|image: ${DOCKER_IMAGE}:${BUILD_NUMBER}|' k8s/deployment.yaml
git config user.email "jenkins@pipeline.local"
git config user.name "Jenkins CI"
git add .
git commit -m "Update image to build ${BUILD_NUMBER}"
git push origin main
"""
}
}
}
}
post {
always {
sh "docker logout || true"
}
}
}
What does the Update Manifests stage do?
This is the GitOps bridge. Jenkins clones the devops-pipeline-manifests repo, uses sed to replace the image tag in deployment.yaml with the new build number, then commits and pushes. ArgoCD detects this commit and automatically syncs the new image to the cluster.
The post block runs docker logout after every build (success or failure) to clean up authentication state on the Jenkins agent.
- Save the Jenkinsfile.
✔️ Awesome, I've got everything!
Great. Make sure both code chunks are combined into a single complete file, with the Update Manifests stage inside the stages { } block.
ⓧ I'd like to double check the full code
Your Jenkinsfile should have these sections in order. Scroll up and confirm each is present:
- Pipeline opener with agent any and environment block containing DOCKER_IMAGE.
- Checkout stage with checkout scm.
- Build Docker Image stage with docker build and docker tag.
- Push to Docker Hub stage with withCredentials using dockerhub-creds.
- Update Manifests stage with withCredentials using github-creds, cloning the manifests repo, running sed, and pushing.
- post block with docker logout.
- All braces properly closed.
- Commit and push the Jenkinsfile to GitHub by running these commands:
cd ~/Desktop/devops-pipeline-app
git add Jenkinsfile
git commit -m "Add CI pipeline Jenkinsfile"
git push origin main
You should see the push succeed with a reference to main in the output.
Push rejected or authentication failed?
If the push fails, confirm you cloned the repo using HTTPS with your GitHub credentials, or that your SSH key is configured. Also check you are on the main branch.
help me fix a git push authentication error to GitHub
Create the pipeline job and trigger the first build
With the Jenkinsfile pushed to GitHub and credentials configured, you can now create the Pipeline job that ties everything together.
- Return to the Jenkins dashboard.
- Click New Item in the left sidebar.
- Enter devops-pipeline-app as the item name.
- Select Pipeline as the project type, then click OK.
- Scroll down to the Pipeline section.
- Under Definition, select Pipeline script from SCM.
- Set SCM to Git.
- Enter your devops-pipeline-app GitHub repository URL in the Repository URL field.
- Under Credentials, select the github-creds credential you created earlier.
- Change the Branch Specifier to */main.
- Leave Script Path as Jenkinsfile (the default).
- Click Save.
Now trigger the build:
- Click Build Now in the left sidebar of the pipeline job.
- Click the build number (e.g. #1) that appears, then click Console Output to watch the build progress.
What should I see in Console Output?
You will see Jenkins check out the code, build the Docker image, push it to Docker Hub, clone the manifests repo, update the image tag with sed, and push the updated manifest. The build should end with Finished: SUCCESS.
Build failed at the Push stage?
Double-check that the dockerhub-creds credential ID matches exactly what you entered in Jenkins. Also verify your Docker Hub token has push permissions. Check the Console Output for the specific error message.
help me debug a failed Jenkins Docker push stage
Once the build succeeds, ArgoCD will detect the updated deployment.yaml in the manifests repo within a few minutes and sync the deployment automatically.
Before you check, what do you think the ArgoCD status will be now that a valid Docker image exists on Docker Hub?
- Check the ArgoCD application status by running:
argocd app get devops-app
You should see the status change to Synced and Healthy. If it still shows OutOfSync or Progressing, wait 2-3 minutes for the reconciliation interval to pass, then check again.
- Verify the pods are running by running:
kubectl get pods -n production
You should see 2 pods with status Running for the devops-app deployment. This confirms that the full pipeline worked: Jenkins built and pushed the image, updated the manifests, and ArgoCD deployed it to Kubernetes.
Pods stuck in ImagePullBackOff?
This means Kubernetes cannot pull the image from Docker Hub. Verify the image exists by checking your Docker Hub repository page. Also confirm the DOCKER_IMAGE environment variable in the Jenkinsfile matches your actual Docker Hub username.
help me fix ImagePullBackOff on my Kubernetes pods
Your full CI/CD pipeline is live. A single commit to your app repo now triggers an automated build, push, and deployment. Next up, you will add production health checks and see ArgoCD self-heal when things go wrong.
Secret mission
Production Health Checks and Self-Healing
Your app is running, but what happens when a pod silently breaks? In production, health checks tell Kubernetes when to restart unhealthy containers. ArgoCD self-healing reverts any manual cluster changes back to your Git-defined state. Add both to your deployment and watch your cluster recover from intentional sabotage.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on Docker Desktop Kubernetes, so there are no ongoing cloud costs.
Resources you used:
- Kubernetes namespaces: production, database, argocd.
- Kubernetes pods: PostgreSQL (1), Flask app (2), ArgoCD system pods.
- Docker Hub repository: your-dockerhub-username/devops-pipeline-app.
- Jenkins Pipeline job and credentials (dockerhub-creds, github-creds).
- GitHub repositories: devops-pipeline-app and devops-pipeline-manifests.
- Local project folders on your Desktop: devops-pipeline-app and devops-pipeline-manifests.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your pipeline.
- Docker Desktop Kubernetes will continue running and consuming local RAM and CPU.
- Jenkins remains available at its configured URL.
- Your Docker Hub repository stays public and free on the free tier.
- Your GitHub repositories remain accessible in your account.
Pause - I'll come back to this later
Shut down running processes to free up local memory and CPU while keeping all your files and configuration intact.
- Open Docker Desktop.
- Go to Settings, then select the Kubernetes tab.
- Click Stop to stop the Kubernetes cluster. This frees resources but preserves your configuration.
- Your local project files, GitHub repos, and Docker Hub images all remain untouched.
- To resume later, return to the same Kubernetes tab in Docker Desktop Settings and re-enable the cluster.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each group below to fully clean up.
Destroy Terraform-managed resources
- Navigate to your Terraform directory and destroy all resources by running:
cd ~/Desktop/devops-pipeline-app/terraform
terraform destroy
- Type yes when prompted to confirm.
Remove ArgoCD from the cluster
- Delete the ArgoCD namespace and all its resources by running:
kubectl delete namespace argocd
Reset Kubernetes entirely
- Open Docker Desktop.
- Go to Settings, then select the Kubernetes tab.
- Click Reset Kubernetes Cluster. This removes all namespaces, deployments, and services from your local cluster.
Delete the Docker Hub repository
- Sign in to Docker Hub at https://hub.docker.com.
- Click Repositories in the top navigation.
- Click your devops-pipeline-app repository.
- Select the Settings tab.
- Click Delete repository, type the repository name to confirm, then click Delete.
Delete Jenkins job and credentials
- In the Jenkins dashboard, click your pipeline job name.
- Click Delete Pipeline in the left sidebar and confirm.
- Navigate to Manage Jenkins, then Credentials.
- Select each credential (dockerhub-creds and github-creds) and click Delete to remove them.
Delete GitHub repositories
- On GitHub, navigate to your devops-pipeline-app repository.
- Click Settings.
- Scroll down to the Danger Zone section and click Delete this repository.
- Follow the confirmation prompts to type the repository name and confirm deletion.
- Repeat these steps for the devops-pipeline-manifests repository.
Delete local project folders
- Remove both local project directories by running:
cd ~/Desktop
rm -rf devops-pipeline-app devops-pipeline-manifests
- Confirm they are gone by running:
ls ~/Desktop
- You should no longer see devops-pipeline-app or devops-pipeline-manifests in the output.
Nice Work!
Nice Work!
You just built a production-grade CI/CD pipeline that mirrors how engineering teams ship software in the real world. From a single code push, your system automatically builds, containerizes, and deploys a database-backed application to Kubernetes with zero manual intervention.
You've learned how to:
- Provision Kubernetes infrastructure entirely as code with Terraform, creating namespaces, a PostgreSQL database deployment, and services from a single terraform apply command.
- Build a Jenkins CI pipeline that automatically builds a multi-stage Docker image, pushes it to Docker Hub, and updates deployment manifests in a separate Git repository.
- Deploy via GitOps with ArgoCD watching a manifests repository and automatically syncing changes to the cluster, following the industry-standard two-repo pattern that separates application source from deployment configuration.
- Added production health checks (liveness and readiness probes) and demonstrated ArgoCD self-healing that automatically recovers from pod failures and reverts manual drift (Secret Mission).
Ready to quiz yourself?