DevSecOps Pipeline with ArgoCD
Build a CI/CD pipeline with security scanning, GitOps, and Kubernetes monitoring.
Introduction
30 Second Summary
Every time a developer pushes code, production teams need confidence that the change is secure, high-quality, and deployed without manual intervention. A single pipeline that scans for secrets, checks code quality, builds containers, deploys automatically, and monitors everything in real time is the gold standard of modern software delivery.
In this project, you will build a complete DevSecOps pipeline that takes a Python application from code commit to monitored deployment on a local Kubernetes cluster.
What You'll Build
A production-grade CI/CD pipeline using GitHub Actions with four security gates, GitOps-based deployment via ArgoCD, and full-stack monitoring with Prometheus and Grafana.
By the end of this project, you'll have:
- A GitHub Actions workflow that runs secret scanning, static analysis, code quality checks, and container vulnerability scanning on every push.
- An ArgoCD instance performing automated GitOps deployments to a local Kubernetes cluster with self-healing and pruning.
- A live Prometheus and Grafana monitoring stack with pre-built dashboards visualizing your cluster and application metrics.
- Secret Mission: A custom alert rule that fires when your app's pod restarts too frequently, verified in Grafana's alerting panel.
Are there any prerequisites?
You should have a basic understanding of Git, Docker containers, and command-line usage. No prior experience with Kubernetes, ArgoCD, or monitoring tools is required.
Set Up Your Local Environment
A DevSecOps pipeline has many moving parts: a container runtime, a local Kubernetes cluster, a package manager for cluster add-ons, and a Git repository to tie everything together.
In this step, you will install every tool the pipeline depends on and create the repository structure that GitHub Actions and ArgoCD will use later.
In this step, get ready to:
- Install Docker Desktop and Minikube, then start a local Kubernetes cluster.
- Install Helm and verify kubectl access.
- Create a public GitHub repository with the project directory structure.
Install Docker Desktop and Minikube
Docker Desktop provides the container runtime that Minikube uses to run a Kubernetes cluster on your machine.
- Download and install Docker Desktop from docker.com/products/docker-desktop.
- Follow the installer prompts and restart your computer if asked.
- Open Docker Desktop and wait until the status bar shows the engine is running.
What is Docker Desktop?
Docker Desktop bundles the Docker engine, CLI, and container runtime into a single installer. Minikube uses Docker as its driver to spin up Kubernetes nodes inside containers instead of full virtual machines.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Confirm Docker is running by running this command:
docker --version
✔️ I see a version number
Docker is installed and running. You are ready to install Minikube.
ⓧ Command not found or Docker not running
Make sure Docker Desktop finished installing and is running. Look for the Docker icon in your system tray (Windows) or menu bar (macOS) showing a green or running state.
Restart Docker Desktop if needed, then try the command again.
Now install Minikube, the tool that creates a local Kubernetes cluster.
- Install Minikube by running the command for your operating system:
macOS
brew install minikube
Windows
winget install Kubernetes.minikube
What is Minikube?
Minikube creates a single-node Kubernetes cluster on your local machine. It gives you a production-like environment for testing deployments, services, and monitoring without needing a cloud provider.
- Start the Minikube cluster with extra resources allocated for all the components you will install later by running this command:
minikube start --driver=docker --cpus=4 --memory=8192
What does this command do?
The --driver=docker flag tells Minikube to run the cluster inside Docker containers. The --cpus=4 and --memory=8192 flags allocate 4 CPU cores and 8 GB of RAM to the cluster.
ArgoCD, Prometheus, and Grafana are resource-hungry. Without these extra resources, pods may fail to schedule or get evicted.
- Wait for the command to finish. You should see a message confirming your cluster is ready.
Cluster failed to start?
- Make sure Docker Desktop is running before you try minikube start. Minikube needs Docker's engine to create the cluster.
- On Windows, run your terminal as Administrator if you see permission errors.
- Verify your machine has at least 4 CPUs and 8 GB of free RAM. If not, reduce to --cpus=2 --memory=4096 (some later pods may be tight on resources).
If you are still stuck, help me troubleshoot my Minikube start failure.
Install Helm and kubectl
Helm is the package manager for Kubernetes. You will use it later to install the Prometheus and Grafana monitoring stack in a single command.
- Verify kubectl works through Minikube by running this command:
minikube kubectl -- version
✔️ I see client and server version info
kubectl is available through Minikube. You can use minikube kubectl -- as a drop-in replacement for kubectl throughout this project.
ⓧ I see an error
Make sure your Minikube cluster started successfully in the previous substep. Run minikube status to check. If the cluster is not running, repeat the minikube start command.
Why use minikube kubectl instead of installing kubectl separately?
Minikube bundles a version of kubectl that matches the Kubernetes version running in your cluster. This avoids version-mismatch errors that can happen when a system-installed kubectl is older or newer than the cluster.
- Install Helm by running the command for your operating system:
macOS
brew install helm
Windows
winget install Helm.Helm
- Confirm Helm installed correctly by running:
helm version
✔️ I see a version number
Helm is ready. You will use it in Step 8 to install the monitoring stack.
ⓧ Command not found
Close and reopen your terminal so it picks up the new PATH entries. Then run helm version again.
If it still fails, repeat the install command above for your operating system.
Create your GitHub repository
The pipeline needs a GitHub repository to store your application code, workflow definitions, and Kubernetes manifests.
- Go to github.com/new in your browser.
- Enter devops-demo-project as the repository name.
- Select Public visibility.
- Leave all other options at their defaults.
- Click Create repository.
Why does the repository need to be public?
SonarQube Cloud (which you will set up in Step 4) provides free code quality analysis for public repositories only. A private repository would require a paid plan.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone the repository and move into it by running these commands (replace the URL with the one GitHub shows you):
git clone [[REPO_URL="https://github.com/your-username/devops-demo-project.git"]]
cd devops-demo-project
- Create the project directory structure by running this command:
mkdir -p app k8s .github/workflows
What are these directories for?
- app/ holds your Python Flask application code.
- k8s/ holds Kubernetes deployment manifests that ArgoCD will sync.
- .github/workflows/ holds GitHub Actions workflow files that define your CI/CD pipeline.
- Verify the directories were created by running:
ls -a
You should see app, k8s, and .github in the output.
- Push the empty structure to GitHub by running these commands:
git add .
git commit -m "chore: initial project structure"
git push
Push failed with authentication error?
GitHub requires token-based authentication for HTTPS pushes. If you have not set up a personal access token or SSH key, follow GitHub's instructions to create one.
You can also use the GitHub CLI (gh auth login) to authenticate quickly.
If you are still stuck, help me authenticate with GitHub.
Your local environment is fully set up. Next, you will build the Flask application and containerize it with Docker.
Build the Flask Application and Dockerfile
Your environment is set up with a running Minikube cluster and a cloned repository. Now you need something to deploy through your pipeline.
In this step, you will build a Python Flask API with built-in Prometheus metrics instrumentation, then package it into a secure container image using a multi-stage Docker build.
In this step, get ready to:
- Create a Flask API with health and metrics endpoints.
- Write a multi-stage Dockerfile that produces a secure, minimal image.
- Build and test the container locally to verify all endpoints.
Create the Flask API with Prometheus metrics
Your application needs three endpoints. The root endpoint serves as a welcome page. The /health endpoint lets Kubernetes verify the app is alive. The /metrics endpoint exposes performance data in a format Prometheus can scrape.
- Create a file called requirements.txt inside your app/ directory with the following contents:
flask==3.1.1
prometheus_client==0.22.1
What are these dependencies?
The flask package is a lightweight Python web framework for building APIs. The prometheus_client library provides Counter, Histogram, and other metric types that output data in the format Prometheus expects.
- Create a file called app.py inside your app/ directory. Start by adding the imports, the Flask app instance, and the metric definitions:
from flask import Flask, jsonify
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
import time
app = Flask(__name__)
# Define a counter to track total requests by method, endpoint, and status
REQUEST_COUNT = Counter(
'app_requests_total',
'Total number of requests',
['method', 'endpoint', 'status']
)
# Define a histogram to track request latency in seconds
REQUEST_LATENCY = Histogram(
'app_request_latency_seconds',
'Request latency in seconds',
['method', 'endpoint']
)
What does this code do?
The Counter metric type only goes up. It counts total requests, labeled by HTTP method, endpoint path, and status code. This lets you query things like "how many GET requests hit the root endpoint?"
The Histogram metric type records how long each request takes. Prometheus automatically creates buckets so you can see latency distributions.
- Below the metric definitions in app/app.py, add the three route functions and the main block:
@app.route('/')
def home():
start_time = time.time()
response = jsonify({"message": "DevOps Demo App", "status": "running"})
# Increment the request counter with labels
REQUEST_COUNT.labels(method='GET', endpoint='/', status=200).inc()
# Record how long the request took
REQUEST_LATENCY.labels(method='GET', endpoint='/').observe(time.time() - start_time)
return response
@app.route('/health')
def health():
# Kubernetes liveness and readiness probes will hit this endpoint
return jsonify({"status": "healthy"}), 200
@app.route('/metrics')
def metrics():
# Return all registered metrics in Prometheus text format
return generate_latest(), 200, {'Content-Type': CONTENT_TYPE_LATEST}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
What do these routes do?
The / route returns a JSON welcome message and records both a counter increment and a latency observation. This is how real applications instrument their business endpoints.
The /health route returns a simple JSON response with status 200. Kubernetes will call this endpoint repeatedly to check if the pod is alive and ready to receive traffic.
The /metrics route calls generate_latest() which serializes all registered metrics into the Prometheus text exposition format. The CONTENT_TYPE_LATEST header tells Prometheus how to parse the response.
- Save both app/requirements.txt and app/app.py.
File not saving in the right place?
Make sure both files are inside the app/ directory of your cloned repository, not in the repository root. The Dockerfile expects app/requirements.txt and app/app.py at those exact paths.
help me find where to save my Flask app files
✔️ Awesome, I've got everything!
Great. Double check you have saved both files inside the app/ directory.
ⓧ I'd like to double check the full code
Here is the complete app/app.py file:
from flask import Flask, jsonify
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
import time
app = Flask(__name__)
REQUEST_COUNT = Counter(
'app_requests_total',
'Total number of requests',
['method', 'endpoint', 'status']
)
REQUEST_LATENCY = Histogram(
'app_request_latency_seconds',
'Request latency in seconds',
['method', 'endpoint']
)
@app.route('/')
def home():
start_time = time.time()
response = jsonify({"message": "DevOps Demo App", "status": "running"})
REQUEST_COUNT.labels(method='GET', endpoint='/', status=200).inc()
REQUEST_LATENCY.labels(method='GET', endpoint='/').observe(time.time() - start_time)
return response
@app.route('/health')
def health():
return jsonify({"status": "healthy"}), 200
@app.route('/metrics')
def metrics():
return generate_latest(), 200, {'Content-Type': CONTENT_TYPE_LATEST}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
And here is the complete app/requirements.txt file:
flask==3.1.1
prometheus_client==0.22.1
Write the multi-stage Dockerfile
A multi-stage Dockerfile separates the build environment from the runtime environment. The first stage installs dependencies with all the build tools. The second stage copies only what the app needs to run, producing a smaller and more secure image.
- Create a file called Dockerfile in the root of your repository (not inside app/) with the following contents:
# Stage 1: Install dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Production image
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app/ .
USER appuser
EXPOSE 5000
CMD ["python", "app.py"]
What does this Dockerfile do?
Stage 1 (builder) installs Python packages into a separate /install prefix. The --no-cache-dir flag keeps the layer small by skipping pip's download cache.
Stage 2 starts fresh from the same slim base. It creates a non-root user (appuser with UID 10001) so the container never runs as root. Then it copies only the installed packages from Stage 1 and the application source code.
The USER appuser instruction ensures the app process runs without root privileges. This is a security best practice that prevents attackers from escalating privileges if they compromise the application.
- Save Dockerfile.
Dockerfile not being recognized?
The file must be named exactly Dockerfile with a capital D and no file extension. It must live in the repository root directory, at the same level as the app/ folder.
help me fix my Dockerfile placement
✔️ Awesome, I've got everything!
Great. Make sure Dockerfile is saved in the root of your repository.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
# Stage 1: Install dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Production image
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app/ .
USER appuser
EXPOSE 5000
CMD ["python", "app.py"]
Build and test the container locally
Before pushing anything to your pipeline, you should verify the application works inside its container. This catches issues early and confirms your Dockerfile is correct.
- Build the Docker image by running this command from your repository root:
docker build -t devops-demo-app:local .
What does this command do?
The -t devops-demo-app:local flag names the image devops-demo-app and tags it as local. The . at the end tells Docker to use the current directory as the build context.
- You should see Docker pulling the python:3.12-slim base image, installing dependencies, and creating the final image. The last line should show a successful build with an image ID.
- Run the container by executing:
docker run -p 5000:5000 devops-demo-app:local
What does this command do?
The -p 5000:5000 flag maps port 5000 on your machine to port 5000 inside the container. This lets you access the Flask app at http://localhost:5000 from your browser or terminal.
- Open a new terminal window (keep the container running in the first one).
- Test all three endpoints by running these commands one at a time:
curl http://localhost:5000/
curl http://localhost:5000/health
curl http://localhost:5000/metrics
- You should see {"message":"DevOps Demo App","status":"running"} from the root endpoint, {"status":"healthy"} from the health endpoint, and a block of Prometheus-formatted text from the metrics endpoint.
- Stop the container by pressing Ctrl+C in the terminal where it is running.
Seeing 'port is already allocated'?
Another process is using port 5000 on your machine. Stop it first, or change the port mapping to -p 5001:5000 and test at http://localhost:5001 instead.
If the build fails with a network error, make sure Docker Desktop is running and connected to the internet.
help me troubleshoot my Docker build or run
Your application is containerized and verified. Next up, you will wire security scanning into a GitHub Actions workflow so every code push gets automatically checked for vulnerabilities before it can be deployed.
Configure Security Scanning in GitHub Actions
Your Flask application and Dockerfile are working locally. But shipping code without automated security checks means vulnerabilities, leaked secrets, and insecure dependencies can slip through unnoticed.
In this step, you'll create a GitHub Actions workflow that runs three security scanners in parallel every time you push code to your repository.
In this step, get ready to:
- Set up Gitleaks to scan for hardcoded secrets.
- Add Semgrep for static application security testing (SAST).
- Configure Trivy to scan your Docker image for vulnerabilities.
Set up the Gitleaks secret scanner
Hardcoded secrets like API keys and passwords in source code are one of the most common security failures. Gitleaks scans your entire Git history to catch these before they reach production.
- Create a new file called ci.yml inside your .github/workflows/ directory.
- Add the workflow header and the Gitleaks job by pasting the following code:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
gitleaks:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
What does this code do?
The on: block triggers the workflow on every push to main and every pull request targeting main.
- fetch-depth: 0 clones the full Git history so Gitleaks can scan every commit for secrets, not just the latest one.
- gitleaks/gitleaks-action@v2 runs the Gitleaks scanner. It uses the built-in GITHUB_TOKEN secret that GitHub provides automatically to every workflow.
- Save ci.yml.
Why fetch the full Git history?
A shallow clone (the default) only contains the latest commit. If a secret was committed three weeks ago and removed yesterday, a shallow clone would miss it entirely. Full history ensures Gitleaks catches secrets no matter when they were introduced.
Add SAST with Semgrep
Secret scanning catches credentials, but it won't find logic bugs or insecure coding patterns. Semgrep performs SAST (Static Application Security Testing) to detect vulnerabilities like SQL injection, command injection, and unsafe deserialization in your Python code.
- In ci.yml, add the following semgrep job directly below the gitleaks job (at the same indentation level as gitleaks:):
semgrep:
name: SAST Scanning
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config=auto --error
What does this code do?
- container: image: semgrep/semgrep runs the entire job inside the official Semgrep Docker container. No installation step needed.
- --config=auto tells Semgrep to automatically select the best rulesets for the languages it detects in your repository.
- --error makes Semgrep exit with a non-zero code if it finds any issues. This causes the workflow job to fail, blocking the pipeline.
- Save ci.yml.
Add container scanning with Trivy
Your application code might be clean, but the Docker image pulls in an entire operating system and dozens of libraries. Trivy scans the built container image for known vulnerabilities in both OS packages and application dependencies.
- In ci.yml, add the following trivy job directly below the semgrep job:
trivy:
name: Container Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devops-demo-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: 'devops-demo-app:${{ github.sha }}'
format: 'table'
exit-code: '1'
severity: 'HIGH,CRITICAL'
What does this code do?
- The job first builds your Docker image and tags it with the Git commit SHA for traceability.
- aquasecurity/trivy-action@0.35.0 scans that exact image for vulnerabilities.
- severity: 'HIGH,CRITICAL' limits the scan to only the most dangerous vulnerabilities. Low and medium findings are logged but don't block the pipeline.
- exit-code: '1' makes the job fail if any HIGH or CRITICAL vulnerabilities are found. This is your security gate.
- Save ci.yml.
- Push your changes to GitHub by running these commands in your terminal:
git add .github/workflows/ci.yml
git commit -m "Add security scanning workflow"
git push origin main
- In your browser, open your GitHub repository.
- Click the Actions tab at the top of the repository page.
- You should see a workflow run named CI/CD Pipeline with three jobs: Secret Scanning, SAST Scanning, and Container Scanning.
Why do all three jobs run in parallel?
Each job in the jobs: block is independent. GitHub Actions runs them simultaneously on separate machines, which means your security checks complete in the time of the slowest scanner rather than the sum of all three.
Don't see the workflow running?
- Make sure the file is located at exactly .github/workflows/ci.yml (note: workflows is plural). GitHub ignores workflow files in any other path.
- Confirm you pushed to the main branch. The workflow only triggers on pushes to main or pull requests targeting main.
- Check that Actions are enabled for your repository under Settings > Actions > General.
My GitHub Actions workflow isn't appearing in the Actions tab after pushing to main.
✔️ Awesome, I've got everything!
Great. Double check you've saved ci.yml and that all three jobs appear in the Actions tab.
ⓧ I'd like to double check the full code
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
gitleaks:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
semgrep:
name: SAST Scanning
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config=auto --error
trivy:
name: Container Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devops-demo-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: 'devops-demo-app:${{ github.sha }}'
format: 'table'
exit-code: '1'
severity: 'HIGH,CRITICAL'
Your pipeline now has three automated security gates. Next up, you'll add SonarQube code quality analysis to enforce standards before any code can be deployed.
Add SonarQube Code Quality Analysis
Your pipeline already catches hardcoded secrets, security vulnerabilities, and container issues. But these pattern-based scanners don't tell you whether your code is actually maintainable or well-structured.
SonarQube Cloud analyzes code quality holistically. It detects bugs, code smells, technical debt, and security hotspots that pattern-based tools miss. In this step, you'll connect your repository to SonarQube Cloud and add a quality gate that blocks deployment when standards aren't met.
In this step, get ready to:
- Set up a SonarQube Cloud project linked to your GitHub repository.
- Configure the SonarQube scanner in your GitHub Actions workflow.
- Enforce a quality gate that blocks deployment if standards aren't met.
Set up SonarQube Cloud
SonarQube Cloud is a hosted code analysis platform. It's free for public repositories and integrates directly with GitHub.
- Open sonarcloud.io in your browser.
- Click Log in and select GitHub from the list of DevOps platforms.
- Sign in with your GitHub credentials.
- You'll be prompted to connect your GitHub organization (or personal account) with SonarQube Cloud. Follow the guided setup to import your organization.
- Select the Free plan when prompted.
- Click Create Organization.
What is SonarQube Cloud?
SonarQube Cloud (formerly SonarCloud) is a cloud-based static analysis service that goes beyond security scanning. It measures code reliability, maintainability, and duplication across your entire codebase.
Unlike Semgrep which focuses on pattern-matching security rules, SonarQube provides holistic code health metrics and a quality gate mechanism that acts as an automated code reviewer.
- On the Analyze projects page, select your DevOps demo repository.
- Click Set up.
- Select Previous version as your new code definition.
- Click Create project.
Your project is now created. You need to note your project key and organization key from the project dashboard. You'll also need a token for authentication.
- Click your account avatar in the top right corner of the SonarQube Cloud interface.
- Select My Account, then click Access Tokens.
- Under Generate Tokens, type a name like github-actions and click Generate Token.
- Copy the token immediately. You will not be able to see it again after leaving this page.
Copy your token now
SonarQube Cloud only shows the token value once at generation time. If you navigate away or dismiss the notification without copying, you'll need to generate a new token.
Configure SonarQube in GitHub Actions
Your workflow needs the token to authenticate with SonarQube Cloud. You'll store it as a GitHub repository secret, then create the configuration file that tells the scanner where to look.
- Open your GitHub repository in the browser.
- Click Settings.
- In the Security section of the sidebar, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, type SONAR_TOKEN.
- In the Secret field, paste the token you copied from SonarQube Cloud.
- Click Add secret.
Why a repository secret?
GitHub encrypts secrets and only exposes them to workflow runners at runtime. Your token never appears in logs or in your code, keeping your SonarQube Cloud account secure.
Now create the configuration file that tells the SonarQube scanner which project to analyze.
- Create a file called sonar-project.properties in the root of your repository (same level as your Dockerfile) with the following content:
sonar.projectKey=[[SONAR_PROJECT_KEY="your-org_devops-demo-project"]]
sonar.organization=[[SONAR_ORG="your-org"]]
sonar.sources=app
sonar.python.version=3.12
sonar.sourceEncoding=UTF-8
What does this configuration do?
- sonar.projectKey identifies your project in SonarQube Cloud. Find this value on your project dashboard page.
- sonar.organization identifies your SonarQube Cloud organization. This matches the organization key you set during setup.
- sonar.sources=app tells the scanner to analyze only the app/ directory where your Python code lives.
- sonar.python.version=3.12 tells SonarQube which Python version your code targets so it applies the correct rules.
- Save sonar-project.properties.
Next, add the SonarQube scan job to your CI workflow.
- Open .github/workflows/ci.yml in your editor.
- Find the trivy job at the bottom of the file. Add the following sonarqube job below it:
sonarqube:
name: SonarQube Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v7.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
What does this job do?
- fetch-depth: 0 clones the full Git history. SonarQube needs this to accurately track new vs. existing issues across commits.
- SonarSource/sonarqube-scan-action@v7.1.0 runs the official SonarQube scanner, which reads your sonar-project.properties file and uploads analysis results to SonarQube Cloud.
- The SONAR_TOKEN environment variable authenticates the scanner using the secret you just created.
- Save ci.yml.
Scan failing with authentication error?
- Double-check that your GitHub secret is named exactly SONAR_TOKEN (all caps, with underscore).
- Verify your sonar-project.properties file is in the repository root, not inside the app/ folder.
- Make sure the project key and organization in sonar-project.properties match exactly what SonarQube Cloud shows on your project dashboard.
Help me troubleshoot my SonarQube scan authentication error in GitHub Actions
Enforce the quality gate
The scan alone reports issues but doesn't block anything. A quality gate is an automated pass/fail check. If your code doesn't meet the configured standards (bugs, vulnerabilities, code smells above a threshold), the gate fails and the pipeline stops.
- In .github/workflows/ci.yml, find the SonarQube Scan step you just added.
- Add the following step directly below it (still inside the sonarqube job's steps: list):
- name: SonarQube Quality Gate
uses: SonarSource/sonarqube-quality-gate-action@v1
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
What does the quality gate step do?
This step polls SonarQube Cloud and waits for the quality gate result. If the gate passes, the step succeeds. If it fails (meaning your code introduced too many issues), the step exits with a non-zero code and the entire job fails.
The timeout-minutes: 5 prevents the step from hanging indefinitely if SonarQube Cloud is slow to respond.
- Save ci.yml.
In the next step, you'll create the build-and-push job that depends on all four security and quality jobs (including this sonarqube job) passing first.
Why SonarQube on top of Semgrep?
Semgrep excels at pattern-based security detection. SonarQube provides a different dimension: it measures code maintainability, duplication, complexity, and provides a threshold-based gate. Together, they cover security (Semgrep) and code health (SonarQube) as complementary layers.
✔️ Awesome, I've got everything!
Great. Double check you've saved both sonar-project.properties and ci.yml.
ⓧ I'd like to double check the full code
Here is the complete sonar-project.properties file:
sonar.projectKey=your-org_devops-demo-project
sonar.organization=your-org
sonar.sources=app
sonar.python.version=3.12
sonar.sourceEncoding=UTF-8
Here is the complete .github/workflows/ci.yml file:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
gitleaks:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
semgrep:
name: SAST Scanning
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config=auto --error
trivy:
name: Container Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devops-demo-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: 'devops-demo-app:${{ github.sha }}'
format: 'table'
exit-code: '1'
severity: 'HIGH,CRITICAL'
sonarqube:
name: SonarQube Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v7.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: SonarQube Quality Gate
uses: SonarSource/sonarqube-quality-gate-action@v1
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
Your pipeline now has four layers of automated checks running in parallel: secret scanning, SAST, container scanning, and code quality analysis. Next up, you'll add the Docker build-and-push job that only runs after all four gates pass.
Add Docker Build and Push to the Pipeline
Your security gates are in place. Every push is now scanned for secrets, vulnerabilities, and code quality issues.
The next piece of the puzzle is turning your verified code into a deployable container image. In this step, you will add a Docker Hub build-and-push job to your pipeline, and then wire up an automatic manifest update that triggers ArgoCD deployments.
In this step, get ready to:
- Configure Docker Hub credentials as GitHub repository secrets.
- Add a build-and-push job that only runs after all security gates pass.
- Add a manifest update job that commits the new image tag for GitOps deployment.
Configure Docker Hub credentials
Your pipeline needs to authenticate with Docker Hub to push container images. You will store your Docker Hub username and an access token as GitHub repository secrets so the workflow can use them securely.
Create a Docker Hub access token
- Open your browser and sign in to Docker Hub.
- Click your avatar in the top-right corner, then select Account settings.
- Select Personal access tokens in the sidebar.
- Click Generate new token.
- Enter a description like devops-demo-pipeline.
- Set the access permission to Read, Write, Delete.
- Click Generate.
- Copy the token immediately and save it somewhere safe. You will not be able to see it again.
Why a personal access token instead of your password?
Access tokens are scoped, revocable, and required if you have two-factor authentication enabled. They prevent your main account password from being exposed in automation.
Add secrets to your GitHub repository
- Open your repository on GitHub.
- Click Settings in the repository navigation bar.
- In the Security section of the left sidebar, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, type DOCKERHUB_USERNAME.
- In the Secret field, enter your Docker Hub username.
- Click Add secret.
- Click New repository secret again.
- In the Name field, type DOCKERHUB_TOKEN.
- In the Secret field, paste the personal access token you just copied.
- Click Add secret.
You should now see three secrets listed: DOCKERHUB_TOKEN, DOCKERHUB_USERNAME, and SONAR_TOKEN.
Don't see the Settings tab?
You need admin or owner permissions on the repository. If you created the repo yourself, you already have this access. If you are collaborating, ask the repository owner to add you as an admin.
help me fix permissions on my GitHub repository so I can add secrets
Add the build and push job
With credentials in place, you can add the job that builds your Docker image and pushes it to Docker Hub. This job only runs after all four security gates pass, and only on pushes to the main branch.
- Open .github/workflows/ci.yml in your code editor.
- Find the closing lines of the sonarqube job (the last line with SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}).
- Add the following build-and-push job below the sonarqube job by pasting this code:
build-and-push:
name: Build and Push Image
needs: [gitleaks, semgrep, trivy, sonarqube]
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/devops-demo-app
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
What does this job do?
- `needs: [gitleaks, semgrep, trivy, sonarqube]` makes this job wait for all four security gates to pass before running.
- `if: github.event_name == 'push' && github.ref == 'refs/heads/main'` ensures images are only built on pushes to main, not on pull requests.
- `docker/login-action@v3` authenticates with Docker Hub using your secrets.
- `docker/metadata-action@v5` generates two tags: one based on the Git commit SHA (for traceability) and a latest tag.
- `docker/build-push-action@v5` builds the image using your multi-stage Dockerfile and pushes it to Docker Hub.
Why gate the build on all security jobs?
If any scanner finds a critical vulnerability or a leaked secret, the build never runs. This prevents insecure images from reaching your registry. In production teams, this is a non-negotiable quality bar.
Add the manifest update job
The final job in your pipeline updates the Kubernetes deployment manifest with the new image tag. When this commit lands in the repository, ArgoCD detects the change and automatically deploys the new version.
- Still in .github/workflows/ci.yml, add the following update-manifest job below the build-and-push job you just added:
update-manifest:
name: Update Kubernetes Manifest
needs: [build-and-push]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update image tag in deployment
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
sed -i "s|image: .*/devops-demo-app:.*|image: ${{ secrets.DOCKERHUB_USERNAME }}/devops-demo-app:${SHORT_SHA}|" k8s/deployment.yaml
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add k8s/deployment.yaml
git diff --staged --quiet || git commit -m "chore: update image tag to ${{ github.sha }}"
git push
What does this job do?
- `needs: [build-and-push]` ensures this runs only after the image has been successfully pushed.
- `SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)` extracts the first 7 characters of the commit SHA for a short, readable image tag.
- `sed -i` performs an in-place replacement of the image reference in k8s/deployment.yaml with the new tag.
- `git diff --staged --quiet || git commit` only commits if the file actually changed, preventing empty commits.
- The pushed commit triggers ArgoCD to sync the new image to your cluster automatically.
What is GitOps?
GitOps treats your Git repository as the single source of truth for infrastructure and deployments. Instead of running manual kubectl apply commands, you update a manifest file in Git and a tool (ArgoCD) automatically reconciles your cluster to match.
- Save .github/workflows/ci.yml.
YAML indentation errors?
YAML is whitespace-sensitive. Both new jobs must be indented at the same level as the existing gitleaks, semgrep, trivy, and sonarqube jobs (two spaces from the left margin, nested under jobs:).
help me fix YAML indentation in my GitHub Actions workflow
✔️ Awesome, I've got everything!
Great. Double check you have saved .github/workflows/ci.yml before moving on.
ⓧ I'd like to double check the full code
Here is the complete .github/workflows/ci.yml file with all six jobs:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
gitleaks:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
semgrep:
name: SAST Scanning
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep scan --config=auto --error
trivy:
name: Container Scanning
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t devops-demo-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.35.0
with:
image-ref: 'devops-demo-app:${{ github.sha }}'
format: 'table'
exit-code: '1'
severity: 'HIGH,CRITICAL'
sonarqube:
name: SonarQube Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v7.1.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: SonarQube Quality Gate
uses: SonarSource/sonarqube-quality-gate-action@v1
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
build-and-push:
name: Build and Push Image
needs: [gitleaks, semgrep, trivy, sonarqube]
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/devops-demo-app
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
update-manifest:
name: Update Kubernetes Manifest
needs: [build-and-push]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update image tag in deployment
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
sed -i "s|image: .*/devops-demo-app:.*|image: ${{ secrets.DOCKERHUB_USERNAME }}/devops-demo-app:${SHORT_SHA}|" k8s/deployment.yaml
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add k8s/deployment.yaml
git diff --staged --quiet || git commit -m "chore: update image tag to ${{ github.sha }}"
git push
Push your updated workflow to GitHub
- Commit and push your changes by running these commands:
git add .github/workflows/ci.yml
git commit -m "feat: add build-and-push and manifest update jobs"
git push
- Open your repository on GitHub and click the Actions tab.
- You should see a new workflow run triggered by your push. The security jobs run in parallel, and the build-and-push job waits for all of them to finish.
What should I see?
In the Actions tab, you will see the workflow graph showing gitleaks, semgrep, trivy, and sonarqube running in parallel. Once all four pass, build-and-push starts. After it completes, update-manifest runs last.
The update-manifest job will fail at this point because k8s/deployment.yaml does not exist yet. That is expected. You will create it in the next step.
Build-and-push job skipped?
This job only runs on pushes to main. If you pushed to a different branch or opened a pull request, the job will be skipped by the if: condition. Make sure you are pushing directly to main.
help me figure out why my build-and-push job is being skipped in GitHub Actions
Your complete CI/CD pipeline is now wired up. Next, you will deploy ArgoCD to your cluster so it can watch for those manifest updates and deploy automatically.
Deploy ArgoCD to Your Cluster
Your CI/CD pipeline builds and pushes Docker images, but it still needs a way to deploy them to your cluster. This is where ArgoCD comes in.
ArgoCD watches your Git repository for changes to Kubernetes manifests and automatically syncs them to your cluster. This pattern is called GitOps. Instead of running manual kubectl apply commands, ArgoCD continuously reconciles the cluster state with what is declared in Git.
In this step, get ready to:
- Install ArgoCD on your Minikube cluster.
- Access the ArgoCD web UI and log in.
- Create an ArgoCD Application pointing to your repository.
Install ArgoCD
ArgoCD runs inside your Kubernetes cluster as a set of controllers and a web server. You install it by applying the official manifests to a dedicated namespace.
- Create the argocd namespace and install the ArgoCD v3.4.3 manifests by running these commands:
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.4.3/manifests/install.yaml
What do these commands do?
- kubectl create namespace argocd creates a dedicated namespace to isolate all ArgoCD components from your application workloads.
- kubectl apply with --server-side --force-conflicts installs the Custom Resource Definitions (CRDs) and controllers. These flags are required because some ArgoCD CRDs exceed the default annotation size limit.
- Wait for the ArgoCD pods to become ready by running:
kubectl get pods -n argocd
You should see several pods (including argocd-server, argocd-repo-server, and argocd-application-controller) with a status of Running. It may take 1-2 minutes for all pods to start.
Pods stuck in Pending or CrashLoopBackOff?
Make sure your Minikube cluster has enough resources. This project requires 4 CPUs and 8GB of memory. If you started Minikube with fewer resources, delete the cluster and recreate it with the correct flags.
help me debug ArgoCD pods that are stuck in Pending or CrashLoopBackOff on my Minikube cluster
Access the ArgoCD UI
ArgoCD's web server is not exposed outside the cluster by default. You need to forward a local port to access the UI from your browser.
- Port-forward the ArgoCD server to your local machine by running:
kubectl port-forward svc/argocd-server -n argocd 8080:443
What does this command do?
This forwards traffic from localhost:8080 on your machine to port 443 (HTTPS) on the ArgoCD server inside the cluster. The terminal will stay occupied while the port-forward is active.
- Open a new terminal window. You need a separate terminal because the port-forward command keeps running in the first one.
- Retrieve the initial admin password by running the appropriate command for your operating system:
macOS
- Run this command to decode the admin password:
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
The password prints directly in your terminal with no newline at the end. Copy it.
Windows
- Run this PowerShell command to decode the admin password:
$encoded = kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}"
[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($encoded))
The decoded password prints in your terminal. Copy it.
- Open your browser and navigate to https://localhost:8080.
- Your browser will show a certificate warning because ArgoCD uses a self-signed certificate. Click Advanced and then Proceed to localhost (or the equivalent option in your browser).
- Enter admin as the username and paste the password you copied from the terminal.
- Click Sign In.
You should see the ArgoCD dashboard with an empty application list. This confirms ArgoCD is running and accessible.
Can't reach localhost:8080?
Make sure the port-forward command is still running in your other terminal. If it exited, re-run it. Also confirm your Minikube cluster is still running with minikube status.
help me troubleshoot why I cannot access ArgoCD at https://localhost:8080 after running the port-forward command
Create the ArgoCD Application
An ArgoCD Application is a custom resource that tells ArgoCD which Git repository to watch and where to deploy the manifests. You will create one that points to the k8s/ directory in your repository.
- Create a new file called k8s/argocd-app.yaml in your editor and paste the following content:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: devops-demo-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/[[GITHUB_USERNAME="your-github-username"]]/[[REPO_NAME="your-repo-name"]].git
targetRevision: main
path: k8s
destination:
server: https://kubernetes.default.svc
namespace: default
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
What does this manifest configure?
- source.repoURL points to your GitHub repository so ArgoCD knows where to pull manifests from.
- source.path: k8s tells ArgoCD to only look at the k8s/ directory for Kubernetes manifests.
- destination.server targets the local cluster (Minikube) using the internal Kubernetes API address.
- syncPolicy.automated enables automatic deployment. prune: true removes resources deleted from Git. selfHeal: true reverts manual changes made directly to the cluster.
- Save k8s/argocd-app.yaml.
- Apply the ArgoCD Application to your cluster by running:
kubectl apply -f k8s/argocd-app.yaml
You should see output confirming the application was created: application.argoproj.io/devops-demo-app created.
Why apply with kubectl instead of through the UI?
Defining ArgoCD Applications as YAML files follows the GitOps principle of declaring everything in code. This makes your deployment configuration reproducible and version-controlled alongside your Kubernetes manifests.
Seeing a connection or sync error?
ArgoCD will show the application as OutOfSync or Missing until you create the actual deployment manifests in the next step. This is expected.
If you see a connection error to the repository, double check that your GitHub repository is public and the repoURL matches your actual repository URL.
help me fix ArgoCD showing a repository connection error for my GitHub repository
✔️ Awesome, I've got everything!
Great. Double check you have saved k8s/argocd-app.yaml and the kubectl apply command returned a success message.
ⓧ I'd like to double check the full code
Here is what k8s/argocd-app.yaml should look like (with your actual GitHub username and repository name substituted):
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: devops-demo-app
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-username/devops-demo-project.git
targetRevision: main
path: k8s
destination:
server: https://kubernetes.default.svc
namespace: default
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
ArgoCD is now installed and watching your repository. In the next step, you will create the Kubernetes deployment manifests that ArgoCD will sync to your cluster.
Set Up Kubernetes Manifests for GitOps
Your ArgoCD application is configured and watching the k8s/ directory in your repository. Right now, there is nothing for it to deploy besides its own application manifest.
In this step, you will create the Kubernetes manifests that describe your application's desired state. Once you push these files, ArgoCD will detect the change and automatically deploy your app to the cluster.
In this step, get ready to:
- Create a Deployment manifest with health probes and resource limits.
- Create a Service and ServiceMonitor for networking and metrics collection.
- Push to GitHub and verify ArgoCD syncs automatically.
Create the Deployment manifest
A Kubernetes Deployment tells the cluster how many copies of your container to run, what image to use, and how to check if the application is healthy.
- Create a new file called k8s/deployment.yaml in your project's k8s/ folder and paste the following content:
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops-demo-app
namespace: default
labels:
app: devops-demo-app
spec:
replicas: 2
selector:
matchLabels:
app: devops-demo-app
template:
metadata:
labels:
app: devops-demo-app
spec:
containers:
- name: devops-demo-app
image: [[DOCKERHUB_USERNAME="your-dockerhub-username"]]/devops-demo-app:latest
ports:
- containerPort: 5000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
livenessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
What does this manifest do?
This Deployment creates 2 replicas of your Flask app. Each pod gets CPU and memory limits so it cannot consume more cluster resources than allocated.
The livenessProbe hits /health every 30 seconds. If it fails, Kubernetes restarts the container. The readinessProbe checks the same endpoint every 10 seconds to decide if the pod should receive traffic.
- Replace the your-dockerhub-username placeholder with your actual Docker Hub username.
- Save k8s/deployment.yaml.
Not sure what your Docker Hub username is?
It is the same value you stored in the DOCKERHUB_USERNAME GitHub secret earlier. You can also find it by logging in to hub.docker.com and checking the top-right corner.
help me find my Docker Hub username
Create the Service and ServiceMonitor
Pods get new IP addresses every time they restart. A Service gives your pods a stable internal address that other components can use to reach your app.
- Create a new file called k8s/service.yaml and paste this content:
apiVersion: v1
kind: Service
metadata:
name: devops-demo-app
namespace: default
labels:
app: devops-demo-app
spec:
selector:
app: devops-demo-app
ports:
- protocol: TCP
port: 80
targetPort: 5000
type: ClusterIP
What does this Service do?
This creates a ClusterIP Service that accepts traffic on port 80 and forwards it to port 5000 on your pods. The selector field matches pods with the label app: devops-demo-app, which is exactly what your Deployment creates.
- Save k8s/service.yaml.
Now you need a ServiceMonitor so that Prometheus knows where to scrape your application's metrics.
- Create a new file called k8s/servicemonitor.yaml and paste this content:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: devops-demo-app
namespace: default
labels:
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app: devops-demo-app
endpoints:
- port: "80"
path: /metrics
interval: 15s
What does the ServiceMonitor do?
A ServiceMonitor is a custom resource that the Prometheus Operator understands. It tells Prometheus to scrape the /metrics endpoint every 15 seconds.
The release: kube-prometheus-stack label is critical. It must match the Helm release name so the Prometheus Operator picks up this rule.
- Save k8s/servicemonitor.yaml.
ServiceMonitor not being picked up later?
The most common cause is a label mismatch. The release: kube-prometheus-stack label must exactly match the name you used when running helm install. You will install the monitoring stack in the next step using that exact release name.
help me debug why my ServiceMonitor is not being discovered by Prometheus
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in the k8s/ directory.
ⓧ I'd like to double check the full code
Here is the complete content for each file:
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops-demo-app
namespace: default
labels:
app: devops-demo-app
spec:
replicas: 2
selector:
matchLabels:
app: devops-demo-app
template:
metadata:
labels:
app: devops-demo-app
spec:
containers:
- name: devops-demo-app
image: your-dockerhub-username/devops-demo-app:latest
ports:
- containerPort: 5000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
livenessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
apiVersion: v1
kind: Service
metadata:
name: devops-demo-app
namespace: default
labels:
app: devops-demo-app
spec:
selector:
app: devops-demo-app
ports:
- protocol: TCP
port: 80
targetPort: 5000
type: ClusterIP
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: devops-demo-app
namespace: default
labels:
release: kube-prometheus-stack
spec:
selector:
matchLabels:
app: devops-demo-app
endpoints:
- port: "80"
path: /metrics
interval: 15s
Verify ArgoCD sync
With all three manifests saved locally, you need to push them to GitHub. ArgoCD is configured for automated sync, so it will detect the new files and deploy your application automatically.
- Stage, commit, and push your changes by running these commands:
git add k8s/deployment.yaml k8s/service.yaml k8s/servicemonitor.yaml
git commit -m "feat: add deployment, service, and servicemonitor manifests"
git push origin main
What happens after you push?
ArgoCD polls your repository and detects that the k8s/ directory now has new manifests. Because you configured automated sync with self-healing and pruning, ArgoCD applies these manifests to your cluster within a few seconds.
- Switch back to the ArgoCD UI at https://localhost:8080.
- Click on the devops-demo-app application tile.
- Wait 30-60 seconds for ArgoCD to detect the push.
- Verify the application status shows Synced and Healthy in the top bar.
You should see a visual graph of your Deployment, ReplicaSet, Pods, and Service, all connected and green.
- Confirm your pods are running by switching back to your terminal and running:
kubectl get pods
You should see two pods with names starting with devops-demo-app- and a status of Running.
ArgoCD still shows OutOfSync or Missing?
Make sure you pushed to the main branch. Your ArgoCD Application manifest has targetRevision: main, so it only watches that branch.
If the app shows Degraded or pods are in ImagePullBackOff, it means the Docker image does not exist on Docker Hub yet. This is expected if your CI pipeline has not run successfully. You can temporarily test by building and loading the image locally with minikube image load devops-demo-app:latest.
help me troubleshoot why ArgoCD is not syncing my Kubernetes manifests
Your application is now deployed and running on Kubernetes through a fully automated GitOps workflow. Next up, you will install a monitoring stack so you can observe your cluster and application metrics in real time.
Install Prometheus and Grafana Monitoring
Your application is deployed, secured, and automatically syncing through ArgoCD. But how do you know it's actually healthy in production?
Without monitoring, you're flying blind. In this step, you'll install a full observability stack that gives you real-time visibility into your cluster and application metrics.
In this step, get ready to:
- Add the Prometheus community Helm chart repository.
- Install the kube-prometheus-stack with Prometheus, Grafana, and Alertmanager.
- Access Grafana dashboards and verify your application's custom metrics.
Add the Prometheus community Helm repository
Helm charts are distributed through repositories. Before you can install the monitoring stack, you need to tell Helm where to find it.
- Add the official Prometheus community chart repository by running this command:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
- Update your local chart index so Helm knows about the latest available charts:
helm repo update
You should see a message confirming the repository was added and the update completed successfully.
Seeing a connection error?
Make sure your internet connection is active. Helm needs to reach the chart repository URL to download the index.
help me troubleshoot my Helm repo add failing
Install kube-prometheus-stack
The kube-prometheus-stack is a single Helm chart that installs an entire monitoring ecosystem. It bundles Prometheus, Grafana, Alertmanager, kube-state-metrics, and node-exporter together with 20+ pre-built dashboards.
- Install the full monitoring stack into a dedicated namespace by running:
helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack --namespace monitoring --create-namespace --set grafana.adminPassword=admin123
What does this command do?
- helm install kube-prometheus-stack creates a Helm release named kube-prometheus-stack.
- prometheus-community/kube-prometheus-stack tells Helm which chart to install from the repository you just added.
- --namespace monitoring --create-namespace puts all monitoring resources in their own namespace, keeping them separate from your application.
- --set grafana.adminPassword=admin123 sets the Grafana admin password so you can log in.
This installation takes 1-2 minutes. Wait for the command to complete and display the release status.
- Verify all monitoring pods are running by running:
kubectl get pods -n monitoring
You should see several pods with names starting with kube-prometheus-stack, prometheus, alertmanager, and grafana. All should show Running status within a few minutes.
Pods stuck in Pending or CrashLoopBackOff?
If pods remain in Pending state, your Minikube cluster may not have enough resources. Confirm you started Minikube with --cpus=4 --memory=8192. If pods are in CrashLoopBackOff, wait 2-3 minutes for all dependent services to start. The monitoring stack has several components that depend on each other.
help me debug my kube-prometheus-stack pods not starting
Access Grafana and explore dashboards
Grafana runs inside your cluster and isn't directly accessible from your browser. You'll use port-forwarding to create a tunnel from your local machine into the cluster.
- Port-forward Grafana to your local machine by running:
kubectl port-forward svc/kube-prometheus-stack-grafana 3000:80 -n monitoring
What does this command do?
This maps port 3000 on your machine to port 80 on the Grafana service inside the monitoring namespace. Your terminal stays occupied while port-forwarding is active.
- Open a browser and navigate to http://localhost:3000.
- Log in with username admin and password admin123.
- Click the hamburger menu icon (three horizontal lines) in the top left.
- Click Dashboards.
- Browse the list of pre-built dashboards. Open the Kubernetes / Compute Resources / Cluster dashboard to see CPU, memory, and network usage across your cluster.
You should see live graphs showing resource consumption for your Minikube node and all running pods, including your devops-demo-app workload.
Why does this dashboard already have data?
The kube-prometheus-stack automatically configures Prometheus to scrape cluster-level metrics from kubelet, kube-state-metrics, and node-exporter. These built-in scrapers start collecting data the moment the stack is installed.
Your application's custom metrics (app_requests_total and app_request_latency_seconds) are collected separately through the ServiceMonitor you created in the previous step.
- Open a new terminal window (keep the Grafana port-forward running in the first one).
- Port-forward Prometheus to verify your custom metrics are being scraped:
kubectl port-forward svc/kube-prometheus-stack-prometheus 9090:9090 -n monitoring
- Open http://localhost:9090 in your browser.
- Type app_requests_total in the expression input box and click Execute.
You should see your application's custom metric listed in the results. If no data appears yet, the ServiceMonitor may need a minute to be picked up by Prometheus.
Custom metrics not showing up?
Check that your ServiceMonitor has the label release: kube-prometheus-stack. This label must match your Helm release name exactly for Prometheus to discover the scrape target.
Also confirm your application pods are running in the default namespace with the label app: devops-demo-app. The ServiceMonitor selector must match your Service labels.
help me troubleshoot why Prometheus is not scraping my custom application metrics
Secret mission
Create a Custom Alert Rule for Pod Restarts
Production teams don't wait for users to report outages. They build automated alert rules that fire the moment something goes wrong. In this secret mission, you'll create a PrometheusRule that detects when your app's pod restarts too frequently, then deliberately crash your app to watch the alert fire in real time.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine using Minikube, with free-tier services for Docker Hub, SonarQube Cloud, and GitHub. There are no ongoing cloud costs.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Minikube cluster (local, no cost).
- Docker Hub repository (free tier).
- SonarQube Cloud project (free for public repos).
- GitHub repository (free).
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your pipeline.
- Your Minikube cluster continues running and using local CPU and memory (4 CPUs, 8 GB RAM as configured).
- Docker Desktop must remain running for Minikube to function.
- Your Docker Hub repository, SonarQube Cloud project, and GitHub repository remain available at no cost.
Pause - I'll come back to this later
Shut down running processes to free up CPU and memory, but keep all your data intact so you can resume later.
- Stop any active port-forward processes by pressing Ctrl+C in each terminal window running them.
- Stop your Minikube cluster by running:
minikube stop
- This preserves all your cluster data. Resume later by running:
minikube start
- Your Docker Hub repository, SonarQube Cloud project, and GitHub repository stay available with no changes needed.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your local cluster and all workloads running in it.
- Stop any active port-forward processes by pressing Ctrl+C in each terminal window running them.
- Delete your Minikube cluster entirely by running:
minikube delete
- This removes the VM, all Kubernetes resources (ArgoCD, Prometheus, Grafana, your application), and all associated files.
- Delete your local project folder.
macOS
cd ~/Desktop
rm -rf devops-demo-project
Windows
cd ~\Desktop
Remove-Item -Recurse -Force devops-demo-project
- Delete your Docker Hub repository:
- Sign in to Docker Hub.
- Select My Hub, then Repositories.
- Select your devops-demo-app repository.
- Select the Settings tab.
- Select Delete repository.
- Enter the repository name to confirm, then select Delete Repository Forever.
- Delete your SonarQube Cloud project:
- Sign in to sonarcloud.io.
- Navigate to your project.
- Select Administration, then Deletion.
- Enter the project name and select Delete.
- Delete your GitHub repository (optional):
- Go to your repository on GitHub.
- Select Settings at the top of the repository page.
- Scroll to the bottom and select Delete this repository.
- Confirm the deletion by typing the repository name.
That's a Wrap!
That's a Wrap!
Nice work! You've built a complete DevSecOps pipeline that takes a Python Flask application from code commit to monitored production deployment.
You've learned how to:
- Build a GitHub Actions CI pipeline with four security and quality gates (Gitleaks for secret scanning, Semgrep for SAST, SonarQube for code quality, and Trivy for container vulnerability scanning) that blocks deployments until code meets security and quality standards.
- Implement a GitOps continuous deployment workflow using ArgoCD that automatically syncs verified container images from Docker Hub to a Kubernetes cluster with automated sync, self-healing, and pruning.
- Deploy a full Prometheus and Grafana monitoring stack with pre-built Kubernetes dashboards, custom application metrics, and real-time observability.
- Create a custom PrometheusRule alert that detects pod restart anomalies for automated incident detection.
Ready to quiz yourself?