Skip to content

Latest commit

 

History

93 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nominal

Nominal

Database-backed endpoint monitoring. Gatus-shaped conditions, Filament admin, GraphQL mutations, Prometheus metrics, Reverb live events.

Documentation

Nominal — Modern self-hosted monitoring

Stack

  • PHP 8.5, Laravel 13, Filament 5, Lighthouse GraphQL, Sanctum, Reverb
  • Docker: serversideup/php:8.5-frankenphp with 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_PROXY for 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

Quick start

composer install
cp .env.example .env
php artisan key:generate
php artisan migrate --seed
php artisan serve

Admin: 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.test

Run checks locally:

php artisan nominal:dispatch-due-checks
php artisan queue:work --queue=checks.local,default

Interface auth

INTERFACE_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-tag

The origin must sit behind Cloudflare Tunnel (or equivalent). A valid Access JWT is a bearer credential.

API-managed resources

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.

GraphQL

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 }
  }
}

Status pages

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.

Reverb

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-monitors
  • private-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.

Prometheus

GET /metrics — Redis/cache-backed counters and gauges, prefix nominal_. Labels: monitor, type, success, region.

Badges

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).

![status](https://nominal.example/embed/badges/{id}/status.svg)

Copy URLs and markdown from the monitor page.

Multi-region workers

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.

Docker

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 --build

App 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:latest

The package is public. If the first publish lands as private, set visibility to Public once under the repo's Packages settings.

License

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.

Releases

Packages

Used by

Contributors

Languages