Skip to content

Observability

View as Markdown

Status pages

A status page tells your customers whether your services work, without giving them a login. It shows the services you choose (its components) under names you choose, 90 days of availability per component, the incidents you write, and the maintenance you announce. It is served read-only on your own hostname, for example status.example.com, by the control plane's status listener. The dashboard is never reachable through that hostname.

The state of each component comes from uptime monitoring: the probes that run from each server every 30 seconds. Incidents and maintenance are written by people. A probe-detected outage does not create a public incident by itself; it turns the component red, and you decide what to say about it.

Everything here needs the admin scope to change (workspace owners and admins, like notification channels) and the projects:read scope to read. A token restricted to some projects can read pages but not change them, and sees no service, environment or project names of projects it cannot access. Every change is audited and refreshes the web UI live.

Create a page#

Status pages in the web UI, or the API:

sh
curl -X POST "$NEBULACTRL_URL/api/v1/status-pages" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: status-1' \
  -d '{"title": "Acme status", "hostname": "status.acme.com", "description": "Live status of the Acme platform."}'
Field Rules
title 1-100 characters, the page heading
slug lowercase letters, digits and single hyphens, up to 50 characters, unique in the workspace. Default: derived from the title (acme-status, then acme-status-2, ...)
description up to 500 characters, plain text
footer up to 500 characters, plain text with line breaks, shown under the page (a contact address, a link to your social account). It is never interpreted as markup
hostname optional at first, see Hostname and DNS
showUptime show the availability percentages next to the day bars. Default true
theme auto (the visitor's preference, default), light or dark
fromProjectId optional: start with a project's production services that have a public domain, see Start from a project

A new page is unpublished: nothing is served yet. PATCH /status-pages/{pageId} changes any field later (GET /status-pages, GET /status-pages/{pageId}, DELETE /status-pages/{pageId} complete the set). Deleting a page withdraws its route; incidents and maintenance windows that were posted to it stay.

Start from a project#

Send fromProjectId (the Start from project picker of the new-page dialog) to start the page with a project's components instead of an empty list. The page gets the services of the project's production environment that have at least one public domain, in name order, each under its service name and in no group. A domain is public when it serves the site (a redirect does not), has no IP allow list, and its service is not Tailnet-only. Other environments (staging, previews), deleted services and services without such a domain are left out; at most 100 are added. Everything is editable afterwards (PUT /status-pages/{pageId}/components). A project of another workspace is 422 unknown_project and nothing is created. The audit entry of the page carries the project and the number of components.

Components#

A component is a service shown on the page. Set them all at once, in display order:

sh
curl -X PUT "$NEBULACTRL_URL/api/v1/status-pages/$PAGE/components" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"components": [
        {"serviceId": "...", "displayName": "Website", "groupName": "Customer-facing"},
        {"serviceId": "...", "displayName": "API",     "groupName": "Customer-facing"},
        {"serviceId": "...", "groupName": "Internal"}
      ]}'
  • PUT replaces the list. Send the full ordered list; the order is the display order.
  • Every service must be a live service of the workspace (422 unknown_service), at most once per page (422 duplicate_component), at most 100 components.
  • displayName is the only name the public sees. It defaults to the service's name, may not be empty and is at most 60 characters. Name services for your customers ("API", not acme-api-prod-eu).
  • groupName (up to 60 characters) groups components: ungrouped ones come first, then the groups in the order they first appear. Empty means ungrouped.
  • url (optional, up to 300 characters) makes the component's name a link on the public page, for example to its documentation. It must be an absolute https URL with a host and no credentials (422 invalid_url); http:, relative and javascript: URLs are refused. Empty means no link.
  • A service that is deleted disappears from the page.

Hostname and DNS#

The hostname is lowercased and must be a valid DNS name with at least two labels (not an IP address). It must be unique and:

  • must not be the dashboard's own host (409 hostname_reserved);
  • must not be a hostname of a service domain (409 hostname_conflict);
  • and the other way around: a service domain cannot take a hostname a status page uses (409 domain_conflict when you add the domain).

The page is served by the control-plane server, so the hostname's DNS must point there. GET /status-pages/{pageId}/dns tells you:

json
{
  "hostname": "status.acme.com",
  "records": [{"type": "A", "name": "status.acme.com", "value": "203.0.113.10"}],
  "resolved": [],
  "status": "missing",
  "message": "No A or AAAA record found for status.acme.com yet. Create the records below."
}

