Python basics · Lesson 9 of 10 · about 25 minutes

Alerts and settings

How to read settings from environment variables, keep secrets out of your code, and have the checker send an alert to your phone when a scheduled run finds a problem.

By the end you'll know

  • How to set an environment variable, and read it with os.environ.get()
  • Why a setting always arrives as text, and how to handle a bad one
  • Why secrets belong in the environment, not in your code
  • How to send a phone notification from Python
  • How to give a cron job the settings it needs

Where your checker is

At the end of Lesson 8, your checker runs by itself every 15 minutes, and logs every run. But a log only helps if you read it. If a site goes down at 3 in the morning, you'll find out whenever you next think to open checker.log.

This lesson makes the checker tell you. When a run finds a problem, your phone buzzes. Getting there takes one idea you've been promised since Lesson 2, environment variables, and one new function. You'll change checker.py in two steps, then give cron what it needs.

Settings that live outside the code

A hotel safe doesn't have its code engraved on the door; you choose the code when you check in, so every guest gets their own. An environment variable works the same way for a program: a named setting that whoever runs the program provides, instead of it being written into the code.

You set one in the terminal. Put it in front of a command, and it applies to that command only:

BASH
CHECKER_TIMEOUT=10 python3 checker.py

Or use export, and it applies to every command you run in that terminal until you close it:

BASH
export CHECKER_TIMEOUT=10

(On Windows, the command is set CHECKER_TIMEOUT=10.) Python reads environment variables with the standard library's os module, short for operating system. os.environ.get() works like .get() on a dictionary in Lesson 3: it takes the name, and a fallback for when it isn't set.

Here's the catch from Lesson 2, back again: an environment variable is always text. Set CHECKER_TIMEOUT=10, and Python gets "10", a str. So it needs converting with int(), and since someone could set it to ten, the conversion needs the try and except ValueError from Lesson 4.

Step 1. Make the timeout a setting.

  1. Add import os to the imports, between logging and sys.
  2. Add this function just above parse_args():
PYTHON
def read_number_setting(name, default):
    text = os.environ.get(name, str(default))
    try:
        return int(text)
    except ValueError:
        logging.warning(f"{name} is {text}, which isn't a number, so using {default}")
        return default

It reads the setting, falling back to the default if it isn't set. str(default) keeps both branches text, so int() always gets text to convert. If the conversion fails, it logs a warning and uses the default rather than crashing, because a typo in a setting shouldn't stop every check.

  1. Let check_site() pass a timeout on to fetch(). Change its first lines to:
PYTHON
def check_site(site, timeout=5):
    name = site["name"]
    response = fetch(site["url"], timeout=timeout)
  1. In main(), read the setting just above sites = load_sites(args.sites):
PYTHON
    timeout = read_number_setting("CHECKER_TIMEOUT", 5)

and in the loop, pass it in:

PYTHON
        result = check_site(site, timeout=timeout)

Run CHECKER_TIMEOUT=10 python3 checker.py, and the checker waits up to 10 seconds per site. Run it without, and it uses 5, as before. Same code, different behaviour, chosen by whoever runs it.

Keeping secrets out of your code

Leaving your door key under the mat works until someone looks under the mat. Writing a password or a secret address into your code is the same: anyone who ever sees the code, or a copy of it on GitHub, has the secret too. Secrets go in environment variables, where they live on the machine that runs the program, not in the files you share.

The checker is about to get its first secret: the name of the channel your phone listens on.

An alert on your phone

A doorbell camera doesn't expect you to watch its feed all day; it sends your phone a notification when someone's at the door. The checker can do the same with ntfy (pronounced "notify"), a free service that turns a web request into a phone notification. There's no account to create: you pick a topic, a channel name, subscribe to it on your phone, and anything sent to that topic appears as a notification.

Because there's no account, the topic name is what protects you. ntfy's own documentation puts it plainly: topic names are public, and the topic is essentially a password. Anyone who knows or guesses yours can read your alerts, and send you fake ones. So make it long and random. Python's standard library has a module for exactly that, called secrets:

BASH
python3 -c "import secrets; print('checker-' + secrets.token_hex(8))"

-c runs the Python in quotes without needing a file. secrets.token_hex(8) makes 16 random letters and digits, so you'll see something like checker-08a93f03a8dd2e80. Yours will be different; that's the point.

Now set it up:

  1. Install the ntfy app on your phone, from the App Store or Google Play.
  2. In the app, subscribe to a topic, and type your new topic name exactly.
  3. In your terminal, with your virtual environment active, set it as an environment variable, using your own topic:
