# NebulaCtrl documentation > NebulaCtrl is a self-hosted, Docker-native platform-as-a-service. You connect your own Linux servers, deploy from Git or a container image, and manage variables, domains, databases, backups, logs and rollbacks from a canvas, a CLI or a REST API. A Go control plane with a Postgres database talks to a small agent on each Docker host; Traefik on each host routes traffic. Every page of the NebulaCtrl docs in one file, in reading order. Each page starts with its title and its source URL. The API endpoint reference is not repeated here: it is generated from https://nebulactrl.com/api/v1/openapi.json. --- # What is NebulaCtrl? Source: https://nebulactrl.com/docs/getting-started/what-is-nebulactrl NebulaCtrl is a self-hosted platform for running your apps on servers you own. You connect a Linux machine with Docker, point NebulaCtrl at a Git repository or a container image, and it builds, starts, routes and watches the result. There is no Kubernetes, no Swarm and no hosted service in the middle: one control plane, one small agent per server, and plain Docker underneath. If you have used Heroku, Railway or Coolify, the shape is familiar. The difference is that everything runs on machines you control, and the interface is a canvas where you see your services and the lines between them. NebulaCtrl is free to use and runs on your own servers. The source is coming soon, under the AGPL-3.0; until it is published, the installer, the container images and the CLI are all downloadable without an account: the installer from `nebulactrl.com`, the images from the public registry `ghcr.io/nebulactrl/nebulactrl`, the CLI from your own control plane. ## What you get - **Deploys from Git or an image.** Push to a branch and deploy, or pull `ghcr.io/you/app:1.4`. A repository without a Dockerfile is built with Railpack. See [builds](https://nebulactrl.com/docs/operations/builds). - **A deploy you can trust.** A new version is started and health-checked *before* traffic moves to it, and a failed deploy leaves the old one serving. See [deployments](https://nebulactrl.com/docs/operations/deployments). - **Domains and HTTPS.** Every server runs a proxy that gets Let's Encrypt certificates for you. See [domains and proxy routes](https://nebulactrl.com/docs/operations/proxy-routes). - **Variables and secrets** that are encrypted at rest, can refer to each other (`${{db.DATABASE_URL}}`), and are never shown again once saved. See [variables and secrets](https://nebulactrl.com/docs/operations/variables). - **Databases and backups**: PostgreSQL, MySQL, MariaDB, Valkey and more, with scheduled backups. See [managed databases](https://nebulactrl.com/docs/operations/databases). - **Previews, scaling, scheduled tasks, a web terminal**, and a catalogue of one-click [templates](https://nebulactrl.com/docs/operations/templates). - **Several servers** in one place, including machines behind a home router: agents dial out, so no inbound port is needed for the control plane. - **A CLI and an API** that do everything the dashboard does. See the [CLI reference](https://nebulactrl.com/docs/reference/cli) and the [API reference](https://nebulactrl.com/docs/reference/api). ## How the pieces fit ``` you ──► dashboard / CLI / API ──► control plane ──► Postgres ▲ outbound WebSocket from each agent │ server A: agent ─ proxy ─ your containers server B: agent ─ proxy ─ your containers ``` - The **control plane** is one Go program with a Postgres database. It holds your projects, settings and history, and tells agents what to run. - An **agent** runs on each server. It builds images, starts containers, runs health checks and reports back. It connects *out* to the control plane, so servers behind NAT work. - A **proxy** (Traefik) on each server terminates HTTPS and routes each domain to the right container. The agent manages it; you never edit its config. The control plane can go down without stopping your apps. Containers keep running; you only lose the ability to change things until it is back. ## What it is not NebulaCtrl is for a trusted team running its own infrastructure. A NebulaCtrl administrator is a root administrator of every connected server, because Docker access is root. It is not a way to host strangers' code safely. Read the [threat model](https://nebulactrl.com/docs/security/threat-model) and the [known limitations](https://nebulactrl.com/docs/operations/limitations) before you rely on it. ## Where next 1. [Learn the vocabulary](https://nebulactrl.com/docs/getting-started/concepts): projects, environments, services, servers and staged changes. 2. [Follow the quickstart](https://nebulactrl.com/docs/getting-started/quickstart): from a bare machine to a running app with a domain and a database. --- # Concepts Source: https://nebulactrl.com/docs/getting-started/concepts Six ideas explain almost everything you see in NebulaCtrl. ## Team A **team** is the top level: it owns the projects, servers, API tokens, integrations and audit log. When you set up NebulaCtrl you create the first team and its owner. People join a team by invitation and get a role, which decides what they can change. See [teams, roles and invites](https://nebulactrl.com/docs/operations/teams). ## Project A **project** groups the services of one product, for example "Shop" with its web app, worker and database. Projects are what you switch between in the top bar. ## Environment Every project has **environments** such as `production` and `staging`. An environment is a separate copy of the project's services, with its own variables, domains and data. Each one has its own private network, so `staging` cannot reach `production` by accident. The environment's name is in the top bar, and production is marked so you notice where you are. See [networking](https://nebulactrl.com/docs/operations/networking). ## Service A **service** is one thing that runs: a web app, a worker, a database or a scheduled job. Its **source** is a Git repository (built into an image) or a ready-made Docker image. Services appear as cards on the **canvas**, and clicking one opens the **inspector** with tabs for Overview, Deployments, Variables, Backups, Metrics, Analytics, Logs and Settings. The lines between cards are connections: they show which service talks to which, and connecting two cards proposes the right variable (for example `DATABASE_URL=${{pg.DATABASE_URL}}`) for you to confirm. A database is a service too, created from **Create → Database**, with its credentials generated and its data kept on a volume. See [managed databases](https://nebulactrl.com/docs/operations/databases). ## Server A **server** is a Linux machine with Docker and the NebulaCtrl agent. Each service is placed on one server. A server's card shows whether it is online, its CPU, memory and disk, and the state of its proxy. The machine you install NebulaCtrl on is your first server. See [servers and agents](https://nebulactrl.com/docs/operations/servers). ## Staged changes Changing a service's settings, variables, domains or volumes does **not** restart it. The change is *staged*: the card shows a **Staged** chip, and a bar says "N changes to apply". Nothing about the running app changes until you deploy. From that bar you can **Review** the exact differences (secret values stay hidden), **Discard** them, or **Deploy** to apply them together. Staging is what lets you change five things and restart once. ## Deployments and releases A **deployment** is one attempt to get a service to its configured state. It moves through *preparing, building, releasing and verifying* and ends as `succeeded`, `failed`, `canceled`, `rolled_back` or `rollback_failed`. The words say what happened, and a failed one says at which step. Each deployment is made from a **release**: a frozen record of the source (image digest or Git commit), the settings and the variables. You can **roll back** to any earlier release. > [!NOTE] > A rollback restores the application image and its configuration only. Databases, volumes and external services are not rolled back. --- # Quickstart Source: https://nebulactrl.com/docs/getting-started/quickstart This walks you from nothing to an app on the internet with its own domain and a PostgreSQL database. Allow about twenty minutes, most of it waiting for downloads. You need: - A Linux machine you can SSH into as root or with `sudo`: Debian 12/13, Ubuntu 22.04, 24.04 or 26.04, or RHEL, Rocky or AlmaLinux 9, on amd64 or arm64. A small VPS with 2 GB of RAM is plenty. See [requirements](https://nebulactrl.com/docs/installing/requirements). - Ports 80 and 443 free on it. - Optional but recommended: a domain you control, with an A record pointing at the machine. ## 1. Install On the machine, run the installer. With a domain for the dashboard: ```sh curl -fsSL https://nebulactrl.com/install.sh | sudo bash -s -- --domain nebula.example.com --email you@example.com ``` Without a domain, run it plain and the dashboard is served over HTTP on port 8000, which is fine for trying things out: ```sh curl -fsSL https://nebulactrl.com/install.sh | sudo bash ``` The installer is published at `https://nebulactrl.com/install.sh`, and every control plane serves the same script at its own `/install.sh`; see [install NebulaCtrl](https://nebulactrl.com/docs/installing). The installer sets up Docker if it is missing, generates every secret, starts the database and the control plane, and prints two things at the end: the **dashboard URL** and a **setup URL** with a one-time token. The control-plane image is public (`ghcr.io/nebulactrl/nebulactrl`), so nothing on your machine needs a GitHub or registry login. > [!WARNING] > Store `NEBULACTRL_SECRET_KEY` somewhere other than the machine. Without it, a restored backup cannot decrypt your variables. The installer prints the command to read it: `sudo grep NEBULACTRL_SECRET_KEY /opt/nebulactrl/.env`. Open the setup URL, choose a team name, and create your account. You land on the projects page, and the machine you installed on is already listed under **Servers** as your first server. ## 2. Add another server (optional) Skip this if one machine is enough: the first server can run your apps. To add a second host: 1. Open **Servers** and choose **Add server**. 2. Give it a name. If you know them, add its public IP, a base domain and an email for Let's Encrypt. The base domain is what generated domains hang off. 3. Choose **Create install command**, copy it, and run it on the new host. The host needs Docker 24 or newer already installed. The dialog waits for the agent. When it connects, the server shows as online with its CPU, memory and disk. Details and troubleshooting are in [add a server](https://nebulactrl.com/docs/installing/servers). ## 3. Create a project On the projects page choose **New project**, name it, and open it. A project starts with a `production` environment and an empty canvas that says "Create your first service". ## 4. Deploy your first app We will use `traefik/whoami`, a tiny web server that prints information about the request it received. It is a good first app because it has nothing to configure. 1. On the canvas choose **Create**, then **Docker image**. 2. Enter `traefik/whoami:latest` as the image. The name fills in as `whoami`. 3. Pick the server to run it on, and set the port to `80`. 4. Choose **Create service**. The card appears with a **Staged** chip, and the toast says nothing runs until you deploy. In production the toast offers **Review & deploy**; in other environments it offers **Deploy now**. Either way, the **Deploy · 1** button in the top bar applies your staged changes. Watch the card: it goes through *Preparing*, *Releasing* and *Verifying*, and settles on a deployed state when the health check passes. Open the service and its **Deployments** tab to see each step and the log. To deploy from Git instead, choose **Create → Git repository**. A repository with a Dockerfile uses it; one without gets a build plan from Railpack. See [builds](https://nebulactrl.com/docs/operations/builds) and [Git providers](https://nebulactrl.com/docs/operations/git-providers). ## 5. Give it a domain 1. Open the service, then **Settings → Networking**. 2. If you set a base domain or public IP on the server, choose **Generate domain**. It creates a hostname like `whoami-production-myproject.`, or one ending in `.sslip.io` when you have only an IP. 3. Or type your own hostname, for example `whoami.example.com`, and choose **Add domain**. Point an A record for it at the server's public IP. A domain is a staged change too. Choose **Deploy** to apply it. The proxy requests a certificate and routes the hostname to your container. Open the domain in a browser and you should see the whoami output over HTTPS. If the page does not load, the domain list shows the DNS status for each hostname, and [troubleshooting](https://nebulactrl.com/docs/operations/troubleshooting) covers the usual causes. ## 6. Add a database 1. On the canvas choose **Create → Database** and pick PostgreSQL and a version. 2. Choose **Create service**, then deploy it. A database is created with generated credentials and a data volume. 3. In your app's **Variables** tab, add `DATABASE_URL` with the value `${{pg.DATABASE_URL}}`, where `pg` is the database's name. Or drag a line from your app's card to the database and accept the variable NebulaCtrl proposes. 4. The **N changes to apply** bar appears. Choose **Deploy** to restart the app with the new variable. The password in `DATABASE_URL` is a secret: it is masked in the dashboard and redacted from logs. The app and the database reach each other by private hostname because they are on the same server and environment. See [variables and secrets](https://nebulactrl.com/docs/operations/variables) and [managed databases](https://nebulactrl.com/docs/operations/databases). ## Where next - Turn on a [daily backup of your databases](https://nebulactrl.com/docs/operations/database-backups), and an off-site destination for the [control-plane backup](https://nebulactrl.com/docs/operations/backup-restore). - Deploy on every push with [Git providers](https://nebulactrl.com/docs/operations/git-providers) or a deploy hook. - Invite your team: [teams, roles and invites](https://nebulactrl.com/docs/operations/teams). - Install something ready-made from [templates](https://nebulactrl.com/docs/operations/templates). - Use the [CLI](https://nebulactrl.com/docs/reference/cli): `nebulactrl services deploy --wait` fits in a CI job. Your control plane serves it: `curl -fsSL https:///cli.sh | sh` installs the build that matches it (also under **Settings → API tokens**), then `nebulactrl login --url https:// --token `. --- # Install NebulaCtrl Source: https://nebulactrl.com/docs/installing One command installs the control plane on a fresh machine, and the machine is the first server your apps can run on: ```sh curl -fsSL https:///install.sh | sudo bash -s -- --domain nebula.example.com --email you@example.com ``` `` is where the installer is published: `https://nebulactrl.com/install.sh`, or `/install.sh` of any running control plane (the repository is private, so the script is not fetched from it). Without `--domain` the dashboard is served on `http://:8000` (plain HTTP, with a warning) so you can try NebulaCtrl on a machine that has no domain yet: ```sh curl -fsSL https:///install.sh | sudo bash ``` When it finishes it prints the dashboard URL and a **setup URL** with a one-time token. Open that, create the admin account, and deploy something. Supported hosts: Debian 12/13, Ubuntu 22.04, 24.04 and 26.04, RHEL/Rocky/AlmaLinux 9; amd64 and arm64. ## What the installer does It always does the following, in this order: 1. Checks the system (distribution, systemd, CPU, RAM, free ports) and installs the base packages it needs. 2. Installs **Docker Engine and the Compose plugin from Docker's official repository** if Docker is missing. 3. Applies **safe Docker daemon defaults**: log rotation, `live-restore`, `userland-proxy: false` and a default address pool that does not overlap the agent's. 4. **Generates every secret** (database password, the instance secret key, the setup token, the local agent's enrollment token) and keeps them in `/opt/nebulactrl/.env`, mode 0600. 5. Starts **Postgres 17 on a private, internal network** (SCRAM authentication, tuned to the machine's RAM, no published port) and the control plane in a hardened container. 6. **Serves the dashboard**: over HTTPS with a Let's Encrypt certificate when you give a domain, otherwise over plain HTTP on port 8000. 7. Schedules a **daily control-plane backup** to a local directory and reminds you to add an off-site destination. 8. Prints the setup URL. These steps are also on by default; each has a flag to turn it off: | Step | Default | Off with | |---|---|---| | Enroll this machine as the **first server** (an agent on the host, "deploy apps here") | on | `--no-local-server` | | Firewall: SSH (detected port), 80, 443/tcp and 443/udp only (ufw, firewalld on RHEL) | on | `--no-firewall` | | Automatic security updates (unattended-upgrades, dnf-automatic) | on | `--no-auto-updates` | | fail2ban for SSH | on | `--no-fail2ban` | | [Lynis](https://nebulactrl.com/docs/security/host-hardening) audit and a curated set of safe fixes | on | `--no-hardening` | | Daily control-plane backup | on | `--no-backup` | Every step is **idempotent**: re-run the installer to upgrade or repair. It never regenerates a secret that exists, and it **never changes your SSH configuration** in a way that could lock you out. ## Pages 1. [The installer](https://nebulactrl.com/docs/installing/installer): every option, every step, the files and ports it touches, HTTPS and the dashboard route, re-running and recovering 2. [Requirements and supported hosts](https://nebulactrl.com/docs/installing/requirements) 3. [The control plane](https://nebulactrl.com/docs/installing/control-plane): how it runs, the layout under `/opt/nebulactrl`, manual Compose install, first run 4. [Add more servers](https://nebulactrl.com/docs/installing/servers): the first server, enrollment tokens, the agent installer, "Accept workloads" 5. [Upgrade and roll back](https://nebulactrl.com/docs/installing/upgrade) 6. [Uninstall](https://nebulactrl.com/docs/installing/uninstall) 7. [Migrate from Coolify](https://nebulactrl.com/docs/installing/migrate-from-coolify): install beside Coolify on the same host, copy the data, cut over, roll back Next, read [`docs/operations`](https://nebulactrl.com/docs/operations) (backups first) and [`docs/security`](https://nebulactrl.com/docs/security). For a development setup (hot reload, dev agent, test database) see [`CONTRIBUTING.md`]() instead. --- # Requirements Source: https://nebulactrl.com/docs/installing/requirements ## Every host (control plane and servers) | | Supported | |---|---| | OS | **Debian 12 and 13, Ubuntu 22.04, 24.04 and 26.04, RHEL, Rocky Linux and AlmaLinux 9** (systemd required) | | CPU | **amd64** and **arm64** (`uname -m` = `x86_64` or `aarch64`) | | Docker | **Docker Engine 24 or newer** from Docker's official repository; the control-plane installer installs it | | Docker Compose plugin | **`docker compose` 2.24 or newer** (`docker-compose-plugin` from Docker's repository). The installer installs it with Docker, and installs or upgrades it when Docker is already there without it. Compose stacks need 2.24; servers without it refuse stack deploys | | Access | root (the agent runs as root because the Docker socket is root-equivalent) | | Network | Outbound HTTPS (Docker's repository, registries, git hosts, Let's Encrypt, and for servers the control plane) | | Ports | **80/tcp, 443/tcp and 443/udp free** for the host's Traefik proxy, plus **9000 on 127.0.0.1** for its admin entrypoint (never exposed). If 80/443 are taken, the server shows a proxy error and adding domains fails with `proxy_unavailable`. `--proxy-http-port`, `--proxy-https-port` and `--proxy-admin-port` move them (agent installer) | | Disk | Images, build cache and volumes live in Docker's data root. A build needs space for the clone, cache and the image. The server card warns when the root filesystem crosses its threshold (default 80 %) and the scheduled Docker cleanup keeps images and cache in check | The installer refuses an unsupported distribution (`--force-unsupported` tries a Debian or RHEL family system anyway). macOS and Windows hosts are not supported; Docker Desktop is for development only. Nothing needs to listen on a *server* for the control plane: the agent connects outbound, so hosts behind NAT or a home router work. Only the proxy's 80/443 must be reachable by your visitors (and by Let's Encrypt). ## The control-plane host It is also a server (its agent runs the proxy that serves the dashboard), so everything above applies, plus: | | Needed | |---|---| | CPU / RAM | 1 vCPU and **1 GB** for the control plane alone; plan for the apps you will deploy there too (turn **Accept workloads** off to keep the host for NebulaCtrl). Postgres is tuned to the RAM it finds | | Disk | Postgres data and the local backups (`/var/backups/nebulactrl`); deployment logs are capped at 10,000 lines each and metrics are pruned ([monitoring](https://nebulactrl.com/docs/operations/monitoring)) | | Network | Reachable by every agent and by the browsers that use the UI | | DNS | For HTTPS: a hostname whose **A record points at this machine** before you run the installer with `--domain`. Without a domain the dashboard is plain HTTP on port 8000 | | Time | NTP: enrollment tokens, sessions and the 30 second handshake window are time based | The control plane is a **single instance**: no clustering, no high availability ([limitations](https://nebulactrl.com/docs/operations/limitations)). ## Domains for your services - A wildcard or per-service DNS A record pointing at the *server's* public IP (the UI shows exactly what to create). - Or no DNS at all: set the server's public IP and use **Generate domain**, which creates `--..sslip.io`. - `*.localhost`, `*.local`, bare names and IP addresses get Traefik's default self-signed certificate (browsers warn; use `curl -k`). - Certificates for other names come from Let's Encrypt via Traefik (HTTP-01 on port 80), or DNS-01 with a provider; set an ACME email on the server (the installer does for the first one). --- # The installer Source: https://nebulactrl.com/docs/installing/installer `deploy/install/install.sh` installs the control plane on one machine and makes that machine the first server. It is a single bash script. The control plane serves a self-contained copy (libraries and files inlined) at `/install.sh`, so this works from any running instance too: ```sh curl -fsSL https:///install.sh | sudo bash -s -- [options] sudo bash deploy/install/install.sh [options] # from a checkout ``` Supported: **Debian 12/13, Ubuntu 22.04, 24.04 and 26.04, RHEL/Rocky/AlmaLinux 9**; amd64 and arm64; systemd; root. Anything else stops with a message (`--force-unsupported` tries a Debian or RHEL family system anyway). `--check` runs the system and port checks, prints the plan and changes nothing. ## Two ways to serve the dashboard | | With `--domain nebula.example.com --email you@example.com` | Without a domain | |---|---|---| | URL | `https://nebula.example.com`, Let's Encrypt certificate | `http://:8000` (plain HTTP) | | Served by | the host's own proxy (see below) | the control-plane container, published on `0.0.0.0:8000` | | Control-plane container binds | `127.0.0.1:8080` (only the host's agent uses it) | `0.0.0.0:8000` | | Before you start | the domain's **A record must point at this machine** and 80/443 must be reachable from the internet (Let's Encrypt HTTP-01) | nothing | Without a domain the installer ends with a notice: passwords and sessions travel unencrypted, and servers you add from other machines need `--insecure-dev`. Use it on a trusted network and switch soon. Switching is a re-run with `--domain`: secrets, data and the agent identity are kept, the dashboard moves behind the proxy, and port 8000 stops being published. The installer warns when the domain does not resolve yet, or resolves to another address than this machine's. The certificate is issued as soon as DNS is right. `--public-ip` overrides the detected address (it is also what **Generate domain** uses, `--..sslip.io`). ### Single proxy on the control-plane host The control-plane host always runs an agent, and **that agent's Traefik is the only proxy on the machine**. The compose file has no Traefik of its own. In domain mode the control plane adds one *system route* to the route table of that server: the dashboard's hostname, automatic Let's Encrypt (the server's ACME email is the one you gave), to `nebulactrl:8080` on the `nebulactrl-edge` network. It is part of every route push, deploy and reconcile of that server, so no deployment can displace it, and no service can take the dashboard's hostname (`409 domain_conflict`). It appears in the server's proxy route table as "NebulaCtrl dashboard". Because the host's agent enrolls **before** the admin account exists (the installer creates the first team and hands the agent its token; **Setup** then adopts that team, and you become its owner), the route and the certificate are in place before you open the setup link, so the setup page already loads over HTTPS. If the dashboard route breaks, the dashboard is still reachable on `http://127.0.0.1:8080` from the machine itself (for example through `ssh -L 8080:127.0.0.1:8080`). With `--no-local-server` there is no agent and no proxy: in domain mode the container stays on `127.0.0.1:8080` and you provide the HTTPS proxy (it must pass WebSocket upgrades on `/agent/v1/connect` and must not buffer `text/event-stream`). ### Accept workloads The first server has a setting **Accept workloads** (Servers → the server → Settings → Scheduling; `acceptWorkloads` in the API), **on by default**: you can deploy apps next to NebulaCtrl. Turn it off to keep the machine for NebulaCtrl alone. Off refuses new services and moves onto it (`422 server_not_accepting_workloads`, also in the drain planner, template and Compose-import checks and the placement pickers) and leaves what already runs untouched. Every server has the setting; the installer's host is the one marked "control plane". ### Dashboard access: allow list and DNS-01 In domain mode the dashboard is one route on the host's proxy, and two options shape it: ```sh ... --domain nebula.example.com --email you@example.com \ --dashboard-allow 100.64.0.0/10,fd7a:115c:a1e0::/48 \ --dashboard-dns01 cloudflare --dns-token-file /root/cf-token ``` - `--dashboard-allow ` puts an **IP allow list** on the dashboard route (Traefik `ipAllowList`, matched against the address of the TCP peer, which is the visitor when nothing sits in front of the proxy). The example above is "tailnet only". Addresses and CIDRs, IPv4 and IPv6, at most 200 entries; `off` removes the list. Everything else gets `403` from the proxy, including the setup link, so open the setup URL from an allowed address. - `--dashboard-dns01 cloudflare` issues the dashboard's certificate with **DNS-01**, which needs no inbound connection: it works when the name resolves to a tailnet or LAN address, or when another proxy holds port 80 (see [side by side](#side-by-side-with-another-proxy)). `--dns-token-file ` names a file that holds the Cloudflare API token (Zone:Read and DNS:Edit on the zone). The token is **read from the file, never from the command line, and never printed**; the installer keeps it in `.env` (0600) and the control plane seals it into the DNS-01 credential store (the same one as Servers → Proxy → DNS-01) the first time it starts. Delete the file afterwards; rotate the token later in the DNS credential, not by re-running the installer. - Both options need `--domain` (without a domain there is no HTTPS route; use `--bind` below) and the local server. - They **seed** the settings once. Afterwards an instance admin edits them in **Settings → Instance → Dashboard access**: the allow list (the dashboard refuses to save a list that excludes your current address unless you confirm) and the certificate method. Changes are audited. Re-running the installer with different values does not overwrite what you changed there. `--dashboard-dns01` supports Cloudflare in the installer. For another provider, add its credential and resolver under Servers → Proxy → DNS-01 after the install, then choose it in Dashboard access. ### Binding the dashboard to one address Without a domain, `--bind ` publishes the dashboard port on that local address only, for example the Tailscale address: `--bind 100.122.182.103 --port 8100`. The dashboard URL becomes `http://:`. The host's own agent still connects over `127.0.0.1`: the installer adds `docker-compose.loopback.yml` (merged by `COMPOSE_FILE` in `.env`) that publishes the same port on loopback. The address is remembered in `install.conf`; an address you put into `.env` by hand is adopted on the next run instead of being reset to `0.0.0.0`. `--bind 0.0.0.0` goes back to every address. `--bind` must be an address of this machine; with `--domain` it is ignored (the container listens on `127.0.0.1` behind the proxy). Docker-published ports bypass ufw/firewalld, so the bind address is the protection, not the firewall. ### Side by side with another proxy NebulaCtrl's Traefik normally owns ports 80 and 443. To run it next to another proxy (the typical case: Coolify's Traefik while you migrate, see [migrate from Coolify](https://nebulactrl.com/docs/installing/migrate-from-coolify)), give it other ports: ```sh ... --port 8100 --proxy-http-port 8080 --proxy-https-port 8443 [--proxy-admin-port 9100] ``` - `--proxy-http-port`, `--proxy-https-port` and `--proxy-admin-port` (loopback only, default 9000) go to the local agent's installer, which writes them into the agent's systemd unit. Apps answer on `https://:8443` until the proxy moves. - With a domain the dashboard URL carries the HTTPS port (`https://nebula.example.com:8443`). `--port` is then the control plane's loopback port (default 8080; the installer picks 8081, and so on, when the proxy sits on 8080), and `--port` is the dashboard port in IP mode. Coolify's dashboard uses 8000, so pass `--port 8100` there. - **Certificates: use DNS-01.** Let's Encrypt HTTP-01 needs port 80 on the internet, which the other proxy holds. The installer warns when the dashboard would use HTTP-01 with the proxy off port 80; use `--dashboard-dns01`. The same is checked for app domains (the plan and the domain list warn "HTTP-01 cannot validate"): give them a DNS-01 resolver or a wildcard certificate under Servers → Proxy. - The firewall step opens the configured ports. On a host that runs another proxy, pass `--no-firewall` (a first ufw activation would close the ports the other proxy needs). **Moving the proxy back to 80/443.** Stop the other proxy first (`docker stop coolify-proxy`), then either 1. re-run the installer with the default ports: `curl -fsSL http://127.0.0.1:8100/install.sh | sudo bash -s -- --proxy-http-port 80 --proxy-https-port 443`. The agent's unit is rewritten, the agent restarts, and Traefik is recreated on the new ports (about a second of dropped connections). The installer checks that 80/443 are free first and refuses otherwise, then waits for the proxy to publish them; or 2. as an instance admin, in **Servers → the server → Proxy → Proxy ports**: review the plan, confirm to stage it, then **Deploy proxy** applies it by recreating Traefik ([proxy ports](https://nebulactrl.com/docs/operations/proxy-ports)). Use Reset to hand control back to the agent's own flags. A port set in Proxy ports **overrides** the installer's flags, so after using it, change ports there (or press Reset) rather than by re-running the installer. In domain mode the dashboard URL (`NEBULACTRL_PUBLIC_URL`) includes the HTTPS port, so after moving back re-run the installer (step 1) to rewrite it, or use the old URL until you do: the route is the same. ## Options | Option | Meaning | |---|---| | `--domain ` | Serve over HTTPS with Let's Encrypt | | `--email ` | Let's Encrypt contact; required with `--domain` | | `--public-ip ` | This machine's public IPv4 address (detected when omitted) | | `--port ` | Dashboard port without a domain (default 8000). With a domain: the control plane's loopback port for the host's agent (default 8080) | | `--bind ` | Without a domain: publish the dashboard on this local address only (for example the Tailscale IP). Remembered | | `--proxy-http-port `, `--proxy-https-port `, `--proxy-admin-port ` | Host ports of the local server's proxy (default 80, 443, 9000), to run beside another proxy. Remembered | | `--dashboard-allow ` | Allow list on the dashboard route (needs `--domain`); `off` removes it | | `--dashboard-dns01 cloudflare`, `--dns-token-file ` | DNS-01 certificate for the dashboard; the token is read from the file and never printed | | `--version `, `--image ` | Image to run. The default is the version of the control plane that served the script (`curl /install.sh`), or `latest` when the script was fetched from nebulactrl.com: `ghcr.io/nebulactrl/nebulactrl:` (a public image; nothing needs `docker login`) | | `--image-archive ` | `docker load` the image from a `docker save` archive instead of pulling it | | `--no-docker-install` | Fail instead of installing Docker | | `--no-daemon-defaults` | Leave `/etc/docker/daemon.json` alone | | `--no-local-server` | No agent on this machine | | `--no-backup` | No daily control-plane backup | | `--no-firewall` / `--no-fail2ban` / `--no-auto-updates` / `--no-hardening` | Skip that step | | `--restart-docker` | Restart Docker to apply `daemon.json` even when containers would stop | | `--dir`, `--backup-dir` | `/opt/nebulactrl`, `/var/backups/nebulactrl` | | `--force-unsupported`, `--check` | see above | | `--force-upgrade` | go on with an upgrade although the control-plane backup taken first failed (without it a failed backup stops the installer before anything is changed) | | `--uninstall …` | run the uninstaller ([uninstall](https://nebulactrl.com/docs/installing/uninstall)) | Options are remembered in `/opt/nebulactrl/install.conf`, so a plain re-run keeps your choices; flags override them. > [!NOTE] > Every release publishes the control-plane image to `ghcr.io/nebulactrl/nebulactrl` (public) for linux/amd64 and linux/arm64, tagged `X.Y.Z`, `X.Y` and `latest`. Before the first release exists, build one from a checkout (`docker build -t nebulactrl:local .`) and use `--image nebulactrl:local` (same machine) or `docker save nebulactrl:local -o nebulactrl.tar` and `--image nebulactrl:local --image-archive nebulactrl.tar` (another machine). The installer's own tests do exactly this. ## What each step does Every step is idempotent and safe to run again. The installer prints `ok`, `warn` and what it did; warnings are repeated at the end. A failure stops the run and says in which step; fix the cause and run it again. 1. **System checks.** Root, Linux, supported distribution, systemd, architecture, RAM (warns below 900 MB), free disk (warns below 3 GB), and that the proxy's ports (80/443 unless `--proxy-http-port`/`--proxy-https-port` say otherwise) and the dashboard port are free (unless NebulaCtrl itself holds them; on a re-run only ports the proxy does not publish yet are checked). Every option is validated here: CIDR lists, ports, `--bind` (an address of this machine), the token file. Missing `curl`, `ss`, `base32`, CA certificates and `gpg` are installed. 2. **Docker.** If `docker` is missing, Docker Engine, the Compose plugin and buildx are installed from Docker's official repository (`download.docker.com`, signed key in `/etc/apt/keyrings` or the `.repo` file for dnf; Rocky and Alma use Docker's CentOS repository). Distribution packages that conflict (`docker.io`, `podman-docker`, …) stop the run with an instruction rather than being removed. An existing Docker is kept: it must be **24 or newer** and have the **Compose plugin** (installed from Docker's repository when only that is missing). 3. **Daemon defaults** (`/etc/docker/daemon.json`): `log-driver json-file` with `max-size 10m` and `max-file 3`; `live-restore: true`; `userland-proxy: false`; `default-address-pools` `10.212.0.0/16` in /24s (the agent allocates its environment networks from `10.213.0.0/16`, so the two never collide). **Existing keys always win**: a key you already set is kept and reported, only missing keys are added, and the original is saved as `daemon.json.nebulactrl-bak`. `userland-proxy` and the address pool need a Docker restart: on a fresh Docker it just happens; when containers already run **without** live-restore it is **not** done (they would stop) and the installer tells you to restart Docker in a maintenance window, or pass `--restart-docker`. 4. **Edge network.** `nebulactrl-edge` is created with the label the agent uses (the agent would create it, but the control plane joins it). 5. **Files and secrets** in `/opt/nebulactrl`: `docker-compose.yml`, `.env` (0600), `install.conf`, `uninstall.sh`, `state/`. Generated once, never regenerated: the Postgres password (48 hex), `NEBULACTRL_SECRET_KEY` (32 random bytes, base64), the setup token, and the local agent's enrollment token. If a database volume from an earlier install exists but `.env` is gone, the installer **refuses** to invent a new key and says so. Postgres settings (`PG_*`) are recomputed from the RAM on every run; unchanged RAM means unchanged file means no restart. 6. **Control plane and Postgres.** Postgres 17 runs on an **internal Docker network** (no route out, no published port, reachable only by the control plane), initialised with SCRAM-SHA-256 for local and host connections and data checksums, capabilities dropped. Tuning from RAM: `shared_buffers` 15 % (128 MB–2 GB, the host also runs apps), `effective_cache_size` 40 %, `work_mem` and `maintenance_work_mem` scaled, `random_page_cost` 1.1 on SSD/NVMe, WAL compression, an idle-in-transaction timeout. The control plane runs read-only, with all capabilities dropped, `no-new-privileges` and without the Docker socket. The installer waits for both to be healthy and for `/readyz`. 7. **Daily control-plane backup.** The instance backup under Settings → Instance is switched on: daily 03:30 UTC, 14 kept, to the **local directory** `/var/backups/nebulactrl/control-plane` (owner uid 65532, mode 0700), with a first run right away. It is only seeded while nobody configured it. The secret key is never in a backup. A local copy is not off-site: the installer and the UI both say so, add an S3-compatible bucket. 8. **Local agent.** The agent binary comes from the control plane (`/install-agent.sh`, the same installer other servers use) and runs as the systemd unit `nebulactrl-agent`, dialing `http://127.0.0.1:`. (The agent accepts plain HTTP only for loopback addresses.) A drop-in sets `RestartSec=12` so that early restarts, before the control plane accepts the token, stay under the enrollment rate limit. Re-runs upgrade the binary and keep the identity. 9. **Firewall.** SSH first, then 80/tcp, 443/tcp, 443/udp, and the dashboard port in IP mode. ufw on Debian/Ubuntu (default deny incoming, allow outgoing, only set when ufw was inactive), firewalld on RHEL family. The SSH ports are the ones `sshd -T` reports, the one your session came in on (`$SSH_CONNECTION`) and anything sshd listens on; rules are added **before** the firewall is switched on. Where the kernel has no firewall support (some containers) the step warns and continues. **Docker-published ports bypass ufw/firewalld rules**; NebulaCtrl never publishes service ports on its own, and TCP/UDP routes you create carry mandatory allowlists. 10. **fail2ban** for sshd (`/etc/fail2ban/jail.d/nebulactrl-sshd.local`: 5 failures in 10 minutes, 1 hour ban, systemd journal backend; EPEL on RHEL family). 11. **Automatic security updates**: `unattended-upgrades` (`20auto-upgrades`) or `dnf-automatic` set to `upgrade_type = security`, `apply_updates = yes`. No automatic reboot. Docker's packages are not security-origin, so Docker is not upgraded behind your back. 12. **Lynis hardening.** `lib/hardening.sh` runs a Lynis audit, applies a curated set of **safe** remediations, re-audits, and prints the hardening index with what remains; the agent repeats it weekly and the server's **Security** tab shows the result. It never touches SSH authentication, root login or firewall rules. It only warns when it fails. See [host hardening](https://nebulactrl.com/docs/security/host-hardening). 13. **Summary:** dashboard URL, the setup URL with the one-time token, where the files and backups are, the secret-key reminder (`sudo grep NEBULACTRL_SECRET_KEY /opt/nebulactrl/.env`: store it apart from the backups), the off-site backup reminder and every warning. **SSH is never modified.** The installer reads sshd's configuration to find the ports to allow and changes nothing in it; the test suite checks that `sshd_config*` is byte-identical after an install, a re-run and an uninstall. ## First run Open the **setup URL** the installer printed (`/setup?token=…`). The page needs the token because the installer sets `NEBULACTRL_SETUP_TOKEN`: without it nobody who finds the dashboard first can claim the admin account (`403 setup_token_invalid`; attempts are rate limited). The token is removed from the address bar after the page reads it and stops mattering when setup is done. It is stored in `/opt/nebulactrl/.env` and `INSTALL-SUMMARY.txt`. Choose your team name, create the account, and the first server (this machine) is already online. ## Files, ports and containers | | | |---|---| | `/opt/nebulactrl/` | `docker-compose.yml`, `.env` (every secret, 0600), `install.conf`, `uninstall.sh`, `state/`, `INSTALL-SUMMARY.txt` | | `/var/backups/nebulactrl/control-plane/` | local control-plane backups | | `/usr/local/bin/nebulactrl-agent`, `nebulactrl-agent.service` (+ `.service.d/10-installer.conf`), `/etc/nebulactrl-agent.env` | the local agent | | `/var/lib/nebulactrl-agent/` | agent identity, build workspaces, log history | | `/etc/docker/daemon.json` (+ `.nebulactrl-bak`), `/etc/apt/keyrings/docker.asc`, `docker.list` / `docker-ce.repo` | Docker | | containers `nebulactrl-nebulactrl-1`, `nebulactrl-postgres-1`; networks `nebulactrl_backend` (internal), `nebulactrl-edge`; volume `nebulactrl_postgres-data` | the control plane | | containers `nebulactrl-traefik`, `nebulactrl-pages`, volume `nebulactrl-traefik-acme` | the agent's proxy | | Listening on the host | 80, 443 (tcp, udp) the proxy, or the `--proxy-http-port`/`--proxy-https-port` you chose; `127.0.0.1:9000` the proxy's admin; `127.0.0.1:8080` (domain mode, `--port` changes it) or `0.0.0.0:8000` (IP mode, `--bind` narrows it) the dashboard; SSH. **Nothing else: Postgres is not published.** | ## Re-running, upgrading, recovering - **On an existing installation the installer backs the control plane up first** (`nebulactrl-server pre-upgrade-backup` in the running container; a `pg_dump` to `/var/backups/nebulactrl/pre-upgrade-