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
| Item | Minimum |
|---|---|
| PHP | 8.0 or newer, with pdo_mysql, curl, openssl, mbstring |
| Database | MySQL 5.7+ / MariaDB 10.3+ / MySQL 8.x |
| Web server | Apache, Nginx, or LiteSpeed |
| Cron | Shell access to add one crontab entry |
ping binary and permission to
use raw sockets. On most shared hosts ICMP is blocked — use an HTTP or TCP-port check instead.Installation
- Upload the contents of the archive to your server. Point your domain or subdomain's
document root at the
public/directory. - Create an empty MySQL database and a user with full privileges on it.
- Visit
https://your-domain.com/install/in a browser. The installer checks your server, creates the tables, and writesapp/config.php. - Delete the
public/install/directory. - Add the cron job described below.
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.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
| Option | Effect |
|---|---|
--limit=N | Cap monitors checked per pass (default 100). Raise it if you have many monitors on short intervals. |
--quiet | Suppress 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());
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:
| Field | What it does |
|---|---|
| Check every | How often the monitor runs, from 1 minute to 1 hour. |
| Alert after | Consecutive failures before an alert fires. Leaving this at 2 removes almost all false alarms from transient network blips. |
| Expected status code | The 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 appear | Text 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 page | Whether 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:
- A valid certificate expiring in 9 days → monitor stays UP, you get an SSL warning.
- An already-expired certificate → monitor goes DOWN (browsers would refuse it too), and the exact expiry date is still recorded so you can see how long it has been broken.
Alert channels
Add channels under Alerts. Every active channel receives every alert: down, recovery, and SSL expiry.
| Channel | What you need |
|---|---|
A destination address. Uses PHP mail(); set a correct mail_from in config so messages pass SPF. | |
| Telegram | A bot token from @BotFather and your chat ID. Message the bot once first, or it cannot reply to you. |
| Slack | An incoming-webhook URL from your Slack app configuration. |
| Webhook | Any 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.
| Key | Default | Purpose |
|---|---|---|
timezone | UTC | Display timezone. Storage is always UTC. |
app_name | PulseWatch | Name shown in the dashboard header. |
status_title | Service Status | Heading on the public status page. |
mail_from | — | From address for email alerts. |
user_agent | PulseWatch/1.0 | Identifies the checker to sites it probes. |
retention_days | 90 | Days of check history kept. 0 disables pruning. |
public_status_page | true | Set 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
- Initial release: HTTP, TCP port and ping monitoring
- SSL certificate expiry tracking with configurable warning window
- Email, Telegram, Slack and webhook alerts
- Public status page with 90-day history
- Incident tracking with duration, uptime reporting, response-time sparklines
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.