BASH
export NTFY_TOPIC=checker-08a93f03a8dd2e80

Step 2. Teach the checker to send alerts.

  1. Add this function below read_number_setting():
PYTHON
def send_alert(topic, message):
    try:
        response = requests.post(f"https://ntfy.sh/{topic}", data=message, timeout=10)
    except requests.RequestException:
        logging.error("Could not send the alert")
        return
    if response.status_code == 200:
        logging.info("Alert sent")
    else:
        logging.error(f"Alert failed with status {response.status_code}")

requests.post() is the sending version of requests.get() from Lesson 6: instead of asking for something, it delivers something, the data. Then the function checks what came back, the same way the checker checks a website: a 200 means ntfy accepted it, and anything else gets logged as an error rather than assumed to have worked. The bare return in the except block ends the function early, handing back nothing.

  1. At the end of main(), the part that returns 1 when something's wrong now sends an alert first. Change it to:
PYTHON
    if down > 0 or errors > 0:
        topic = os.environ.get("NTFY_TOPIC")
        if topic:
            send_alert(topic, f"{down} down, {errors} unreachable. See checker.log.")
        else:
            logging.warning("NTFY_TOPIC isn't set, so no alert was sent")
        return 1
    return 0

os.environ.get("NTFY_TOPIC") has no fallback, so it gives back None when the variable isn't set. if topic: treats None, or empty text, as false, so the alert only goes out when there's a topic to send it to. When there isn't, the log says so, rather than staying silent. And because the alert sits inside the same if as return 1, the alert and the exit code always agree.

Checkpoint: your checker so far

The top of the file hasn't changed apart from import os, and check_site() now takes a timeout. Here's everything from save_results() down:

PYTHON
def save_results(path, results):
    with open(path, "w") as file:
        json.dump(results, file, indent=2)


def read_number_setting(name, default):
    text = os.environ.get(name, str(default))
    try:
        return int(text)
    except ValueError:
        logging.warning(f"{name} is {text}, which isn't a number, so using {default}")
        return default


def send_alert(topic, message):
    try:
        response = requests.post(f"https://ntfy.sh/{topic}", data=message, timeout=10)
    except requests.RequestException:
        logging.error("Could not send the alert")
        return
    if response.status_code == 200:
        logging.info("Alert sent")
    else:
        logging.error(f"Alert failed with status {response.status_code}")


def parse_args():
    parser = argparse.ArgumentParser(description="Check that websites are up.")
    parser.add_argument("--sites", default="sites.csv", help="CSV file of sites to check")
    parser.add_argument("--results", default="results.json", help="where to save the results")
    parser.add_argument("--log", default="checker.log", help="where to write the log")
    return parser.parse_args()


def main():
    args = parse_args()
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(message)s",
        handlers=[logging.FileHandler(args.log), logging.StreamHandler()],
    )

    timeout = read_number_setting("CHECKER_TIMEOUT", 5)
    sites = load_sites(args.sites)
    logging.info(f"Checking {len(sites)} sites from {args.sites}")

    down = 0
    errors = 0
    results = []
    for site in sites:
        result = check_site(site, timeout=timeout)
        results.append({"name": site["name"], "result": result})
        if result == "down":
            down = down + 1
        elif result == "error":
            errors = errors + 1

    save_results(args.results, results)
    logging.info(f"{down} of {len(sites)} sites down, {errors} unreachable")
    logging.info(f"Results saved to {args.results}")

    if down > 0 or errors > 0:
        topic = os.environ.get("NTFY_TOPIC")
        if topic:
            send_alert(topic, f"{down} down, {errors} unreachable. See checker.log.")
        else:
            logging.warning("NTFY_TOPIC isn't set, so no alert was sent")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

With NTFY_TOPIC exported, run it:

BASH
python3 checker.py

The output ends like this:

TEXT
2026-10-24 23:42:17,725 INFO 1 of 4 sites down, 1 unreachable
2026-10-24 23:42:17,726 INFO Results saved to results.json
2026-10-24 23:42:17,728 INFO Alert sent

and a moment later, your phone shows a notification: 1 down, 1 unreachable. See checker.log. That's the missing page and the site that doesn't exist from sites.csv, doing their job. Run pytest too: still 5 passed.

