Dockerized System Health CLI with CI/CD

Build a containerized CLI tool that reports system health via a CI/CD pipeline.

Introduction

30 Second Summary

Every server and cloud instance needs a way to report its vital signs. Just like a car dashboard shows fuel and engine temperature, system administrators rely on tools that surface CPU load, memory, and disk space at a glance.

In this project, you will build a command-line health reporter that collects system metrics, displays them as a color-coded terminal dashboard, and gets packaged into a Docker container that ships automatically via a CI/CD pipeline.

What You'll Build

A multi-language CLI tool called syshealth that uses Bash for Linux data collection and Python for user-facing output, packaged in a Docker container and published to GitHub Container Registry via GitHub Actions.

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

  • A Bash script that collects CPU, memory, disk, and network metrics using core Linux utilities.
  • A Python CLI that formats raw data into a color-coded terminal dashboard with optional JSON output.
  • A multi-stage Docker image published automatically to GitHub Container Registry through a GitHub Actions pipeline.
  • Secret Mission: a live-updating watch mode and Docker HEALTHCHECK for production readiness.

Are there any prerequisites?

You'll need a free GitHub account and basic familiarity with the command line (knowing what a terminal is and how to type commands). No prior Docker or Linux experience is required.

Set Up Your Linux Development Environment

The system health tool you are building relies on Linux utilities like /proc/loadavg, free, df, and ss. These live inside the Linux kernel and are not available natively on Windows.

To get a full Linux environment on your Windows machine, you will use WSL2 (Windows Subsystem for Linux). WSL2 runs a real Linux kernel alongside Windows, giving you access to all the system utilities the project needs. You will also install Docker Desktop to containerize your tool later.

In this step, get ready to:
  • Install WSL2 and Ubuntu on your Windows machine.
  • Install Docker Desktop for Windows with the WSL2 backend.
  • Install Python 3, Git, and verify all tools inside WSL2.
Install WSL2 and Ubuntu

WSL2 gives you a real Linux kernel running inside Windows. The wsl --install command enables the required features, sets WSL 2 as the default, and installs Ubuntu in one step.

  • Press the Windows key to open your search bar.
  • Type PowerShell, then right-click Windows PowerShell and select Run as administrator.
  • Install WSL2 with Ubuntu by running this command:
wsl --install

What does this command do?

The wsl --install command does four things: enables the WSL and Virtual Machine Platform components, downloads the latest Linux kernel, sets WSL 2 as the default version, and installs the Ubuntu distribution.

  • When prompted, restart your computer to finish the installation.
  • After restarting, Ubuntu will launch automatically and ask you to create a username and password. Enter a username and a password you will remember.

Why a username and password?

This creates your Linux user account inside Ubuntu. You will use this password whenever you run sudo commands (which grant admin permissions inside Linux).

  • Verify your WSL installation by running this command in the Ubuntu terminal:
wsl --version
  • You should see version information including WSL version and Kernel version in the output.
  • Confirm Ubuntu is running as WSL 2 by running:
wsl -l -v
  • You should see Ubuntu listed with VERSION showing 2.

Seeing WSL version 1 instead of 2?

If the VERSION column shows 1 instead of 2, you can upgrade your distribution. Open PowerShell as administrator and run wsl --set-version Ubuntu 2. This converts your existing Ubuntu install to use the WSL 2 kernel.

If the command fails entirely, make sure your Windows build is 20262 or higher. Press Win+R, type winver, and check your build number.

Help me troubleshoot my WSL2 installation

Install Docker Desktop

Docker Desktop integrates directly with WSL2, which means containers run on the Linux kernel you just installed. This is the same way your CLI tool will run in production.

  • Open your web browser and navigate to the Docker Desktop download page:
  • https://docs.docker.com/desktop/setup/install/windows-install/
  • Click the download button for Windows to download Docker Desktop Installer.exe.
  • Double-click Docker Desktop Installer.exe to run the installer.
  • When the installer asks about the installation mode, select the per-user option (recommended).
  • On the Configuration page, ensure the Use WSL 2 instead of Hyper-V option is selected.
  • Follow the remaining prompts and click Close when the installation finishes.
  • Launch Docker Desktop from the Start menu and accept the service agreement when prompted.

What is Docker Desktop?

Docker Desktop is an application that manages the Docker engine on your machine. It handles starting and stopping the Docker daemon and integrates with WSL2 so that docker commands work seamlessly from your Ubuntu terminal.

  • Wait for Docker Desktop to finish starting (the whale icon in your system tray will stop animating).
  • Switch to your Ubuntu terminal and verify Docker is accessible by running:
docker --version
  • You should see a version number in the output. Now test that Docker can pull and run images by running:
docker run hello-world
  • You should see a message that says Hello from Docker! followed by an explanation of what happened.

Docker command not found in Ubuntu?

Docker Desktop needs WSL 2 integration enabled for your Ubuntu distro. Open Docker Desktop, go to Settings then Resources then WSL integration. Make sure the toggle next to Ubuntu is enabled.

If Docker Desktop is still starting up (whale icon is animating), wait for it to finish. The docker command only becomes available after the engine is fully running.

Help me troubleshoot Docker in WSL2

Install Python and verify tools

Your CLI tool is written in Python and uses Git for version control. Ubuntu comes with Bash pre-installed, but you need to install Python 3 and Git explicitly.

  • In your Ubuntu terminal, update your package lists by running:
sudo apt update
  • Enter your Linux password when prompted (this is the password you created during Ubuntu setup).
  • Install Python 3, pip, and Git by running:
sudo apt install -y python3 python3-pip git

What are these packages?

  • python3 is the Python interpreter that will run your CLI script.
  • python3-pip is the Python package manager (not needed for this project since you use only the standard library, but useful to have ready).
  • git is the version control tool you will use to push your code to GitHub.
  • Confirm all your tools are working by running these version checks:
