PulseWatch

Self-hosted uptime, TCP port, ping and SSL-certificate monitoring with a public status page. Plain PHP and MySQL — no framework, no Composer, no external services.

Requirements

ItemMinimum
PHP8.0 or newer, with pdo_mysql, curl, openssl, mbstring
DatabaseMySQL 5.7+ / MariaDB 10.3+ / MySQL 8.x
Web serverApache, Nginx, or LiteSpeed
CronShell access to add one crontab entry
Note: ping (ICMP) checks need the ping binary and permission to use raw sockets. On most shared hosts ICMP is blocked — use an HTTP or TCP-port check instead.

Installation

  1. Upload the contents of the archive to your server. Point your domain or subdomain's document root at the public/ directory.
  2. Create an empty MySQL database and a user with full privileges on it.
  3. Visit https://your-domain.com/install/ in a browser. The installer checks your server, creates the tables, and writes app/config.php.
  4. Delete the public/install/ directory.
  5. Add the cron job described below.
Security: only public/ should be web-accessible. If you cannot point the document root there, the included .htaccess files block direct access to app/, cron/ and database/. On Nginx, deny those paths in your server block.
Check your file permissions. app/config.php contains your database password. The installer writes it as 0640, but some hosts override this with a permissive umask. Verify after installing:
ls -l app/config.php     # expect -rw-r----- , never -rw-r--r--
chmod 640 app/config.php # or 600 if web server and cron run as the same user
On shared hosting a world-readable config is readable by every other account on the server.

Installing by hand

If you prefer not to use the installer: import database/schema.sql, copy app/config.sample.php to app/config.php and fill in your credentials, then insert an admin user:

php -r 'echo password_hash("your-password", PASSWORD_DEFAULT), "\n";'
INSERT INTO users (email, password_hash, name, created_at)
VALUES ('you@example.com', '<paste the hash>', 'Administrator', UTC_TIMESTAMP());

Setting up cron

PulseWatch does no work until cron runs. Add one entry — the runner decides internally which monitors are due, so a one-minute tick supports every interval:

* * * * * /usr/bin/php /home/you/pulsewatch/cron/check.php --quiet

To keep a log instead:

* * * * * /usr/bin/php /home/you/pulsewatch/cron/check.php >> /var/log/pulsewatch.log 2>&1
OptionEffect
--limit=NCap monitors checked per pass (default 100). Raise it if you have many monitors on short intervals.
--quietSuppress per-monitor output; errors still reach the PHP error log.

A lock file prevents overlapping runs, so a slow pass will never stack up behind the next tick.

Your account

Click your email address in the top-right, or go to /account.php, to change your sign-in email, display name, or password.

Changing your password requires your current one, and signs out any other session using the old credentials. There is no reset-by-email — see Troubleshooting if you lock yourself out.

Multiple users

PulseWatch is designed for single-operator use, but the database supports additional accounts and each is fully isolated — a user sees only their own monitors, incidents and alert channels. There is no admin screen for this, so add accounts directly:

php -r 'echo password_hash("their-password", PASSWORD_DEFAULT), "\n";'
INSERT INTO users (email, password_hash, name, created_at)
VALUES ('them@example.com', '<paste the hash>', 'Their Name', UTC_TIMESTAMP());
Before you do: there are no roles — every account has equal control of its own data and none can administer another. The public status page shows a single account's monitors (the first account created, or whichever id you set as status_owner_user_id). Deleting a user cascades: their monitors, history and incidents go with them.

Creating monitors

Go to Monitors → Add monitor. The fields that matter most:

FieldWhat it does
Check everyHow often the monitor runs, from 1 minute to 1 hour.
Alert afterConsecutive failures before an alert fires. Leaving this at 2 removes almost all false alarms from transient network blips.
Expected status codeThe HTTP status that counts as healthy. Set 0 to accept any response below 400 — redirects are followed first, so this means any successful page.
Keyword must appearText that must be present in the response body. This catches the case where a page returns 200 OK but renders an error.
Show on status pageWhether the monitor appears on your public status page.

Check types

HTTP(S)

Requests the URL, follows up to 5 redirects, and verifies the status code and optional keyword. Response time is measured for the full request. This is the right choice for websites and APIs.

TCP port

Opens a socket to a host and port. Use it for services with no HTTP interface — SMTP on 25, MySQL on 3306, SSH on 22, a game server on a custom port.

Ping (ICMP)

Sends a single ICMP echo. Reports the round-trip time from the ping binary. Blocked on many hosts and by many firewalls; prefer a TCP-port check where possible.

