Build a Server Health Checker CLI Tool

Build a Python CLI tool that checks server health and outputs structured JSON.

Introduction

30 Second Summary

Imagine learning that your company's website was down for two hours and nobody noticed. In DevOps, automated health checks catch problems like this before users ever do.

In this project, you will build a Python command-line tool that checks a list of servers, measures their response times, and writes the results to a file. You will use Git to version-control your code and push it to GitHub.

What You'll Build

You'll run the tool against a YAML config of your servers and watch each one's health status stream into your terminal, with a structured JSON results file waiting in your project folder.

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

  • A health checker CLI you run from the terminal to see each server's status code and response time printed live.
  • Error handling that catches unreachable servers and prints a clean status instead of crashing with a traceback.
  • A JSON results file you can open to see timestamps and per-server metrics, formatted so Jenkins and Grafana can consume it in follow-on projects.
  • Secret Mission: Add configurable response time thresholds to your YAML config and generate a pass/warn/fail summary table.

Do I need any prior experience?

No prior experience with Python or DevOps is required. This project walks you through installing every tool from scratch.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Install Python, Git, and Set Up Your Project

Your health checker needs three things before you can write a single line of code. Python runs the script. Git tracks your changes and gets the code to GitHub.

A virtual environment keeps your project's packages separate from everything else on your machine. This step gets all three installed and verified so you have a clean workspace ready to go.

In this step, get ready to:
  • Install Python 3.13.15 and verify it runs in your terminal.
  • Install Git 2.55.0 and configure your identity.
  • Create a virtual environment and install project dependencies.
Install Python 3.13.15

Python is the language you will use to build the health checker. You need it installed and accessible from your terminal before anything else can happen.

  • Press the Windows key to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open a terminal inside VS Code by pressing Ctrl+` (the backtick key, usually above Tab).
  • Check if Python is already installed by running this command:
python --version

✔️ I see Python 3.13 or higher

Python is ready to go. Continue to the next substep below.

ⓧ I see an older version

You have an older Python version installed. You need Python 3.13.15 for this project.

  • Open your browser and go to the Python 3.13.15 release page:
  • https://www.python.org/downloads/release/python-31315/
  • Scroll down to Files and click Windows installer (64-bit) to download the installer.
  • Run the downloaded installer.
  • On the first screen, check the Add python.exe to PATH checkbox. This is critical.
  • Click Install Now and wait for the installation to finish.
  • Back in VS Code, close your current terminal by clicking the trash can icon in the terminal panel.
  • Open a new terminal by pressing Ctrl+`.
  • Run python --version again. You should now see Python 3.13.15.

Why check Add python.exe to PATH?

PATH is a list of folders your terminal searches when you type a command. Without this checkbox, your terminal will not find the python command and every step from here will fail.

ⓧ Command not found

Python is not installed yet. Follow these steps to install it.

  • Open your browser and go to the Python 3.13.15 release page:
  • https://www.python.org/downloads/release/python-31315/
  • Scroll down to Files and click Windows installer (64-bit) to download the installer.
  • Run the downloaded installer.
  • On the first screen, check the Add python.exe to PATH checkbox. This is critical.
  • Click Install Now and wait for the installation to finish.
  • Back in VS Code, close your current terminal by clicking the trash can icon in the terminal panel.
  • Open a new terminal by pressing Ctrl+`.
  • Run python --version again. You should now see Python 3.13.15.

Why check Add python.exe to PATH?

PATH is a list of folders your terminal searches when you type a command. Without this checkbox, your terminal will not find the python command and every step from here will fail.

Still seeing command not found after installing?

Close VS Code entirely and reopen it. VS Code reads the PATH when it launches, so a full restart picks up the new Python installation.

If it still does not work, re-run the installer, select Modify, and make sure Add python.exe to PATH is checked.

help me troubleshoot why python --version is not working after installing Python on Windows

Install Git 2.55.0

Git tracks every change you make to your code. You will use it to commit your health checker and push it to GitHub later in the project.

  • Check if Git is already installed by running this command:
git --version

✔️ I see git version 2.55 or higher

Git is ready. Continue to the configuration step below.

ⓧ I see an older version

You have an older Git version. Download the latest version to replace it.

  • Open your browser and go to https://git-scm.com/download/win.
  • Click the 64-bit Git for Windows Setup link to download the installer.
  • Run the installer and accept the default options on each screen.
  • When you reach the Credential Helper screen, make sure Git Credential Manager is selected. This handles GitHub login for you later.
  • Click Install and wait for the installation to complete.
  • Back in VS Code, close your terminal and open a new one with Ctrl+`.
  • Run git --version again. You should see git version 2.55.0.windows.4 or similar.

ⓧ Command not found