python3 --version
git --version
bash --version
  • You should see version numbers for each tool. Python should show 3.x.x, Git should show a version number, and Bash should show 5.x.x or similar.

Your Docker image pins Python 3.14

The Python version installed locally does not need to match the Docker image exactly. Your Dockerfile will use python:3.14-alpine as its base image, which pins the production version independently. Any Python 3 works for local testing since the CLI uses only standard library modules.

Permission denied or package not found?

If you see a permission error, make sure you included sudo before the command. The sudo prefix grants admin permissions for installing packages.

If apt cannot find a package, run sudo apt update first to refresh the package lists. This is required the first time you use Ubuntu or if it has been a while since the last update.

Help me troubleshoot installing packages in WSL2

Your Linux development environment is ready with all the tools you need. Next up, you will write the Bash script that collects system health metrics using those Linux utilities.

Write the System Health Checks in Bash

Your development environment is set up and ready to go. Now it's time to write the actual code that inspects your system's health.

Linux exposes system information through special files and utilities. In this step, you'll write a Bash script that reads CPU load, memory usage, disk space, and network connections, then outputs them in a structured format your Python CLI can parse later.

In this step, get ready to:
  • Create the project directory structure and initialize a Git repository.
  • Write a Bash script that collects system metrics as structured key=value pairs.
  • Test the script manually and verify its output.
Create the project structure

Every project starts with a clean directory layout. You'll create a folder for the Bash scripts and another for the Python source code.

  • Make sure ~/Desktop exists, then move to it by running these commands:
  • If ~/Desktop already exists, the first command does nothing.
mkdir -p ~/Desktop
cd ~/Desktop
  • Create your project folder with its subdirectories, then move into it by running these commands:
mkdir -p syshealth/scripts syshealth/src
cd syshealth
  • Confirm the directories exist by running:
ls

You should see scripts and src listed in the output.

  • Initialize a Git repository by running:
git init

You should see a message that says Initialized empty Git repository followed by a path.

Why initialize Git now?

Starting with Git from the very first file means every change you make is trackable. In Step 5, you'll push this repository to GitHub and connect it to a CI/CD pipeline.

Write the system health checks script

Linux exposes system data through virtual files like /proc/loadavg and /proc/uptime, plus utilities like free, df, and ss.

Your script will call these tools and output the results as simple key=value pairs that are easy to parse programmatically.

  • Create the file scripts/checks.sh and paste the following code into it:
#!/bin/bash
# System health checks - outputs key=value pairs

# CPU load (1-minute average)
cpu_load=$(cat /proc/loadavg | awk '{print $1}')
echo "cpu_load_1m=$cpu_load"

# Memory usage
mem_info=$(free -m | awk 'NR==2{printf "%s %s %s", $2, $3, $7}')
mem_total=$(echo $mem_info | awk '{print $1}')
mem_used=$(echo $mem_info | awk '{print $2}')
mem_available=$(echo $mem_info | awk '{print $3}')
echo "mem_total_mb=$mem_total"
echo "mem_used_mb=$mem_used"
echo "mem_available_mb=$mem_available"

# Disk usage (root filesystem)
disk_info=$(df -h / | awk 'NR==2{printf "%s %s %s %s", $2, $3, $4, $5}')
disk_total=$(echo $disk_info | awk '{print $1}')
disk_used=$(echo $disk_info | awk '{print $2}')
disk_avail=$(echo $disk_info | awk '{print $3}')
disk_pct=$(echo $disk_info | awk '{print $4}')
echo "disk_total=$disk_total"
echo "disk_used=$disk_used"
echo "disk_available=$disk_avail"
echo "disk_used_pct=$disk_pct"

# Network connections count
tcp_connections=$(ss -t state established | tail -n +2 | wc -l)
echo "tcp_established=$tcp_connections"

# Uptime
uptime_seconds=$(awk '{print int($1)}' /proc/uptime)
echo "uptime_seconds=$uptime_seconds"

What does this script do?

Each section reads one category of system data and outputs it as a key=value pair:

  • CPU load reads /proc/loadavg to get the 1-minute load average.
  • Memory uses free -m to get total, used, and available memory in megabytes.
  • Disk uses df -h / to check the root filesystem's capacity and usage percentage.
  • Network uses ss -t state established to count active TCP connections.
  • Uptime reads /proc/uptime and truncates to whole seconds.

The structured output format makes it simple for Python to split each line on = and build a dictionary of metrics.

  • Save scripts/checks.sh.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

#!/bin/bash
# System health checks - outputs key=value pairs

# CPU load (1-minute average)
cpu_load=$(cat /proc/loadavg | awk '{print $1}')
echo "cpu_load_1m=$cpu_load"

# Memory usage
mem_info=$(free -m | awk 'NR==2{printf "%s %s %s", $2, $3, $7}')
mem_total=$(echo $mem_info | awk '{print $1}')
mem_used=$(echo $mem_info | awk '{print $2}')
mem_available=$(echo $mem_info | awk '{print $3}')
echo "mem_total_mb=$mem_total"
echo "mem_used_mb=$mem_used"
echo "mem_available_mb=$mem_available"

# Disk usage (root filesystem)
disk_info=$(df -h / | awk 'NR==2{printf "%s %s %s %s", $2, $3, $4, $5}')
disk_total=$(echo $disk_info | awk '{print $1}')
disk_used=$(echo $disk_info | awk '{print $2}')
disk_avail=$(echo $disk_info | awk '{print $3}')
disk_pct=$(echo $disk_info | awk '{print $4}')
echo "disk_total=$disk_total"
echo "disk_used=$disk_used"
echo "disk_available=$disk_avail"
echo "disk_used_pct=$disk_pct"

# Network connections count
tcp_connections=$(ss -t state established | tail -n +2 | wc -l)
echo "tcp_established=$tcp_connections"

# Uptime
uptime_seconds=$(awk '{print int($1)}' /proc/uptime)
echo "uptime_seconds=$uptime_seconds"
Test the script manually