records are the control-plane server's public address as the dashboard's domain DNS guidance names it (an A record for an IPv4 address, AAAA for IPv6); resolved is what the name resolves to right now. status is:

Status Meaning
ok the hostname resolves to the control-plane server and nowhere else
mismatch it resolves, but to other addresses too or instead (a record proxied through a CDN shows its addresses here)
missing no record yet
unknown the control-plane server has no public IP set, or the lookup failed

Create the record at your DNS provider; changes can take a while to spread. The certificate is issued automatically (HTTP-01) the first time the hostname is served, which needs the record in place and ports 80 and 443 reachable.

Cloudflare DNS#

When the hostname is in a zone of the team's Cloudflare connection, NebulaCtrl creates (or adopts) the DNS records itself, the way it does for service domains. Without a connection, or for a hostname in none of the connected zones, the page editor shows the records above to create by hand and nothing else changes.

  • The records. An A record per IPv4 address and an AAAA record per IPv6 address of the control-plane server's public IP (the server may have both). A Cloudflare Tunnel is never used for a status page: the tunnel's ingress only carries service hostnames. Every record NebulaCtrl creates is marked nebulactrl:<pageId> (and tagged where the plan allows) and only a marked record that NebulaCtrl recorded creating is ever modified or deleted. A record NebulaCtrl did not create is reported (present, or wrong_target) and left alone until you Fix with Adopt, which marks it and points it at the server. A CNAME at the name blocks adoption (409 dns_conflict): remove it in Cloudflare first. The ownership rules are those of service domains.
  • Settings (per page): Manage (NebulaCtrl keeps the records in sync), Proxied (the orange cloud) and Lock the origin to Cloudflare (default on). A page whose hostname first lands in a zone takes the team's defaults for Manage and Proxied (Team settings → Integrations → Cloudflare); a page that exists when the connection is added is matched at once.
  • They follow the server. The records are re-synced when the control-plane server's public IP changes, when the hostname changes (the old hostname's records NebulaCtrl created are deleted) or is removed, and when the page is deleted; a periodic sweep (every ten minutes) repairs drift. A failing sync is shown as error in the status, retried in the background, and announced once as DNS sync failed.
  • A proxied record is correct. The DNS check of a page whose record NebulaCtrl manages as proxied expects Cloudflare's addresses: it is ok with "proxied": true when every address the hostname resolves to is in Cloudflare's published ranges. While the record still points at the server (not proxied yet) it is ok too, with "proxied": false. Anything else is a mismatch as before.
  • The lock. A page that is proxied, published, not opted out and whose DNS NebulaCtrl manages is locked to Cloudflare, exactly like a service domain: the status route on the control-plane server accepts only connections from Cloudflare's ranges (403 otherwise), requires Cloudflare's client certificate when the zone has Authenticated Origin Pulls on (NebulaCtrl turns it on, if the token may), and trusts the forwarded visitor address only from Cloudflare. Layers, states and the IP ranges are described in Cloudflare → Origin lock. The lock follows the proxied record: turning Proxied off, opting out, unmanaging DNS, unpublishing or deleting the page opens the route again (the route table is re-sent before the DNS record changes). Only the status hostname is locked; the dashboard's own route and the other service routes are unaffected. An unpublished page has no route yet: its lock state says not_published and it is locked from the moment it is published.
sh
# Cloudflare state of the hostname: zone, records, lock layers (changes nothing)
curl "$NEBULACTRL_URL/api/v1/status-pages/$PAGE/cloudflare" -H "Authorization: Bearer $TOKEN"
# Proxy it (and lock it); omitted fields stay as they are
curl -X PUT "$NEBULACTRL_URL/api/v1/status-pages/$PAGE/cloudflare" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"proxied": true}'
# Create or correct the records; adopt takes over a record NebulaCtrl did not create
curl -X POST "$NEBULACTRL_URL/api/v1/status-pages/$PAGE/cloudflare/fix" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"adopt": true}'

The answers have the shape of the domain DNS endpoints (GET, PUT /domains/{id}/dns, POST /domains/{id}/dns/fix): status (managed, present, wrong_target, missing, not_in_zone), zoneName, manage, proxied, the records NebulaCtrl wants (desiredRecords) and the ones in the zone (records), warnings (a Flexible SSL/TLS mode breaks a proxied record) and originLock with enabled, reason, aop. 422 no_hostname while the page has no hostname, 422 not_configured or not_in_zone for Fix without a connection or zone, 409 adoption_required when a record NebulaCtrl did not create differs.

