Skip to content

The control plane

The control plane is one container (nebulactrl-server, with the web UI embedded) and Postgres 17, started by Docker Compose from deploy/install/docker-compose.yml. The installer sets this up; you do not write the files yourself. This page explains what it made and how to run the same thing by hand.

Layout#

text
/opt/nebulactrl/
  docker-compose.yml     written by the installer; edit the repository's copy, not this one
  .env                   every secret and setting, mode 0600
  install.conf           the installer's remembered options
  uninstall.sh
  state/                 markers (which steps ran)
/var/backups/nebulactrl/control-plane/   local control-plane backups
sh
cd /opt/nebulactrl
docker compose ps
docker compose logs -f nebulactrl         # JSON logs; migrations are applied on startup
curl -fsS http://127.0.0.1:8080/readyz    # domain mode; http://127.0.0.1:8000 without a domain

.env holds:

Variable Meaning
POSTGRES_PASSWORD Database password (hex, because it is embedded in the connection URL)
NEBULACTRL_SECRET_KEY Base64 of 32 random bytes. Encrypts every sealed value: variables, git tokens, registry credentials, SSH keys, integration tokens, TOTP seeds, backup passwords (AES-256-GCM). Exists nowhere else: back it up apart from the database backups. Rotate it with nebulactrl-server rekey, never by replacing it (secrets at rest). Alternatives: NEBULACTRL_SECRET_KEY_FILE (path to a file holding the key, keeps it out of docker inspect) and NEBULACTRL_SECRET_KEY_PREVIOUS[_FILE] (earlier keys, during a rotation)
NEBULACTRL_PUBLIC_URL The URL users and agents use. An https:// value turns on the Secure session-cookie attribute and is baked into agent install commands
NEBULACTRL_BIND, NEBULACTRL_PORT Host interface and port the container publishes (127.0.0.1:8080 with a domain, 0.0.0.0:8000 without)
NEBULACTRL_SETUP_TOKEN Required by the setup page until the admin exists
NEBULACTRL_DASHBOARD_UPSTREAM nebulactrl:8080 in domain mode: the dashboard's system route target
NEBULACTRL_DASHBOARD_ALLOW_CIDRS Comma separated CIDRs or addresses (IPv4, IPv6, at most 200) that may reach the dashboard's hostname. A malformed value stops the start. Seeds the setting; see Dashboard access
NEBULACTRL_DASHBOARD_DNS_RESOLVER, NEBULACTRL_DASHBOARD_DNS_TOKEN (or _FILE) DNS-01 provider (cloudflare) and its API token for the dashboard's certificate. Both or neither. The token is sealed into the DNS credential store, never logged
NEBULACTRL_LOCAL_ENROLL_TOKEN, NEBULACTRL_LOCAL_SERVER_NAME, NEBULACTRL_LOCAL_SERVER_IP, NEBULACTRL_ACME_EMAIL Enrollment of the host's own agent as the first server
NEBULACTRL_BACKUP_HOST_DIR Host directory behind the local backup destination
NEBULACTRL_MCP_ENABLED, NEBULACTRL_LOG_LEVEL Optional: /mcp (off by default), debug/info/warn/error
PG_* Postgres tuning computed from the RAM

The server also reads NEBULACTRL_LISTEN, NEBULACTRL_AGENT_BINARIES_DIR and NEBULACTRL_BACKUP_DIR; the compose file sets them. NEBULACTRL_MAX_DUMP_UPLOAD_BYTES (default 5 GiB) limits the dump files restored into a database. It reads NEBULACTRL_FORCE_UPGRADE=true (default off) to start after a version change although the backup it takes first failed (upgrades). A few advanced timers (NEBULACTRL_MESH_LINK_GRACE, NEBULACTRL_REPLICA_BROKEN_AFTER) are listed under advanced settings; leave them unset.

Warning

Back up NEBULACTRL_SECRET_KEY now. sudo grep NEBULACTRL_SECRET_KEY /opt/nebulactrl/.env, into a password manager or vault, apart from the database dumps. If you lose it every stored secret is unrecoverable and every variable must be re-entered; a key that is not in the key ring stops the values sealed with it from decrypting. See backup and restore and secrets at rest. Settings → Instance keeps a reminder card until an administrator confirms it.