Git is not installed yet. Follow these steps to install it.

  • Open your browser and go to https://git-scm.com/download/win.
  • Click the 64-bit Git for Windows Setup link to download the installer.
  • Run the installer and accept the default options on each screen.
  • When you reach the Credential Helper screen, make sure Git Credential Manager is selected. This handles GitHub login for you later.
  • Click Install and wait for the installation to complete.
  • Back in VS Code, close your terminal and open a new one with Ctrl+`.
  • Run git --version again. You should see git version 2.55.0.windows.4 or similar.

Still seeing command not found after installing Git?

Close VS Code completely and reopen it so it picks up the new PATH. If the issue persists, re-run the Git installer and make sure Git from the command line and also from 3rd-party software is selected on the PATH adjustment screen.

help me troubleshoot why git --version is not working after installing Git on Windows

Now that Git is installed, tell it who you are. Every commit you make will carry this name and email.

  • Configure your Git identity by running these commands:
git config --global user.name "[[YOURNAME=\"Your Name\"]]"
git config --global user.email "[[YOUREMAIL=\"your-email@gmail.com\"]]"

What do these commands do?

The --global flag sets your name and email for all Git repositories on your machine. Every commit you make will be tagged with this identity, so use the name and email you want to appear on GitHub.

Create the project folder and virtual environment

A virtual environment is an isolated copy of Python for your project. It ensures the packages you install here do not conflict with other projects on your machine.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and move into it by running these commands:
mkdir health-checker
cd health-checker
  • Confirm the folder was created by running:
ls

You should see the terminal path now ends with health-checker. The folder is empty, which is expected.

  • Now open this folder in VS Code so it becomes your workspace.
  • Click File in the VS Code menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the health-checker folder, and click Select Folder.

VS Code reloads with health-checker as your workspace. The Explorer sidebar on the left shows the empty folder.

  • Open a terminal in this workspace by pressing Ctrl+`.
  • Create and activate a virtual environment by running these commands:
python -m venv venv
venv\Scripts\activate

Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.

What is a virtual environment?

Think of it as a private toolbox for this project. Packages you install inside (venv) stay here and do not affect any other Python project on your computer.

The python -m venv venv command creates a folder called venv/ containing a copy of the Python interpreter. The activate script tells your terminal to use that copy instead of the system-wide one.

Seeing "cannot be loaded because running scripts is disabled"?

This means your VS Code terminal is using PowerShell with a restricted execution policy. Fix it by running this command:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Then try the activation command again:

venv\Scripts\activate

help me fix PowerShell execution policy so I can activate my Python virtual environment in VS Code

  • Install the project's two dependencies by running this command:
pip install requests==2.34.2 PyYAML==6.0.3

What are these packages?

The requests library lets Python send HTTP requests to servers and read the responses. You will use it to check whether each server is healthy.

The PyYAML library reads YAML files. YAML is the standard config format across DevOps tools like Kubernetes, Ansible, and CI/CD pipelines, so learning to parse it transfers directly to real-world work.

Before you run the next command, think about what you expect to see. You installed two packages. What should pip list show?

  • Verify the packages are installed by running:
pip list

You should see requests and PyYAML in the list, along with their dependencies like urllib3 and charset-normalizer.

Packages not showing in pip list?

Make sure your terminal prompt starts with (venv). If it does not, the virtual environment is not active. Run venv\Scripts\activate first, then try the pip install command again.

help me figure out why pip list is not showing requests and PyYAML after installing them

Your tools are locked in and your project workspace is ready. In the next step, you will write Python code that reaches out to a live server and reports back with its status code and response time.

Check Your First Server

Your tools are installed and your virtual environment is ready. Now it's time to see Python do something real.

Before building a full health checker, you need to prove the core mechanic works. Can Python hit a URL, get a response, and measure how fast it came back? This step answers that question with a minimal script.

In this step, get ready to:
  • Write a Python script that sends an HTTP request to a server.
  • Run the script and see a live status code and response time.
Create the health checker script

The requests library makes sending HTTP requests in Python simple. You will use it to hit a URL, capture the status code, and measure how long the server took to respond.

  • In VS Code, hover over the HEALTH-CHECKER heading in the Explorer sidebar on the left.
  • Click the New File icon that appears.
  • Type health_checker.py and press Enter.
  • Write the health check script by copying this code into the file:
import requests

# Set the target URL to check
url = "https://github.com"
# Send a GET request with a 5-second timeout
response = requests.get(url, timeout=5)
# Measure how long the server took to respond
response_time = response.elapsed.total_seconds()

# Display the health check results
print(f"URL: {url}")
print(f"Status Code: {response.status_code}")
print(f"Response Time: {response_time}s")

What does this code do?

  • requests.get() sends an HTTP GET request to the URL, just like a browser visiting a page. The timeout=5 tells Python to give up if the server doesn't respond within 5 seconds.
  • response.elapsed.total_seconds() measures the time between sending the request and receiving the response headers. This is the response time you would monitor in a real health check.
  • response.status_code returns the HTTP status code. A 200 means the server is healthy and responded successfully.
  • Save health_checker.py by pressing Ctrl+S.

