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
Notification channels, probes, users, and API tokens live under Settings.
Create a GraphQL/Terraform token from Settings → API Tokens, or:
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.
NOMINAL_API_MANAGED=true locks monitor and notification-channel create, edit, and delete in the Filament admin. GraphQL and Sanctum tokens stay available so Terraform can manage those records. Pause, resume, and maintenance stay available in the admin.
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 }
}Notification channels take name, type, and the nested input for that type (mail, slack, microsoftTeams, discord, webhook, pagerduty):
mutation {
createNotificationChannel(input: {
name: "Ops Slack"
type: Slack
slack: { webhookUrl: "https://hooks.slack.com/services/T/B/xxx" }
}) {
id
slack { webhookUrl }
}
}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.