Hardening of the containers#

  • Postgres: internal network only (no route out, no published port), SCRAM-SHA-256, data checksums, capabilities dropped, no-new-privileges.
  • Control plane: read-only root filesystem, all capabilities dropped, no-new-privileges, no Docker socket; its only writable path is the backup directory.
  • There is no reverse proxy in the compose file: the host's agent's Traefik serves the dashboard (single proxy on the control-plane host).

Dashboard access#

In domain mode the dashboard is a system route on the control-plane host's proxy. Who may reach it and how its certificate is issued is an instance setting: Settings → Instance → Dashboard access (instance admin only), GET/PUT /api/v1/instance/dashboard-access, or nebulactrl instance dashboard-access get|set.

  • Allow list. CIDRs or addresses, IPv4 and IPv6 (at most 200). It becomes an ipAllowList on the route and applies as soon as it is saved: no proxy restart. Anybody else gets 403 from the proxy. Agents on other servers and webhooks use the same hostname, so keep their addresses in the list. Add my current IP and the Tailnet only preset (100.64.0.0/10, fd7a:115c:a1e0::/48) fill it in.
  • Lock-out guard. A list that leaves out the address you are connecting from is refused with 409 dashboard_lockout unless you confirm (confirmLockout; --confirm-lockout in the CLI). The address is the one the proxy saw: the last X-Forwarded-For entry, trusted only from a private or loopback peer. A caller who reaches the control plane directly (an SSH tunnel to 127.0.0.1:8080) never meets the list and is never warned; that is also the way back in after a lock-out.
  • Certificate. Automatic HTTP-01, or DNS-01 with a provider that already has a resolver on the control-plane server (Servers → Proxy). 422 resolver_not_configured otherwise. A resolver that is staged but not deployed yet sets proxyDeployRequired: the dashboard keeps its HTTP-01 certificate until Deploy proxy applies the resolver.
  • Where the values come from. NEBULACTRL_DASHBOARD_ALLOW_CIDRS and NEBULACTRL_DASHBOARD_DNS_RESOLVER/_TOKEN only seed the setting. While nobody has edited it the installer's values are written on every start; after the first save the database row wins and the environment is ignored, so edits survive restarts and installer re-runs. When the environment names a DNS-01 provider and token, the control plane, once the host's agent is online, creates the DNS credential dashboard-<provider> (sealed, scoped to that server), sets the server's resolver, deploys the proxy and only then makes the route use it, retrying with backoff. It never changes an existing credential, so rotate the token in the DNS credential.
  • Audit. dashboard.access.update (an admin's change: networks, provider, whether the lock-out was confirmed) and dashboard.access.seed (the installer's values). Tokens are never recorded.

Running it by hand#

If you cannot use the installer (another distribution, a managed Docker host):

  1. Install Docker Engine 24+ and the Compose plugin 2.24+, and create the edge network once: docker network create --label nebulactrl.managed=true nebulactrl-edge.
  2. Build or load the image (docker build -t nebulactrl:local .).
  3. Copy deploy/install/docker-compose.yml next to a .env made from deploy/install/env.example (mode 0600) and run docker compose up -d.
  4. Put HTTPS in front of 127.0.0.1:8080 yourself (any reverse proxy; it must pass WebSocket upgrades on /agent/v1/connect and must not buffer text/event-stream responses on /api/v1/events and the log streams), or install an agent on the host as a server (below) and add the system route by setting NEBULACTRL_DASHBOARD_UPSTREAM=nebulactrl:8080 and the NEBULACTRL_LOCAL_* variables.
  5. Open NEBULACTRL_PUBLIC_URL, create the admin on the setup page, then add servers.

On start the server applies its database migrations (and the job queue's) and waits up to 30 seconds for Postgres.

First run#

While no user exists the setup page asks for an email, name, password, team name and (with the installer) the setup token, and creates the admin and the team. Afterwards setup is closed (POST /api/v1/setup returns 409). Then:

  1. The first server is already there when you used the installer: the control-plane host, online within a minute, with Accept workloads on.
  2. Create a project, open its canvas, add a service (placed on that server, or on one you add).
  3. Settings → Instance: add an off-site backup destination and confirm you stored the secret key. Settings → API tokens for the CLI, CI or MCP.

The login endpoint is rate limited to 10 attempts per minute per IP. Setup with the token is limited the same way.

Production notes#

  • Pin the image version you run; do not run latest of anything you cannot roll back (upgrade).
  • Keep the control plane's time in sync (NTP).
  • Agents keep your apps running through any control-plane restart or upgrade: the control plane never stops remote containers while it is down.