Before you run this script, what status code do you think GitHub will return?

  • Run the script in your terminal by entering this command:
python health_checker.py

You should see output showing the URL, Status Code: 200, and a response time in seconds.

Seeing an error instead of a status code?

  • If you see ModuleNotFoundError: No module named 'requests', your virtual environment is not activated. Check that your terminal prompt starts with (venv). If not, run venv\Scripts\activate and try again.
  • If you see a timeout or connection error, check your internet connection and try again.
  • If you see a SyntaxError, double check that your print() lines use f"..." with the f prefix before the opening quote.

Still stuck? Help me debug why my health_checker.py script is not running correctly.

Test with a different server

Different servers respond at different speeds. Testing with another URL shows how response times vary in the real world.

  • In health_checker.py, find this line:
url = "https://github.com"
  • Change "https://github.com" to "https://google.com", so the line looks like this:
url = "https://google.com"
  • Save the file and run the script again:
python health_checker.py

You should see Status Code: 200 again, but with a different response time compared to GitHub.

  • Change the URL back to "https://github.com" so your script is ready for the next step.
  • Save health_checker.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved health_checker.py with the URL set back to https://github.com.

ⓧ I'd like to double check the full code

Here is the complete health_checker.py file. Make sure yours matches:

import requests

url = "https://github.com"
response = requests.get(url, timeout=5)
response_time = response.elapsed.total_seconds()

print(f"URL: {url}")
print(f"Status Code: {response.status_code}")
print(f"Response Time: {response_time}s")

Your script can check a single server and report back. But a real health checker needs to handle multiple servers, and it can't crash when one of them is down. Next up, you will make the script handle a list of servers and survive failures.

Handle Multiple Servers Without Crashing

Your health checker hits one hardcoded URL and reports the result. In the real world, you need to check dozens of servers from a configuration file.

In this step, you will move your server list into a YAML config file and add error handling so the script recovers from failures instead of crashing.

In this step, get ready to:
  • Discover what happens when the script hits an unreachable server.
  • Create a YAML config file listing multiple servers.
  • Add error handling so failures are reported instead of crashing the script.
See what happens when a server is unreachable

What happens when one of your servers is down or does not exist? Right now, the script has no way to deal with that.

  • In health_checker.py, add these lines at the bottom of the file to try checking a server that does not exist:
# Try checking a server that doesn't exist
url2 = "https://thisserverdoesnotexist.invalid"
response2 = requests.get(url2, timeout=5)

What is .invalid?

The .invalid domain is reserved by RFC 2606 and is guaranteed to never resolve in DNS. It is a reliable way to simulate a completely unreachable server.

Before you run this, what do you think will happen? Will the script print results for both URLs, or will something go wrong?

  • Save health_checker.py.
  • Run the script by running this command:
python health_checker.py

The script prints the GitHub result, then crashes with a requests.exceptions.ConnectionError traceback. One bad server takes down the entire health check.

Why did it crash?

Python stops execution when it hits an unhandled exception. The requests.get() call raised a ConnectionError because the DNS lookup for the fake domain failed, and nothing in the code catches that error.

Create the server config and rewrite the script

To fix this, you need two things. A config file that lists all your servers, and a function that wraps each request in error handling.

  • Create a new file called servers.yaml in your health-checker folder. Add this server configuration:
servers:
  - name: GitHub
    url: https://github.com
    timeout: 5
  - name: Google
    url: https://google.com
    timeout: 5
  - name: Fake Server
    url: https://thisserverdoesnotexist.invalid
    timeout: 3

What is this YAML file?

YAML is a human-readable config format used everywhere in DevOps, from Kubernetes manifests to CI/CD pipelines. Each server entry has a name for display, a url to check, and a timeout in seconds.

The Fake Server entry uses the same unreachable domain that just crashed your script. This time, the code will handle it gracefully.

  • Save servers.yaml.
  • Confirm the file exists by running:
ls

You should see servers.yaml alongside health_checker.py in the output.

Don't see servers.yaml?

  • Make sure you created the file inside the health-checker folder, not in a subfolder or parent directory.
  • Check that you saved the file after adding the content.

Still stuck? Ask for help.

✔️ Awesome, I've got everything!

Great. Make sure you have saved servers.yaml before moving on.

ⓧ I'd like to double check the full code

Here is the complete servers.yaml file. Compare it against yours:

servers:
  - name: GitHub
    url: https://github.com
    timeout: 5
  - name: Google
    url: https://google.com
    timeout: 5
  - name: Fake Server
    url: https://thisserverdoesnotexist.invalid
    timeout: 3