From a failed check to your phoneOn the left, the environment holds CHECKER_TIMEOUT and NTFY_TOPIC, set by whoever runs the checker, not written in the code. The checker reads them when it starts. If a run finds a site down or unreachable, it posts a message to ntfy.sh on your topic, and ntfy.sh sends a notification to the phone subscribed to that topic. The exit code is 1 at the same time.environmentCHECKER_TIMEOUTNTFY_TOPICset outside the codereadchecker.pya problem foundexit code 1postntfy.shyour topicYour phone1 down,1 unreachableSettings come in from outside. Bad news goes out to you.
The checker reads its settings from the environment when it starts. If a run finds a problem, it posts to your ntfy topic, and the phone subscribed to that topic shows the alert.

Giving cron its settings

When you leave a house-sitter in charge, they only know what's on the note you leave them. cron is the same: it doesn't see anything you exported in your terminal. So as things stand, your scheduled runs would never send an alert.

Step 3. Open your crontab again with EDITOR=nano crontab -e, and add a line above the checker's line, with your own topic:

TEXT
NTFY_TOPIC=checker-08a93f03a8dd2e80

A line like that, a name, = and a value, sets an environment variable for every job below it. Save and exit as before (Ctrl+O, Enter, Ctrl+X). From the next scheduled run, problems reach your phone even when your terminal is closed.

Your crontab now holds a secret, but it lives on your machine, not in your code. That's where secrets belong.

Break it on purpose

Try each one, then put it back.

A setting that isn't a number. Run the checker with a bad timeout:

BASH
CHECKER_TIMEOUT=ten python3 checker.py
TEXT
2026-10-24 23:41:49,210 WARNING CHECKER_TIMEOUT is ten, which isn't a number, so using 5
2026-10-24 23:41:49,210 INFO Checking 4 sites from sites.csv

and the run carries on as normal. int("ten") raised a ValueError, read_number_setting() caught it, said so in the log, and used the default. Without the try, the checker would have crashed before checking a single site. There's nothing to put back, because the setting only applied to that one command.

No topic. Close your terminal, open a new one, cd into site-checker, activate the virtual environment, and run the checker without exporting anything. The log ends:

TEXT
2026-10-24 23:41:49,128 INFO 1 of 4 sites down, 1 unreachable
2026-10-24 23:41:49,129 INFO Results saved to results.json
2026-10-24 23:41:49,129 WARNING NTFY_TOPIC isn't set, so no alert was sent

Your phone stays quiet, but the log tells you why. An exported variable disappears with the terminal it was set in, the same way an activated virtual environment does. The exit code is still 1, so anything checking it still knows there was a problem.

Forget cron's copy. Delete the NTFY_TOPIC line from your crontab, leave the export in place in your terminal, and wait for the next scheduled run. Then look at the end of checker.log:

TEXT
2026-10-24 23:42:33,449 WARNING NTFY_TOPIC isn't set, so no alert was sent

Alerts work when you run the checker by hand, but not on schedule, because cron never saw your terminal's export. "It works when I run it, but not from cron" is one of the most common scheduling problems there is, and a missing environment variable is the usual reason. Put the line back in your crontab.

Where you are now

Your checker runs by itself, keeps a log, takes its settings from the environment, and tells your phone when something's wrong. That's a complete, working tool. In Lesson 10, the last lesson, you'll turn it around: instead of calling other people's APIs, you'll build a small API of your own with FastAPI, so other programs can ask your checker for its latest results.

Check yourself

  1. Why read CHECKER_TIMEOUT from an environment variable instead of writing 5 in the code?

  2. CHECKER_TIMEOUT is set to ten. What does read_number_setting() do?

  3. Why keep NTFY_TOPIC out of checker.py?

  4. You exported NTFY_TOPIC in your terminal. Why don't scheduled runs send alerts?

  5. In the checker, what does if topic: check?

  6. ntfy.sh answers an alert with status 400. What does send_alert() do?

Key terms

  • Environment variable: a named setting provided by whoever runs a program, like NTFY_TOPIC. Always text.
  • export: sets an environment variable for every command in the current terminal, until it's closed.
  • os.environ.get(): reads an environment variable, with an optional fallback, or None if it isn't set.
  • Secret: anything that grants access, like a password, key or private address. It belongs in the environment, never in code.
  • ntfy: a free service that turns a web request into a phone notification, on a topic you choose.
  • Topic: the ntfy channel your phone subscribes to. Treat it like a password.
  • requests.post(): sends data to a web address, where requests.get() asks for it.
  • Crontab variable: a NAME=value line in your crontab, which sets an environment variable for the jobs below it.

Go deeper