Pharos / documentation / v1.0

Pharos documentation

Everything below is written against the shipping source. Where something is not implemented yet, it says so rather than describing what it ought to do.

What Pharos is

Pharos is a self-hosted status page that runs its own checks. It polls HTTP endpoints and TCP ports, listens for heartbeats from jobs it cannot see from outside, and sets component status without anyone pressing a button. A failing check opens an incident and posts the first update; a recovered check closes it and posts the closing update. It speaks the Cachet 2.x API shape on purpose, so scripts written against Cachet keep reading it unchanged.

PHP8.3 or newer
DatabaseSQLite (default) or MySQL
Extensionssodium, pdo_sqlite or pdo_mysql, zip, openssl, mbstring. HTTP checks need allow_url_fopen on.
Web serverDocument root must point at public/, with mod_rewrite
TargetsShared cPanel hosting, or Docker

A health endpoint is available at /up.

Install on shared hosting (cPanel)

1. Upload the code

Put the application outside the web root and point the domain at its public/ directory. Never set the document root to the application root — that exposes your .env.

rsync -az --delete \
  --exclude '.git' --exclude 'node_modules' \
  --exclude '.env' --exclude 'database/database.sqlite' \
  ./ [email protected]:~/pharos/

2. Point the document root at public/

In cPanel → Domains, set the document root for status.example.com to /home/you/pharos/public.

3. Install and configure

cd ~/pharos
composer install --no-dev --optimize-autoloader

cp .env.example .env
php artisan key:generate

Then set at least these in .env:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://status.example.com
DB_CONNECTION=sqlite

SQLite needs a writable directory, not just a writable file. It writes its journal next to the database, so permissions on the folder matter as much as on the file itself.

touch database/database.sqlite
chmod -R 775 storage bootstrap/cache database

4. Migrate and create the first admin

php artisan migrate --force
php artisan storage:link
php artisan pharos:user [email protected] --name="Your Name"

pharos:user prints a generated password once if you do not pass --password. It updates an existing account rather than failing, which is also how you get back in if you lock yourself out.

storage:link is required before any logo or favicon upload will be reachable.

5. Cache for production

php artisan config:cache
php artisan route:cache
php artisan view:cache

Run php artisan optimize:clear after any .env change.

6. The one cron line

Everything time-based hangs off one scheduled command. In cPanel → Cron Jobs, add a job that runs every minute:

* * * * * cd /home/you/pharos && php artisan schedule:run >/dev/null 2>&1

If your host's default php is not 8.3, use the full path cPanel gives you, for example /opt/cpanel/ea-php83/root/usr/bin/php. Verify by hand with php artisan pharos:check --force.

Install with Docker

Two containers from the same image: Apache with mod_php serving the app, and a second one running the scheduler. They share the database and storage volumes.

cp .env.docker.example .env
docker compose build
docker compose run --rm app php artisan key:generate --show

Paste that whole string, including the base64: prefix, into APP_KEY=. Compose refuses to start without it. Then:

docker compose up -d
docker compose exec app php artisan pharos:user [email protected] --name="Your Name"

There is no separate migrate step. The entrypoint runs migrations on every container start, clears the caches and rebuilds them, and for SQLite fixes the database directory's ownership.

The scheduler container must stay up. If you scale it to zero, nothing is checked and every component freezes at whatever status it last had — which looks exactly like everything being fine.

First run

Sign in at /admin/login. Create services first — they are the headings your customers read — then put components inside them.

A component with no service never appears on the public page and does not count toward the headline uptime figure, even though it still shows in the admin and is still checked. Always put components in a service.

Choosing a source

The Source field decides whether a check exists at all:

  • Built-in check — creates an HTTP or TCP check with your target and interval.
  • Heartbeat — generates an unguessable token once and shows you the URL to call.
  • Manual, Uptime Kuma, Webhook, Upstream — no check row; status is set by hand or over the API. These three labels behave identically inside Pharos today.

Hidden service versus disabled component

 Service hiddenComponent disabled
Shown to customersNoNo
Checks keep runningYesNo
Counts toward headline uptimeYesNo
History keptYesYes