Now it is time to rewrite health_checker.py so it reads from this config file and handles errors.

  • Switch back to health_checker.py in VS Code.
  • Select all the existing code and delete it. You are replacing the entire file.
  • Add the imports and the load_config function by pasting this code:
import requests
import yaml


def load_config(config_path):
    # Open and parse the YAML config file
    with open(config_path, "r") as f:
        config = yaml.safe_load(f)
    return config["servers"]

What does load_config do?

  • yaml.safe_load() parses the YAML file into a Python dictionary. It only recognizes standard YAML tags, so it is safe to use on untrusted input.
  • config["servers"] pulls out just the list of server entries so the rest of the code can loop through them directly.
  • Save health_checker.py.
  • Verify that your config loads correctly by running this quick test:
python -c "from health_checker import load_config; print(load_config('servers.yaml'))"

You should see a Python list of dictionaries printed out, one for each server in your YAML file.

Seeing a FileNotFoundError?

  • Make sure your terminal is in the health-checker folder where both servers.yaml and health_checker.py live.
  • Check that you saved health_checker.py after deleting the old code and pasting the new code.

Need a hand? Ask for help.

The config loads. Now you need a function that checks a single server and catches errors instead of crashing.

  • In health_checker.py, add the check_server function below the load_config function:
def check_server(server):
    # Extract server details from the config entry
    name = server["name"]
    url = server["url"]
    timeout = server.get("timeout", 5)

    try:
        # Send the request and capture timing
        response = requests.get(url, timeout=timeout)
        response_time = response.elapsed.total_seconds()
        status_code = response.status_code
        print(f"{name} ({url}) - Status Code: {status_code}, Response Time: {response_time}s")
    except requests.exceptions.Timeout:
        # Server took too long to respond
        print(f"{name} ({url}) - UNREACHABLE (Timeout)")
    except requests.exceptions.ConnectionError:
        # Server is completely unreachable (DNS failure, refused connection)
        print(f"{name} ({url}) - UNREACHABLE (Connection failed)")

What does check_server do?

  • server.get("timeout", 5) reads the timeout from the config, falling back to 5 seconds if no timeout is specified.
  • The try/except block wraps the request so that if the server times out or is unreachable, Python catches the error and prints UNREACHABLE instead of crashing.
  • requests.exceptions.Timeout fires when the server does not respond within the timeout window. requests.exceptions.ConnectionError fires when the server is completely unreachable, like a failed DNS lookup.
Run the health checker on all servers

You have the config file and the functions. The last piece is a loop that loads the servers and checks each one.

  • At the bottom of health_checker.py, below the check_server function, add the main loop:
# Load servers from config and check each one
servers = load_config("servers.yaml")
for server in servers:
    check_server(server)

What does this code do?

  • load_config("servers.yaml") reads the YAML file and returns the list of server dictionaries.
  • The for loop passes each server dictionary to check_server(), which handles the request and any errors.
  • Save health_checker.py.

Before you run this, what do you think will happen when the script reaches Fake Server this time?

  • Run the health checker by running this command:
python health_checker.py

You should see all three servers checked. GitHub and Google show their status codes and response times. Fake Server prints UNREACHABLE (Connection failed) instead of crashing the script.

Script still crashing?

  • Make sure you deleted all the old code from health_checker.py before adding the new functions. Leftover requests.get() calls outside the try/except block will still crash.
  • Check that the except lines reference requests.exceptions.ConnectionError and requests.exceptions.Timeout with exact spelling and capitalization.
  • Verify that the for loop and check_server(server) call sit outside the function definitions, at the bottom of the file with no indentation.

Still stuck? Ask for help.

✔️ Awesome, I've got everything!

Great. Double check you have saved both health_checker.py and servers.yaml.

ⓧ I'd like to double check the full code

Here is the complete health_checker.py at this point. Compare it against yours:

import requests
import yaml


def load_config(config_path):
    with open(config_path, "r") as f:
        config = yaml.safe_load(f)
    return config["servers"]


def check_server(server):
    name = server["name"]
    url = server["url"]
    timeout = server.get("timeout", 5)

    try:
        response = requests.get(url, timeout=timeout)
        response_time = response.elapsed.total_seconds()
        status_code = response.status_code
        print(f"{name} ({url}) - Status Code: {status_code}, Response Time: {response_time}s")
    except requests.exceptions.Timeout:
        print(f"{name} ({url}) - UNREACHABLE (Timeout)")
    except requests.exceptions.ConnectionError:
        print(f"{name} ({url}) - UNREACHABLE (Connection failed)")


servers = load_config("servers.yaml")
for server in servers:
    check_server(server)

Your health checker now handles multiple servers from a config file without crashing. Next, you will upgrade it with proper logging, CLI flags, and structured JSON output that downstream tools like Jenkins and Grafana can consume.

Build a Professional CLI with JSON Output

Your health checker handles multiple servers and catches errors gracefully. That is solid progress.