Publish#

sh
curl -X PATCH "$NEBULACTRL_URL/api/v1/status-pages/$PAGE" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"published": true}'

Publishing needs a hostname (422 publish_needs_hostname) and at least one component (422 publish_needs_components). A published page needs its components too: replacing them with an empty list, or removing the hostname, is refused until you unpublish. Publishing, unpublishing, changing the hostname of a published page and deleting it re-send the control-plane server's route table, so the page appears (or is withdrawn) within moments, with no deployment. An unpublished page answers 404 on its hostname.

What a visitor sees#

  • Overall state: the worst of the component states and of the impact of the active incidents, shown as a banner.

    State When
    Major outage a component is down and every component is down, or an active incident has impact critical
    Partial outage some components are down, or an active incident has impact major
    Degraded a component is degraded, or an active incident has impact minor
    Maintenance nothing is worse, and a component is in maintenance or a window posted to the page is active
    Operational everything else
    Unknown no component has data yet (not probed, paused, or the agent is not reporting)
  • Components, grouped, each with its current state (operational, degraded, major_outage, maintenance, unknown), its availability over the last 24 hours and the last 90 days and a bar of 90 UTC days. A component with a url shows its name as a link to it. A day shows its availability, an estimate of the outage (failed checks times 30 seconds), whether maintenance took part, and the titles of the incidents that affected the component that day.

  • Availability is (up + degraded checks) / (all checks minus maintenance checks). Days or periods without checks (agent offline) have no data, never 0%. Next to the 90-day figure each component shows the availability of the last 24 hours, computed the same way from the minute rollups; no checks in that time (or only maintenance) means no figure.

  • Active incidents with their timelines, scheduled and active maintenance that starts within 14 days, and past incidents resolved in the last 30 days.

  • Footer: the plain text you set on the page, under the content (line breaks kept, never interpreted as markup or links).

What is never shown#

The page is built from a fixed view model that holds only the text above. It never contains: service, environment, project or server names (only your display names), IDs, hostnames of your services, error messages or probe details, latency, replica counts, deployments, who wrote an incident, or anything from probe-detected incidents. Incident and maintenance text is plain text, shown escaped. Incidents and maintenance windows carry an opaque key (the first 16 hex characters of a SHA-256 of their kind and ID) for anchors and feeds; it is stable and cannot be turned back into the ID. The only links on the page are the https URLs you set on components.

Incidents#

Write an incident when you want to say something. It is posted to the pages you choose and shows on each page that has the affected components.

sh
# Open (the body is the first update of the timeline)
curl -X POST "$NEBULACTRL_URL/api/v1/status-incidents" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: inc-1' \
  -d '{"title": "Elevated API errors", "impact": "major", "status": "investigating",
       "body": "We are looking into failing API requests.", "pageIds": ["..."], "serviceIds": ["..."]}'

# Post an update; status resolved closes it
curl -X POST "$NEBULACTRL_URL/api/v1/status-incidents/$INCIDENT/updates" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"status": "resolved", "body": "A bad deploy was rolled back."}'
  • Impact (none, minor, major, critical) moves the overall state of the pages while the incident is not resolved (see the table above). Status is investigating, identified, monitoring or resolved.
  • POST /status-incidents/{incidentId}/updates adds a timeline entry and moves the incident to that status. resolved sets resolvedAt; a later update with another status reopens it.
  • PATCH /status-incidents/{incidentId} changes the title, impact, pages or services (an empty pageIds list withdraws the incident from every page). GET /status-incidents filters by status (or active) and pageId; DELETE removes the incident and its timeline from the pages.
  • Bodies are plain text of up to 4000 characters; titles up to 120.

Maintenance windows#

A window announces planned work and puts its services into maintenance while it runs: no uptime.down alerts and their checks are left out of the availability, so planned work does not cost you availability.

