Kubernetes CI/CD with Helm and Monitoring

Deploy a monitored Flask app to Kubernetes with Jenkins, Prometheus, and Grafana.

Introduction

30 Second Summary

Think about how apps like Netflix or Spotify stay online 24/7. Behind the scenes, teams rely on automated pipelines to ship updates and monitoring dashboards to catch problems before users notice.

In this project, you will build that same production DevOps infrastructure around a gym workout tracker app. You'll deploy it to Kubernetes with a Jenkins CI/CD pipeline, then set up Prometheus and Grafana to monitor everything in real time.

What You'll Build

A multi-page FitTrack gym workout tracker deployed to a local Kubernetes cluster with Helm-managed monitoring, a Jenkins CI/CD pipeline, and custom Grafana dashboards with alerting.

By the end of this project, you'll have:

  • A FitTrack Flask application with built-in Prometheus metrics, deployed to Kubernetes with production-grade health checks and resource limits.
  • A Helm-deployed monitoring stack (Prometheus + Grafana) configured for automatic metrics discovery across your cluster.
  • A Jenkins CI/CD pipeline that builds, pushes, and deploys your app to the cluster on every code change.
  • A custom Grafana dashboard and alerting rule for monitoring app health. Plus a secret mission to package your entire app as a reusable Helm chart.

Are there any prerequisites?

This project assumes you have basic familiarity with `kubectl` and Kubernetes concepts like pods, deployments, and services. You should also have Rancher Desktop, Jenkins, Docker, and a free Docker Hub account already set up on your machine.

Before We Start

Before We Start...

Install Helm and Verify Your Environment

Your Kubernetes cluster is running on Rancher Desktop, but deploying a production monitoring stack by hand would mean writing dozens of YAML files and wiring them together yourself. That is where Helm comes in.

Helm is a package manager for Kubernetes. It bundles all the manifests, config, and dependencies for a tool into a single installable unit called a chart. In this step, you will install Helm, learn its core commands, and connect it to the monitoring charts you will use throughout this project.

In this step, get ready to:
  • Install Helm and verify it connects to your Rancher Desktop cluster.
  • Explore Helm basics like repos, charts, and releases.
  • Add the prometheus-community Helm repository for the monitoring stack.
Install Helm and verify your cluster connection

Helm needs two things to work: the helm CLI on your machine, and a running Kubernetes cluster it can talk to. Your Rancher Desktop cluster handles the second part.

macOS

  • Install Helm 3 using Homebrew by running this command:
brew install helm@3

Why Helm 3 instead of Helm 4?

Helm 4 was released recently with breaking changes. The kube-prometheus-stack chart and most Kubernetes tooling documentation still targets Helm 3. Starting with Helm 3 gives you the most stable and well-documented experience.

Homebrew not installed?

If you see command not found: brew, install Homebrew first by visiting brew.sh and following the instructions. Then re-run the brew install command above.

Help me install Homebrew on my Mac

Windows

  • Install Helm 3 using Chocolatey by running this command in an Administrator PowerShell:
choco install kubernetes-helm

Why Helm 3 instead of Helm 4?

Helm 4 was released recently with breaking changes. The kube-prometheus-stack chart and most Kubernetes tooling documentation still targets Helm 3. Starting with Helm 3 gives you the most stable and well-documented experience.

Chocolatey not installed?

If you see an error about choco not being recognized, install Chocolatey first by visiting chocolatey.org/install and following the instructions. Then re-run the install command above.

Help me install Chocolatey on Windows

  • Verify Helm installed correctly by checking its version:
helm version

You should see output that includes a version number starting with v3, like v3.19.0.

I see a version number

Helm is installed and ready to go.

Command not found

Your shell cannot find the helm binary. Try closing and reopening your terminal, then run helm version again.

If it still does not work, the binary may not be on your PATH.

Help me fix Helm not being found on my PATH

  • Verify Helm can connect to your cluster by listing any existing releases:
helm list --all-namespaces

You should see an empty table with column headers but no rows. This confirms Helm is talking to your Rancher Desktop cluster. No releases are installed yet.

Seeing a connection error?

Make sure Rancher Desktop is running. Open Rancher Desktop and confirm the Kubernetes status indicator shows a green checkmark.

If the error mentions kubectl or kubeconfig, verify your cluster is reachable by running:

kubectl get nodes

You should see at least one node in Ready status. If not, restart Rancher Desktop and try again.

Help me fix my Kubernetes cluster connection

Explore Helm basics with hands-on commands

Before deploying anything, it helps to understand the three core Helm concepts you will use throughout this project.

What are charts, repos, and releases?

  • A chart is a package of Kubernetes YAML templates and default configuration. Think of it like a blueprint for deploying an application.
  • A repo (repository) is where charts are published and downloaded from. It works like npm for JavaScript or pip for Python.
  • A release is a specific installation of a chart on your cluster. You can install the same chart multiple times, and each installation is its own release with its own name.
  • See what Helm repos you currently have by running:
helm repo list

You should see Error: no repositories to show. That is expected since you have not added any repos yet.

  • Check what charts are available to you right now by running:
helm search repo

This also returns nothing because your repo list is empty. In the next substep, you will add a repo and this command will start returning results.

  • Confirm there are no releases on your cluster by running:
helm list --all-namespaces

You already ran this command, but now the output makes more sense. The empty table means zero charts have been installed as releases on your cluster.

Add the prometheus-community Helm repository

The monitoring stack you will deploy in the next step lives in a community-maintained Helm repo. You need to add this repo so Helm knows where to download the chart from.

  • Add the prometheus-community Helm repository and update your local chart index by running these commands:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

What do these commands do?

  • helm repo add registers the remote repo URL under the name prometheus-community on your machine.
  • helm repo update downloads the latest chart index from every registered repo. This is like running apt update or brew update before installing packages.
  • Verify the repo was added by running:
helm repo list

This time, you should see prometheus-community listed with its URL.

  • Search for the specific chart you will install by running:
helm search repo kube-prometheus-stack

You should see prometheus-community/kube-prometheus-stack listed with its latest version and description.

What is kube-prometheus-stack?

This single chart bundles Prometheus (metrics collection), Grafana (dashboards and visualization), Alertmanager (alert routing), and a set of pre-built dashboards and alerting rules. Instead of deploying and wiring together each tool separately, the chart handles all of it in one install.

Not seeing the chart?

Double-check you typed the repo URL correctly in the helm repo add command. Run helm repo list to confirm the URL is https://prometheus-community.github.io/helm-charts.

If the URL is wrong, remove the repo with helm repo remove prometheus-community and add it again.

Help me fix my Helm repo setup

Helm is installed, connected to your cluster, and loaded with the chart repo you need. Next up, you will use Helm to deploy a full Prometheus and Grafana monitoring stack with a single command.

Deploy the Monitoring Stack with Helm

In Step 1, you installed Helm and added the prometheus-community chart repository. Your cluster is ready for its first real deployment.

The kube-prometheus-stack chart bundles Prometheus, Grafana, and Alertmanager into one deployable package. Out of the box, its resource settings are tuned for production servers. You'll create a custom values file to right-size everything for local development, then deploy the full stack with a single command.

In this step, get ready to:
  • Create a custom Helm values file that configures the monitoring stack for local development.
  • Install the kube-prometheus-stack chart into your cluster.
  • Access the Prometheus and Grafana UIs running inside your Kubernetes cluster.
Create the custom values file

Helm charts come with default configuration values. You can override any of these defaults by providing your own values file at install time.

For the monitoring stack, you need to reduce resource requests so everything fits on a local machine. You also need to flip two critical settings that control how Prometheus discovers your application metrics.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a project folder called fittrack-project and move into it by running these commands:
mkdir fittrack-project
cd fittrack-project
  • Confirm the folder exists by running:
ls ~/Desktop
  • You should see fittrack-project in the output.
  • Open the fittrack-project folder in your preferred code editor.
  • Create a new file called monitoring-values.yaml in the root of the fittrack-project folder.
  • Add the Prometheus configuration to monitoring-values.yaml by pasting in this code:
# Prometheus server configuration
prometheus:
  prometheusSpec:
    # Keep metrics data for 7 days
    retention: 7d
    # Lightweight resources for local development
    resources:
      requests:
        cpu: 200m
        memory: 512Mi
      limits:
        cpu: 500m
        memory: 1Gi
    # Discover ServiceMonitors in ALL namespaces, not just this one
    serviceMonitorSelectorNilUsesHelmValues: false
    # Discover PrometheusRules in ALL namespaces too
    ruleSelectorNilUsesHelmValues: false

What do those two discovery settings do?

By default, Prometheus only discovers ServiceMonitor and PrometheusRule resources that match the Helm release's own labels. That means custom resources you create in other namespaces would be invisible.

Setting both to false tells Prometheus to discover resources across all namespaces. Your FitTrack app will live in a separate fittrack namespace later, so this setting is essential.

  • Save monitoring-values.yaml.
  • You won't see any changes in the cluster yet. The next chunk completes the configuration.
  • Below the prometheus section, add the Grafana and supporting service configuration by pasting this code:
# Grafana dashboard UI
grafana:
  # Custom admin password (default would be prom-operator)
  adminPassword: "fittrack-admin"
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    limits:
      cpu: 250m
      memory: 256Mi

# Alert routing and notification manager
alertmanager:
  alertmanagerSpec:
    resources:
      requests:
        cpu: 50m
        memory: 64Mi
      limits:
        cpu: 100m
        memory: 128Mi

# Collects hardware and OS metrics from each node
nodeExporter:
  resources:
    requests:
      cpu: 50m
      memory: 32Mi
    limits:
      cpu: 100m
      memory: 64Mi

# Tracks the state of Kubernetes objects (pods, deployments)
kubeStateMetrics:
  resources:
    requests:
      cpu: 50m
      memory: 64Mi
    limits:
      cpu: 100m
      memory: 128Mi

What does this code do?

  • grafana sets a custom admin password and caps CPU and memory so the dashboard stays lightweight.
  • alertmanager handles alert routing and notifications. You are giving it minimal resources since you only have one alert rule in this project.
  • nodeExporter scrapes hardware-level metrics (CPU, disk, memory) from each cluster node.
  • kubeStateMetrics tracks the state of Kubernetes objects like pods and deployments.
  • Save monitoring-values.yaml.

Awesome, I've got everything!

Double check you've saved your file before moving on.

I'd like to double check the full code

prometheus:
  prometheusSpec:
    retention: 7d
    resources:
      requests:
        cpu: 200m
        memory: 512Mi
      limits:
        cpu: 500m
        memory: 1Gi
    serviceMonitorSelectorNilUsesHelmValues: false
    ruleSelectorNilUsesHelmValues: false

grafana:
  adminPassword: "fittrack-admin"
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    limits:
      cpu: 250m
      memory: 256Mi

alertmanager:
  alertmanagerSpec:
    resources:
      requests:
        cpu: 50m
        memory: 64Mi
      limits:
        cpu: 100m
        memory: 128Mi

nodeExporter:
  resources:
    requests:
      cpu: 50m
      memory: 32Mi
    limits:
      cpu: 100m
      memory: 64Mi

kubeStateMetrics:
  resources:
    requests:
      cpu: 50m
      memory: 64Mi
    limits:
      cpu: 100m
      memory: 128Mi
Install the kube-prometheus-stack chart

With your values file ready, one Helm command deploys the entire monitoring stack into a dedicated namespace.

Resource heads-up

The monitoring stack recommends at least 4 GB of RAM and 2 CPU cores. Make sure Rancher Desktop is allocated enough resources before proceeding. You can check this in Rancher Desktop's preferences under the virtual machine settings.

  • Make sure your terminal is inside the fittrack-project folder (where monitoring-values.yaml lives).
  • Install the monitoring stack by running this command:
helm install prometheus-stack prometheus-community/kube-prometheus-stack \
    --namespace monitoring \
    --create-namespace \
    -f monitoring-values.yaml

What does this command do?

  • prometheus-stack is the release name. Helm uses this to track the installation so you can upgrade or uninstall it later.
  • prometheus-community/kube-prometheus-stack tells Helm which chart to install from the repository you added in Step 1.
  • --namespace monitoring --create-namespace creates a new namespace called monitoring and installs everything into it.
  • -f monitoring-values.yaml applies your custom configuration, overriding the chart's defaults.

This command takes 1-2 minutes to complete. Helm downloads the chart, renders all the templates with your values, and applies dozens of Kubernetes resources to your cluster.

  • Once the command finishes, verify the pods are starting by running:
kubectl get pods -n monitoring

You should see 6 or more pods listed. Some may show ContainerCreating or Init status initially. Wait about a minute and run the command again until all pods show Running.

Pods stuck in Pending or CrashLoopBackOff?

  • Pending usually means your cluster does not have enough CPU or memory. Open Rancher Desktop's preferences and increase the virtual machine resources to at least 4 GB RAM and 2 CPU cores, then restart Rancher Desktop.
  • CrashLoopBackOff on the Prometheus pod can happen if the values file has a YAML indentation error. Double check that your monitoring-values.yaml matches the full code in the double-check tab above.
  • Run kubectl describe pod <pod-name> -n monitoring to see detailed error messages for a specific pod.

Help me troubleshoot pods that won't start in my monitoring namespace

Access the Prometheus and Grafana UIs

Prometheus and Grafana are running inside your cluster, but their services are not exposed to your host machine by default. kubectl port-forward creates a tunnel from a port on your machine to a service inside the cluster.

Each port-forward command keeps the tunnel open until you stop it, so you will need separate terminal tabs for each one.

  • Open a new terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows/Linux).
  • Start the Grafana port-forward by running this command:
kubectl port-forward svc/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 cluster. The terminal stays occupied while the tunnel is active.

  • You should see a message like Forwarding from 127.0.0.1:3000 -> 3000 in the terminal. Leave this tab running.
  • Open another new terminal tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Windows/Linux).
  • Start the Prometheus port-forward by running this command:
kubectl port-forward svc/prometheus-stack-kube-prometheus-prometheus 9090:9090 -n monitoring
  • You should see a similar forwarding message. Leave this tab running too.

Now open both UIs in your browser to confirm they are working.

  • Open your browser and go to http://localhost:9090.
  • You should see the Prometheus web UI with a query bar at the top. This confirms Prometheus is running.
  • Open a new browser tab and go to http://localhost:3000.
  • You should see the Grafana login page.
  • Enter admin in the Email or username field.
  • Enter fittrack-admin in the Password field.
  • Click Log in.

Where does that password come from?

The password fittrack-admin is the value you set under grafana.adminPassword in your monitoring-values.yaml. Without that override, the chart default password would be prom-operator.

You should now see the Grafana home dashboard with a welcome panel and links to explore data sources.

Can't reach localhost:3000 or localhost:9090?

  • Check that both port-forward terminal tabs are still running. If you see an error or the command exited, re-run the port-forward command.
  • Make sure nothing else is using port 3000 or 9090 on your machine. You can check with lsof -i :3000 (macOS/Linux) or netstat -ano | findstr :3000 (Windows).
  • Verify all pods are Running by running kubectl get pods -n monitoring again in a separate terminal tab.

Help me troubleshoot port-forwarding to Grafana and Prometheus

Your monitoring stack is live. Prometheus is collecting cluster metrics, and Grafana is ready to visualize them. Next up, you'll dig into exactly what Helm created and explore the dashboards that came pre-built with the chart.

Explore What Helm Created

Your monitoring stack is deployed and running. In the previous step, a single helm install command created dozens of Kubernetes resources across your cluster.

Before building on top of this stack, it is worth understanding what Helm actually created. In this step, you will inspect the release from the Helm side, explore the Kubernetes resources from the kubectl side, and browse the pre-built Grafana dashboards that come for free with the chart.

In this step, get ready to:
  • Inspect your Helm release with helm commands to understand how charts, values, and manifests fit together.
  • Explore the Kubernetes resources Helm created, including CRDs like ServiceMonitors and PrometheusRules.
  • Browse the pre-built Grafana dashboards already collecting cluster metrics.
Inspect the Helm release with helm commands

Helm keeps track of every chart you install as a release. Three commands give you full visibility into a release: what is installed, what values were applied, and what Kubernetes manifests were generated.

  • List all Helm releases in the monitoring namespace by running this command:
helm list -n monitoring

You should see a table with one row showing your prometheus-stack release, its status as deployed, the chart version, and the app version.

What does this output tell you?

  • NAME is the release name you chose during helm install. This name is how you reference the release in all future Helm commands.
  • CHART shows which chart package was installed and its version.
  • STATUS tells you whether the release is currently active. deployed means all resources were successfully applied to the cluster.

Don't see any releases?

If the output is empty, the install from Step 2 may not have completed successfully. Re-run the install command from Step 2 and wait for it to finish before continuing.

Help me troubleshoot my Helm install of kube-prometheus-stack

  • View the custom values you applied during install by running this command:
helm get values prometheus-stack -n monitoring

You should see the exact contents of your monitoring-values.yaml file printed back to you.

What are user-supplied values?

Every Helm chart ships with a default values.yaml containing hundreds of settings. When you pass -f monitoring-values.yaml during install, your file overrides only the specific defaults you want to change. Everything else stays at the chart's defaults.

The helm get values command shows only your overrides. To see the full merged result (your values plus all defaults), add the --all flag.

  • Preview the rendered Kubernetes manifests that Helm generated from the chart templates by running this command:
helm get manifest prometheus-stack -n monitoring | head -80

You should see raw Kubernetes YAML scrolling by. Each section separated by --- is a separate resource that Helm applied to your cluster.

How do charts, values, and manifests fit together?

Think of it as a three-layer system. The chart is a package of Go templates with placeholder variables. Your values fill in those placeholders with your specific configuration. The manifest is the final rendered YAML that Helm sends to Kubernetes.