Right now, the script outputs plain text using print() statements. Automation tools like Jenkins and Grafana cannot parse terminal text. They need structured, machine-readable data.

In this step, get ready to:
  • Replace print() with Python's logging module for timestamped, leveled output.
  • Add argparse CLI flags so the script accepts --config, --output, and --verbose arguments.
  • Write structured JSON results to a file for downstream automation tools.
Replace print() with structured logging

Python's logging module adds timestamps and severity levels to your output automatically. Each message gets tagged as INFO, WARNING, or ERROR, making it far easier to filter and search through logs.

  • In health_checker.py, replace everything from the first import line through the end of the load_config function with this code:
import logging

import requests
import yaml

# Set up logging with timestamps and severity levels
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
)


def load_config(config_path):
    """Load server configuration from a YAML file."""
    with open(config_path, "r") as f:
        config = yaml.safe_load(f)
    return config["servers"]

What does this code do?

The logging.basicConfig() call configures Python's built-in logging system. The format string adds a timestamp and severity level to every log line automatically.

The level=logging.INFO setting means messages at INFO severity and above (WARNING, ERROR) will be shown. DEBUG messages will be hidden.

  • Now replace your existing check_server function with this version that uses logging calls instead of print():
def check_server(server):
    """Check a single server's health."""
    name = server["name"]
    url = server["url"]
    timeout = server.get("timeout", 5)

    # Log which server we're checking
    logging.info("Checking %s (%s)...", name, url)

    try:
        response = requests.get(url, timeout=timeout)
        response_time = response.elapsed.total_seconds()
        status_code = response.status_code
        # Use different log levels for healthy vs error responses
        if status_code < 400:
            logging.info("%s is healthy - status %d in %.3fs", name, status_code, response_time)
        else:
            logging.warning("%s returned error status %d", name, status_code)
    except requests.exceptions.Timeout:
        logging.error("%s timed out after %ds", name, timeout)
    except requests.exceptions.ConnectionError:
        logging.error("%s is unreachable", name)

What changed here?

  • logging.info() replaces print() for normal status messages.
  • logging.warning() flags servers that return HTTP error codes (400+).
  • logging.error() marks unreachable servers and timeouts.
  • The %s and %d placeholders insert values into the log message. This is Python's printf-style formatting, which logging prefers over f-strings.

Before you run the script, what do you think the output will look like now that you have switched from print() to logging?

  • Run the script to see the new logging output:
python health_checker.py

Each line now starts with a timestamp and a severity label like INFO or ERROR. The Fake Server line shows ERROR instead of the plain UNREACHABLE text from before.

Not seeing any output?

  • Make sure you saved health_checker.py before running it.
  • Check that logging.basicConfig() appears after the imports and before the load_config function. If it is inside a function, it will not run at startup.
  • Confirm your virtual environment is still active. You should see (venv) at the start of your terminal prompt.

help me debug my logging output

Add argparse and restructure into a CLI

The script still hardcodes "servers.yaml" as the config file path. There is no way to control verbosity or specify an output file from the command line.

Python's argparse module lets you define CLI flags that users pass when running your script. You will also update check_server to return structured data and wrap everything in a main() function.

  • First, update the imports at the top of health_checker.py. Replace everything from import logging through the logging.basicConfig(...) block with this code:
import argparse
import json
import logging
import sys
from datetime import datetime

import requests
import yaml

# Temporary: keep logging config at module level for now
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
)

What are the new imports for?

  • argparse defines CLI flags like --config and --verbose.
  • json writes results to a structured JSON file.
  • sys provides sys.exit() to set the script's exit code for automation tools.
  • datetime timestamps the results report.
  • Now replace your check_server function with this new version. It returns a structured dictionary instead of just logging. Paste the function definition and success path first:
def check_server(server):
    """Check a single server's health and return the result as a dictionary."""
    name = server["name"]
    url = server["url"]
    timeout = server.get("timeout", 5)

    logging.info("Checking %s (%s)...", name, url)

    try:
        response = requests.get(url, timeout=timeout)
        response_time = response.elapsed.total_seconds()
        status_code = response.status_code

        # Build a result dictionary with all server info
        result = {
            "name": name,
            "url": url,
            "status_code": status_code,
            "response_time": response_time,
            "healthy": status_code < 400,
        }

        if result["healthy"]:
            logging.info("%s is healthy - status %d in %.3fs", name, status_code, response_time)
        else:
            logging.warning("%s returned error status %d", name, status_code)
  • Directly below, add the error handling blocks for timeouts and connection failures:
    # Handle each failure type with a structured error result
    except requests.exceptions.Timeout:
        logging.error("%s timed out after %ds", name, timeout)
        result = {
            "name": name,
            "url": url,
            "status_code": None,
            "response_time": None,
            "healthy": False,
            "error": "Timeout",
        }
    except requests.exceptions.ConnectionError:
        logging.error("%s is unreachable", name)
        result = {
            "name": name,
            "url": url,
            "status_code": None,
            "response_time": None,
            "healthy": False,
            "error": "Connection failed",
        }
  • Add the final catch-all handler and the return statement:
    except requests.exceptions.RequestException as e:
        logging.error("%s failed: %s", name, str(e))
        result = {
            "name": name,
            "url": url,
            "status_code": None,
            "response_time": None,
            "healthy": False,
            "error": str(e),
        }

    return result