Hide a service to stop showing customers something you are still watching. Disable a component to stop watching it entirely while keeping its past.

How checks work

One command does all of it. The scheduler calls the plain form every minute; --force is the diagnostic tool.

php artisan pharos:check          # only checks that are due
php artisan pharos:check --force  # every enabled check, with output

The three probe types

TypeTargetUp when
HTTPA full URLFinal response code is 200–399. TLS is verified.
TCPhost:portThe socket connects.
HeartbeatA generated tokenSomething called in within two intervals.

What turns a component red

A component goes to Major outage once consecutive_failures reaches the check's retry count — two by default. One failure is never enough. The first healthy result flips it straight back to operational.

With the defaults, an HTTP or TCP target is red about two minutes after it starts failing. A heartbeat takes roughly two intervals plus one more run, because silence has to last before it means anything.

What opens and closes an incident

Crossing the retry threshold opens an incident named {component} unreachable, status Investigating, impact major, with one automatic update naming the probe error. A grouping key stops a second incident being opened while one is already open, and it is what powers the repeat-outage counter on the incidents list.

After three consecutive successful checks the incident closes itself and posts a closing update. Incidents you created by hand are never closed by a check.

Heartbeats

POST https://status.example.com/api/v1/heartbeat/hb_xxxxxxxxxxxxxxxxxxxxxxxx

No authentication header — the unguessable path is the credential, so a backup script needs no token. Call it only when the job actually succeeded:

# the status page hears about it only if restic exits 0
30 3 * * * restic backup /data && curl -fsS -m 10 -X POST \
  https://status.example.com/api/v1/heartbeat/hb_xxxxxxxx >/dev/null

Set the component's interval to the ping frequency. A job pinging every five minutes wants an interval of 300, which gives it a ten-minute grace period.

Incidents

Status and impact

Incident status is Investigating, Identified, Watching or Resolved — the same integers Cachet used. Component status is separate:

ValueLabelCounts as down
1Operationalno
2Degraded performanceno
3Partial outageyes
4Major outageyes
5Under maintenanceno

Impact — minor, major or critical — is stored separately from status and is meant for reporting and for whatever consumes the outgoing webhook.

Visibility

Only public incidents reach the status page and the public API. Anything marked internal is stored and visible in the admin only. There is no signed-in customer view yet, so authenticated currently behaves the same as internal.

Backdating

Set Occurred at to any past timestamp; the public page groups incidents by that date. How far back the page lists days is the Days of incident history setting, 1 to 30.

Templates with variables

Templates are applied through the API, not the admin form. Placeholders look like {{server}}, are case-insensitive, and tolerate inner spaces. An unknown placeholder is left in the text verbatim rather than blanked — publishing “Outage on .” to customers would be worse than publishing the placeholder.

There is no admin screen for creating templates yet. Create them with php artisan tinker or directly in the incident_templates table.

What happens when you resolve

Resolving puts every attached component back to operational — from the admin, from the API, and when a check closes an incident it opened. To close an incident while leaving a component degraded, name that component explicitly in the same request.

Uptime and what the bar means

Every check run adds its interval to that day's up_seconds or down_seconds — one row per component per day. That is why a 90-day bar costs 90 rows rather than every raw result.

DayCell
99.99% or bettergreen
99.0% or betteramber
95.0% or betterorange
below 95%red
no datagrey

Grey means no data, not fine. A component created yesterday shows 89 grey cells, which is the honest answer. The percentage averages only the days actually measured: counting unmeasured days as 100% would invent uptime, and counting them as 0% would punish you for installing recently.

The public page

Every section is a checkbox in Settings, and all of them default to on.

ToggleWhat switching it off hides
Overall status bannerThe headline, the coloured dot and the “updated” stamp
Uptime bar and percentageThe big figure and the combined 90-day bar
Services listEvery service and component row
Uptime bar per componentThe small bars and per-component figures
Incident historyThe whole incidents section
Days without incidentsQuiet days are skipped instead of printing “No incidents”
Subscribe buttonThe “Get notified” link
API link in the footerThe link to the public JSON feed

