Skip to content

Putting a status page on its own domain

The deployment this page describes: a visitor opens https://status.example.com/ and lands directly on one public status page — no /status in the address, no #/<slug> either — while the console, the admin API, the agent channel, and every other status page you have created stay unreachable on that domain.

Below are complete nginx and Caddy configurations, for each of two topologies.

You may not need this page

If all you want is for visitors to land on a board when they open this server's own root address, no reverse proxy is involved: edit the status page in the console and turn on Set as home page. Visitors who are not signed in are then sent to that board from /, with a sign-in link in the top corner, while a signed-in admin still gets the console. One server can have only one home page.

Note what that does to the address, because it is the reason this page exists: "Set as home page" is a 302 redirect, and the visitor ends up at /status/#/<slug>/status and the hash are both still there; only the entry point moved to the root. It is also a same-domain answer: the console and the admin API remain on that domain, they just no longer occupy the root.

Reach for the reverse proxy in this guide when you want the address itself to be clean, or when the status page's domain must not be able to reach the console, the admin API or the agent channel at all — which is also why every configuration here sets console: false: /login is deliberately blocked on that domain, so the board must not advertise it.

The console can generate this for you

Every row in the console's "Public status pages" list has a Proxy config button: give it a domain and it produces the configuration below with the slug already filled in, for nginx or Caddy and either topology. This page explains why the configuration looks the way it does, and how to check it once generated.

Before you start

  1. Create and publish a status page in the console, and note its slug (home-lab throughout this page).
  2. The proxy machine must be able to reach the server. It is written as 127.0.0.1:12450 below; use the internal address if they are separate hosts.
  3. Do not expose the server itself to the internet. All of the isolation here comes from this one proxy; if the server's own port is also reachable from outside, the console is still out there and the allowlist buys you nothing. Bind it to loopback or a private network, or keep its own domain restricted.

Allow exactly four read-only endpoints

At runtime a public status page requests four anonymous GET endpoints and nothing else:

/api/v1/public/pages/<slug>
/api/v1/public/pages/<slug>/agent-statuses
/api/v1/public/pages/<slug>/target-statuses
/api/v1/public/pages/<slug>/incidents

The configurations below hard-code the slug into the rule and 404 everything else. Hard-coding it is the load-bearing part: an allowlist written as /api/v1/public/pages/ lets anyone who guesses another slug read your other status pages on this domain.