What does the new check_server do?

Instead of just printing status, check_server now builds and returns a Python dictionary for every server. Each dictionary contains the server name, URL, status code, response time, and whether it is healthy.

The three except blocks catch increasingly general errors. Timeout catches slow servers. ConnectionError catches unreachable ones. RequestException is the base class that catches anything else.

  • Save health_checker.py and run the script to confirm it still works:
python health_checker.py

You should see the same timestamped logging output as before. The return values are not used yet by the for loop at the bottom, but check_server is now building structured data behind the scenes.

Seeing a SyntaxError?

  • Make sure all three except blocks are indented at the same level as the try: keyword.
  • Check that return result appears at the end of the function, at the same indentation level as the try statement.
  • Verify you did not accidentally leave the old check_server code below the new version.

help me fix my check_server function

  • Now add the run_health_checks function after check_server. This function loops through all servers and collects results into a list:
def run_health_checks(servers):
    """Run health checks on all servers and return the results."""
    results = []
    for server in servers:
        result = check_server(server)
        results.append(result)
    return results
  • Finally, replace everything from the logging.basicConfig(...) call through the end of the file (the old servers = load_config(...) and for server in servers: lines) with the main function and entry point guard:
def main():
    # Set up CLI flags for config path, output path, and verbosity
    parser = argparse.ArgumentParser(description="Server Health Checker")
    parser.add_argument(
        "--config",
        default="servers.yaml",
        help="Path to YAML config file (default: servers.yaml)",
    )
    parser.add_argument(
        "--output",
        default="results.json",
        help="Path to JSON output file (default: results.json)",
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        help="Enable verbose logging",
    )
    args = parser.parse_args()
    # Configure logging level based on --verbose flag
    log_level = logging.DEBUG if args.verbose else logging.INFO
    logging.basicConfig(
        level=log_level,
        format="%(asctime)s - %(levelname)s - %(message)s",
    )
    logging.info("Loading config from %s", args.config)
    servers = load_config(args.config)
    results = run_health_checks(servers)

if __name__ == "__main__":
    main()

What does main() do?

The argparse.ArgumentParser defines three CLI flags. --config sets the YAML file path. --output sets the JSON output path. --verbose switches the log level from INFO to DEBUG.

The if __name__ == "__main__": guard ensures main() only runs when you execute the script directly. If another script imports this file, the health checker will not run automatically.

  • Save health_checker.py and test that argparse is working by running:
python health_checker.py --help

You should see a usage message listing the three flags: --config, --output, and --verbose. Each one shows its default value and description.

  • Now run the health checker with the --verbose flag:
python health_checker.py --verbose

You should see timestamped log output for all three servers, including the Loading config from servers.yaml line at the top.

Seeing 'unrecognized arguments' error?

  • Check that you removed the old logging.basicConfig(...) block, the servers = load_config("servers.yaml") line, and the for server in servers: loop. The main() function replaces all of those.
  • Make sure the if __name__ == "__main__": guard is at the very bottom of the file with no indentation.

help me fix my argparse setup

Add JSON output and project files

Your script now runs as a proper CLI. The final piece is saving results to a JSON file so that tools like Jenkins and Grafana can consume the data programmatically.

  • In health_checker.py, add the save_results function between run_health_checks and main:
def save_results(results, output_path):
    """Save health check results to a JSON file."""
    # Build report with metadata and per-server results
    report = {
        "timestamp": datetime.now().isoformat(),
        "total": len(results),
        "healthy": sum(1 for r in results if r["healthy"]),
        "unhealthy": sum(1 for r in results if not r["healthy"]),
        "results": results,
    }

    # Write formatted JSON to the output file
    with open(output_path, "w") as f:
        json.dump(report, f, indent=2)

    logging.info("Results saved to %s", output_path)
    return report

What does save_results build?

The report dictionary includes a timestamp from datetime.now().isoformat(), the total server count, and separate tallies for healthy and unhealthy servers.

The json.dump() call writes it all to a file with indent=2 so the output is human-readable. The function returns the report so main() can use its data.

  • In your main() function, find this line:
    results = run_health_checks(servers)
  • Add these lines directly below it to save results and print a summary:
    report = save_results(results, args.output)

    # Print a human-readable summary to the terminal
    print(f"\nHealth Check Summary: {report['healthy']}/{report['total']} servers healthy")
    # Exit with error code 1 if any server is unhealthy
    if report["unhealthy"] > 0:
        sys.exit(1)

