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.
| PHP | 8.3 or newer |
|---|---|
| Database | SQLite (default) or MySQL |
| Extensions | sodium, pdo_sqlite or pdo_mysql, zip, openssl, mbstring. HTTP checks need allow_url_fopen on. |
| Web server | Document root must point at public/, with mod_rewrite |
| Targets | Shared 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 hidden | Component disabled | |
|---|---|---|
| Shown to customers | No | No |
| Checks keep running | Yes | No |
| Counts toward headline uptime | Yes | No |
| History kept | Yes | Yes |
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
| Type | Target | Up when |
|---|---|---|
| HTTP | A full URL | Final response code is 200–399. TLS is verified. |
| TCP | host:port | The socket connects. |
| Heartbeat | A generated token | Something 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:
| Value | Label | Counts as down |
|---|---|---|
| 1 | Operational | no |
| 2 | Degraded performance | no |
| 3 | Partial outage | yes |
| 4 | Major outage | yes |
| 5 | Under maintenance | no |
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.
| Day | Cell |
|---|---|
| 99.99% or better | green |
| 99.0% or better | amber |
| 95.0% or better | orange |
| below 95% | red |
| no data | grey |
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.
| Toggle | What switching it off hides |
|---|---|
| Overall status banner | The headline, the coloured dot and the “updated” stamp |
| Uptime bar and percentage | The big figure and the combined 90-day bar |
| Services list | Every service and component row |
| Uptime bar per component | The small bars and per-component figures |
| Incident history | The whole incidents section |
| Days without incidents | Quiet days are skipped instead of printing “No incidents” |
| Subscribe button | The “Get notified” link |
| API link in the footer | The 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:
- Create a Pharos component with source Heartbeat and save it.
- Copy the heartbeat URL from the component form, or from Integrations.
- In Kuma, have the monitor call that URL on every successful check.
- 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-Tokenis accepted everywhere Bearer is, andPOSTworks as an alias forPUTon 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.
| Install | How it updates |
|---|---|
| Shared hosting | The app downloads the release, checks it against the signed checksum, copies the current version into storage/app/backups, replaces its files and migrates. |
| Docker | The host owns the image. The app shows what the host reported and drops a trigger file when you press the button. |
| Neither | The 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
- Prove the runner works:
php artisan pharos:check --force. If that works but nothing happens on its own, the scheduler is the problem. - 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. - Check the component is enabled. Disabled components are skipped entirely.
- If every HTTP check says
No response, your host almost certainly hasallow_url_fopenoff or blocks outbound HTTP. - Look in
storage/logs/laravel.log. The shipped log level iswarning.
A component is stuck down
- Run
php artisan pharos:check --forceand read the message beside it. - 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.
- 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.