sh
curl -X POST "$NEBULACTRL_URL/api/v1/maintenance-windows" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: mw-1' \
  -d '{"title": "Database upgrade", "description": "The API is read-only for a few minutes.",
       "startsAt": "2026-11-03T22:00:00Z", "endsAt": "2026-11-03T23:00:00Z",
       "serviceIds": ["..."], "pageIds": ["..."]}'
  • endsAt must be after startsAt (422 invalid_window) and in the future (422 window_in_past).
  • Pages announce a window it is posted to from 14 days ahead, and while it is active.
  • GET /maintenance-windows?phase=upcoming|active|past (past: ended or canceled) and pageId filter the list.
  • PATCH /maintenance-windows/{windowId} edits a window that is not over (409 window_ended, window_canceled).
  • POST /maintenance-windows/{windowId}/cancel ends it now and sets canceledAt; the services leave maintenance and the pages stop announcing it. Cancelling twice changes nothing.
  • DELETE /maintenance-windows/{windowId} removes it.

Permissions and audit#

Action Needs
List and read pages, incidents, windows, DNS check, Cloudflare DNS status projects:read
Create, change, publish, delete; change a page's Cloudflare settings or fix its records admin scope (workspace owner or admin), and a token not restricted to projects

Audit actions: status_page.create, status_page.update, status_page.delete, status_page.components, status_page.dns.update (the Manage, Proxied and origin lock settings, with the new values); status_incident.create, status_incident.update, status_incident.post, status_incident.delete; maintenance_window.create, maintenance_window.update, maintenance_window.cancel, maintenance_window.delete. The DNS records NebulaCtrl creates, changes or deletes for a page are audited as dns.record.create, dns.record.update and dns.record.delete on the status_page resource; turning on Authenticated Origin Pulls in the zone is audited as cloudflare.zone.aop.enable. Incident text is not copied into the audit log.

The MCP server exposes the reads to agents (including getStatusPageCloudflare); every write is denied, because a status page is a public statement that a person makes, and so is where its hostname points and whether it is proxied.

Operations#

OperationID Request
listStatusPages, createStatusPage GET, POST /status-pages
getStatusPage, updateStatusPage, deleteStatusPage GET, PATCH, DELETE /status-pages/{pageId}
setStatusPageComponents PUT /status-pages/{pageId}/components
checkStatusPageDNS GET /status-pages/{pageId}/dns
getStatusPageCloudflare, updateStatusPageCloudflare GET, PUT /status-pages/{pageId}/cloudflare
fixStatusPageCloudflare POST /status-pages/{pageId}/cloudflare/fix
listStatusIncidents, createStatusIncident GET, POST /status-incidents
getStatusIncident, updateStatusIncident, deleteStatusIncident GET, PATCH, DELETE /status-incidents/{incidentId}
postStatusIncidentUpdate POST /status-incidents/{incidentId}/updates
listMaintenanceWindows, createMaintenanceWindow GET, POST /maintenance-windows
getMaintenanceWindow, updateMaintenanceWindow, deleteMaintenanceWindow GET, PATCH, DELETE /maintenance-windows/{windowId}
cancelMaintenanceWindow POST /maintenance-windows/{windowId}/cancel

Creates take an Idempotency-Key header and replay the original response for 24 hours.

How public pages are served#

A published page is not served by the dashboard. The control plane runs a second, separate HTTP listener (the status listener) that does one thing: look a page up by the request's Host header and render it. The dashboard, the API, /agent/v1/*, /mcp, the installer scripts and the SPA are not mounted on it, so there is nothing else to reach through a status hostname.

visitor ──443──▶ control-plane server's Traefik ──▶ nebulactrl:8081 (status listener)
                  status.acme.com  (public, TLS)       Host → page → HTML / JSON / badge
dashboard users ─▶ nebula.acme.com (allow list, TLS) ─▶ nebulactrl:8080 (dashboard listener)

For every published page with a hostname the control plane adds one route to the route table of the control-plane server only (re-sent whenever a page is published, unpublished, renamed or deleted):

  • the page's hostname, to the status upstream, with a /healthz upstream health check;
  • public: no allow list, whatever you set for the dashboard; basic security headers (X-Frame-Options, nosniff);
  • TLS: automatic HTTP-01 by default. The route uses DNS-01 instead when the dashboard's route has a DNS-01 provider (Admin → Dashboard access), the control-plane server has a resolver for that provider (Servers → Proxy → DNS-01) and the page's hostname is in the same registrable domain as the dashboard's, so the same token can edit its zone. Anything else stays on HTTP-01;
  • a stable route id derived from the page id, so the proxy's objects and the certificate survive route rebuilds and restarts;
  • never a route for a hostname another route already serves (the dashboard's, a service domain's).