Before building anything else on top of this script, you need to confirm it runs correctly. Running it now catches typos and permission issues early.

  • Make the script executable by running:
chmod +x scripts/checks.sh
  • Run the script by executing:
bash scripts/checks.sh

You should see output similar to this (your values will differ):

cpu_load_1m=0.12
mem_total_mb=7951
mem_used_mb=1203
mem_available_mb=6542
disk_total=251G
disk_used=14G
disk_available=225G
disk_used_pct=6%
tcp_established=3
uptime_seconds=84523

What should I see?

Every line follows the key=value pattern with no spaces around the = sign. The numbers reflect your WSL2 environment's current state.

Inside WSL2, /proc/loadavg shows the host Windows machine's CPU load because containers and WSL2 share the host kernel. This is normal and expected.

Seeing "Permission denied" or missing output?

  • If you see Permission denied, double check that you ran chmod +x scripts/checks.sh before executing it.
  • If free or ss is not found, install the required packages by running sudo apt install procps iproute2.
  • If you see blank values, make sure you copied the awk commands exactly, including the single quotes and curly braces.

Help me troubleshoot my checks.sh script output

Your health checks script is working and producing structured output. Next up, you'll build a Python CLI that runs this script, parses its output, and displays everything as a color-coded dashboard.

Build the Python CLI

Your Bash script is collecting real system metrics and outputting them as structured key=value pairs. That's the raw data layer done.

Now you need a user-friendly interface on top of that data. In this step, you'll build a Python CLI that calls your Bash script, parses the output, and presents it as a color-coded terminal dashboard.

In this step, get ready to:
  • Create cli.py with subprocess logic to execute the Bash health checks.
  • Add --json and --verbose flags using argparse.
  • Test both output modes from the terminal.
Create the CLI file with imports and color codes

The CLI needs ANSI escape codes to print colored text in the terminal. These are special character sequences that tell the terminal to change text color.

  • Create the src/cli.py file by running this command:
touch src/cli.py
  • Confirm the file exists by running:
ls src/

You should see cli.py in the output.

  • Open src/cli.py in VS Code and add the following imports and color definitions:
#!/usr/bin/env python3
"""syshealth - A system health reporting CLI."""

import argparse
import json
import os
import subprocess
import sys