Why sys.exit(1)?

A non-zero exit code tells automation tools that something went wrong. When Jenkins runs this script, a sys.exit(1) signals a failed build step. A sys.exit(0) (or no explicit exit) signals success.

Before you run the final command, what do you expect to see in the terminal and in the results.json file?

  • Save health_checker.py and run the full CLI command:
python health_checker.py --config servers.yaml --output results.json --verbose

You should see timestamped log lines for each server, a Results saved to results.json message, and a summary line showing 2 out of 3 servers healthy.

  • Open results.json in VS Code to inspect the output:
type results.json

You should see structured JSON with a timestamp, healthy and unhealthy counts, and a results array containing each server's status code, response time, and health status.

Not seeing results.json?

  • Make sure save_results is defined before main() in the file. Python reads top to bottom, so main() cannot call a function defined below it.
  • Check that report = save_results(results, args.output) appears inside main(), after the results = run_health_checks(servers) line.
  • If you see a NameError, confirm that import json and from datetime import datetime are both in your imports at the top of the file.

help me debug my JSON output

✔️ Awesome, I've got everything!

Great. Double check you have saved health_checker.py.

ⓧ I'd like to double check the full code

Your health_checker.py should contain these sections in order. Scroll through your file and confirm each one is present:

  • Imports: argparse, json, logging, sys, datetime, requests, yaml.
  • load_config() function (unchanged from Step 3).
  • check_server() function with result dictionaries, three except blocks, and return result.
  • run_health_checks() function that loops through servers.
  • save_results() function that writes JSON.
  • main() function with argparse, logging config, and the summary print.
  • if __name__ == "__main__": guard at the very bottom.

Now create two project support files that make your repository professional and reproducible.

  • Create a new file called requirements.txt in your health-checker folder and add this content:
requests==2.34.2
PyYAML==6.0.3

What is requirements.txt for?

This file lists the exact package versions your project depends on. Anyone who clones your repository can run pip install -r requirements.txt to install the same versions you used. Pinning versions prevents surprising breakages from package updates.

  • Create another new file called .gitignore in your health-checker folder and add this content:
venv/
__pycache__/
*.pyc
results.json

What does .gitignore do?

Git tracks every file in your project folder by default. The .gitignore file tells Git to skip files that should not be in your repository.

The venv/ folder contains your virtual environment (hundreds of files that can be recreated). __pycache__/ and *.pyc are compiled bytecode files. results.json is generated output that changes every run.

  • Confirm both files exist by running:
ls

You should see health_checker.py, servers.yaml, requirements.txt, .gitignore, results.json, and the venv folder all listed.

Don't see .gitignore in the listing?

  • Files starting with a dot are hidden by default. Run ls -a to show hidden files, or check the health-checker folder in the VS Code Explorer sidebar.

help me find my .gitignore file

Your health checker is now a professional CLI tool with structured logging, configurable flags, and machine-readable JSON output. Next up, you will push this code to GitHub so it is ready for automation.

Push Your Code to GitHub

In the last step, you turned your health checker into a professional CLI tool with structured logging, argparse flags, and JSON output. Your code is polished and pipeline-ready.

Code that only lives on your laptop is not DevOps. If your hard drive fails tomorrow, your health checker disappears with it. In this step, you'll push your code to GitHub so it's version-controlled, accessible from anywhere, and ready for Jenkins to pick up in a follow-on project.

In this step, get ready to:
  • Initialize a local Git repository and commit your project files.
  • Create a GitHub repository and push your code to it.
Initialize your Git repository and commit

Git tracks every change you make to your code. A commit is a saved snapshot of your files at a specific point in time.

  • Return to your terminal in VS Code. Initialize a new Git repository by running this command:
git init

You should see a message confirming the repository was initialized.

  • Stage your project files and create your first commit by running these commands:
git add health_checker.py servers.yaml requirements.txt .gitignore
git commit -m "Add server health checker CLI tool"

What do these commands do?

git add stages the four files you want to track. Files listed in your .gitignore (like venv/ and results.json) are automatically excluded.

git commit -m "..." saves a snapshot of those staged files with a descriptive message. Think of each commit as a checkpoint you can always return to.

You should see output listing the files that were committed, including health_checker.py, servers.yaml, requirements.txt, and .gitignore.

Seeing "nothing to commit"?

Make sure you saved all four files in VS Code before running git add. Unsaved files won't be picked up by Git. Save them, then run the git add and git commit commands again.

Help me debug my git commit issue

Create a GitHub repository and push your code

Your commit is saved locally. To make it accessible from anywhere, you need to push it to a remote repository on GitHub.

  • Open your browser and go to github.com. Sign in to your account.
  • Click the + button in the top right corner of the page.
  • Select New repository.
  • Enter health-checker in the Repository name field.
  • Leave everything else empty. Do not check Add a README file, Add .gitignore, or Choose a license.
  • Click Create repository.

