How to know when your website goes down: simple monitoring for your own server
Check your site every minute from somewhere else, look for a word that should be on the page, and get one message when it breaks and one when it's back. Add a health URL that also checks the database, and a ping for your backups.
Before you start
- You need the address of your site, and a word that is always on the home page, such as your shop's name.
- For the script in step 4 you need a second machine that is always on: a small, cheap VPS at another provider, or a Raspberry Pi at home. A laptop works too, but it only checks while it's awake.
- The examples use
shop.example.comfor a Laravel app andblog.example.comfor WordPress. Change them to yours.
1. Why the check must run from outside
Most outages stop the server from doing anything at all: the provider has a problem, the disk is full, the server ran out of memory, or a firewall change locked everyone out. A script on that server can't send an email in any of these cases.
A check from outside also sees what your visitors see. It goes through DNS, the network, HTTPS and the web server, so it notices problems a check on the server itself would miss, such as an expired certificate or a wrong DNS record.
2. What to check
- The home page loads and answers with status 200 (OK), not an error such as 500 or 502.
- The page contains a word you expect. This matters more than it sounds. In our test, we switched off the sites' Nginx settings, and both addresses showed the default "Welcome to nginx!" page instead. The status was 200, so a status-only check said everything was fine. The word check caught it.
- The page answers quickly. A page that takes 20 seconds is down for most visitors.
- The database works. A page can load without it. In our test, with the database stopped, the Laravel home page still answered 200. A health URL (steps 5 and 6) checks the database for you.
- The HTTPS certificate has enough days left. Renewal should run on its own (see our certificate renewal guide), but a warning 14 days ahead gives you time if it stops working.
3. The easy way: a free uptime service
Several services check your site from their own servers and email you when it fails. Some have a free plan. Three well-known ones:
- UptimeRobot
- Better Stack
- Uptime Kuma, which is free, open-source software you run yourself, on a second machine (not on the server it watches).
Free plans change often, so read the current limits on each pricing page: how many checks you get, how often they run, which alert types are included, and whether the free plan is for personal use only. We don't get anything for naming them.
Whichever you pick, set it up like this:
- Use a keyword check (often called "keyword" or "content" monitor), not only a status check.
- Add a second check for the health URL from step 5 or 6.
- Turn on the certificate expiry warning if the plan has one.
- Make sure you get a message both when the site goes down and when it's back.
4. Or: your own check script
If you prefer to keep it in your own hands, a short script on the second machine does the same job. It needs only curl. Save it as /usr/local/bin/site-check and put your email address in the ALERT_EMAIL line:
#!/bin/sh
# Check one web page: site-check NAME URL WORD
# A check fails when the page doesn't load within 10 seconds, returns an
# error, doesn't contain WORD, or takes 5 seconds or more.
# Alerts after 2 failures in a row, and once more when the page is back.
ALERT_EMAIL=you@example.com
alert() {
logger -t site-check "$1"
echo "$1" | mail -s "$1" "$ALERT_EMAIL"
}
name=$1 url=$2 word=$3
state=/var/lib/site-check/$name
mkdir -p /var/lib/site-check
fails=$(cat "$state" 2>/dev/null || echo 0)
page=$(mktemp) err=$(mktemp)
if ! secs=$(curl -fsS --max-time 10 -o "$page" -w '%{time_total}' "$url" 2>"$err"); then
problem=$(cat "$err")
elif ! grep -qF -- "$word" "$page"; then
problem="the page loaded, but \"$word\" is missing"
elif [ "${secs%.*}" -ge 5 ]; then
problem="slow: the page took $secs seconds"
else
problem=""
fi
rm -f "$page" "$err"
if [ -z "$problem" ]; then
if [ "$fails" -ge 2 ]; then
alert "UP again: $name ($url)"
fi
echo 0 > "$state"
else
fails=$((fails + 1))
echo "$fails" > "$state"
if [ "$fails" -eq 2 ]; then
alert "DOWN: $name ($url): $problem"
fi
fi
What it does:
curl -fsS --max-time 10treats an error status (such as 500 or 502) as a failure, stays quiet unless something goes wrong, and gives up after 10 seconds.- It remembers how many checks in a row failed. One failed check can be a short network hiccup, so it alerts only after 2 failures in a row.
- It alerts once per outage, not every minute, and once more when the page works again.
Then list your pages in /usr/local/bin/site-checks, one line each: a short name, the address and a word that must be on the page:
#!/bin/sh
/usr/local/bin/site-check shop https://shop.example.com/ "My Shop"
/usr/local/bin/site-check shop-up https://shop.example.com/up "Application up"
/usr/local/bin/site-check blog https://blog.example.com/ "My Blog"
/usr/local/bin/site-check blog-up "https://blog.example.com/?health-check" "database ok"
sudo chmod 755 /usr/local/bin/site-check /usr/local/bin/site-checks
sudo /usr/local/bin/site-checks
It prints nothing when all is well. Each page gets its own file in /var/lib/site-check/ with the number of failures in a row, 0 when it works.
Getting the message: the mail command only works if the machine can send email (on Debian or Ubuntu, sudo apt install mailutils plus a mail server or relay). If yours can't, change the mail line in alert() to send the text to a chat or push service you already use, usually one curl line. Test it once by checking an address that doesn't exist, two times in a row.
Run it every minute with a systemd timer. Make /etc/systemd/system/site-check.service:
[Unit]
Description=Check that the websites work
[Service]
Type=oneshot
ExecStart=/usr/local/bin/site-checks
# Let the mail program finish sending after the script ends.
KillMode=process
The KillMode line matters. Without it, systemd stopped the mail program as soon as the script ended. In our test, the last alert of each run then sat in the mail queue instead of being sent.
And /etc/systemd/system/site-check.timer:
[Unit]
Description=Check the websites every minute
[Timer]
OnCalendar=*:*:00
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now site-check.timer
systemctl list-timers site-check.timer
See the alerts it sent with journalctl -t site-check. On a laptop or a Mac, use cron instead: sudo crontab -e and add * * * * * /usr/local/bin/site-checks.
In our test we stopped Nginx on the web server. The first check failed quietly, the second sent one "DOWN" message per page, and the next checks sent nothing more. When Nginx was back, the next check sent "UP again".
5. A health URL for Laravel
Laravel 11 and newer have a health URL built in: /up. It's switched on in bootstrap/app.php with the line health: '/up'. It answers 200 with the words "Application up" when the app starts without errors, and 500 when it doesn't.
By default it does not check the database. In our test (Laravel 13), with the database stopped, both the home page and /up still answered 200. To add a database check, listen for the DiagnosingHealth event in app/Providers/AppServiceProvider.php:
use Illuminate\Foundation\Events\DiagnosingHealth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(function (DiagnosingHealth $event) {
DB::select('select 1');
});
}
Now /up answers 500 when the database is down, and the monitor sends an alert. You can check Redis or anything else your app needs in the same place. If a check fails, it throws an error, and that's all the health URL needs.
Using an older Laravel? Add your own small route that runs the same database query and returns a short text.
6. A health URL for WordPress
WordPress has no public health URL. Its "Site Health" page is only for logged-in admins. A tiny "must-use" plugin adds one. Make wp-content/mu-plugins/health-check.php (create the mu-plugins folder if it doesn't exist):
<?php
/*
* Plugin Name: Health check
* Description: Answers https://example.com/?health-check with "database ok" when WordPress and its database work.
*/
if ( isset( $_GET['health-check'] ) ) {
add_action( 'init', function () {
global $wpdb;
nocache_headers();
header( 'Content-Type: text/plain' );
if ( '1' === $wpdb->get_var( 'SELECT 1' ) ) {
status_header( 200 );
echo 'database ok';
} else {
status_header( 500 );
echo 'Database error';
}
exit;
} );
}
Must-use plugins load on their own, with no need to activate them. Try it:
curl -i "https://blog.example.com/?health-check"
You want 200 and database ok. With the database stopped, WordPress itself answered 500 with "Error establishing a database connection" in our test, so the check fails as it should. The address has a ? in it, which most page caching plugins skip, so you see the live state, not a saved copy.
Pick a word for your checks that only shows up when things work. "database ok" is better than "OK", which can appear on almost any page.
7. Warn before the HTTPS certificate runs out
Run this once a day from the same second machine. Save it as /usr/local/bin/cert-check:
#!/bin/sh
# Warn when a site's HTTPS certificate ends within 14 days: cert-check example.com
ALERT_EMAIL=you@example.com
host=$1
cert=$(echo | timeout 15 openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null)
end=$(echo "$cert" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$end" ]; then
msg="CERT: could not read the certificate of $host"
elif ! echo "$cert" | openssl x509 -noout -checkend 1209600 >/dev/null; then
msg="CERT: the certificate of $host ends on $end"
else
exit 0
fi
logger -t cert-check "$msg"
echo "$msg" | mail -s "$msg" "$ALERT_EMAIL"
1209600 is 14 days in seconds. Add it to cron with sudo crontab -e:
0 8 * * * /usr/local/bin/cert-check shop.example.com; /usr/local/bin/cert-check blog.example.com
It reminds you every morning until the certificate is renewed. In our test, a certificate with 10 days left gave a warning, one with 90 days left stayed quiet, and a host with no HTTPS gave "could not read". If the warning comes, check renewal as shown in our certificate renewal guide.
8. Know when cron jobs and backups stop running
An uptime check can't tell you that last night's backup never ran. For that, turn it around: the job sends a ping to a URL when it finishes, and a "heartbeat" service (sometimes called a dead man's switch) alerts you when the ping doesn't arrive in time.
Services that do this include Healthchecks.io and Cronitor, and Better Stack has heartbeats too. Some have free plans; check their pricing pages. Healthchecks.io is also open source, so you can run it yourself on your second machine, and Uptime Kuma has a similar "push" check.
The service gives you a unique ping address. Add it to the end of the job, so it only runs when the job worked. Cron:
30 2 * * * /usr/local/sbin/site-backup && curl -fsS -m 10 --retry 5 -o /dev/null https://ping.example.com/your-unique-id
&& means "only if the backup ended without an error". systemd, for example the backup service from our backup guide: add this line under [Service]:
ExecStartPost=-/usr/bin/curl -fsS -m 10 --retry 5 -o /dev/null https://ping.example.com/your-unique-id
ExecStartPost only runs when the main command succeeded, and the - in front means a failed ping doesn't mark the backup as failed. Laravel's scheduler can ping by itself:
Schedule::command('backup:run')->daily()->pingOnSuccess('https://ping.example.com/your-unique-id');
We tested all three with a job that worked and one that failed. Only the working job sent a ping.
Set the grace time in the service a bit longer than the job usually takes. Then you hear about it when the job fails, hangs, or doesn't start at all, for example because the server is down or the timer was switched off.
9. Checks on the server itself
Outside checks tell you that something broke. Checks on the server warn you early and help you find why. They don't replace each other. We cover the two most common causes in their own guides:
- A full disk: the warning script in how to stop logs from filling your disk.
- Running out of memory: how to see if the OOM killer stopped your database or PHP in how to stop a small VPS from running out of memory.
If something goes wrong
- No alert came, but
journalctl -t site-checkshows one: the email didn't leave the machine. Check the mail log (/var/log/mail.logor/var/log/exim4/mainlog), the mail queue (mailq), and theKillMode=processline from step 4. - The script alerts, but the site works in your browser: the word isn't on the page exactly as written (it is case-sensitive), or the site blocks the monitor. Check with
curl -s https://shop.example.com/ | grep "My Shop"from the monitor. - "curl: (60) SSL certificate problem": the certificate expired or doesn't match the name. That's a real problem for your visitors too.
- "curl: (28) Operation timed out": the page took longer than 10 seconds. Look at the server's load and the PHP-FPM and web server logs.
/upanswers 404: the app is older than Laravel 11, orbootstrap/app.phphas nohealth:line.- Too many messages: each page alerts on its own, so one outage sends one message per page. Check fewer pages, or only the health URLs.
Official sources
- The health route (Laravel docs) and pinging URLs from the scheduler
- Must-use plugins (WordPress)
- curl manual and openssl x509 (-checkend)
- systemd.timer and KillMode (systemd)
- Healthchecks.io docs and Uptime Kuma on GitHub
Next steps: make sure there is something to go back to when a check fails, with backups you have tested, and work through the security checklist for a new PHP server.
ServerNest is in development. Its private preview already checks each site from outside, warns before HTTPS certificates run out, and sends alerts by email, Slack, Discord, Telegram or webhook when a server stops reporting or a disk is almost full. Monitoring for AI machines with graphics cards is planned next. Until you use it, an outside check like the one above is worth having. Join early access to try it first.