Subscriber notifications are not implemented. The table and model exist; the form, verification and sending do not, and the button links to an anchor that is not on the page. Leave that toggle off until it ships, and use the outgoing webhook for alerting in the meantime.

Next to the settings form is a live preview: the real page rendered from your unsaved values, with Desktop and Phone widths. It writes nothing. Service blocks that start collapsed are forced open whenever that service is down.

API and tokens

Create tokens under Integrations or with php artisan pharos:token "n8n". A token is 40 characters and only its SHA-256 hash is stored, so it is shown exactly once. Tokens are all-or-nothing: there are no scopes.

Write endpoints accept either header — the second exists so Cachet scripts need no editing:

Authorization: Bearer <token>
X-Cachet-Token: <token>

Reading, without a token

curl -s https://status.example.com/api/v1/components
curl -s https://status.example.com/api/v1/incidents

/components returns the Cachet envelope with the same field names and status integers. /incidents returns the 50 most recent public incidents with their updates and components.

Setting one component

curl -sX PUT https://status.example.com/api/v1/components/1 \
  -H "Authorization: Bearer $PHAROS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": 4}'

This does not open an incident. If a built-in check also watches that component, the next check run overwrites whatever you set — pick one owner per component.

Opening an incident across several components

curl -sX POST https://status.example.com/api/v1/incidents \
  -H "Authorization: Bearer $PHAROS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "template":     "server-unreachable",
    "vars":         { "server": "web-06.example.net", "started_at": "16:50" },
    "status":       "investigating",
    "impact":       "major",
    "components":   { "7": "major_outage", "8": "degraded" },
    "auto_resolve": true
  }'

Status is a name, not an integer: investigating, identified, watching or resolved. Component keys may be an id or a name. An unrecognised component status silently becomes operational, so check your spelling.

Adding an update

curl -sX POST https://status.example.com/api/v1/incidents/12/updates \
  -H "Authorization: Bearer $PHAROS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "resolved", "message": "Back up at 17:22."}'

Uptime Kuma

Kuma is wired in as a heartbeat rather than a separate integration:

  1. Create a Pharos component with source Heartbeat and save it.
  2. Copy the heartbeat URL from the component form, or from Integrations.
  3. In Kuma, have the monitor call that URL on every successful check.
  4. Set the Pharos interval to match how often Kuma reports.

Silence for two intervals turns the component red and opens an incident.

n8n and the outgoing webhook

Inbound is just the API: an HTTP Request node posting to /api/v1/incidents or /api/v1/components/{id}.

Outbound is configured under Integrations. Paste an n8n webhook URL and save; the first save generates a 32-character signing secret. It fires on every incident created or updated — by you, by the API, or by a check that opened one itself.

{
  "event": "incident.created",
  "incident": {
    "id": 12,
    "name": "Mail queue backed up",
    "status": "Identified",
    "impact": "major",
    "occurred_at": "2026-08-25T16:50:03+00:00",
    "resolved_at": null,
    "components": ["Mail relay", "Webmail"]
  }
}

Every request carries X-Pharos-Signature: a lowercase hex HMAC-SHA256 of the exact request body, keyed with your secret. Verify it — anyone who learns your webhook URL can otherwise forge events.

const expected = crypto.createHmac('sha256', SECRET)
  .update(req.body)              // the RAW body; a JSON parser changes the bytes
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
  return res.status(401).json({ error: 'bad signature' });
}

Delivery is one attempt with a five-second timeout and no retries. A slow receiver must never hold up publishing an incident, so a failure is logged and never shown to you mid-outage.

Zabbix and Grafana

Neither has a Pharos-specific integration; both use the API.

Zabbix: create a Webhook media type with parameters for the URL, token and component id, and attach it to an action.

var p = JSON.parse(value), req = new HttpRequest();
req.addHeader('Content-Type: application/json');
req.addHeader('Authorization: Bearer ' + p.pharos_token);

// event_value "1" = problem, "0" = recovery
var body = JSON.stringify({ status: p.event_value === '1' ? 4 : 1 });
return req.post(p.pharos_url + '/api/v1/components/' + p.component_id, body);