The kube-prometheus-stack chart generates thousands of lines of YAML from your small values file. That is the real power of Helm: one file of overrides produces an entire production monitoring stack.

Pro tip: count the lines

Run helm get manifest prometheus-stack -n monitoring | wc -l to see just how many lines the chart generated. It is typically several thousand for kube-prometheus-stack.

Explore the deployed Kubernetes resources

The Helm side shows you the release and its templates. Now look at the same resources from the Kubernetes side to see what is actually running in your cluster.

  • List all standard resources in the monitoring namespace by running this command:
kubectl get all -n monitoring

You should see a long list of pods, services, deployments, replicasets, statefulsets, and daemonsets.

What did Helm create here?

  • Prometheus runs as a StatefulSet. It scrapes metrics from everything in your cluster and stores them in a time-series database.
  • Grafana runs as a Deployment. It connects to Prometheus and visualizes metrics as dashboards.
  • Alertmanager runs as a StatefulSet. It receives alerts from Prometheus and routes them to notification channels.
  • Node Exporter runs as a DaemonSet (one pod per node). It collects hardware and OS-level metrics like CPU, memory, and disk usage.
  • kube-state-metrics runs as a Deployment. It generates metrics about the state of Kubernetes objects like pods, deployments, and nodes.
  • View the ConfigMaps that store Grafana dashboards and Prometheus configuration by running this command:
kubectl get configmaps -n monitoring

You should see many ConfigMaps with names like prometheus-stack-grafana and entries that contain dashboard JSON. Each one represents a pre-built dashboard that was packaged inside the chart.

  • The chart also installed Custom Resource Definitions (CRDs) that extend Kubernetes with monitoring-specific resource types. List them by running this command:
kubectl get crd | grep monitoring.coreos.com

You should see several CRDs including servicemonitors.monitoring.coreos.com and prometheusrules.monitoring.coreos.com.

What are CRDs?

CRDs let you add new resource types to Kubernetes beyond the built-in ones like Pods and Services. The Prometheus Operator introduces its own resource types so you can configure monitoring using the same kubectl workflow you already know.

You will use two of these CRDs later in this project: ServiceMonitor (tells Prometheus what to scrape) and PrometheusRule (defines alerting conditions).

  • See what ServiceMonitors already exist in the monitoring namespace by running this command:
kubectl get servicemonitors -n monitoring

You should see several ServiceMonitors that the chart created automatically. Each one tells Prometheus to scrape a specific service, like the node exporter, kube-state-metrics, or Prometheus itself.

  • Check what PrometheusRules are installed by running this command:
kubectl get prometheusrules -n monitoring

You should see pre-built alert rules for common cluster issues like high CPU, memory pressure, and pod crash loops. These came bundled with the chart.

Browse the pre-built Grafana dashboards

The kube-prometheus-stack chart ships with over a dozen Grafana dashboards already configured. These dashboards visualize cluster health, node performance, and Kubernetes object states with no extra setup required.

Need to restart your port-forward?

If you closed your terminal since Step 2, your port-forward sessions are no longer running. Re-establish the Grafana port-forward before continuing.

kubectl port-forward svc/prometheus-stack-grafana 3000:80 -n monitoring
  • Open your browser and navigate to http://localhost:3000.
  • Log in with username admin and password fittrack-admin.
  • Click the hamburger menu (three horizontal lines) in the top left corner of the Grafana UI.
  • Click Dashboards.

You should see a list of folders and dashboards. These were all created automatically by the Helm chart.

  • Find and click the dashboard called Kubernetes / Compute Resources / Cluster.

You should see panels showing CPU usage, memory usage, and network bandwidth across your entire cluster. All of this data is being collected by Prometheus and visualized by Grafana in real time.

  • Go back to the dashboards list and explore a few others. Try Kubernetes / Compute Resources / Namespace (Pods) to see per-pod resource usage in the monitoring namespace.

Where do these dashboards come from?

The kube-prometheus-stack chart packages dashboard JSON inside ConfigMaps. When Grafana starts, it reads those ConfigMaps and loads the dashboards automatically. This is why you saw so many ConfigMaps earlier in this step.

Later in this project, you will build your own custom dashboard for the FitTrack app. It will sit alongside these built-in dashboards.

Seeing "No data" on the dashboard panels?

Prometheus needs a few minutes after startup to collect enough data for the panels to populate. Wait 2-3 minutes and refresh the page.

Also check that all pods in the monitoring namespace are in Running status by running kubectl get pods -n monitoring in a separate terminal window.

Help me fix Grafana dashboards showing no data

You now understand the full picture of what Helm deployed. You have explored the release, the rendered manifests, the Kubernetes resources, and the pre-built dashboards. Next up, you will build the FitTrack app itself and instrument it with Prometheus metrics so your monitoring stack has something custom to track.

Build the FitTrack App with Prometheus Metrics

In the last step, you explored the monitoring stack that Helm created and browsed the pre-built Grafana dashboards already collecting cluster metrics.

Now it is time to build the application those dashboards will monitor. You will create FitTrack, a gym workout tracker built with Flask that exposes Prometheus metrics so the monitoring stack can scrape real application data.

In this step, get ready to:
  • Create a Flask app with Dashboard, Workouts, and Progress pages.
  • Instrument the app with Prometheus metrics for request tracking, workout counts, and active users.
  • Write a production Dockerfile with a multi-stage build and health check.
Create the project structure and templates

FitTrack needs a project folder to hold the application code, templates, and container configuration. Let's set that up along with the HTML pages the app will serve.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder with a templates directory and move into it by running these commands:
mkdir -p fittrack/templates
cd fittrack
  • Confirm the folder was created by running:
ls

You should see a templates directory in the output.

  • Open the fittrack folder in your code editor.
  • Create a new file called requirements.txt in the root of the fittrack folder and paste the following code into it:
flask==3.1.3
prometheus-flask-exporter==0.23.2
prometheus-client==0.24.1

What are these packages?

  • flask is the Python web framework that powers FitTrack's routes and page rendering.
  • prometheus-flask-exporter automatically tracks request counts and latency for every Flask route. It also creates a /metrics endpoint that Prometheus can scrape.
  • prometheus-client is the core Prometheus library for defining custom metrics like counters and gauges.

