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:
CHECKER_TIMEOUT=10 python3 checker.py
Or use export, and it applies to every command you run in that terminal until you close it:
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.
- Add
import osto the imports, betweenloggingandsys. - Add this function just above
parse_args():
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.
- Let
check_site()pass a timeout on tofetch(). Change its first lines to:
def check_site(site, timeout=5):
name = site["name"]
response = fetch(site["url"], timeout=timeout)
- In
main(), read the setting just abovesites = load_sites(args.sites):
timeout = read_number_setting("CHECKER_TIMEOUT", 5)
and in the loop, pass it in:
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:
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:
- Install the ntfy app on your phone, from the App Store or Google Play.
- In the app, subscribe to a topic, and type your new topic name exactly.
- In your terminal, with your virtual environment active, set it as an environment variable, using your own topic:
export NTFY_TOPIC=checker-08a93f03a8dd2e80
Step 2. Teach the checker to send alerts.
- Add this function below
read_number_setting():
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.
- At the end of
main(), the part that returns1when something's wrong now sends an alert first. Change it to:
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:
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:
python3 checker.py
The output ends like this:
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.
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:
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:
CHECKER_TIMEOUT=ten python3 checker.py
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:
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:
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
Why read CHECKER_TIMEOUT from an environment variable instead of writing 5 in the code?
CHECKER_TIMEOUT is set to ten. What does read_number_setting() do?
Why keep NTFY_TOPIC out of checker.py?
You exported NTFY_TOPIC in your terminal. Why don't scheduled runs send alerts?
In the checker, what does if topic: check?
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
Noneif 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=valueline in your crontab, which sets an environment variable for the jobs below it.
Go deeper
- os.environ (opens in a new tab) in the Python documentation.
- The ntfy documentation (opens in a new tab) covers phone setup, and the publishing guide (opens in a new tab) shows titles, priorities and more.
- The secrets module (opens in a new tab) explains generating tokens that are safe to use as passwords.