What that shuts out:

  • /api/v1/auth/*, /api/v1/sites, /api/v1/agents/* and every other session-backed endpoint — they already answer 401, but on this domain not even the 401 should be reachable;
  • /api/v1/enroll and /api/v1/agent/ws — agent enrollment and the agent connection;
  • /api/v1/events — the console's SSE stream;
  • the console front end itself. Note that / and /assets/* on the server are the console's files; the status app lives under /status/, which is why both topologies below have to point the domain root at that copy explicitly.

Making / be the page: page in config.js

The status app is hash-routed (#/<slug>), and a fragment is never sent to the server — so "which page does this domain show" is a question no proxy can answer. Only the app's own runtime config can, which is what the page field in config.js is for:

js
window.NETTACT_STATUS_CONFIG = { apiBase: '', page: 'home-lab', console: false }
  • An empty apiBase means same-origin data. Both topologies below serve the four endpoints from this same proxy, so both leave it empty.
  • page is the page to show when the address names none. It is a default, not a lock: a visitor who types #/other still requests other. "This domain publishes one page" is enforced by the endpoint allowlist above — a slug that is not on it gets no data, and the app renders "page not found".

config.js is not bundled: it is a plain file in the build output you could edit in place — but neither topology below does. Both have the proxy serve that line, so the build output stays untouched.

The static files come from the server too. It serves the status app at /status/, and that app references its assets with relative URLs — so mapping the domain root onto the upstream /status/ is enough to make it work at the root. Nothing to do on a server upgrade: front end and back end always ship as one matched pair.

nginx

nginx
# /etc/nginx/conf.d/status.example.com.conf
#
# This domain publishes exactly one status page: home-lab. To publish a
# different one, change every place home-lab appears below.

upstream nettact_home_lab {
    server 127.0.0.1:12450;
    keepalive 16;          # each viewer polls every 30s; reuse saves handshakes
}

server {
    listen 443 ssl;
    http2 on;              # on nginx < 1.25.1 write: listen 443 ssl http2;
    server_name status.example.com;

    ssl_certificate     /etc/letsencrypt/live/status.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/status.example.com/privkey.pem;

    # 1) Runtime config served by the proxy, overriding the upstream copy: this
    #    is what names the page shown at the root.
    location = /config.js {
        default_type application/javascript;
        add_header Cache-Control "no-store" always;
        return 200 'window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };';
    }

    # 2) The only endpoints allowed through. The slug is fixed, so no other
    #    status page exists on this domain.
    location ~ ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$ {
        limit_except GET HEAD { deny all; }
        proxy_pass http://nettact_home_lab;        # no URI part: path passes through as-is
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 3) The page itself: the root, favicon and assets map onto upstream /status/.
    #    Only public status-page build output is exposed; other console files stay closed.
    location = / {
        proxy_pass http://nettact_home_lab/status/;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
    }

    location = /favicon.svg {
        proxy_pass http://nettact_home_lab/status/favicon.svg;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
    }

    location ^~ /assets/ {
        proxy_pass http://nettact_home_lab/status/assets/;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
    }

    # 4) Everything else is closed: console, admin API, agent channel, other pages.
    location / { return 404; }
}

server {
    listen 80;
    server_name status.example.com;
    return 301 https://$host$request_uri;
}

Two points about match order that edits must not break:

  • location = / is an exact match and wins over location /, which is why the root serves /status/ and not the console's home page;
  • the regex location is evaluated before the location / catch-all, so the four public endpoints pass while everything else under /api/ falls to return 404.

Caddy

txt
# /etc/caddy/Caddyfile
#
# This domain publishes exactly one status page: home-lab. To publish a
# different one, change every place home-lab appears below.
# Caddy obtains and renews the certificate itself and redirects :80 to :443.

status.example.com {
	encode zstd gzip

	# 1) The only endpoints allowed through.
	@public {
		method GET HEAD
		path_regexp ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$
	}
	handle @public {
		reverse_proxy 127.0.0.1:12450
	}

	# 2) Runtime config served by the proxy: it names the page shown at the root.
	handle /config.js {
		header Content-Type "application/javascript"
		header Cache-Control "no-store"
		respond `window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };` 200
	}

	# 3) The page itself: the root, favicon and /assets/ map onto upstream /status/.
	handle /assets/* {
		rewrite * /status{uri}
		reverse_proxy 127.0.0.1:12450
	}
	handle /favicon.svg {
		rewrite * /status/favicon.svg
		reverse_proxy 127.0.0.1:12450
	}
	handle / {
		rewrite * /status/
		reverse_proxy 127.0.0.1:12450
	}

	# 4) Everything else is closed. handle blocks are mutually exclusive and match
	#    in the order written, so this catch-all has to come last.
	handle {
		respond 404
	}
}

Topology B: host the files statically (proxy only the API)

Keep a built copy of the status app on the proxy machine and forward only those four endpoints to the server. This suits a narrow channel between proxy and server, or putting the page on a CDN or object store.

The cost is that the copy is yours to maintain: re-copy it after a server upgrade. NetTact is pre-release and carries no backward-compatibility code, so public payload fields can change outright between versions — a mismatch topology A cannot have.

Get the files first. status/ is a subdirectory of the dist/ build output; any of three sources will do:

bash
# a) Copy from a deployed server (web console install dir, default <db dir>/webui)
docker compose exec server ls /data/webui           # list installed version dirs
docker compose cp server:/data/webui/<version>/status ./nettact-status

# b) Download the release tarball. <web-console tag> is NOT the server's own
#    version: it is the web-console tag this server build pins, which is the
#    directory name listed by (a) above (the startup log prints it too).
curl -fsSLO https://d.nettact.org/web-console/<web-console tag>/web-console-dist-<web-console tag>.tar.gz
tar xzf web-console-dist-<web-console tag>.tar.gz   # yields index.html, assets/, status/

# c) Build from source
cd web-console && npm run build                     # produces web-console/dist/status/

Put the contents of status/ in /var/www/nettact-status/ on the proxy. Do not edit the config.js in it — in the configuration below /config.js is served by the proxy, exactly as in topology A:

js
window.NETTACT_STATUS_CONFIG = { apiBase: '', page: 'home-lab', console: false }

The copied files then stay pristine, and changing the published page is a one-line edit in one place. apiBase stays empty here too: the same vhost proxies the endpoints, so page and API are same-origin and no cross-origin request is involved.

You could skip the API proxy

Pointing apiBase straight at the server's public address also works — the public endpoints send Access-Control-Allow-Origin: *, so the browser allows it. But that requires the server to be reachable from the internet, which is the one thing this page is avoiding. Keep apiBase: '' and let the proxy forward.

nginx

nginx
server {
    listen 443 ssl;
    http2 on;
    server_name status.example.com;

    ssl_certificate     /etc/letsencrypt/live/status.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/status.example.com/privkey.pem;

    root /var/www/nettact-status;      # the copy of dist/status/

    # The page, favicon, runtime config and hashed assets; everything else 404s.
    location = / {
        try_files /index.html =404;
        add_header Cache-Control "no-store" always;
    }
    location = /favicon.svg {
        try_files /favicon.svg =404;
    }
    # Served by the proxy rather than read from disk: a fresh copy of
    # config.js has an empty page, and forgetting to edit it shows the
    # "no status page selected" state.
    location = /config.js {
        default_type application/javascript;
        add_header Cache-Control "no-store" always;
        return 200 'window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };';
    }
    location ^~ /assets/ {
        # content-hashed filenames, safe to cache forever
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }

    # The only endpoints allowed through.
    location ~ ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$ {
        limit_except GET HEAD { deny all; }
        proxy_pass http://10.0.0.5:12450;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / { return 404; }
}

Caddy

txt
status.example.com {
	encode zstd gzip
	root * /var/www/nettact-status

	@public {
		method GET HEAD
		path_regexp ^/api/v1/public/pages/home-lab(/(agent-statuses|target-statuses|incidents))?$
	}
	handle @public {
		reverse_proxy 10.0.0.5:12450
	}

	handle /config.js {
		header Content-Type "application/javascript"
		header Cache-Control "no-store"
		respond `window.NETTACT_STATUS_CONFIG = { apiBase: "", page: "home-lab", console: false };` 200
	}

	handle /assets/* {
		header Cache-Control "public, max-age=31536000, immutable"
		file_server
	}
	handle /favicon.svg {
		file_server
	}
	handle / {
		header Cache-Control "no-store"
		file_server
	}

	handle {
		respond 404
	}
}

Checking it

Reload, then walk the list. The first five should be 200, the rest must be 404:

bash
H=https://status.example.com

curl -so /dev/null -w '%{http_code}  /\n'                 $H/
curl -s  $H/config.js                                     # should contain page: "home-lab" and console: false
curl -so /dev/null -w '%{http_code}  favicon\n'           $H/favicon.svg
curl -so /dev/null -w '%{http_code}  page\n'              $H/api/v1/public/pages/home-lab
curl -so /dev/null -w '%{http_code}  targets\n'           $H/api/v1/public/pages/home-lab/target-statuses

curl -so /dev/null -w '%{http_code}  another page\n'      $H/api/v1/public/pages/other-page
curl -so /dev/null -w '%{http_code}  console API\n'       $H/api/v1/sites
curl -so /dev/null -w '%{http_code}  login\n'             $H/api/v1/auth/login
curl -so /dev/null -w '%{http_code}  agent channel\n'     $H/api/v1/agent/ws
curl -so /dev/null -w '%{http_code}  enrollment\n'        $H/api/v1/enroll
curl -so /dev/null -w '%{http_code}  console SSE\n'       $H/api/v1/events
curl -so /dev/null -w '%{http_code}  old path\n'          $H/status/

Then open https://status.example.com/ in a browser: the address bar should stay at the bare root and the page should render home-lab directly. If you get "no status page selected" instead, page in config.js did not take effect — start with curl $H/config.js to see which copy is being served (in topology A the usual cause is location = /config.js written as a prefix match, letting the request reach the upstream).

Details

Several pages, several domains. One copy of the above per domain, with three things changed: the page it serves, the regex in the endpoint allowlist, and the upstream block's nameupstream lives in nginx's global http scope, so two files declaring the same name make nginx refuse to start. Neither domain can see the other's page.

Caching. Filenames under assets/ are content-hashed and can be cached for a long time; index.html and config.js must be no-store, or after a server upgrade a browser will ask for asset names that no longer exist. Both topologies set these headers explicitly; in topology A the shell additionally inherits no-store from the upstream.

Search engines. The status page's HTML carries <meta name="robots" content="noindex"> — by default it is not meant to be indexed. If you want this domain indexed, topology B can edit that line in the static file; topology A needs sub_filter on the proxy to strip it, and also proxy_set_header Accept-Encoding "";, because otherwise the upstream body arrives compressed and sub_filter will not match it.

Rate limiting (optional). This is an anonymous endpoint facing an unknown number of readers. The page polls once every 30 seconds, so normal traffic is small and a limit costs legitimate visitors nothing:

nginx
limit_req_zone $binary_remote_addr zone=nettact_public:10m rate=2r/s;
# then, inside each of the four endpoint locations:
limit_req zone=nettact_public burst=10 nodelay;

Favicon. The build output includes favicon.svg, referenced by the status page as ./favicon.svg, so it also resolves when the app is mounted below a path prefix such as /status/. The configurations above expose only /favicon.svg and the files the status page needs. Add that exact rule when upgrading an older configuration, or the browser will still receive a 404.

Do not put the console on this domain too. The console uses an HttpOnly session cookie; sharing an origin with the public page would put your admin session in the same cookie scope as anonymous visitors. Give the console its own domain, or keep it on the internal network only.

The single source of truth for configuration is each binary’s --help output