SSL expiry monitoring

Enabled by default on every HTTPS monitor. On each check PulseWatch reads the peer certificate and records the expiry date and issuer. When the certificate falls inside the warning window (14 days by default) it sends an alert, then at most once per day after that.

Certificate inspection is deliberately independent of the up/down result:

Tip: set the warning window wider than your renewal cycle. With Let's Encrypt renewing at 30 days remaining, a 14-day warning fires only when automatic renewal has actually failed — which is exactly when you want to hear about it.

Alert channels

Add channels under Alerts. Every active channel receives every alert: down, recovery, and SSL expiry.

ChannelWhat you need
EmailA destination address. Uses PHP mail(); set a correct mail_from in config so messages pass SPF.
TelegramA bot token from @BotFather and your chat ID. Message the bot once first, or it cannot reply to you.
SlackAn incoming-webhook URL from your Slack app configuration.
WebhookAny URL that accepts a JSON POST.

Use Send test alert to confirm delivery before you rely on it. A channel that fails is logged and skipped — it can never interrupt the check loop.

Public status page

Available at /status.php. It shows only monitors marked public, and never exposes target URLs, error messages, or anything else operationally sensitive — just the service name, current state, a 90-day history bar, and resolved incidents from the last 30 days.

Set public_status_page to false in config to require login instead. Change the heading with status_title.

Configuration

All settings live in app/config.php.

KeyDefaultPurpose
timezoneUTCDisplay timezone. Storage is always UTC.
app_namePulseWatchName shown in the dashboard header.
status_titleService StatusHeading on the public status page.
mail_fromFrom address for email alerts.
user_agentPulseWatch/1.0Identifies the checker to sites it probes.
retention_days90Days of check history kept. 0 disables pruning.
public_status_pagetrueSet false to require login for the status page.

Webhook payload

{
  "event": "down",
  "monitor": { "id": 4, "name": "Marketing site", "target": "https://example.com" },
  "detail": "Expected HTTP 200, got 502",
  "occurred_at": "2026-07-25T09:14:02+00:00"
}

event is one of down, up, or ssl_expiring.

File structure

pulsewatch/
├── app/            Core classes — not web-accessible
│   ├── Auth.php        Session authentication
│   ├── Checker.php     HTTP / port / ping / SSL engine
│   ├── Database.php    PDO wrapper, prepared statements only
│   ├── Monitors.php    State machine, incidents, reporting
│   ├── Notifier.php    Alert delivery
│   └── config.php      Your settings (created by the installer)
├── cron/check.php  Check runner — add to crontab
├── database/       schema.sql
├── documentation/  This file
└── public/         Web root — point your document root here
    ├── index.php       Dashboard
    ├── monitor.php     Monitor detail
    ├── monitors.php    Create / edit / delete
    ├── channels.php    Alert channels
    ├── status.php      Public status page
    ├── install/        Installer — delete after setup
    └── assets/         CSS and JS

Troubleshooting

Monitors stay on "PENDING"

Cron is not running. Run the command by hand first — it prints what it checked:

/usr/bin/php /path/to/pulsewatch/cron/check.php

If that works but cron does not, your crontab is likely using a different PHP binary. Use the absolute path from which php.

"Previous run still in progress — skipping"

A pass took longer than a minute. Lower the number of monitors on 1-minute intervals, reduce per-monitor timeouts, or raise --limit so more get through each pass.

Email alerts never arrive

PHP mail() depends on a working local MTA and correct SPF/DKIM records. Test with Send test alert. If email is unreliable on your host, Telegram or Slack is more dependable and takes two minutes to set up.

A site works in my browser but PulseWatch says DOWN

Usually a firewall or WAF blocking the checker's user agent, or a server that requires a browser-like request. Change user_agent in config, or allowlist your server's IP.

I forgot my password

PulseWatch has no reset-by-email — there is nothing to attack, but it does mean recovery is manual. Generate a new hash and update the row directly:

php -r 'echo password_hash("your-new-password", PASSWORD_DEFAULT), "\n";'
UPDATE users SET password_hash = '<paste the hash>' WHERE email = 'you@example.com';

Then sign in and change it again from Account if you prefer.

SSL days-left shows "—"

Certificate inspection only runs on https:// HTTP monitors with the SSL option enabled. TCP-port and ping monitors do not perform a TLS handshake.

Changelog

1.0.0

Thank you for purchasing PulseWatch. If something here does not answer your question, contact support through the channel you purchased from, quoting your PHP version, database version, and the relevant lines from your web server error log.