Why leave the repository empty?

You already have your files locally with their own commit history. If GitHub adds a README or .gitignore for you, it creates a separate commit that conflicts with your local history. Starting empty keeps the push clean.

GitHub shows a quick setup page with your repository URL. You'll use this URL to connect your local repository.

  • Return to your terminal in VS Code. Connect your local repository to GitHub and push your code by running these commands:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/health-checker.git
git push -u origin main

What happens when you push?

git remote add origin tells Git where your GitHub repository lives. git push -u origin main uploads your commit to GitHub's main branch.

Git Credential Manager will open a browser window or dialog asking you to sign in to GitHub. Enter your GitHub credentials to authorize the push.

You should see output showing your files being uploaded to GitHub, ending with a line that sets up tracking for main.

Seeing "src refspec main does not match any"?

This means your local branch is named master instead of main. Rename it by running:

git branch -M main

Then run the git push -u origin main command again.

Help me fix my git push error

Verify your code is on GitHub

Before you check, what do you expect to see when you run git log? Think about how many commits you made and what message you used.

  • Confirm your commit history by running this command:
git log

You should see one commit with the message Add server health checker CLI tool, along with your name and email. Press q to exit the log viewer.

  • Open your GitHub repository in your browser. Navigate to https://github.com/ followed by your username and /health-checker.

You should see all four files listed: health_checker.py, servers.yaml, requirements.txt, and .gitignore. No venv/ folder and no results.json. Your .gitignore is doing its job.

Don't see your files on GitHub?

Make sure the push completed without errors in your terminal. If you see an authentication failure, try running git push -u origin main again and complete the sign-in prompt.

If the GitHub page shows an empty repository with setup instructions, your push did not go through. Check the terminal output for error messages.

Help me troubleshoot my GitHub push

Secret mission

Add Response Time Thresholds

Your health checker tells you whether servers are up or down. But is "up" good enough? Add configurable response time thresholds and a pass/warn/fail summary table to turn your tool into a real monitoring instrument.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with a free GitHub repository, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • health-checker project folder on your Desktop (includes the virtual environment and all project files).
  • GitHub repository called health-checker.
  • Python 3.13.15 installed on your system.
  • Git 2.55.0 installed on your system.

Keep everything running

No action needed. Choose this if you plan to continue building on this project or move on to follow-on projects in this series.

  • Your health-checker folder, virtual environment, and all project files remain on your Desktop.
  • Your GitHub repository stays online and accessible.
  • Python and Git remain installed and ready for other projects.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. Your project files and GitHub repository stay intact for when you return.

  • In your VS Code terminal, deactivate the virtual environment by running:
deactivate
  • Close the VS Code window for the health-checker project.
  • When you come back, reactivate the virtual environment by opening a terminal in the health-checker folder and running venv\Scripts\activate.

Delete - I don't want to use this again

Remove all project resources and start fresh. Only do this if you are sure you will not return to this project.

Delete the health-checker project folder from your Desktop:

macOS

  • Open a new terminal and run:
cd ~/Desktop
rm -rf health-checker
  • Confirm the folder is gone by running ls and checking that health-checker no longer appears.

Windows

  • Open a new terminal and run:
cd ~\Desktop
Remove-Item -Recurse -Force health-checker
  • Confirm the folder is gone by running ls and checking that health-checker no longer appears.

Linux

  • Open a new terminal and run:
cd ~/Desktop
rm -rf health-checker
  • Confirm the folder is gone by running ls and checking that health-checker no longer appears.

Delete the GitHub repository:

  • Go to your health-checker repository on github.com.
  • Click Settings in the repository tabs.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Click I want to delete this repository.
  • Click I have read and understand these effects.
  • Type the repository name to confirm, then click Delete this repository.

Uninstall Python and Git (optional if you use them for other projects):

  • Press the Windows key and type Add or remove programs. Press Enter.
  • Find Python 3.13.15 in the list and click Uninstall.
  • Find Git in the list and click Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a production-ready Python CLI tool that checks server health, handles errors gracefully, and outputs structured JSON results. Your health checker is version-controlled, pushed to GitHub, and ready for automation.

You've learned how to:

  • Build a Python CLI tool with argparse flags, structured logging, and JSON output that checks server health, catches errors with exception handling, and reads config from a YAML file.
  • Apply core Python fundamentals in a real DevOps context: functions, dictionaries, file I/O, HTTP requests with timeouts, and virtual environments.
  • Use Git to version-control your code and push it to GitHub, designing the JSON output so Jenkins can run the script and Grafana can visualize the results in follow-on projects.
  • Secret Mission: Add configurable response time thresholds and a pass/warn/fail summary table to turn the health checker into a real monitoring instrument.

Ready to quiz yourself?