Database-backed endpoint monitoring. Gatus-shaped conditions, Filament admin, GraphQL mutations, Prometheus metrics, Reverb live events.
- PHP 8.5, Laravel 13, Filament 5, Lighthouse GraphQL, Sanctum, Reverb
- Docker:
serversideup/php:8.5-frankenphpwith Laravel Octane, OPcache, and FrankenPHP worker mode - Monitors: HTTP/HTTPS, GraphQL, ICMP ping, TCP, DNS, TLS, heartbeat, UDP, WebSocket, MySQL, Redis, and PostgreSQL
- Proxies: per-monitor HTTP/SOCKS URL for HTTP, GraphQL, TCP, TLS, WebSocket, and Redis;
HTTP_PROXY/ALL_PROXYfor HTTP checks and notification webhooks - Conditions:
[STATUS],[BODY],[REDIRECT],[RESPONSE_TIME],[IP],[CONNECTED],[CERTIFICATE_EXPIRATION],[DOMAIN_EXPIRATION],[DNS_RCODE] - Notifications: mail, Slack, Teams, Discord webhook, generic webhook, PagerDuty
- Public status pages: multiple branded pages, custom domains, incidents, optional password
- Terraform provider:
returnearly/terraform-provider-nominal
composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
php artisan serveAdmin: http://localhost:8000/admin
Create a GraphQL/Terraform token:
php artisan nominal:token admin@nominal.testRun checks locally:
php artisan nominal:dispatch-due-checks
php artisan queue:work --queue=checks.local,defaultINTERFACE_AUTH controls the Filament admin only. GraphQL always uses Sanctum tokens.
| Value | Behavior |
|---|---|
login |
Email and password (default). Seeded: admin@nominal.test / password |
none |
No login page. Visitors are signed in as operator@nominal.local (created on first visit). |
cloudflare |
Cloudflare Access via returnearly/laravel-cloudflare-zero-trust. SSO users are created on first request. |
Cloudflare Access:
INTERFACE_AUTH=cloudflare
CLOUDFLARE_ZERO_TRUST_ENABLED=true
CLOUDFLARE_TEAM_DOMAIN=https://returnearly.cloudflareaccess.com
CLOUDFLARE_ADMIN_AUD=your-application-aud-tagThe origin must sit behind Cloudflare Tunnel (or equivalent). A valid Access JWT is a bearer credential.
POST /graphql with a Sanctum bearer token (php artisan nominal:token you@example.com).
mutation {
createMonitor(input: {
name: "API"
type: Http
target: "https://example.com/health"
conditions: ["[STATUS] == 200"]
}) { id }
}[BODY] == pat(*healthy*) matches a substring in the raw response. [REDIRECT] is the Location header when follow redirects is off, or the final URL when it is on:
mutation {
createMonitor(input: {
name: "Login redirect"
type: Http
target: "https://example.com/login"
followRedirects: false
conditions: ["[STATUS] == 302", "[REDIRECT] == pat(https://example.com/app/*)"]
}) { id }
}GraphQL monitors wrap requestBody as {"query": "..."} and default to POST:
mutation {
createMonitor(input: {
name: "Countries"
type: GraphQL
target: "https://countries.trevorblades.com/"
requestBody: "{ __typename }"
conditions: ["[STATUS] == 200", "has([BODY].errors) == false"]
}) { id }
}Unauthenticated clients receive GraphQL errors[]. HTTP status is still 200 — Terraform maps those errors as failed applies.
Database monitors take a connection URL, log in, and run a version/status query (SHOW TABLES / public tables / Redis INFO and DBSIZE). Optional requestBody is custom SQL or a Redis command:
mutation {
createMonitor(input: {
name: "Primary Postgres"
type: Postgres
target: "postgres://monitor:secret@db.example.com:5432/app"
conditions: ["[CONNECTED] == true", "has([BODY].version) == true"]
}) { id }
}Nominal’s Filament UI is admin-only. Publish one or more public status pages (Uptime Kuma-style) instead of using the dashboard as the status page (Gatus-style).
Each page can:
- List a subset of monitors, with optional public names (targets hidden by default)
- Use a custom domain, logo, favicon, theme, footer, and CSS
- Optionally require a password
- Show incidents and scheduled maintenance with a public timeline
Path URL: /status/{slug}. Custom domain: CNAME the hostname to Nominal; the page is served at / on that host.
GraphQL: createStatusPage, createIncident, addIncidentUpdate.
The Filament admin subscribes to live events over Reverb and refreshes when a check finishes. Tables and widgets still poll every 10s if the WebSocket is down.
Live events are broadcast on:
private-monitorsprivate-monitors.{uuid}
Payloads: MonitorStatusUpdated, CheckCompleted. Authenticate /broadcasting/auth with the same Sanctum session or bearer token used for GraphQL.
Public status pages keep a meta refresh (refresh_seconds) — they are unauthenticated, so they do not use the private monitor channels.
Reverb through Cloudflare needs extra setup; GraphQL HTTP does not.
GET /metrics — Redis/cache-backed counters and gauges, prefix nominal_. Labels: monitor, type, success, region.
Public SVG and JSON badges for each monitor — the same first-class integration Gatus, Healthchecks, and Uptime Kuma expose. Served from /embed so they can be allowlisted independently of /api (e.g. through a firewall).
GET /embed/badges/{id}/status.svg
GET /embed/badges/{id}/status.json
GET /embed/badges/{id}/uptime/{period}/badge.svg
GET /embed/badges/{id}/uptime/{period}
GET /embed/badges/{id}/latency/{period}/badge.svg
GET /embed/badges/{id}/latency/{period}
Periods: 1h, 24h, 7d, 30d (any {n}h / {n}d up to 90 days). Omit the period to default to 24h. JSON is Shields.io endpoint compatible (schemaVersion, label, message, color).
Copy URLs and markdown from the monitor page.
Each probe has a queue such as checks.us-east. Workers set PROBE_REGION=us-east and listen to checks.us-east. SQLite is single-node only; use MySQL or Postgres for multiple writers.
ICMP inside Docker needs cap_add: [NET_RAW]. Ping falls back to TCP 443/80 when ICMP is blocked.
App container is serversideup/php:8.5-frankenphp running Laravel Octane (octane:start --server=frankenphp) with OPcache on. Compose (development) adds --watch plus opcache.validate_timestamps so PHP/config changes reload without rebuilding. Production image leaves timestamps off and omits --watch.
cp .env.example .env
php artisan key:generate
touch database/database.sqlite
composer install
docker compose up --buildApp is on http://localhost:8000 (container port 8080). Queue, scheduler, and Reverb are the same image with command: overrides. Extra regional workers copy worker and change PROBE_REGION / --queue.
Production images (linux/amd64 and linux/arm64) are published to the GitHub Container Registry on every push to master (latest) and on version tags:
docker pull ghcr.io/returnearly/nominal:latestThe package is public. If the first publish lands as private, set visibility to Public once under the repo's Packages settings.
Copyright © 2026 Return Early.
Nominal is licensed under the Elastic License 2.0 (source-available, not OSI open source). You may self-host it and run it for your own business, including for-profit internal use. You may not offer Nominal to third parties as a hosted or managed service.