Now let's create the HTML templates. FitTrack uses Jinja2 template inheritance, where a base layout holds the shared navigation and styling, and each page template extends it.

  • Create a new file called base.html inside the templates folder and paste the following code into it:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>FitTrack - {% block title %}{% endblock %}</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
        nav { background: #1a1a2e; padding: 1rem 2rem; display: flex; gap: 2rem; align-items: center; }
        nav a { color: #e0e0e0; text-decoration: none; font-weight: 500; transition: color 0.2s; }
        nav a:hover, nav a.active { color: #4fc3f7; }
        nav .brand { color: #4fc3f7; font-size: 1.4rem; font-weight: 700; }
        .container { max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
        .card { background: white; border-radius: 12px; padding: 1.5rem; margin-bottom: 1rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
        .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
        .stat-number { font-size: 2.5rem; font-weight: 700; color: #1a1a2e; }
        .stat-label { color: #666; margin-top: 0.25rem; }
        h1 { color: #1a1a2e; margin-bottom: 1rem; }
        form { display: flex; gap: 1rem; flex-wrap: wrap; align-items: end; }
        select, input, button { padding: 0.6rem 1rem; border-radius: 8px; border: 1px solid #ddd; font-size: 1rem; }
        button { background: #4fc3f7; color: white; border: none; cursor: pointer; font-weight: 600; }
        button:hover { background: #039be5; }
        .workout-item { padding: 0.75rem 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }
        .workout-item:last-child { border-bottom: none; }
    </style>
</head>
<body>
    <nav>
        <span class="brand">FitTrack</span>
        <a href="/" class="{% if request.path == '/' %}active{% endif %}">Dashboard</a>
        <a href="/workouts" class="{% if request.path == '/workouts' %}active{% endif %}">Workouts</a>
        <a href="/progress" class="{% if request.path == '/progress' %}active{% endif %}">Progress</a>
    </nav>
    <div class="container">
        {% block content %}{% endblock %}
    </div>
</body>
</html>

What does base.html do?

This is the shared layout for every page. It defines the dark navigation bar with links to Dashboard, Workouts, and Progress. The {% block content %} tag is where each page template injects its own content.

The active class on each nav link highlights the current page by checking request.path. All styling is embedded directly in the template so the app stays self-contained.

  • Create a new file called dashboard.html inside the templates folder and paste the following code into it:
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="stats">
    <div class="card">
        <div class="stat-number">{{ today_count }}</div>
        <div class="stat-label">Workouts Today</div>
    </div>
    <div class="card">
        <div class="stat-number">{{ total_count }}</div>
        <div class="stat-label">Total Workouts</div>
    </div>
    <div class="card">
        <div class="stat-number">{{ streak }}</div>
        <div class="stat-label">Day Streak</div>
    </div>
</div>
{% endblock %}
  • Create a new file called workouts.html inside the templates folder and paste the following code into it:
{% extends "base.html" %}
{% block title %}Workouts{% endblock %}
{% block content %}
<h1>Log a Workout</h1>
<div class="card">
    <form method="POST" action="/workouts">
        <div>
            <label for="type">Type</label>
            <select name="type" id="type">
                {% for wtype in workout_types %}
                <option value="{{ wtype }}">{{ wtype }}</option>
                {% endfor %}
            </select>
        </div>
        <div>
            <label for="duration">Duration (min)</label>
            <input type="number" name="duration" id="duration" value="30" min="1" max="300">
        </div>
        <button type="submit">Log Workout</button>
    </form>
</div>
<h2 style="margin-top:1.5rem;">Recent Workouts</h2>
<div class="card">
    {% if recent %}
        {% for w in recent|reverse %}
        <div class="workout-item">
            <span><strong>{{ w.type }}</strong> - {{ w.duration }} min</span>
            <span>{{ w.date }} {{ w.time }}</span>
        </div>
        {% endfor %}
    {% else %}
        <p style="color:#666;">No workouts yet. Log your first one above!</p>
    {% endif %}
</div>
{% endblock %}
  • Create a new file called progress.html inside the templates folder and paste the following code into it:
{% extends "base.html" %}
{% block title %}Progress{% endblock %}
{% block content %}
<h1>Progress</h1>
<div class="card">
    <div class="stat-number">{{ total }}</div>
    <div class="stat-label">Total Workouts Logged</div>
</div>
<h2 style="margin-top:1.5rem;">Workout History</h2>
<div class="card">
    {% if workouts %}
        {% for w in workouts|reverse %}
        <div class="workout-item">
            <span><strong>{{ w.type }}</strong> - {{ w.duration }} min</span>
            <span>{{ w.date }} {{ w.time }}</span>
        </div>
        {% endfor %}
    {% else %}
        <p style="color:#666;">No workout history yet. Start logging workouts to see your progress!</p>
    {% endif %}
</div>
{% endblock %}

What do the page templates do?

Each page template extends base.html and fills in the content block with its own page layout.

  • dashboard.html shows three stat cards for today's workouts, total workouts, and a streak counter.
  • workouts.html has a form for logging new workouts and a list of recent entries.
  • progress.html displays the total workout count and the full history.
  • Save all four template files and requirements.txt.

You won't see a visual change yet. These templates come alive once you create the Flask app in the next substep.

Not sure your file structure is right?

Run ls templates/ from the fittrack folder. You should see base.html, dashboard.html, workouts.html, and progress.html. If any file is missing, make sure you created it inside the templates folder and not the project root.

My FitTrack template files are not in the right place. How should the fittrack project folder be structured?

Add Flask routes and Prometheus instrumentation

The templates define what the user sees. Now you need the Python code that handles requests, passes data to those templates, and exposes metrics for Prometheus to scrape.

  • Create a new file called app.py in the root of the fittrack folder.
  • Paste the following code into the top of app.py to set up imports, metrics, and in-memory storage:
from flask import Flask, render_template, request, jsonify
from prometheus_flask_exporter import PrometheusMetrics
from prometheus_client import Counter, Gauge
import time
import random

# Initialize the Flask app and attach Prometheus metrics
app = Flask(__name__)
metrics = PrometheusMetrics(app)
metrics.info('fittrack_app_info', 'FitTrack Application Info', version='1.0.0')

# Custom metrics for tracking business-level data
workouts_logged = Counter(
    'fittrack_workouts_logged_total',
    'Total number of workouts logged',
    ['workout_type']
)
active_users = Gauge(
    'fittrack_active_users',
    'Number of currently active users'
)

# In-memory storage for workout data
workout_log = []

WORKOUT_TYPES = ['Running', 'Weight Training', 'Cycling', 'Swimming', 'Yoga', 'HIIT']

What does this code do?

  • PrometheusMetrics(app) wraps the Flask app to automatically track request counts, duration, and errors for every route. It also creates a /metrics endpoint that Prometheus scrapes.
  • workouts_logged is a Counter that only goes up. It tracks how many workouts have been logged, broken down by workout type.
  • active_users is a Gauge that can go up or down. It represents the current number of active users at any moment.

Why prometheus-flask-exporter instead of raw prometheus_client?

The prometheus_client library lets you define custom metrics, but you would need to manually create a /metrics endpoint and manually track every route's request count and latency.

The prometheus-flask-exporter library wraps prometheus_client and handles all of that automatically. One line of setup gives you request duration histograms, request totals, and exception counters for free.

  • Add the route functions below the WORKOUT_TYPES list in app.py:

@app.route('/')
def dashboard():
    # Count today's workouts and calculate streak
    today_count = len([w for w in workout_log if w['date'] == time.strftime('%Y-%m-%d')])
    total_count = len(workout_log)
    streak = min(total_count, 7)
    # Simulate active user count for the dashboard
    active_users.set(random.randint(5, 25))
    return render_template('dashboard.html',
                           today_count=today_count,
                           total_count=total_count,
                           streak=streak)


@app.route('/workouts', methods=['GET', 'POST'])
def workouts():
    if request.method == 'POST':
        # Build a workout entry from the form data
        workout = {
            'type': request.form.get('type', 'Running'),
            'duration': request.form.get('duration', '30'),
            'date': time.strftime('%Y-%m-%d'),
            'time': time.strftime('%H:%M')
        }
        workout_log.append(workout)
        # Increment the Prometheus counter for this workout type
        workouts_logged.labels(workout_type=workout['type']).inc()
    return render_template('workouts.html',
                           workout_types=WORKOUT_TYPES,
                           recent=workout_log[-5:])


@app.route('/progress')
def progress():
    return render_template('progress.html',
                           workouts=workout_log,
                           total=len(workout_log))


@app.route('/healthz')
def health():
    # Health check endpoint for Kubernetes probes
    return jsonify({'status': 'healthy'}), 200


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

What do these routes do?

  • / renders the dashboard with today's workout count, total count, and streak. It also updates the active_users gauge so Prometheus always has a current value.
  • /workouts handles both GET (display form) and POST (log a workout). Each POST increments the workouts_logged counter with the workout type as a label.
  • /progress shows the full workout history.
  • /healthz returns a JSON health response. Kubernetes liveness and readiness probes will call this endpoint to check if the app is running.
  • Save app.py.

Let's test the app locally to make sure everything works before containerizing it.

  • Install the Python dependencies by running this command from the fittrack folder:
pip install -r requirements.txt
  • Start the app by running:
python app.py
  • Open your browser and go to http://localhost:5000. You should see the FitTrack dashboard with three stat cards showing 0 for Workouts Today, Total Workouts, and Day Streak.
  • Visit http://localhost:5000/metrics in your browser. You should see raw Prometheus metric data including lines starting with flask_http_request_total and flask_http_request_duration_seconds.
  • Press Ctrl+C in the terminal to stop the app.

Seeing an ImportError or ModuleNotFoundError?

Make sure you ran pip install -r requirements.txt from the fittrack folder where requirements.txt lives. If you have multiple Python versions, try pip3 install -r requirements.txt and python3 app.py instead.

My FitTrack Flask app won't start due to import errors. How do I fix this?

Awesome, I've got everything!

Double check you have saved app.py before moving on.

I'd like to double check the full code

Here is the complete app.py file. Make sure yours matches exactly.

from flask import Flask, render_template, request, jsonify
from prometheus_flask_exporter import PrometheusMetrics
from prometheus_client import Counter, Gauge
import time
import random

app = Flask(__name__)
metrics = PrometheusMetrics(app)
metrics.info('fittrack_app_info', 'FitTrack Application Info', version='1.0.0')

# Custom metrics
workouts_logged = Counter(
    'fittrack_workouts_logged_total',
    'Total number of workouts logged',
    ['workout_type']
)
active_users = Gauge(
    'fittrack_active_users',
    'Number of currently active users'
)

# In-memory storage
workout_log = []

WORKOUT_TYPES = ['Running', 'Weight Training', 'Cycling', 'Swimming', 'Yoga', 'HIIT']


@app.route('/')
def dashboard():
    today_count = len([w for w in workout_log if w['date'] == time.strftime('%Y-%m-%d')])
    total_count = len(workout_log)
    streak = min(total_count, 7)
    active_users.set(random.randint(5, 25))
    return render_template('dashboard.html',
                           today_count=today_count,
                           total_count=total_count,
                           streak=streak)


@app.route('/workouts', methods=['GET', 'POST'])
def workouts():
    if request.method == 'POST':
        workout = {
            'type': request.form.get('type', 'Running'),
            'duration': request.form.get('duration', '30'),
            'date': time.strftime('%Y-%m-%d'),
            'time': time.strftime('%H:%M')
        }
        workout_log.append(workout)
        workouts_logged.labels(workout_type=workout['type']).inc()
    return render_template('workouts.html',
                           workout_types=WORKOUT_TYPES,
                           recent=workout_log[-5:])


@app.route('/progress')
def progress():
    return render_template('progress.html',
                           workouts=workout_log,
                           total=len(workout_log))


@app.route('/healthz')
def health():
    return jsonify({'status': 'healthy'}), 200


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)
Write a production Dockerfile

The app works locally, but Kubernetes runs containers. You need a Docker image that packages the app with its dependencies in a secure, production-ready format.

  • Create a new file called Dockerfile in the root of the fittrack folder (not inside templates) and paste the following code into it:
# Stage 1: Install dependencies into a separate layer
FROM python:3.12-slim AS builder

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: Copy only the installed packages and app code
FROM python:3.12-slim

# Create a non-root user for security
RUN useradd -r -s /bin/false appuser
WORKDIR /app

COPY --from=builder /install /usr/local
COPY . .

RUN chown -R appuser:appuser /app
USER appuser

EXPOSE 5000

# Health check for Docker and Kubernetes to verify the app is running
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/healthz')" || exit 1

CMD ["python", "app.py"]

What does this Dockerfile do?

This is a multi-stage build. It uses two stages to keep the final image small and secure.

  • Stage 1 (builder) installs Python packages into a temporary layer. Build tools and cache files stay in this stage and never make it into the final image.
  • Stage 2 starts from a clean python:3.12-slim base, copies only the installed packages from stage 1, and adds the app code.
  • useradd -r creates a non-root user called appuser. Running as non-root is a production security best practice that prevents container escape attacks.
  • The HEALTHCHECK directive tells Docker to call the /healthz endpoint every 30 seconds. If the app stops responding, Docker marks the container as unhealthy.
  • Save the Dockerfile.
  • Test the Docker build by running this command from the fittrack folder:
docker build -t fittrack:test .

You should see each build stage complete with output ending in a line like Successfully tagged fittrack:test.

  • Verify the image was created by running:
docker images fittrack

You should see a fittrack image with the test tag listed in the output.

Docker build failing?

  • If you see COPY failed: file not found, make sure you are running the command from the fittrack folder where Dockerfile and requirements.txt both live.
  • If you see Cannot connect to the Docker daemon, make sure Docker Desktop or Rancher Desktop is running.

My Docker build for FitTrack is failing. How do I debug this?

Your FitTrack app is built, instrumented with Prometheus metrics, and packaged in a production-ready container. Next up, you will deploy it to your Kubernetes cluster and connect it to the monitoring stack.

Deploy FitTrack to Kubernetes

In the previous step, you built the FitTrack Flask application with Prometheus metrics and packaged it into a production Docker image. The app is ready to run, but it's sitting on your local machine.

Now it's time to get FitTrack running on your Kubernetes cluster. You'll write production-grade manifests that define resource limits, health checks, and automatic metrics discovery.

In this step, get ready to:
  • Write a Kubernetes Deployment manifest with resource limits and health checks.
  • Create a Service and ServiceMonitor for networking and metrics discovery.
  • Build, push, and deploy the app to your cluster.
Write the Deployment manifest

Kubernetes needs a Deployment manifest to know how to run your application. This manifest defines the container image, replica count, resource boundaries, and health checks that Kubernetes uses to keep the app running smoothly.

  • Create a k8s directory inside your fittrack project folder by running this command:
mkdir k8s
  • Confirm the directory was created by running:
ls

You should see k8s listed alongside your app.py, Dockerfile, and templates folder.

  • Create a new file called k8s/deployment.yaml and paste the following:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fittrack
  namespace: fittrack
  labels:
    app: fittrack
spec:
  replicas: 2
  selector:
    matchLabels:
      app: fittrack
  template:
    metadata:
      labels:
        app: fittrack
    spec:
      containers:
        - name: fittrack
          image: [[DOCKERHUB_USERNAME="your-dockerhub-username"]]/fittrack:latest
          ports:
            - containerPort: 5000
              name: http
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 250m
              memory: 256Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 10
            periodSeconds: 15
          readinessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5

What does this manifest do?

  • replicas: 2 runs two copies of your app for redundancy. If one pod crashes, the other keeps serving traffic.
  • resources.requests is what Kubernetes guarantees for the container. resources.limits is the maximum it can use before Kubernetes throttles or kills it.
  • livenessProbe hits /healthz every 15 seconds. If it fails, Kubernetes restarts the container automatically.
  • readinessProbe hits /healthz every 5 seconds. Until it passes, Kubernetes won't send any traffic to that pod.
  • Save k8s/deployment.yaml.

Seeing YAML indentation errors later?

  • YAML is whitespace-sensitive. Make sure you're using spaces, not tabs.
  • Each nested level uses exactly 2 spaces of indentation.
  • Help me fix YAML indentation errors in my Kubernetes manifest.

Awesome, I've got everything!

Double check you've saved k8s/deployment.yaml before moving on.

I'd like to double check the full code

apiVersion: apps/v1
kind: Deployment
metadata:
  name: fittrack
  namespace: fittrack
  labels:
    app: fittrack
spec:
  replicas: 2
  selector:
    matchLabels:
      app: fittrack
  template:
    metadata:
      labels:
        app: fittrack
    spec:
      containers:
        - name: fittrack
          image: [[DOCKERHUB_USERNAME="your-dockerhub-username"]]/fittrack:latest
          ports:
            - containerPort: 5000
              name: http
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 250m
              memory: 256Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 10
            periodSeconds: 15
          readinessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
Write the Service and ServiceMonitor manifests

Two more manifests connect FitTrack to the rest of the cluster. A Service gives your app a stable network endpoint inside Kubernetes. A ServiceMonitor tells Prometheus exactly where to find your app's metrics.

  • Create a new file called k8s/service.yaml and paste the following:
apiVersion: v1
kind: Service
metadata:
  name: fittrack
  namespace: fittrack
  labels:
    app: fittrack
spec:
  selector:
    app: fittrack
  ports:
    - port: 80
      targetPort: http
      name: http
  type: ClusterIP

What does this Service do?

  • type: ClusterIP makes the app reachable only inside the cluster. You'll use port-forwarding to access it from your browser.
  • port: 80 maps incoming traffic on port 80 to targetPort: http, which resolves to port 5000 on the container.
  • selector: app: fittrack routes traffic only to pods that have the matching label in your Deployment.
  • Save k8s/service.yaml.

Service not routing traffic later?

  • Check that the selector labels in service.yaml match the pod labels in your deployment.yaml exactly.
  • Why isn't my Kubernetes Service routing traffic to my pods?

Awesome, I've got everything!

Make sure you've saved k8s/service.yaml before continuing.

I'd like to double check the full code

apiVersion: v1
kind: Service
metadata:
  name: fittrack
  namespace: fittrack
  labels:
    app: fittrack
spec:
  selector:
    app: fittrack
  ports:
    - port: 80
      targetPort: http
      name: http
  type: ClusterIP

Now for the ServiceMonitor. This is a custom resource defined by the Prometheus Operator that shipped with the monitoring stack you installed in Step 2.

  • Create a new file called k8s/servicemonitor.yaml and paste the following:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: fittrack
  namespace: fittrack
  labels:
    app: fittrack
    release: prometheus-stack
spec:
  selector:
    matchLabels:
      app: fittrack
  endpoints:
    - port: http
      path: /metrics
      interval: 15s
  namespaceSelector:
    matchNames:
      - fittrack

What does this ServiceMonitor do?

  • A ServiceMonitor tells Prometheus which services to scrape for metrics. No manual Prometheus configuration needed.
  • endpoints.path: /metrics points Prometheus to the metrics endpoint that prometheus-flask-exporter created automatically in your app.
  • interval: 15s means Prometheus collects new metrics from your app every 15 seconds.

Why does the ServiceMonitor need a release label?

The release: prometheus-stack label must match the Helm release name you used when installing the monitoring stack in Step 2. This is how the Prometheus Operator discovers which ServiceMonitors belong to your deployment.

If you used a different release name in your helm install command, update this label to match. Without it, Prometheus won't find your app's metrics.

  • Save k8s/servicemonitor.yaml.

Prometheus not scraping your app later?

  • Double-check that release: prometheus-stack in your ServiceMonitor matches your Helm release name exactly.
  • Verify the namespaceSelector.matchNames includes fittrack.
  • Why isn't Prometheus discovering my ServiceMonitor?

Awesome, I've got everything!

Make sure you've saved k8s/servicemonitor.yaml before continuing.

I'd like to double check the full code

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: fittrack
  namespace: fittrack
  labels:
    app: fittrack
    release: prometheus-stack
spec:
  selector:
    matchLabels:
      app: fittrack
  endpoints:
    - port: http
      path: /metrics
      interval: 15s
  namespaceSelector:
    matchNames:
      - fittrack
Build, push, and deploy to your cluster

With all three manifests written, it's time to build your Docker image, push it to Docker Hub, and deploy everything to Kubernetes.

  • Create the fittrack namespace in your cluster by running:
kubectl create namespace fittrack

You should see namespace/fittrack created in the output.

Namespace already exists?

If you see an already exists error, that's fine. It means the namespace was already created and you can continue to the next command.

  • Build your Docker image by running this command from inside your fittrack project folder:
docker build -t [[DOCKERHUB_USERNAME="your-dockerhub-username"]]/fittrack:latest .

The build takes a minute or two. You should see a Successfully tagged message at the end.

Build failed?

  • Make sure you're running the command from inside your fittrack folder where the Dockerfile lives.
  • Check that Docker is running. Open Rancher Desktop and confirm the container engine is active.
  • Help me debug my Docker build failure.
  • Log in to Docker Hub by running:
docker login
  • Enter your Docker Hub username and password when prompted.
  • Push the image to Docker Hub by running:
docker push [[DOCKERHUB_USERNAME="your-dockerhub-username"]]/fittrack:latest

You should see progress bars as each layer uploads, finishing with a latest: digest: sha256:... confirmation.

Push denied or unauthorized?

  • Make sure docker login completed successfully before pushing.
  • Verify the Docker Hub username in the image tag matches your actual account name exactly.
  • Why is my Docker push being denied?
  • Apply all your Kubernetes manifests at once by running:
kubectl apply -f k8s/

What does this command do?

The -f k8s/ flag tells kubectl to apply every YAML file in the folder at once. Kubernetes creates the Deployment, Service, and ServiceMonitor from your manifests in a single command.

  • Check that your pods are running by running:
kubectl get pods -n fittrack

You should see two pods with fittrack in their names, both showing a Running status and 1/1 in the READY column.

Pods stuck in ImagePullBackOff?

  • This means Kubernetes can't pull your Docker image. Verify the image name in your Deployment matches what you pushed to Docker Hub.
  • Make sure the image is in a public repository on Docker Hub. Private repos require an image pull secret.
  • My Kubernetes pods are stuck in ImagePullBackOff.
  • Port-forward to access FitTrack in your browser by running:
kubectl port-forward svc/fittrack 8080:80 -n fittrack
  • Open your browser and go to http://localhost:8080.

You should see the FitTrack Dashboard page with stats for Workouts Today, Total Workouts, and Day Streak.

  • Open a new terminal tab and verify the metrics endpoint is working by running:
curl http://localhost:8080/metrics

You should see a wall of Prometheus metrics text. Look for lines starting with flask_http_request_total and fittrack_workouts_logged_total to confirm your custom metrics are being exposed.

Can't reach localhost:8080?

  • Make sure the kubectl port-forward command is still running in your terminal. Closing that terminal window stops the port-forward.
  • Check that no other service is already using port 8080 on your machine.
  • Help me debug my kubectl port-forward.

The final check is to confirm that Prometheus is automatically scraping your app's metrics through the ServiceMonitor.

  • If you still have Prometheus port-forwarded from Step 3, open http://localhost:9090 in your browser. If not, open a new terminal tab and run:
kubectl port-forward svc/prometheus-stack-kube-prometheus-prometheus 9090:9090 -n monitoring
  • In the Prometheus UI, click Status in the top menu.
  • Click Targets.
  • Look for a target group that includes fittrack in the list. It should show a status of UP.

If the fittrack target shows as UP, Prometheus is successfully scraping your app's metrics every 15 seconds.

Don't see fittrack in Prometheus targets?

  • The ServiceMonitor's release: prometheus-stack label must match your Helm release name from Step 2.
  • Wait 30 seconds and refresh the page. Prometheus takes a moment to discover new targets after a ServiceMonitor is created.
  • Verify that serviceMonitorSelectorNilUsesHelmValues: false is set in your monitoring-values.yaml from Step 2. Without this, Prometheus only discovers ServiceMonitors with its own release labels.
  • Help me debug why Prometheus isn't discovering my ServiceMonitor.

Your app is live on Kubernetes with production health checks and automatic metrics collection. Next up, you'll automate the entire build-and-deploy process with a Jenkins CI/CD pipeline.

Set Up the Jenkins CI/CD Pipeline

In the last step, you deployed FitTrack to Kubernetes by manually building a Docker image, pushing it to Docker Hub, and applying manifests with kubectl.

That manual process works, but it does not scale. In a real team, every code change would need someone to remember the exact build, push, and deploy commands. Jenkins solves this by automating the entire workflow into a CI/CD pipeline that runs every time you change your code.

In this step, get ready to:
  • Configure Jenkins with the plugins and credentials it needs to build and deploy.
  • Write a declarative Jenkinsfile that automates the build, push, and deploy cycle.
  • Create and trigger a pipeline job to watch the full automation in action.
Configure Jenkins plugins and credentials

Jenkins needs two things before it can run your pipeline. It needs plugins that let it interact with Docker and Kubernetes. It also needs stored credentials so it can push images to Docker Hub on your behalf.

  • Open Jenkins in your browser at http://localhost:8080.
  • Click Manage Jenkins in the left sidebar.
  • Click Plugins.
  • Click the Available plugins tab.
  • Search for Docker Pipeline and check the box next to Docker Pipeline.
  • Search for Kubernetes CLI and check the box next to Kubernetes CLI.
  • Click Install at the top right.
  • Wait for the installation to finish. If Jenkins prompts you to restart, click Restart Jenkins when installation is complete and no jobs are running.

What do these plugins do?

  • Docker Pipeline lets Jenkins build Docker images and push them to registries directly from your pipeline script.
  • Kubernetes CLI gives Jenkins the ability to run kubectl commands against your cluster during the deploy stage.

Now you need to store your Docker Hub credentials so Jenkins can authenticate when pushing images.

  • Go back to the Manage Jenkins page.
  • Click Credentials.
  • Under Stores scoped to Jenkins, click (global).
  • Click Add Credentials.
  • 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.
  • Enter dockerhub-credentials in the ID field.
  • Click Create.

Why does the ID matter?

The ID dockerhub-credentials is the exact value your Jenkinsfile references to authenticate with Docker Hub. If the ID does not match, the Push stage will fail because Jenkins cannot find the credentials.

You should see dockerhub-credentials listed on the credentials page.

What about kubectl access?

Since Jenkins runs on your local machine and Rancher Desktop automatically manages your ~/.kube/config file, Jenkins already has access to your cluster. The pipeline's Deploy stage uses kubectl commands directly, and they work because Jenkins inherits your local kubeconfig.

Can't find the plugins?

If the search returns no results, click the Available plugins tab again to make sure you are not on the Installed plugins tab. If the plugins still do not appear, Jenkins may need an internet connection to fetch the plugin index. Help me find and install Jenkins plugins.

Write the Jenkinsfile

A Jenkinsfile is a text file that lives in your project repository and defines the entire pipeline as code. Jenkins reads this file and executes each stage in order.

  • Create a file called Jenkinsfile in the root of your fittrack project folder (the same level as app.py and your Dockerfile).
  • Copy the following pipeline definition into Jenkinsfile:
pipeline {
    agent any

    environment {
        // Docker Hub image path - replace with your username
        DOCKER_IMAGE = '[[DOCKERHUB_USERNAME="your-dockerhub-username"]]/fittrack'
        // Use the Jenkins build number as the image tag
        IMAGE_TAG = "${env.BUILD_NUMBER}"
        // Must match the credential ID you created in Jenkins
        DOCKER_CREDENTIALS = 'dockerhub-credentials'
    }

    stages {
        stage('Checkout') {
            steps {
                // Pull the latest code from the repository
                checkout scm
            }
        }

        stage('Build') {
            steps {
                script {
                    // Build the Docker image from the Dockerfile
                    dockerImage = docker.build("${DOCKER_IMAGE}:${IMAGE_TAG}")
                }
            }
        }

        stage('Push') {
            steps {
                script {
                    // Authenticate with Docker Hub and push the image
                    docker.withRegistry('https://registry.hub.docker.com', DOCKER_CREDENTIALS) {
                        dockerImage.push("${IMAGE_TAG}")
                        dockerImage.push('latest')
                    }
                }
            }
        }

        stage('Deploy') {
            steps {
                // Update the Kubernetes deployment with the new image tag
                sh """
                    kubectl set image deployment/fittrack \
                        fittrack=${DOCKER_IMAGE}:${IMAGE_TAG} \
                        -n fittrack
                    kubectl rollout status deployment/fittrack \
                        -n fittrack \
                        --timeout=120s
                """
            }
        }
    }

    post {
        success {
            echo "Pipeline completed successfully. FitTrack v${IMAGE_TAG} deployed."
        }
        failure {
            echo "Pipeline failed. Check logs for details."
        }
    }
}

What does each stage do?

  • environment defines variables the whole pipeline can use. IMAGE_TAG uses the Jenkins build number so every build produces a uniquely tagged image.
  • Checkout pulls the latest code from whatever source control Jenkins is pointed at.
  • Build uses the Docker Pipeline plugin to run docker.build(), which builds your image from the Dockerfile.
  • Push authenticates with Docker Hub using your stored credentials, then pushes the image with both the build number tag and the latest tag.
  • Deploy runs kubectl set image to update the running Kubernetes Deployment to the new image tag. It then waits for the rollout to finish successfully.
  • post runs after all stages. It prints a success or failure message depending on the outcome.
  • Replace your-dockerhub-username with your actual Docker Hub username (the same one you used in k8s/deployment.yaml).
  • Save Jenkinsfile.

Not sure what your Docker Hub username is?

It is the same username you used when running docker push in the previous step. You can confirm it by logging in at hub.docker.com. Help me find my Docker Hub username.

Awesome, I've got everything!

Double check you have saved your Jenkinsfile.

I'd like to double check the full code

pipeline {
    agent any

    environment {
        DOCKER_IMAGE = 'your-dockerhub-username/fittrack'
        IMAGE_TAG = "${env.BUILD_NUMBER}"
        DOCKER_CREDENTIALS = 'dockerhub-credentials'
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
            }
        }

        stage('Build') {
            steps {
                script {
                    dockerImage = docker.build("${DOCKER_IMAGE}:${IMAGE_TAG}")
                }
            }
        }

        stage('Push') {
            steps {
                script {
                    docker.withRegistry('https://registry.hub.docker.com', DOCKER_CREDENTIALS) {
                        dockerImage.push("${IMAGE_TAG}")
                        dockerImage.push('latest')
                    }
                }
            }
        }

        stage('Deploy') {
            steps {
                sh """
                    kubectl set image deployment/fittrack \
                        fittrack=${DOCKER_IMAGE}:${IMAGE_TAG} \
                        -n fittrack
                    kubectl rollout status deployment/fittrack \
                        -n fittrack \
                        --timeout=120s
                """
            }
        }
    }

    post {
        success {
            echo "Pipeline completed successfully. FitTrack v${IMAGE_TAG} deployed."
        }
        failure {
            echo "Pipeline failed. Check logs for details."
        }
    }
}
Create the pipeline job and trigger a build

The Jenkinsfile defines your pipeline, but Jenkins needs a job that points to your code repository. The job tells Jenkins where to find the Jenkinsfile and when to run it.

First, your project needs to be a Git repository so Jenkins can check out the code.

  • Initialize a Git repository and commit your project by running these commands in your fittrack project folder:
git init
git add .
git commit -m "FitTrack project with CI/CD pipeline"
  • Check which branch you are on by running:
git branch

You should see * main or * master. Note this branch name for the Jenkins configuration below.

Pro tip: rename to main

If your branch is called master and you want to use main instead, run git branch -m main to rename it.

Now create the pipeline job in Jenkins.

  • Go back to the Jenkins dashboard at http://localhost:8080.
  • Click New Item in the left sidebar.
  • Enter fittrack-pipeline as the item name.
  • Select Pipeline as the project type.
  • Click OK.

This opens the job configuration page. Scroll down to the Pipeline section at the bottom.

  • Set the Definition dropdown to Pipeline script from SCM.
  • Set SCM to Git.
  • In the Repository URL field, enter the absolute path to your fittrack project folder on your machine.

What path do I enter?

On macOS, the path looks like /Users/yourname/Desktop/fittrack. On Windows, it looks like C:\Users\yourname\Desktop\fittrack. You can find your exact path by running pwd in your terminal while inside the fittrack folder.

  • Under Branches to build, change the Branch Specifier to */main (or */master if that is your branch name).
  • Leave Script Path as Jenkinsfile (the default).
  • Click Save.

You are now on the fittrack-pipeline job page. Time to trigger the first build.

  • Click Build Now in the left sidebar.

A build appears in the Build History panel on the left. You can watch it progress through each stage in real time.

  • Click the build number (e.g. #1) to open the build details.
  • Click Console Output in the left sidebar to see the live log.

You should see Jenkins move through the Checkout, Build, Push, and Deploy stages. A successful build ends with Finished: SUCCESS and prints the message Pipeline completed successfully. FitTrack v1 deployed.

  • Verify the deployment updated by running:
kubectl get pods -n fittrack

You should see new pods starting up or already in a Running state. The pod age should be recent, confirming the pipeline deployed a new version.

Pipeline failed at the Build stage?

  • Check that Docker is running on your machine. Jenkins needs the Docker daemon to build images.
  • If you see a permission error, the Jenkins user may not have access to the Docker socket. On macOS and Linux, add the Jenkins user to the docker group.
  • If the Push stage fails, double check that the credential ID in Jenkins is exactly dockerhub-credentials (case-sensitive) and that your Docker Hub username and password are correct.
  • If the Deploy stage fails with kubectl: command not found, Jenkins cannot find kubectl on its PATH. Make sure Rancher Desktop is running and kubectl is accessible from a regular terminal first.
  • Help me debug my Jenkins pipeline failure.

Your CI/CD pipeline is live. Every time you make a code change, commit it, and click Build Now, Jenkins handles the entire build-push-deploy cycle for you. Next up, you will build custom monitoring dashboards and alerting rules so you can see exactly how your app is performing in real time.

Build Custom Monitoring and Alerting

Your app is deployed, your pipeline is automating builds, and Prometheus is collecting metrics from your FitTrack pods. But raw metrics sitting in a database are only useful if you can see them and get alerted when things go wrong.

In this step, you will build the observability layer that turns raw numbers into actionable insight. You will create a PrometheusRule that fires an alert when your error rate spikes, build a custom Grafana dashboard showing real-time application health, and then generate traffic to watch everything respond live.

In this step, get ready to:
  • Verify Prometheus is scraping your app and create an alerting rule for high error rates.
  • Build a custom Grafana dashboard with request rate, latency, error ratio, and workout panels.
  • Generate traffic and watch your metrics, dashboards, and alerts respond in real time.
Verify Prometheus scraping and create an alerting rule

Before building dashboards, you need to confirm that Prometheus is actually receiving metrics from your FitTrack pods. The ServiceMonitor you deployed in the previous step should have told Prometheus where to find your app's /metrics endpoint.

  • Open a port-forward to the Prometheus UI by running this command:
kubectl port-forward svc/prometheus-stack-kube-prometheus-prometheus 9090:9090 -n monitoring
  • Open your browser and navigate to http://localhost:9090.
  • In the query input box at the top of the page, type flask_http_request_total and click Execute.

You should see results appearing in the table below the query box. Each row represents a metric time series with labels like method, status, and endpoint.

No results for flask_http_request_total?

Check that your ServiceMonitor has the label release: prometheus-stack matching your Helm release name. Without this label, Prometheus does not discover the ServiceMonitor.

Also verify your FitTrack pods are running with kubectl get pods -n fittrack. If the pods are not healthy, Prometheus cannot scrape them.

Help me troubleshoot why Prometheus is not scraping my FitTrack app metrics

Now that you have confirmed Prometheus is receiving your metrics, you can create an alerting rule. A PrometheusRule is a Kubernetes custom resource that defines conditions under which Prometheus should fire alerts.

  • Create the alerting rule file by running:
touch k8s/prometheusrule.yaml
  • Open k8s/prometheusrule.yaml in your editor and paste the following:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: fittrack-alerts
  namespace: monitoring
  labels:
    release: prometheus-stack
spec:
  groups:
    - name: fittrack.rules
      rules:
        - alert: FitTrackHighErrorRate
          expr: |
            (
              sum(rate(flask_http_request_total{status=~"5..", job="fittrack"}[5m]))
              /
              sum(rate(flask_http_request_total{job="fittrack"}[5m]))
            ) > 0.05
          for: 2m
          labels:
            severity: warning
          annotations:
            summary: "FitTrack high error rate"
            description: "More than 5% of requests are returning 5xx errors for the last 2 minutes."

What does this alerting rule do?

  • The expr calculates the ratio of 5xx responses to total responses over a 5-minute window.
  • The for: 2m clause means the condition must be true for 2 continuous minutes before the alert fires. This prevents noisy alerts from single request failures.
  • The release: prometheus-stack label in metadata is critical. It must match your Helm release name so the Prometheus Operator discovers this rule.
  • Apply the PrometheusRule to your cluster by running:
kubectl apply -f k8s/prometheusrule.yaml
  • Verify the rule is loaded in Prometheus by navigating to http://localhost:9090/alerts in your browser.

You should see FitTrackHighErrorRate listed under the fittrack.rules group. Its state should show as inactive (green) since your app is not producing errors yet.

Alert rule not appearing in Prometheus?

Make sure the PrometheusRule is in the monitoring namespace and has the release: prometheus-stack label. Confirm with kubectl get prometheusrule -n monitoring.

Also verify that your monitoring-values.yaml includes ruleSelectorNilUsesHelmValues: false. Without this, Prometheus only picks up rules that match the Helm chart's own labels.

Help me debug why my PrometheusRule is not showing up in the Prometheus alerts page

Build a custom Grafana dashboard

Alerts tell you when something is wrong. Dashboards tell you what is wrong and how the system is behaving over time. You will now create a custom Grafana dashboard with four panels focused on your FitTrack application.

  • Open a port-forward to Grafana by running this command (open a new terminal tab so your Prometheus port-forward stays active):
kubectl port-forward svc/prometheus-stack-grafana 3000:80 -n monitoring
  • Open your browser and navigate to http://localhost:3000.
  • Log in with username admin and password fittrack-admin (the password you set in monitoring-values.yaml).

Now create a new dashboard with four panels that visualize your FitTrack metrics.

  • Click the + icon in the left sidebar.
  • Select New dashboard.
  • Click Add visualization.
  • Select Prometheus as the data source.

Create the first panel for request rate. This shows how many requests per second your app is handling.

  • In the query editor at the bottom, enter this PromQL expression:
sum(rate(flask_http_request_total{job="fittrack"}[5m]))
  • In the Panel title field on the right side panel, enter Request Rate (req/s).
  • Click Apply in the top right corner to save this panel.

Add the second panel for P99 latency. This shows the worst-case response time that 99% of requests fall under.

  • Click Add in the top toolbar, then Visualization.
  • Select Prometheus as the data source and enter this query:
histogram_quantile(0.99, sum(rate(flask_http_request_duration_seconds_bucket{job="fittrack"}[5m])) by (le))
  • Set the panel title to Latency P99 (seconds).
  • Click Apply.

Add the third panel for error ratio. This is the same metric your alerting rule watches.

  • Add another visualization with Prometheus as the data source and this query:
sum(rate(flask_http_request_total{status=~"5..", job="fittrack"}[5m])) / sum(rate(flask_http_request_total{job="fittrack"}[5m]))
  • Set the panel title to Error Ratio (5xx/total).
  • Click Apply.

Add the fourth panel for workouts logged. This uses your custom business metric.

  • Add another visualization with Prometheus as the data source and this query:
sum(rate(fittrack_workouts_logged_total[5m])) * 60
  • Set the panel title to Workouts Logged (per min).
  • Click Apply.

Now save your completed dashboard.

  • Click the Save dashboard icon (floppy disk) in the top right.
  • Enter FitTrack Application Health as the dashboard name.
  • Click Save.

You should see all four panels displayed on your dashboard. Some panels may show "No data" until you generate traffic in the next substep.

Why these four panels?

These four panels cover the core pillars of application monitoring. Request rate tells you how busy the app is. Latency P99 reveals the worst user experience. Error ratio flags reliability issues. Workouts logged tracks your business-specific outcome.

This pattern (rate, latency, errors, business metrics) is called the RED method and is the standard starting point for service-level monitoring.

Panels showing No Data even after generating traffic?

Make sure the job label in your queries matches what Prometheus assigned. Check in the Prometheus UI at http://localhost:9090/targets to see the actual job name for your FitTrack target.

Help me figure out why my Grafana panels show No Data for FitTrack metrics

Generate traffic and watch your monitoring respond

Your dashboards and alerts are set up, but they need actual traffic to display meaningful data. You will use a simple loop to send requests to your FitTrack app and watch the metrics come alive.

  • Open a new terminal tab (keep your existing port-forwards running).
  • Start a port-forward to your FitTrack service by running:
kubectl port-forward svc/fittrack 8080:80 -n fittrack
  • Open another terminal tab and generate traffic by running this loop:
# Send requests to all three pages in a loop
for i in $(seq 1 50); do
  curl -s http://localhost:8080/ > /dev/null
  curl -s http://localhost:8080/workouts > /dev/null
  curl -s http://localhost:8080/progress > /dev/null
  sleep 0.5
done
echo "Traffic generation complete"

What does this script do?

This loop sends 50 rounds of requests to all three FitTrack pages with a half-second pause between rounds. That is 150 total requests spread over about 25 seconds. Each request increments the flask_http_request_total counter and records a latency sample in the flask_http_request_duration_seconds histogram.

  • Log a few workouts to generate business metrics by running:
# Log some workouts via POST requests
curl -s -X POST http://localhost:8080/workouts -d "type=Running&duration=30" > /dev/null
curl -s -X POST http://localhost:8080/workouts -d "type=Cycling&duration=45" > /dev/null
curl -s -X POST http://localhost:8080/workouts -d "type=HIIT&duration=20" > /dev/null
curl -s -X POST http://localhost:8080/workouts -d "type=Swimming&duration=60" > /dev/null
curl -s -X POST http://localhost:8080/workouts -d "type=Yoga&duration=40" > /dev/null
echo "Workouts logged"

Now check your monitoring to see the results.

  • Switch to your Grafana tab at http://localhost:3000 and open the FitTrack Application Health dashboard.
  • Click the refresh icon in the top right corner of the dashboard (or set auto-refresh to 10s).

You should see your Request Rate panel showing a spike of traffic. The Latency P99 panel should show response times. The Workouts Logged panel should show activity from the POST requests you just sent.

  • Switch to your Prometheus tab at http://localhost:9090/alerts and confirm that FitTrackHighErrorRate is still inactive (green).

The alert stays inactive because all your requests returned 200 status codes. The error ratio is 0%, well below the 5% threshold.

Pro tip: test your alert

To see the alert fire, you could add a test route to your Flask app that always returns a 500 error, redeploy, and send traffic to that route. In a production environment, you would test alerting rules using tools like Prometheus unit testing framework (promtool test rules).

  • Run one final query in the Prometheus UI to see your custom metric. Enter fittrack_workouts_logged_total in the query box and click Execute.

You should see entries with labels like workout_type="Running" and workout_type="Cycling" matching the workouts you just logged. Each counter shows how many times that workout type was recorded.

Dashboard still empty after generating traffic?

Check the time range in Grafana. Click the clock icon in the top right and set it to Last 15 minutes. If your traffic was recent, it might be outside the default time window.

Also confirm your port-forward to the FitTrack service is still running. If it dropped, your curl commands would have silently failed.

Help me troubleshoot why my Grafana dashboard is not showing the traffic I generated

You now have a complete production monitoring setup. Your app exposes metrics, Prometheus scrapes and stores them, Grafana visualizes them in real time, and alerting rules watch for problems automatically. This is the same observability pattern used by engineering teams running services in production.

Secret mission

Package FitTrack as a Helm Chart

You deployed FitTrack using raw kubectl manifests. But in production, teams package their applications as Helm charts so they can install, upgrade, and roll back with a single command. Can you turn your FitTrack manifests into a reusable, configurable Helm chart?

Before you go

Clean Up Your Resources

This project runs entirely on your local machine via Rancher Desktop, so 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:

  • Rancher Desktop Kubernetes cluster (running k3s locally).
  • kube-prometheus-stack Helm release (Prometheus, Grafana, Alertmanager) in the monitoring namespace.
  • FitTrack application Deployment, Service, ServiceMonitor, and PrometheusRule in the fittrack and monitoring namespaces.
  • Docker image pushed to your Docker Hub account.
  • Jenkins pipeline job configured on your local Jenkins instance.
  • Local project files (app code, Kubernetes manifests, Jenkinsfile, Helm values file).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the monitoring stack and CI/CD pipeline.

  • Your Helm releases, deployments, and Jenkins job will stay running as long as Rancher Desktop is active.
  • Prometheus continues scraping metrics and Grafana dashboards stay accessible via port-forwarding.
  • You can keep triggering Jenkins builds to test pipeline changes.

Pause - I'll come back to this later

Shut down Rancher Desktop to free up system resources like CPU and memory. Your Helm releases and Kubernetes deployments will resume automatically when you restart it.

  • Stop any active port-forwarding sessions by pressing Ctrl+C in those terminal windows.
  • Quit Rancher Desktop from the system tray or application menu.
  • Your Docker Hub image stays available. No action needed there.
  • When you reopen Rancher Desktop, your cluster will start back up with all Helm releases and workloads intact.

Delete - I don't want to use this again

Remove all project resources from your cluster and start fresh.

  • Uninstall the kube-prometheus-stack Helm release:
helm uninstall prometheus-stack -n monitoring
  • Delete the Kubernetes namespaces and all resources within them:
kubectl delete namespace fittrack monitoring
  • Optionally delete the Docker image from your Docker Hub account by visiting hub.docker.com, navigating to your fittrack repository, and selecting delete.
  • Optionally delete the Jenkins pipeline job from your Jenkins dashboard.
  • Delete your local project folder if you no longer need the source files.

Mission accomplished

That's a Wrap!

Nice work! You just built a production-grade DevOps pipeline from scratch. FitTrack is running on Kubernetes with automated CI/CD, full observability, and alerting. That is the same infrastructure stack that powers real production systems.

Here's what you accomplished:

  • Deployed a full Prometheus and Grafana monitoring stack using Helm, complete with custom values, ServiceMonitors for automatic metrics discovery, and PrometheusRule alerts.
  • Built a Jenkins CI/CD pipeline that automates building, pushing, and deploying a containerized Flask application to Kubernetes on every code change.
  • Created production-grade Kubernetes manifests with resource limits, liveness and readiness probes, and a custom Grafana dashboard for real-time app and cluster monitoring.
  • Packaged FitTrack as a reusable Helm chart with configurable values for replicas, image tags, and resource limits.

Ready to quiz yourself?