Grafana: its webhook posts Grafana's own alert JSON, which Pharos does not parse. Point the contact point at a small relay — an n8n webhook node is enough — and have the relay call the API. Map firing to 4 and resolved to 1. Going the other way, /api/v1/components needs no token, so the Infinity datasource can chart current status directly.

Migrating from Cachet 2.x

Three things are deliberately identical, so existing scripts survive:

  • GET /api/v1/components — same path, same envelope, same field names.
  • Status integers — 1 operational, 2 degraded, 3 partial, 4 major. Pharos adds 5 for maintenance.
  • X-Cachet-Token is accepted everywhere Bearer is, and POST works as an alias for PUT on a component.

What needs editing:

  • Creating incidents. Cachet took an integer status and a single component; Pharos takes a status name and a components object.
  • Endpoints that do not exist here — component groups, metrics, subscribers, actions, ping, version, and fetching or deleting a single incident.
  • No pagination parameters. Components return in one page; incidents are capped at the 50 most recent.
  • No importer. Moving existing data across means writing it in through the API. The matching status integers are what make that straightforward.

Branding and the Brand pack

Free: the name on the page and the accent colour.

Brand pack: your own logo and favicon, and the footer credit removed.

SVG uploads are refused on purpose. An SVG can carry script, and this file is served to every visitor of your status page.

A key is an Ed25519 signature over a small payload, verified locally against the public key in your .env. No network call is made at activation or afterwards, which means a Pharos outage cannot break your branding, activation works on a host with no outbound HTTP, and the licence keeps working whether or not you renew. Renewing buys updates and support, not permission to run.

Updates

php artisan pharos:update --check   # what is available
php artisan pharos:update           # install it

Pharos fetches a signed manifest once an hour. It carries purpose: pharos-release, which is why a licence key can never be replayed as an update manifest even though the same key signs both. A release server that cannot be reached reads as no news, never as an error on a status page.

InstallHow it updates
Shared hostingThe app downloads the release, checks it against the signed checksum, copies the current version into storage/app/backups, replaces its files and migrates.
DockerThe host owns the image. The app shows what the host reported and drops a trigger file when you press the button.
NeitherThe directory is not writable — update over SSH.

Your .env, database and uploads are never touched. A downgrade is refused, because an older build may not survive migrations the current one already ran. Nothing prunes old backups — they accumulate until you delete them, and they are not a database backup.

Users and access

There is one role: every account can do everything. Login is throttled at five attempts per email and IP with a five-minute lockout. Passwords are at least twelve characters.

You cannot delete the account you are signed in with, and you cannot delete the last account — locking everyone out of a self-hosted install is not recoverable through the interface. There is no password-reset email; recover from the shell:

php artisan pharos:user [email protected] --password='a-new-long-password'

Troubleshooting

Checks are not running

  1. Prove the runner works: php artisan pharos:check --force. If that works but nothing happens on its own, the scheduler is the problem.
  2. Prove the scheduler fires: php artisan schedule:run, then check the cron line uses an absolute path and the right PHP binary. On Docker, docker compose ps — the scheduler must be up.
  3. Check the component is enabled. Disabled components are skipped entirely.
  4. If every HTTP check says No response, your host almost certainly has allow_url_fopen off or blocks outbound HTTP.
  5. Look in storage/logs/laravel.log. The shipped log level is warning.

A component is stuck down

  1. Run php artisan pharos:check --force and read the message beside it.
  2. For heartbeats, call the URL by hand. A 404 means the token is wrong; a 200 means Pharos is fine and your cron is not calling it.
  3. Check nothing else owns it. A workflow that also sets this component will fight the built-in check every minute — pick one owner.

The admin looks stale

Admin pages already send no-store, so the browser cannot serve you a screen the server never rendered. If it persists, something in front of Pharos is caching — exclude /admin/* from it. After editing .env, run php artisan optimize:clear.

Mail is not sending

Pharos does not send any mail yet. The MAIL_* settings configure Laravel's mailer, which nothing currently uses. For alerting, use the outgoing webhook into n8n and send from there.


Written against the shipping source. If something here does not match what you see, the source is right and this page is wrong — please say so.