# ANSI color codes
class Colors:
    HEADER = "\033[95m"
    BLUE = "\033[94m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    RED = "\033[91m"
    RESET = "\033[0m"
    BOLD = "\033[1m"

What does this code do?

The shebang line (#!/usr/bin/env python3) lets the file run directly as a script without typing python3 first.

The Colors class stores ANSI escape codes as class attributes. When you print these codes, the terminal switches to that color until it encounters RESET.

  • Save src/cli.py.

You won't see a change yet. The next chunk adds the function that actually calls your Bash script.

Add the run_checks function

This function bridges your Bash data-collection layer with the Python display layer. It uses subprocess.run() to execute checks.sh and parse the key=value output into a Python dictionary.

  • In src/cli.py, add the following function below the Colors class:
def run_checks(verbose=False):
    """Execute the Bash health checks and parse output."""
    script_path = os.path.join(
        os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "checks.sh"
    )

    try:
        result = subprocess.run(
            ["bash", script_path],
            capture_output=True,
            text=True,
            timeout=10,
        )
    except subprocess.TimeoutExpired:
        print(f"{Colors.RED}Error: Health checks timed out{Colors.RESET}", file=sys.stderr)
        sys.exit(1)
    except FileNotFoundError:
        print(f"{Colors.RED}Error: bash not found{Colors.RESET}", file=sys.stderr)
        sys.exit(1)

    if result.returncode != 0:
        print(f"{Colors.RED}Error running checks:{Colors.RESET}", file=sys.stderr)
        print(result.stderr, file=sys.stderr)
        sys.exit(1)

    if verbose:
        print(f"{Colors.BLUE}Raw output:{Colors.RESET}")
        print(result.stdout)

    metrics = {}
    for line in result.stdout.strip().split("\n"):
        if "=" in line:
            key, value = line.split("=", 1)
            metrics[key.strip()] = value.strip()

    return metrics

What does this code do?

The script_path calculation finds checks.sh relative to cli.py's location. This means the CLI works regardless of which directory you run it from.

The subprocess.run() call uses capture_output=True to grab stdout and stderr, text=True to get strings instead of bytes, and timeout=10 to prevent the script from hanging forever.

The parsing loop splits each line on = and builds a dictionary like {"cpu_load_1m": "0.52", "mem_total_mb": "7962", ...}.

  • Save src/cli.py.

You won't see output yet. The next chunk adds functions that color-code the data based on severity thresholds.

Seeing a FileNotFoundError when you test later?

Make sure you're running the CLI from the syshealth/ directory. The relative path ../scripts/checks.sh only resolves correctly when cli.py is inside src/.

help me debug a FileNotFoundError in my syshealth CLI

Add the status color helper

A dashboard is only useful if critical values stand out visually. This function maps metric values to colors based on severity thresholds.

  • In src/cli.py, add the following function below run_checks():
def get_status_color(metric_name, value):
    """Return color based on metric thresholds."""
    try:
        if metric_name == "disk_used_pct":
            pct = int(value.replace("%", ""))
            if pct > 90:
                return Colors.RED
            elif pct > 70:
                return Colors.YELLOW
            return Colors.GREEN
        if metric_name == "cpu_load_1m":
            load = float(value)
            if load > 4.0:
                return Colors.RED
            elif load > 2.0:
                return Colors.YELLOW
            return Colors.GREEN
    except (ValueError, TypeError):
        pass
    return Colors.BLUE

What does this code do?

The function checks two metrics: disk_used_pct and cpu_load_1m. Disk above 90% or CPU load above 4.0 shows red (critical). Disk above 70% or CPU above 2.0 shows yellow (warning). Everything else shows green or blue (healthy).

  • Save src/cli.py.

You won't see a change yet. The next chunk adds the uptime formatter and then the dashboard display.

Add the uptime formatter

Raw uptime in seconds is hard to read. This helper converts it to a human-friendly format like 2d 5h 30m.

  • In src/cli.py, add the following function below get_status_color():
def format_uptime(seconds_str):
    """Convert seconds to human-readable uptime."""
    try:
        seconds = int(seconds_str)
        days = seconds // 86400
        hours = (seconds % 86400) // 3600
        minutes = (seconds % 3600) // 60
        parts = []
        if days > 0:
            parts.append(f"{days}d")
        if hours > 0:
            parts.append(f"{hours}h")
        parts.append(f"{minutes}m")
        return " ".join(parts)
    except (ValueError, TypeError):
        return seconds_str

What does this code do?

Integer division (//) and modulo (%) break the total seconds into days, hours, and minutes. Only non-zero parts are included, so 3600 seconds becomes 1h 0m and 90000 seconds becomes 1d 1h 0m.

  • Save src/cli.py.

You won't see a change yet. The next function puts everything together into the visual dashboard.

Add the display dashboard function

This is the main display function that prints a formatted, color-coded report to the terminal. It pulls data from the metrics dictionary and applies the color helpers you just wrote.

  • In src/cli.py, add the following function below format_uptime():
def display_dashboard(metrics):
    """Print a color-coded terminal dashboard."""
    print(f"\n{Colors.BOLD}{Colors.HEADER}{'=' * 50}{Colors.RESET}")
    print(f"{Colors.BOLD}{Colors.HEADER}  SYSTEM HEALTH REPORT{Colors.RESET}")
    print(f"{Colors.BOLD}{Colors.HEADER}{'=' * 50}{Colors.RESET}\n")

    # CPU section
    cpu_load = metrics.get("cpu_load_1m", "N/A")
    color = get_status_color("cpu_load_1m", cpu_load)
    print(f"  {Colors.BOLD}CPU{Colors.RESET}")
    print(f"    Load (1m avg): {color}{cpu_load}{Colors.RESET}\n")

    # Memory section
    mem_total = metrics.get("mem_total_mb", "N/A")
    mem_used = metrics.get("mem_used_mb", "N/A")
    mem_available = metrics.get("mem_available_mb", "N/A")
    print(f"  {Colors.BOLD}MEMORY{Colors.RESET}")
    print(f"    Total:     {Colors.BLUE}{mem_total} MB{Colors.RESET}")
    print(f"    Used:      {Colors.YELLOW}{mem_used} MB{Colors.RESET}")
    print(f"    Available: {Colors.GREEN}{mem_available} MB{Colors.RESET}\n")

    # Disk section
    disk_pct = metrics.get("disk_used_pct", "N/A")
    color = get_status_color("disk_used_pct", disk_pct)
    print(f"  {Colors.BOLD}DISK (root){Colors.RESET}")
    print(f"    Total:     {Colors.BLUE}{metrics.get('disk_total', 'N/A')}{Colors.RESET}")
    print(f"    Used:      {color}{metrics.get('disk_used', 'N/A')} ({disk_pct}){Colors.RESET}")
    print(f"    Available: {Colors.GREEN}{metrics.get('disk_available', 'N/A')}{Colors.RESET}\n")

    # Network section
    tcp_conn = metrics.get("tcp_established", "N/A")
    print(f"  {Colors.BOLD}NETWORK{Colors.RESET}")
    print(f"    TCP Established: {Colors.BLUE}{tcp_conn}{Colors.RESET}\n")

    # Uptime section
    uptime = format_uptime(metrics.get("uptime_seconds", "0"))
    print(f"  {Colors.BOLD}UPTIME{Colors.RESET}")
    print(f"    {Colors.GREEN}{uptime}{Colors.RESET}\n")

    print(f"{Colors.HEADER}{'=' * 50}{Colors.RESET}")

What does this code do?

Each section (CPU, Memory, Disk, Network, Uptime) pulls its values from the metrics dictionary using .get() with a fallback of N/A if the key is missing.

The disk and CPU sections call get_status_color() to dynamically pick red, yellow, or green based on the current value. Other sections use fixed colors for consistency.

  • Save src/cli.py.

You won't see output yet. The file still needs a main() function to actually run.

Add argument parsing and the main function

The argparse module lets you define command-line flags that change how your CLI behaves. You'll add --json for machine-readable output and --verbose for raw debugging info.

  • In src/cli.py, add the following main() function and entry point below display_dashboard():
def main():
    parser = argparse.ArgumentParser(
        description="syshealth - System health reporting CLI"
    )
    parser.add_argument(
        "--json", action="store_true", help="Output metrics as JSON"
    )
    parser.add_argument(
        "--verbose", action="store_true", help="Show raw check output"
    )

    args = parser.parse_args()

    metrics = run_checks(verbose=args.verbose)

    if args.json:
        print(json.dumps(metrics, indent=2))
    else:
        display_dashboard(metrics)


if __name__ == "__main__":
    main()

What does this code do?

The ArgumentParser defines two flags. When --json is passed, the CLI outputs the metrics dictionary as formatted JSON instead of the color-coded dashboard.

The if __name__ == "__main__" guard ensures main() only runs when you execute the file directly, not when it's imported as a module.

  • Save src/cli.py.

Your CLI is now complete and ready to test.

Seeing a SyntaxError?

Check that f-strings use matching quotes. The display_dashboard function mixes double quotes for the outer string and single quotes inside .get() calls. Mismatched quotes are the most common cause.

help me fix a SyntaxError in my syshealth cli.py file

✔️ Awesome, I've got everything!

Great. Double check you've saved src/cli.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/cli.py at this point:

#!/usr/bin/env python3
"""syshealth - A system health reporting CLI."""

import argparse
import json
import os
import subprocess
import sys


# ANSI color codes
class Colors:
    HEADER = "\033[95m"
    BLUE = "\033[94m"
    GREEN = "\033[92m"
    YELLOW = "\033[93m"
    RED = "\033[91m"
    RESET = "\033[0m"
    BOLD = "\033[1m"


def run_checks(verbose=False):
    """Execute the Bash health checks and parse output."""
    script_path = os.path.join(
        os.path.dirname(os.path.abspath(__file__)), "..", "scripts", "checks.sh"
    )

    try:
        result = subprocess.run(
            ["bash", script_path],
            capture_output=True,
            text=True,
            timeout=10,
        )
    except subprocess.TimeoutExpired:
        print(f"{Colors.RED}Error: Health checks timed out{Colors.RESET}", file=sys.stderr)
        sys.exit(1)
    except FileNotFoundError:
        print(f"{Colors.RED}Error: bash not found{Colors.RESET}", file=sys.stderr)
        sys.exit(1)

    if result.returncode != 0:
        print(f"{Colors.RED}Error running checks:{Colors.RESET}", file=sys.stderr)
        print(result.stderr, file=sys.stderr)
        sys.exit(1)

    if verbose:
        print(f"{Colors.BLUE}Raw output:{Colors.RESET}")
        print(result.stdout)

    metrics = {}
    for line in result.stdout.strip().split("\n"):
        if "=" in line:
            key, value = line.split("=", 1)
            metrics[key.strip()] = value.strip()

    return metrics


def get_status_color(metric_name, value):
    """Return color based on metric thresholds."""
    try:
        if metric_name == "disk_used_pct":
            pct = int(value.replace("%", ""))
            if pct > 90:
                return Colors.RED
            elif pct > 70:
                return Colors.YELLOW
            return Colors.GREEN
        if metric_name == "cpu_load_1m":
            load = float(value)
            if load > 4.0:
                return Colors.RED
            elif load > 2.0:
                return Colors.YELLOW
            return Colors.GREEN
    except (ValueError, TypeError):
        pass
    return Colors.BLUE


def format_uptime(seconds_str):
    """Convert seconds to human-readable uptime."""
    try:
        seconds = int(seconds_str)
        days = seconds // 86400
        hours = (seconds % 86400) // 3600
        minutes = (seconds % 3600) // 60
        parts = []
        if days > 0:
            parts.append(f"{days}d")
        if hours > 0:
            parts.append(f"{hours}h")
        parts.append(f"{minutes}m")
        return " ".join(parts)
    except (ValueError, TypeError):
        return seconds_str


def display_dashboard(metrics):
    """Print a color-coded terminal dashboard."""
    print(f"\n{Colors.BOLD}{Colors.HEADER}{'=' * 50}{Colors.RESET}")
    print(f"{Colors.BOLD}{Colors.HEADER}  SYSTEM HEALTH REPORT{Colors.RESET}")
    print(f"{Colors.BOLD}{Colors.HEADER}{'=' * 50}{Colors.RESET}\n")

    # CPU section
    cpu_load = metrics.get("cpu_load_1m", "N/A")
    color = get_status_color("cpu_load_1m", cpu_load)
    print(f"  {Colors.BOLD}CPU{Colors.RESET}")
    print(f"    Load (1m avg): {color}{cpu_load}{Colors.RESET}\n")

    # Memory section
    mem_total = metrics.get("mem_total_mb", "N/A")
    mem_used = metrics.get("mem_used_mb", "N/A")
    mem_available = metrics.get("mem_available_mb", "N/A")
    print(f"  {Colors.BOLD}MEMORY{Colors.RESET}")
    print(f"    Total:     {Colors.BLUE}{mem_total} MB{Colors.RESET}")
    print(f"    Used:      {Colors.YELLOW}{mem_used} MB{Colors.RESET}")
    print(f"    Available: {Colors.GREEN}{mem_available} MB{Colors.RESET}\n")

    # Disk section
    disk_pct = metrics.get("disk_used_pct", "N/A")
    color = get_status_color("disk_used_pct", disk_pct)
    print(f"  {Colors.BOLD}DISK (root){Colors.RESET}")
    print(f"    Total:     {Colors.BLUE}{metrics.get('disk_total', 'N/A')}{Colors.RESET}")
    print(f"    Used:      {color}{metrics.get('disk_used', 'N/A')} ({disk_pct}){Colors.RESET}")
    print(f"    Available: {Colors.GREEN}{metrics.get('disk_available', 'N/A')}{Colors.RESET}\n")

    # Network section
    tcp_conn = metrics.get("tcp_established", "N/A")
    print(f"  {Colors.BOLD}NETWORK{Colors.RESET}")
    print(f"    TCP Established: {Colors.BLUE}{tcp_conn}{Colors.RESET}\n")

    # Uptime section
    uptime = format_uptime(metrics.get("uptime_seconds", "0"))
    print(f"  {Colors.BOLD}UPTIME{Colors.RESET}")
    print(f"    {Colors.GREEN}{uptime}{Colors.RESET}\n")

    print(f"{Colors.HEADER}{'=' * 50}{Colors.RESET}")


def main():
    parser = argparse.ArgumentParser(
        description="syshealth - System health reporting CLI"
    )
    parser.add_argument(
        "--json", action="store_true", help="Output metrics as JSON"
    )
    parser.add_argument(
        "--verbose", action="store_true", help="Show raw check output"
    )

    args = parser.parse_args()

    metrics = run_checks(verbose=args.verbose)

    if args.json:
        print(json.dumps(metrics, indent=2))
    else:
        display_dashboard(metrics)


if __name__ == "__main__":
    main()
Test the CLI end-to-end

Time to see your CLI in action. You'll test both the dashboard mode and the JSON mode to confirm everything works.

  • Run the dashboard mode by running this command:
python3 src/cli.py

You should see a color-coded report with sections for CPU, MEMORY, DISK, NETWORK, and UPTIME. The values reflect your current WSL2 system state.

  • Run the JSON mode by running this command:
python3 src/cli.py --json

You should see a formatted JSON object containing all the metrics as key-value pairs.

Why offer a JSON mode?

The --json flag makes your CLI composable. Other tools and scripts can pipe the JSON output into jq, monitoring dashboards, or alerting systems. This is how production CLI tools work.

Seeing Permission denied or No such file?

Make sure checks.sh is executable. Run chmod +x scripts/checks.sh from the syshealth/ directory if you haven't already.

Also confirm you're running the command from inside the syshealth/ directory, not from src/.

help me troubleshoot my syshealth CLI not finding checks.sh

Your multi-language CLI tool is alive. Next up, you'll package it into a minimal Docker container so it runs identically on any machine.

Containerize with Docker

Your CLI tool works perfectly on your local machine. But what happens when you want to share it with a teammate, or run it on a server?

Docker solves this by packaging your code, its dependencies, and the exact Linux tools it needs into a single portable image. In this step, you will write a Dockerfile that builds a minimal production container for your syshealth CLI.

In this step, get ready to:
  • Write a multi-stage Dockerfile that validates and packages your CLI.
  • Build and run the container image locally.
  • Debug container issues using Docker's troubleshooting commands.
Write the Dockerfile

A multi-stage build separates the "build" work from the final production image. The first stage validates your scripts, and the second stage creates the slim container that actually runs.

  • In your WSL2 terminal, make sure you are inside the syshealth project folder.
  • Create a new file called Dockerfile (no file extension) in the project root by running:
touch Dockerfile
  • Open Dockerfile in VS Code and paste the following:
# Stage 1: Build/verification stage
FROM python:3.14-alpine AS builder

WORKDIR /app
COPY scripts/ scripts/
COPY src/ src/

# Verify scripts are valid
RUN bash -n scripts/checks.sh

# Stage 2: Production image
FROM python:3.14-alpine

# Install system utilities needed by checks.sh
RUN apk add --no-cache \
    bash \
    procps \
    iproute2 \
    coreutils

WORKDIR /app
COPY --from=builder /app/scripts/ scripts/
COPY --from=builder /app/src/ src/

# Make the check script executable
RUN chmod +x scripts/checks.sh

ENTRYPOINT ["python3", "src/cli.py"]

What does this Dockerfile do?

The build has two stages that each start with a FROM instruction:

  • Stage 1 (builder): Copies your scripts and source code, then runs bash -n checks.sh to syntax-check the Bash script. If checks.sh has a syntax error, the build fails immediately.
  • Stage 2 (production): Starts from a fresh python:3.14-alpine image, installs the Linux utilities your Bash script needs (procps for free, iproute2 for ss), copies only the verified files from stage 1, and sets the Python CLI as the container's entrypoint.

The multi-stage approach means your final image never carries leftover build artifacts. It stays minimal.

Why Alpine Linux?

Alpine is a tiny Linux distribution designed for containers. A standard Python image is hundreds of megabytes. The Alpine variant is a fraction of that size, which means faster pulls and less attack surface in production.

  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile listed in the output.

Don't see the file?

  • Make sure you created the file in the syshealth/ project root, not inside scripts/ or src/.
  • The filename is exactly Dockerfile with a capital D and no file extension.

Help me troubleshoot my Dockerfile creation

✔️ Awesome, I've got everything!

Great. Make sure you have saved your Dockerfile before moving on.

ⓧ I'd like to double check the full code

# Stage 1: Build/verification stage
FROM python:3.14-alpine AS builder

WORKDIR /app
COPY scripts/ scripts/
COPY src/ src/

# Verify scripts are valid
RUN bash -n scripts/checks.sh

# Stage 2: Production image
FROM python:3.14-alpine

# Install system utilities needed by checks.sh
RUN apk add --no-cache \
    bash \
    procps \
    iproute2 \
    coreutils

WORKDIR /app
COPY --from=builder /app/scripts/ scripts/
COPY --from=builder /app/src/ src/

# Make the check script executable
RUN chmod +x scripts/checks.sh

ENTRYPOINT ["python3", "src/cli.py"]
Build and run the container locally

With the Dockerfile written, you can now build it into an image and run your CLI inside a container. This is where you see the difference between running on your host machine and running in an isolated Linux environment.

  • Build the Docker image by running this command:
docker build -t syshealth .

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to create an image.
  • -t syshealth tags the resulting image with the name syshealth so you can reference it easily.
  • . tells Docker to use the current directory as the build context (where it looks for files to COPY).

You should see Docker pulling the python:3.14-alpine base image, installing packages, and completing the build. The final line shows the image ID.

Build failing?

  • Make sure Docker Desktop is running. You can check by running docker --version in WSL2.
  • If you see a "permission denied" error, confirm you are running the command from inside the syshealth/ directory where the Dockerfile lives.
  • If the syntax check on checks.sh fails, open your script and fix any typos, then rebuild.

Help me debug my Docker build failure

  • Run your containerized CLI by running:
docker run --rm syshealth

You should see the same color-coded SYSTEM HEALTH REPORT dashboard you saw when running locally. The --rm flag automatically removes the container after it exits.

Why do the metrics look different?

Inside the container, the values may differ from your WSL2 host. CPU load still reflects the host because containers share the host kernel. But memory and disk values reflect the container's own cgroup limits and overlay filesystem.

Network connections (tcp_established) will likely be 0 because the container has its own isolated network namespace with no active connections.

  • Test the JSON output mode by running:
docker run --rm syshealth --json

You should see valid JSON output with all the metric keys and values. This confirms both the Bash script and the Python CLI are working correctly inside the container.

Debug container issues from the terminal

When something goes wrong inside a container, you need to get inside it and look around. Docker provides several commands for this kind of troubleshooting.

  • Run the container with a name (without --rm) so it persists after exiting:
docker run --name syshealth-debug syshealth
  • View the logs from that container by running:
docker logs syshealth-debug

What does docker logs show?

docker logs displays everything the container printed to stdout and stderr during its run. Even after a container exits, the logs remain accessible as long as the container has not been removed.

  • Open an interactive shell inside a new container by running:
docker run -it --entrypoint /bin/sh syshealth

What does this command do?

  • --entrypoint /bin/sh overrides the ENTRYPOINT in the Dockerfile so you get a shell instead of the Python CLI.
  • -it allocates an interactive terminal so you can type commands.

This drops you directly into the container's filesystem. You can inspect files, run commands, and test things manually.

  • Inside the container shell, explore the filesystem and run the CLI manually:
ls /app/scripts/
python3 src/cli.py --json
  • Exit the container shell by typing:
exit

What about docker exec?

docker exec lets you run commands inside a container that is already running. Since your CLI exits immediately after printing, docker exec is most useful for long-running containers (like web servers or databases) that stay alive.

For quick debugging of a short-lived container like yours, docker run -it --entrypoint /bin/sh is the faster approach.

  • Clean up the named debug container by running:
docker rm syshealth-debug

You should see the container name echoed back, confirming it has been removed.

Seeing "No such container"?

  • If the docker rm command says the container does not exist, you may have used --rm when running it (which auto-removes on exit).
  • Run docker ps -a to list all containers (including stopped ones) and check what is available.

Help me understand Docker container lifecycle

Your syshealth CLI is now fully containerized and ready to ship. Next up, you will set up a CI/CD pipeline that automatically builds, tests, and publishes this image to GitHub Container Registry on every push.

Set Up GitHub Actions CI/CD

In the last step, you containerized your CLI tool with Docker and tested it locally. Your image works, but you're the only one who can access it.

To share your container image with the world, you need to publish it to a container registry. You also want this to happen automatically on every push. GitHub Actions gives you free, unlimited CI/CD minutes on public repositories, and GitHub Container Registry (ghcr.io) lets you host your image right alongside your code.

In this step, get ready to:
  • Create a public GitHub repository and push your project.
  • Write a CI/CD workflow that builds, tests, and publishes your Docker image.
  • Watch the pipeline run and verify the published image on ghcr.io.
Create the GitHub repository

Before you can push code or trigger a pipeline, you need a remote repository on GitHub to host your project.

  • Create a .gitignore file to exclude generated files from version control by running this command:
cat > .gitignore << 'EOF'
__pycache__/
*.pyc
.env
.venv/
EOF

What does this code do?

The cat > .gitignore << 'EOF' syntax creates a file and writes everything between the two EOF markers into it.

The patterns tell Git to ignore Python bytecode (__pycache__/, *.pyc), environment files (.env), and virtual environments (.venv/). These files are machine-generated or contain secrets, so they should never be committed.

  • Stage and commit the .gitignore along with any uncommitted files by running:
git add -A
git commit -m "Add .gitignore and prepare for GitHub"

Now create the remote repository on GitHub.

  • Open your browser and go to github.com.
  • In the upper-right corner, click the + menu, then click New repository.
  • Type syshealth in the Repository name field.
  • Select Public for the repository visibility.
  • Do NOT check any of the initialization options (no README, no .gitignore, no license). You already have local files to push.
  • Click Create repository.

Why must the repository be public?

GitHub Actions is free and unlimited for public repositories. A private repository would use your monthly free-tier minutes (2,000 minutes on the GitHub Free plan).

Public visibility also means anyone can pull your published container image from ghcr.io without authentication.

  • Back in your WSL2 terminal, add the GitHub repository as your remote origin by running:
git remote add origin [[YOUR_GITHUB_REPO_URL="https://github.com/your-username/syshealth.git"]]
  • Rename your branch to main (if it isn't already) and push by running:
git branch -M main
git push -u origin main

What does git branch -M main do?

Some Git versions default to naming the first branch master instead of main. The -M flag forcefully renames your current branch to main, which matches what the CI workflow expects.

  • Refresh your GitHub repository page in the browser. You should see your project files (scripts/, src/, Dockerfile, .gitignore) listed on the repository's main page.

Push rejected or authentication error?

If Git asks for a username and password, GitHub no longer accepts password authentication. You need a Personal Access Token (PAT) or SSH key. Visit github.com/settings/tokens to generate a token with repo scope, then use it as your password when prompted.

If you see "remote origin already exists", run git remote set-url origin <your-url> instead of git remote add.

Help me fix my git push authentication error

Write the CI/CD workflow

GitHub Actions workflows live in a special .github/workflows directory. Any YAML file you place there becomes a pipeline that GitHub runs automatically based on the triggers you define.

  • Create the workflow directory structure by running:
mkdir -p .github/workflows
  • Create the CI/CD workflow file by running:
cat > .github/workflows/ci.yml << 'EOF'
name: Build and Publish syshealth

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build Docker image
        run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .

      - name: Smoke test - JSON output
        run: docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} --json

      - name: Tag as latest
        run: docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      - name: Push image
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: |
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
EOF

What does this workflow do?

  • on: push/pull_request to main triggers the workflow whenever you push to main or open a pull request targeting it.
  • permissions: packages: write gives the built-in GITHUB_TOKEN permission to push images to the container registry.
  • docker/login-action@v3 authenticates the runner with ghcr.io using the automatic GITHUB_TOKEN. No manual secrets setup needed.
  • The build step tags the image with the commit SHA so every build is uniquely identifiable.
  • The smoke test runs --json to verify the container starts, the Python CLI executes, and the Bash checks run. If anything fails, the pipeline stops.
  • The push step only runs on direct pushes to main (not on pull requests), preventing unreviewed code from being published.

Why raw docker commands instead of docker/build-push-action?

Many production workflows use the docker/build-push-action composite action, which handles build, tag, and push in one step. This workflow uses raw docker build, docker tag, and docker push commands so you can see exactly what happens at each stage.

Understanding the underlying mechanics makes debugging easier and lets you customize the pipeline later.

  • Verify the file was created by running:
ls .github/workflows/

You should see ci.yml in the output.

File not created?

Make sure you ran the mkdir -p .github/workflows command first. The -p flag creates parent directories if they don't exist.

If you see an error about the heredoc, make sure both EOF markers are on their own lines with no leading spaces.

Help me create my ci.yml workflow file

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file and you're ready to push it to GitHub.

ⓧ I'd like to double check the full code

Here is the complete ci.yml workflow file:

name: Build and Publish syshealth

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build Docker image
        run: docker build -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} .

      - name: Smoke test - JSON output
        run: docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} --json

      - name: Tag as latest
        run: docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      - name: Push image
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        run: |
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
Push and watch it run

The workflow triggers on pushes to main. Committing and pushing the workflow file is all it takes to kick off your first pipeline run.

  • Stage and commit the workflow file by running:
git add .github/workflows/ci.yml
git commit -m "Add CI/CD workflow for Docker build and publish"
  • Push to GitHub by running:
git push

The push to main immediately triggers the workflow. Now watch it run.

  • In your browser, navigate to your syshealth repository on GitHub.
  • Click the Actions tab near the top of the page.
  • You should see a workflow run named "Add CI/CD workflow for Docker build and publish" (matching your commit message). Click on it.
  • Click the build-and-push job to expand the step logs.
  • Watch each step complete with a green checkmark. The full run typically takes 1-2 minutes.

What should I see in the logs?

Each step shows its output in expandable sections. The most interesting one is the smoke test step. You should see JSON output from your CLI with keys like cpu_load_1m, mem_total_mb, and disk_used_pct. These are the system metrics from the ubuntu-latest runner (4 CPUs, 16 GB RAM), not your local machine.

  • Once the workflow completes successfully, verify the published image by clicking on your repository name to return to the main page.
  • In the right sidebar, look for Packages and click it.
  • You should see your syshealth container image listed with the latest tag.

Workflow failed or no Packages link?

If the workflow shows a red X, click into the failed step to read the error log. Common causes:

  • YAML indentation errors. YAML is sensitive to spaces. Make sure every level uses exactly 2 spaces, not tabs.
  • Repository is private. Private repos have limited free Actions minutes. Switch to public in Settings > General > Change visibility.
  • Packages link doesn't appear immediately. It can take a minute after the push step completes. Refresh the page.

Help me debug my failing GitHub Actions workflow

Your syshealth CLI is now fully automated. Every time you push code to main, GitHub builds a fresh Docker image, verifies it works with a smoke test, and publishes it to ghcr.io for anyone to pull. That's a production-grade CI/CD pipeline.

Secret mission

Add a --watch Mode and Docker HEALTHCHECK

Your health report runs once and exits. What if you could watch it update in real time, like a live monitoring dashboard? In this mission, you'll add a --watch mode that refreshes every 5 seconds, plus teach Docker how to monitor the container's own health.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses free services. GitHub Actions is free and unlimited for public repositories, and ghcr.io storage is free for public packages. There are no ongoing costs to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local project files (syshealth/ folder on your WSL2 Desktop).
  • Local Docker images (syshealth and tagged variants).
  • GitHub repository (public repo with source code and CI workflow).
  • Container image on ghcr.io (published by the CI pipeline).
  • Docker Desktop for Windows (installed application).
  • WSL2 Ubuntu distribution (Linux environment).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with more system checks.

  • Your local Docker images take up disk space but cost nothing.
  • Your GitHub repository and ghcr.io container image remain free for public repos.
  • Docker Desktop continues running in the background. You can quit it from the system tray when you do not need it.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping all your files intact.

  • Right-click the Docker Desktop whale icon in your system tray and select Quit Docker Desktop.
  • Close your WSL2 terminal. Docker Desktop will also shut down the WSL2 backend when it quits.
  • Your project files, Docker images, GitHub repo, and ghcr.io packages all remain untouched and ready to resume.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each item below to fully clean up.

Remove local project files
  • Open your WSL2 Ubuntu terminal and remove the project folder by running:
rm -rf ~/Desktop/syshealth
Remove local Docker images
  • Remove all project-related Docker images by running:
docker rmi syshealth
docker system prune -a

What does docker system prune do?

This command removes all unused images, containers, and build cache. Only run it if you have no other Docker projects you want to keep. If you do have other projects, skip the prune and just remove the syshealth image.

Delete the container image on ghcr.io
  • Go to your repository on GitHub.
  • In the right sidebar, click Packages.
  • Click the name of your container package.
  • On the package landing page, click Package settings on the right-hand side.
  • Scroll to the bottom of the page. Under Danger Zone, click Delete this package.
  • Type the package name to confirm and click I understand, delete this package.
Delete the GitHub repository
  • On GitHub, navigate to your repository's main page.
  • Click Settings.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Click I want to delete this repository.
  • Read the warnings and click I have read and understand these effects.
  • Type the repository name to confirm and click Delete this repository.
Uninstall Docker Desktop
  • From the Windows Start menu, select Settings then Apps then Apps & features.
  • Select Docker Desktop from the list and click Uninstall.
  • Confirm the uninstall when prompted.
Remove the WSL2 Ubuntu distribution
  • Open a PowerShell or Command Prompt window (not WSL) and run:
wsl --unregister Ubuntu

This permanently deletes all data in your Ubuntu filesystem

Running wsl --unregister Ubuntu destroys the entire Linux filesystem, including any files stored inside it. Make sure you have saved anything you want to keep before running this command.

Nice Work!

Nice Work!

You did it. You built a real systems tool from scratch and shipped it through a full CI/CD pipeline.

You've learned how to:

  • Write a Bash script that leverages core Linux utilities (top, free, df, ss) to collect system health metrics.
  • Build a Python CLI with argument parsing, subprocess management, and ANSI color-coded terminal output.
  • Containerize the tool with a multi-stage Docker image and set up a GitHub Actions pipeline that automatically builds, tests, and publishes container images to ghcr.io.
  • Secret Mission: Add a live-updating --watch mode and a Docker HEALTHCHECK instruction for production readiness.

Ready to quiz yourself?