The listener and its settings#

Variable Default Meaning
NEBULACTRL_STATUS_LISTEN :8081 Address of the status listener inside the control-plane container. A second http.Server with its own timeouts; it shuts down gracefully with the main one.
NEBULACTRL_STATUS_UPSTREAM nebulactrl:8081 host:port the control-plane host's proxy sends status hostnames to, reachable on the edge network. A leading http:// is dropped. The installer writes it in domain mode.

Never publish the status port on the host. The compose file has no ports entry for it and the installer's tests check that nothing listens on 8081 on the host: only the host's proxy, over the edge network, reaches the listener. If you run the control plane by hand, keep it that way (and keep NEBULACTRL_STATUS_LISTEN off public interfaces). Status routes exist only on the server flagged as the control plane (the host the installer enrolls); without one there is nothing to route the hostnames.

What the listener serves#

Only GET and HEAD; everything else is 405.

Path Response
/ the page: server-rendered HTML, no JavaScript, refreshes itself every minute (<meta http-equiv="refresh">)
/status.json the same model as JSON with stable, public-safe field names (below)
/feed.atom an Atom feed (below)
/badge/status.svg badge of the whole page's state
/badge/<slug>/status.svg, /badge/<slug>/uptime.svg badges of one component, by the slug of its display name (below); 404 for an unknown component
/status.css, /fonts/* the stylesheet (versioned URL, cached for a year) and the self-hosted IBM Plex fonts taken from the embedded web UI build (the system font stack when the build has none)
/healthz 200 ok for any Host: the proxy's health check
anything else 404 with a minimal page (the earlier /badge.svg?component= form is gone). An unknown, unpublished or invalid Host gets the same 404.

/status.json is written field by field, not marshaled from the internal model, so a field added later never reaches it by accident. Keys: title, description, footer, status {state, label}, showUptime, generatedAt, groups[] {name, components[] {name, slug, url, status, uptime, uptime24h, days[] {date, status, uptime, downMinutes, maintenance, incidents}}}, activeIncidents[], maintenance[], pastIncidents[] (incidents and windows carry an opaque key). uptime (90 days) and uptime24h are percentages truncated (never rounded up) to two decimals; with Show uptime off they, downMinutes and the day figures are left out and a day only has its band (operational 99.9% or more, minor_issues 99%, degraded 95%, outage, maintenance, no_data). slug is the component's name in badge URLs and url its link, only when it is an https URL. The JSON sends Access-Control-Allow-Origin: *, so other sites can fetch it.

The page#

  • Header: title, the overall state as a banner with an icon and a text label, the description.
  • Components in groups: a named group is a <details open> that a visitor can collapse (no JavaScript), components without a group are always shown. Each shows its state, a name that links to its url when the page has one (rel="noopener noreferrer nofollow"), its availability for the last 24 hours and 90 days when Show uptime is on (a window without data is left out), and the 90-day bar. Each day of the bar has a hover title; the bar itself is focusable and announces "Last 90 days: 99.95% available, 187 min down".
  • Footer: your footer text (plain text, escaped, line breaks kept), "Last updated" as a <time datetime> in UTC with the age at render time ("2 min ago"), a note that the page refreshes every minute, links to the JSON and the feed, and "Powered by NebulaCtrl".
  • Head: <meta name="description"> (the description cut at 155 characters, or the overall state when there is none), Open Graph title and description, and <link rel="alternate" type="application/atom+xml"> for the feed.
  • Anchors: every incident and window has an element id (inc-<key>, mw-<key>) that the feed links to.

The page follows the page's theme: light, dark, or auto, which follows the visitor's prefers-color-scheme. Every state has an icon shape and a text label, so colour is never the only cue. On narrow screens the bar shows the last 30 days.

Atom feed#

/feed.atom has one entry for every update of every incident on the page (active and the last 30 days) and one for every maintenance window it announces, newest first. Entry ids are urn:nebulactrl:<key>:<n>, where <key> is the opaque key of the incident or window and <n> numbers an incident's updates from the oldest (0), so an entry keeps its id when later updates are posted and a reader never shows it twice. The text is plain, and the feed is cached like the page.

Badges#

Shaped like Uptime Kuma's, so existing README snippets only change their URL:

URL Shows
/badge/status.svg the page's overall state
/badge/<slug>/status.svg a component's state
/badge/<slug>/uptime.svg?window=24h or 90d (default) a component's availability; 404 when the page hides uptime, N/A without data

<slug> is the display name in lower case with every run of other characters replaced by one hyphen (Customer API (EU) is customer-api-eu); two components that would share a slug get -2, -3, in page order. /status.json lists each slug. Query parameters, all optional:

Parameter Effect
label the left text (default: the component's name, status for the page, <name> uptime (90d))
upLabel, downLabel, maintenanceLabel the right text for an operational, a down and a maintenance state (defaults Up, Down, Maintenance; degraded, partial outage and no data have fixed texts)
style flat (default), flat-square or for-the-badge
color, labelColor fill of the right and left part: a name (green, red, orange, yellow, blue, grey, brightgreen, ...) or a hex value (ff8800, #f80); anything else is ignored. The text colour follows the fill

Texts are cut at 60 (label) and 40 (value) characters and everything is escaped. An unknown component is 404, an unknown window is 400.

Caching, rate limit and headers#

  • Cache. Each host's rendered page, JSON and feed are cached in memory for 20 seconds (badges are drawn per request from the cached page), with one lookup per host at a time (concurrent visitors share it). A status_page.updated or uptime.state.changed event makes the next request rebuild it, at most once every 2 seconds per host, so a posted incident or a state change shows within moments even on a busy instance. When the database is down a page that expired within the last 10 minutes is still served. The page, the JSON and the feed carry Cache-Control: public, max-age=30 and an ETag (304 on If-None-Match) and are gzip-compressed when the client accepts it; badges carry Cache-Control: public, max-age=300 and Access-Control-Allow-Origin: *. Errors and the 404 page are no-store.

  • Rate limit. A token bucket of 60 requests per client address (one per second sustained), then 429 with Retry-After: 1. The client is the last X-Forwarded-For entry when the peer is the proxy (a private or loopback peer), the TCP peer otherwise; an IPv6 client is its /64. The table of clients is bounded; when it is full, new clients share one overflow bucket instead of growing it. /healthz and unknown paths are not counted.

  • Headers on every response:

    Header Value
    Content-Security-Policy default-src 'none'; style-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none': no script, no inline style, no framing, nothing from another origin
    X-Content-Type-Options nosniff
    Referrer-Policy no-referrer (the proxy's basic preset may replace it with strict-origin-when-cross-origin; the page's only external link carries rel="noopener noreferrer")
    X-Frame-Options DENY
    Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy same-origin (cross-origin on the JSON and the badge)
    Permissions-Policy camera, microphone, geolocation, payment off

Troubleshooting#

Symptom Check
404 Page not found on the hostname The page is unpublished, the hostname differs (it is matched case-insensitively, without the port), or the route did not reach the proxy yet: the control-plane server's route table (Servers → Proxy) lists it as Status page <hostname>.
502 or no available server The proxy cannot reach NEBULACTRL_STATUS_UPSTREAM: the control plane must be on the edge network as nebulactrl, and NEBULACTRL_STATUS_LISTEN must be the port of the upstream.
The browser shows the dashboard's login on the hostname It should be impossible: a status hostname only ever routes to the status listener. Check that the hostname is not also a service domain (the control plane refuses that) and that no other proxy fronts the host.
Certificate errors The hostname's DNS must point at the control-plane server, with ports 80 and 443 reachable for HTTP-01.
403 Forbidden on the hostname from a browser that goes through Cloudflare The page is proxied and locked, and the visitor came from an address the stored list of Cloudflare's ranges lacks: see Cloudflare IP ranges in Cloudflare. A request that skips Cloudflare (straight to the server's IP) gets 403 by design.
525 from Cloudflare on the hostname The page is locked with Authenticated Origin Pulls but AOP is off in Cloudflare; the next sync (within ten minutes) turns it on, or switch Lock the origin to Cloudflare off for the page while you investigate.
The hostname's DNS check says mismatch although the record is proxied NebulaCtrl only expects Cloudflare's addresses for a page whose record it manages as proxied (the page's Cloudflare panel says so). A record you proxied by hand is not known to be this server's: manage it with NebulaCtrl (Fix, then Adopt).