This project shifted focus away from the AWS and Kubernetes work I've been doing and into Python automation. The goal: build a program that monitors a running web application, sends an email notification when it goes down, and automatically restarts it — either at the container level or by rebooting the server entirely — without any manual intervention.
By the end, a single Python script was doing all of that on a schedule, every five minutes, by itself.
What I had going in: Python fundamentals, some experience with the boto3 library from previous AWS automation work. What this project added: HTTP monitoring, SMTP email via Python, SSH into remote servers using paramiko, the Linode API, and scheduling with the schedule library.
The Setup
The application being monitored is a simple Nginx container running on a Linode server — the same kind of setup I've done before on DigitalOcean, just on a different provider. Nginx runs on port 8080 and is accessible via the server's public IP.
docker run -d -p 8080:80 nginx
Navigating to http://<server-ip>:8080 returns the standard Nginx welcome page with a 200 status code. That 200 is what the monitoring script watches for.
Part 1: Checking Application Health
The core of the script is a single HTTP request using the requests library. Same thing a browser does — just from Python.
import requests
response = requests.get('http://50.116.45.66:8080/')
if response.status_code == 200:
print('Application is running successfully!')
else:
print('Application is down. Fix it!')
response.status_code is the HTTP status code returned by the server. 200 means healthy. Anything else means something is wrong.
But there's a second failure mode that an if/else can't handle: what if the server doesn't return anything? If the container has crashed, or the server is completely unreachable, requests.get() throws an exception — the if/else never even runs. That's where try/except comes in.
def monitor_application():
try:
response = requests.get('http://50.116.45.66:8080/')
if response.status_code == 200:
print('Application is running successfully!')
else:
print('Application is down. Fix it!')
except Exception as ex:
print(f'Connection error happened: {ex}')
Two distinct failure cases, handled separately:
- Non-200 response — server is reachable but returning an error. The container is probably still running but the application inside is unhealthy.
- Exception — server is completely unreachable. The container may have crashed entirely, or the server itself is down.
Both need different recovery actions. That distinction drives the rest of the script's logic.
Part 2: Email Notification
Knowing the application is down is only useful if someone gets told about it. Python has a built-in smtplib module for sending email, so no external library needed for this part.
The flow: connect to Gmail's SMTP server on port 587, start TLS to encrypt the connection, authenticate with credentials, send the email.
import smtplib
import os
EMAIL_ADDRESS = os.environ.get('EMAIL_ADDRESS')
EMAIL_PASSWORD = os.environ.get('EMAIL_PASSWORD')
def send_notification(email_msg):
print('Sending an email...')
with smtplib.SMTP('smtp.gmail.com', 587) as smtp:
smtp.ehlo()
smtp.starttls()
smtp.login(EMAIL_ADDRESS, EMAIL_PASSWORD)
message = f"Subject: SITE DOWN\n{email_msg}"
smtp.sendmail(EMAIL_ADDRESS, EMAIL_ADDRESS, message)
A few things worth noting here:
Credentials as environment variables. The email address and password are pulled from environment variables, not hardcoded. Hardcoding credentials in source code is never the right move — especially for a script that's going to sit in a repo.
Gmail app passwords. Gmail requires either a dedicated app password (if 2FA is enabled) or explicitly allowing less secure app access. The app password approach is the right one — it's a credential scoped specifically to this use case and can be revoked independently.
with statement. The with smtplib.SMTP(...) syntax ensures the connection is properly closed even if something goes wrong mid-send. Same reason you use with open() for files — it handles cleanup automatically.
One function for two scenarios. Both the non-200 case and the connection exception case need to send an email, but with different message bodies. Rather than duplicating the SMTP logic, send_notification takes the message as a parameter. The calling code just passes a different string depending on which failure happened.
Part 3: Restarting the Container
Sending an email tells someone the application is down. The next step is actually fixing it — automatically, without waiting for a human to respond.
When the application returns a non-200 status, the likely fix is restarting the Docker container. The script connects to the Linode server over SSH using paramiko and runs docker start remotely.
import paramiko
def restart_container():
print('Restarting the application...')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('50.116.45.66', username='root', key_filename='/home/noah/.ssh/id_rsa')
stdin, stdout, stderr = ssh.exec_command('docker start 1b0b2b85614f')
print(stdout.readlines())
ssh.close()
A few things happening here:
set_missing_host_key_policy(paramiko.AutoAddPolicy()) — when you SSH into a server for the first time manually, you get an interactive prompt asking whether to trust the host key. In an automated script there's no interactive mode, so this automatically confirms that prompt. Without it, the connection fails the first time.
Private key authentication. The script connects using the SSH private key at /home/noah/.ssh/id_rsa, which corresponds to the public key added to the Linode server. No password needed — same mechanism as the regular SSH command, just explicitly specified.
exec_command returns three streams: stdin, stdout, stderr. We only care about stdout here — readlines() gives us the output of the Docker command so we can confirm it ran.
Part 4: Rebooting the Server
If the server is completely unreachable — the exception case — restarting the container isn't possible because there's no SSH connection to make. The fix at that level is rebooting the server itself using the Linode API, then restarting the container once it comes back up.
import linode_api4
import time
LINODE_TOKEN = os.environ.get('LINODE_TOKEN')
def restart_server_and_container():
print('Rebooting the server...')
client = linode_api4.LinodeClient(LINODE_TOKEN)
nginx_server = client.load(linode_api4.Instance, 97410049)
nginx_server.reboot()
while True:
nginx_server = client.load(linode_api4.Instance, 97410049)
if nginx_server.status == 'running':
time.sleep(5)
restart_container()
break
The reboot triggers immediately, but the server takes time to come back up. Trying to SSH in and restart the container the moment reboot() returns will fail — the server isn't ready yet.
The while True loop solves this by polling the Linode API for the server's status on each iteration. The instance gets reloaded on every loop so we're checking the live state, not a cached value. As soon as status == 'running', we sleep an extra 5 seconds as a buffer — even when the API reports running, the server may need a moment before it's ready to accept SSH connections — then call restart_container() and break the loop.
This is the same pattern used elsewhere in Python AWS automation: poll in a loop, check state, act when the condition is met, break.
Part 5: Scheduling
A monitoring script that only runs when you manually execute it isn't monitoring anything. The whole point is that it runs continuously on its own. The schedule library handles this cleanly.
import schedule
schedule.every(5).minutes.do(monitor_application)
while True:
schedule.run_pending()
schedule.every(5).minutes.do(monitor_application) registers the function to run every 5 minutes. The while True loop keeps the program alive and calls schedule.run_pending() on each iteration to check whether it's time to fire the scheduled job.
The Complete Script
import requests
import smtplib
import os
import paramiko
import linode_api4
import time
import schedule
EMAIL_ADDRESS = os.environ.get('EMAIL_ADDRESS')
EMAIL_PASSWORD = os.environ.get('EMAIL_PASSWORD')
LINODE_TOKEN = os.environ.get('LINODE_TOKEN')
def restart_server_and_container():
print('Rebooting the server...')
client = linode_api4.LinodeClient(LINODE_TOKEN)
nginx_server = client.load(linode_api4.Instance, 97410049)
nginx_server.reboot()
while True:
nginx_server = client.load(linode_api4.Instance, 97410049)
if nginx_server.status == 'running':
time.sleep(5)
restart_container()
break
def send_notification(email_msg):
print('Sending an email...')
with smtplib.SMTP('smtp.gmail.com', 587) as smtp:
smtp.starttls()
smtp.ehlo()
smtp.login(EMAIL_ADDRESS, EMAIL_PASSWORD)
message = f"Subject: SITE DOWN\n{email_msg}"
smtp.sendmail(EMAIL_ADDRESS, EMAIL_ADDRESS, message)
def restart_container():
print('Restarting the application...')
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect('50.116.45.66', username='root', key_filename='/home/noah/.ssh/id_rsa')
stdin, stdout, stderr = ssh.exec_command('docker start 1b0b2b85614f')
print(stdout.readlines())
ssh.close()
def monitor_application():
try:
response = requests.get('http://50.116.45.66:8080/')
if response.status_code == 200:
print('Application is running successfully!')
else:
print('Application is down. Fix it!')
msg = f"Application returned {response.status_code}"
send_notification(msg)
restart_container()
except Exception as ex:
print(f'Connection error happened: {ex}')
msg = f"Application not accessible at all."
send_notification(msg)
restart_server_and_container()
schedule.every(5).minutes.do(monitor_application)
while True:
schedule.run_pending()
What I Learned
if/else handles expected outcomes — a response came back, but it wasn't what we wanted. try/except handles unexpected failures — the code itself threw an exception before producing any result. The distinction matters because each failure mode requires a different recovery path. Using only one of them would leave gaps.
The pattern — create client, set host key policy, connect with credentials, execute command, read output, close — is clean and reusable. The same approach works for any maintenance or automation task on a remote server, not just Docker commands.
Rebooting a server and then immediately trying to use it doesn't work. The pattern of loading fresh state on each loop iteration, checking a condition, and breaking when it's met is the right way to wait for an async operation to complete without blocking indefinitely.
Nothing in this script is hardcoded except the server IP and container ID. In production you'd pull those from config as well, but the credentials — email, password, API token — are always environment variables. That's not optional.
Production Considerations
Hardcoded server IP and container ID. Both are embedded in the script. In a real setup these would come from config or environment variables so the script doesn't need to change when the infrastructure does.
Single point of monitoring. This script runs on a local machine. If that machine goes down, monitoring stops. Production monitoring would run on a separate server, ideally in a different region, or use a managed monitoring service.
No success notification after recovery. The script sends an alert when the application goes down and tries to fix it, but doesn't confirm whether the fix worked. Adding a follow-up health check after restart_container() and sending a recovery email would close that loop.
Gmail app password expiry. App passwords don't expire on their own, but they're tied to the Google account and can be revoked. For a long-running production script, a dedicated sending account or a transactional email service (SendGrid, SES) is more appropriate.
Reflection
What this project was: The first one that felt like a complete operational tool rather than a demo. It monitors, alerts, and self-heals — three distinct capabilities wired together into a single script that runs on its own.
What worked: The incremental build approach. Starting with just the HTTP check, then adding email, then SSH restart, then server reboot, then scheduling. Each step worked before moving to the next. By the time the scheduler was added, every piece was already verified.
What I'd do differently: The hardcoded container ID is the biggest limitation. docker start 1b0b2b85614f only works for that specific container on that specific server. A more robust version would look up the container by name rather than ID.
What's next: Ansible — configuration management and automation of server setup. Rather than SSHing into servers and running commands manually the way this script does, Ansible lets you define the desired state of a server in a playbook and apply it repeatably across any number of hosts.