# 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-.dump` when that control plane is too old to have the command) and stops before changing anything if the backup failed, unless `--force-upgrade`. See [upgrades](https://nebulactrl.com/docs/operations/upgrades#backup-before-a-control-plane-upgrade).
- `curl … | sudo bash` again, or `sudo bash /opt/nebulactrl/…` with the same options: nothing that exists is regenerated; changed settings are applied; containers are only recreated when their definition changed (a new image tag, new RAM tuning); the agent binary is upgraded to the control plane's version. See [upgrade](https://nebulactrl.com/docs/installing/upgrade).
- A step failed: fix the cause and run again. A power loss or a killed run leaves nothing a re-run does not repair.
- `.env` lost but the database volume remains: restore `.env` from your backup (the installer will not invent a key), or [uninstall with `--purge`](https://nebulactrl.com/docs/installing/uninstall) and restore the database from a backup.
- **Locked out** (lost password or lost 2FA of the only Owner): `cd /opt/nebulactrl && docker compose exec nebulactrl nebulactrl-server reset-password you@example.com` prints a one-time reset link (30 minutes); `reset-2fa you@example.com` removes the authenticator app, passkeys and recovery codes after you type the email. Both run on the host against the database, end the account's sessions and are audited as system actions. If setup was never completed there is no account to reset: use the setup URL instead. See [Troubleshooting → Locked out](https://nebulactrl.com/docs/operations/troubleshooting#locked-out).
- The host's agent identity from an earlier installation is moved aside (`/var/lib/nebulactrl-agent.old-`) when a fresh control plane is installed over it.
## Tested
`tests/install/run.sh` runs the real installer in disposable systemd machines (OrbStack) for Debian 12, Ubuntu 22.04, 24.04 and 26.04, and Rocky 9: install through the piped, self-contained script, secrets absent from its output, Postgres private and SCRAM-only, the daily backup written, firewall, fail2ban and updates configured; a re-run that changes nothing; setup with and without the token and enrollment of the host's agent before the admin exists; the switch to a domain with the dashboard served through the host proxy (HTTP→HTTPS redirect, WebSocket upgrade, login through the proxy); an app deployed on the control-plane host and **Accept workloads** off refusing the next one while the first keeps serving; uninstall (data kept), reinstall over the kept data, and `--purge`. See [`tests/install/README.md`](). The audit/hardening step is exercised when `NC_TEST_HARDENING=1`.
---
# Migrate from Coolify
Source: https://nebulactrl.com/docs/installing/migrate-from-coolify
This page moves **one host** from Coolify 4.x to NebulaCtrl without a second server. Both run side by side on the host while you rebuild and rehearse; Coolify keeps serving the whole time. A short **cutover window** (minutes) then moves ports 80/443 to NebulaCtrl, and Coolify stays stopped but intact for a soak period, which is also your rollback.
If you can afford a second machine, installing NebulaCtrl there, copying the data and switching DNS is simpler and needs no downtime. This page is for the single-host case.
## What changes hands
| Coolify | NebulaCtrl |
|---|---|
| `coolify-proxy` (Traefik) on 80/443, dashboard on `:8000` | NebulaCtrl's own Traefik (run by its agent), first on spare ports and then on 80/443; dashboard on a port you pick (`--port 8100` below) or behind its own domain |
| Applications (Dockerfile, image, Git) | Services; see the [Coolify importer](#2-recreate-the-projects) |
| Databases | Managed databases (Postgres, PostGIS, Valkey and others); see [restoring a dump](#3-rehearse-copy-the-data-and-test-on-the-spare-ports) |
| Per-domain rules (IP allow lists, compression) | Per-domain access and compress options |
| Daily database backup | Scheduled backups to S3, off the host's disk |
| Dynamic Traefik files (an external upstream such as Plex on a tailnet node) | An external-service route; by hand |
## 0. Before you start
- A way to **log in over the network that survives the cutover**: SSH is untouched by NebulaCtrl's installer, and Tailscale (if you use it) is separate software.
- List what holds the ports you care about: `ss -ltnp | grep -E ':(80|443|8000|8100|8080|8443)\b'`. Coolify uses 80/443 (proxy), 8000 (dashboard) and 6001/6002 (realtime). The commands below use **8100** for NebulaCtrl's dashboard and **8080/8443** for its proxy.
- Cloudflare: while HTTP-01 cannot work (see below), every certificate is issued with **DNS-01**. Create an API token with **Zone:Read** and **DNS:Edit** on the zones of your domains, and keep it in a file readable by root only. If your DNS is Cloudflare-proxied, also connect Cloudflare in NebulaCtrl (Settings, Integrations) so it owns the records and can lock the origin; see [Cloudflare](https://nebulactrl.com/docs/operations/cloudflare).
- Keep the Coolify host's **Coolify API token** (read, and read-sensitive if you want the secrets imported) for the importer.
- Know your cutover window: the apps are down for the time between stopping Coolify and the first successful request through NebulaCtrl (plan 5 to 10 minutes).
## 1. Install NebulaCtrl side by side
Run this on the Coolify host. It does not touch ufw, fail2ban or the hardening settings, and it leaves ports 80, 443 and 8000 to Coolify:
```sh
echo -n '' | sudo install -m 0600 /dev/stdin /root/cf-token
curl -fsSL https://nebulactrl.com/install.sh | sudo bash -s -- \
--no-firewall --no-fail2ban --no-hardening \
--port 8100 --proxy-http-port 8080 --proxy-https-port 8443 \
--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
```
(`/install.sh` of any running NebulaCtrl control plane serves the same script. Add `--no-auto-updates` too if you do not want unattended upgrades on this host.)
What each part does:
| Option | Why |
|---|---|
| `--no-firewall --no-fail2ban --no-hardening` | Keep the host as it is. Without `--no-firewall` the installer would switch on ufw, which also closes ports Coolify needs (6001/6002, 8000) because it has no tailnet rules |
| `--port 8100` | With a domain this is the control plane's **loopback** port that the host's agent uses (it is 8080 by default, which is now the proxy's). Without a domain it is the dashboard's port; Coolify's dashboard holds 8000 |
| `--proxy-http-port 8080 --proxy-https-port 8443` | NebulaCtrl's Traefik runs next to Coolify's. Apps answer on `https://:8443` until you move it |
| `--domain`, `--email` | The dashboard's own HTTPS name. Its URL carries the port: `https://nebula.example.com:8443` |
| `--dashboard-allow ` | Only these addresses may open the dashboard. `100.64.0.0/10` and `fd7a:115c:a1e0::/48` are the tailnet ranges |
| `--dashboard-dns01 cloudflare --dns-token-file …` | The dashboard certificate comes from DNS-01. It works while the name resolves to a tailnet address and while another proxy holds port 80. The token is read from the file, never printed, and sealed into NebulaCtrl's credential store |
**Without a domain**, tailnet only: replace the domain, allow list and DNS-01 options with `--bind --port 8100`. The dashboard is then `http://:8100` (plain HTTP over the tailnet), published on that address only and remembered by later runs. See [the installer](https://nebulactrl.com/docs/installing/installer#binding-the-dashboard-to-one-address).
Point `nebula.example.com` at the host's **tailnet** address (A record). Open the setup link the installer prints from a tailnet device; the allow list also blocks the setup page from everywhere else. The installer warns about HTTP-01 where it could not work, and the first server (this host) is already enrolled: Servers, the host, **Proxy** shows its ports as 8080/8443.
Check that nothing of Coolify's moved: `curl -sI http://127.0.0.1:80/`, your sites, and Coolify's dashboard on `:8000` all answer as before.
## 2. Recreate the projects
Pre-issue certificates **before** any DNS change: for each domain set the DNS-01 resolver (`cloudflare`) or add a wildcard certificate under Servers, Proxy, DNS-01. HTTP-01 cannot validate while Coolify owns port 80; NebulaCtrl warns on every domain that would use it ("HTTP-01 cannot validate"). See [DNS-01 and wildcard certificates](https://nebulactrl.com/docs/operations/dns01-wildcard-certificates).
### Importer
Import projects, applications, variables, domains and databases from the Coolify API. The **control plane** (not your laptop) must reach the Coolify URL; a tailnet address is fine. The token needs the Coolify abilities `read` and `read:sensitive` (the second one is what lets secrets come across). Review first, then stage; nothing is deployed unless you pass `--deploy`:
```sh
export COOLIFY_TOKEN=
nebulactrl import coolify --url https://coolify.example.com --plan # review (alias --dry-run; exit 1 on blockers; --json)
nebulactrl import coolify --url https://coolify.example.com --server \
[--registry-user ghcr.io= --registry-token-env ghcr.io=] [--git-connection ] \
[--set-env .=]
```
It stages projects and environments, image and Git services, managed databases of the same engine and version, **every** domain of each application, health checks, limits, Docker options, pre- and post-deploy commands, volumes (empty) and variables with secrets marked and database references rewritten to `${{db.*}}`. **Stays manual:** the data of databases and volumes (the importer prints the `databases restore` command to use), registry credentials unless you pass them, private-repository access (a GitHub App or deploy key cannot be exported), locked variable values unless you supply them with `--set-env`, DNS, Traefik labels and dynamic files (an external Plex route, per-domain IP allow lists, redirects), scheduled tasks, one-click services and host port mappings. More flags (`--exclude`, `--rename`, `--domain`, `--secret`, `--skip-var`, ...) and the full mapping are on the importer's own page: [import from Coolify](https://nebulactrl.com/docs/operations/import-coolify).
Then: review every staged service in the dashboard (variables marked secret, health checks, the `entrypoint` and pre-deploy command overrides your Coolify apps used), create the managed databases **empty**, and add an S3 backup destination and schedule.
## 3. Rehearse: copy the data and test on the spare ports
Copy each database with `pg_dump` from Coolify's container into the new managed database, then deploy the apps on the spare ports and test with `curl --resolve` so no DNS has to change:
```sh
curl -sk --resolve app.example.com:8443:127.0.0.1 https://app.example.com:8443/
```
### Restoring a dump
Dump from Coolify's database container (the apps' writers stopped for the final one) and restore into a managed database, by default as a **new sibling database** so the original stays untouched:
```sh
docker exec pg_dump -U -Fc > db.pgdump
nebulactrl databases restore --file db.pgdump
```
The upload streams through the control plane to the agent, with no temporary copy; `--no-owner` and `--no-acl` are applied for you. In the dashboard: the database, Backups, "Restore from a dump file". Details, limits and `--in-place` are in [restore from a dump file](https://nebulactrl.com/docs/operations/database-backups#restore-from-a-dump-file) (that page is the reference; this section is short on purpose). Do the rehearsal restore as often as you like; only the final one matters.
Cache and queue stores such as Valkey/Redis usually start **empty** after the queue drains; copy them only when you know they hold state you cannot rebuild.
Rehearse everything: logins, background jobs, scheduled tasks, uploads, third-party webhooks that would hit a different host name, the external routes.
## 4. The cutover window
Pick the window when traffic is lowest and tell people. Have a second SSH session open.
1. **Stop the writers.** Stop the queue workers and let the queues drain, then stop the Coolify applications (Coolify, or `docker stop `).
2. **Final data copy.** Take a last `pg_dump` and restore it into the managed database (as above, with the apps stopped). Point the new services at the new databases if they are not already.
3. **Stop Coolify's proxy.** Stop Coolify itself first, because it can start its proxy again, then the proxy: `docker stop coolify coolify-realtime`, then `docker stop coolify-proxy`. Confirm 80 and 443 are free: `ss -ltn | grep -E ':(80|443)\b'` prints nothing.
4. **Move NebulaCtrl's proxy to 80/443.** Either re-run the installer with the default ports,
```sh
curl -fsSL http://127.0.0.1:8100/install.sh | sudo bash -s -- --proxy-http-port 80 --proxy-https-port 443
```
or, as the instance admin, set the ports in **Servers, the host, Proxy, Proxy ports** (review the plan, confirm, then **Deploy proxy**; Reset hands control back to the agent's flags; see [proxy ports](https://nebulactrl.com/docs/operations/proxy-ports)). Traefik is recreated in about a second. The installer refuses while something still holds 80 or 443 and changes nothing in that case. A port set there overrides the installer's flags. See [moving the proxy back to 80/443](https://nebulactrl.com/docs/installing/installer#side-by-side-with-another-proxy).
5. **Switch DNS** where it has to change. A Cloudflare-proxied domain needs no change (the origin is the same host); a name that pointed at the tailnet address stays as it is.
6. **Smoke-test every host name and the external routes**, then start the writers (workers) on NebulaCtrl. Check the dashboard on `https://nebula.example.com` (the installer rewrote its URL without the port).
## 5. Roll back
If something is wrong, you can be back on Coolify in about a minute, because Coolify's data and containers are untouched:
```sh
sudo systemctl stop nebulactrl-agent # stops NebulaCtrl's management
sudo docker stop nebulactrl-traefik # releases 80/443
sudo docker start coolify coolify-realtime coolify-proxy # and your Coolify apps
```
Data written to NebulaCtrl's databases since the cutover is not in Coolify's: if that matters, `pg_dump` the new database and restore it into Coolify's before starting the apps. To retry NebulaCtrl later, re-run the installer with `--proxy-http-port 8080 --proxy-https-port 8443` (it restarts the agent and puts the proxy on the spare ports).
## 6. Soak, then clean up
Run for about **7 days** with Coolify stopped but intact. Then take a final backup of the NebulaCtrl instance (Settings, Instance; the secret key apart from it) and a last Coolify backup, and remove Coolify: its containers, images and volumes (`docker system prune` after listing what it removes) and, if you want it, turn on the firewall and the hardening that you skipped (`--no-firewall` and `--no-hardening` are only install-time options: re-run the installer without them).
## What to expect
- Coolify's custom Traefik files (`/data/coolify/proxy/dynamic/*.yaml`) are not in its API: recreate them as external routes and per-domain access rules.
- Imported HTTP health checks run Coolify's own request **inside the container** (`curl`, else `wget`, against `http://localhost:`), as Coolify did: frameworks with a host allow list (Laravel trusted hosts, Django `ALLOWED_HOSTS`) refuse a probe from outside, whose Host is the container address. Apps without a port deploy as recreate with a short gap.
- Compression, IP allow lists and redirects are options per domain, not per proxy.
- Plan the secrets: variables the importer cannot read stay empty until you fill them in, and `APP_KEY`-style secrets must keep their exact values to keep existing sessions and encrypted data valid.
---
# The control plane
Source: https://nebulactrl.com/docs/installing/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](https://nebulactrl.com/docs/installing/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](https://nebulactrl.com/docs/security/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](#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](https://nebulactrl.com/docs/operations/database-backups#restore-from-a-dump-file). It reads `NEBULACTRL_FORCE_UPGRADE=true` (default off) to start after a version change although the backup it takes first failed ([upgrades](https://nebulactrl.com/docs/operations/upgrades#backup-before-a-control-plane-upgrade)). A few advanced timers (`NEBULACTRL_MESH_LINK_GRACE`, `NEBULACTRL_REPLICA_BROKEN_AFTER`) are listed under [advanced settings](https://nebulactrl.com/docs/operations/configuration); 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](https://nebulactrl.com/docs/operations/backup-restore) and [secrets at rest](https://nebulactrl.com/docs/security/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](https://nebulactrl.com/docs/installing/installer#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-` (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](https://nebulactrl.com/docs/installing/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](https://nebulactrl.com/docs/installing/servers)).
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](https://nebulactrl.com/docs/installing/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.
---
# Upgrade and roll back
Source: https://nebulactrl.com/docs/installing/upgrade
## Before you upgrade
1. Read the release notes for schema or protocol changes.
2. Confirm you hold `NEBULACTRL_SECRET_KEY` ([backup and restore](https://nebulactrl.com/docs/operations/backup-restore)). The backup of the database is taken **for you**: the installer backs the control plane up before it changes anything, and the control plane does the same at startup when its version changed ([how it works](https://nebulactrl.com/docs/operations/upgrades#backup-before-a-control-plane-upgrade)). **If that backup fails the upgrade stops**; fix the cause, or re-run with `--force-upgrade` to go on without one.
3. Note the version you run now (`docker compose -f /opt/nebulactrl/docker-compose.yml --env-file /opt/nebulactrl/.env images`).
## Upgrade the control plane (and the host's agent)
Re-run the installer with the new version. Nothing that exists is regenerated:
```sh
curl -fsSL https://nebula.example.com/install.sh | sudo bash -s -- --version 1.4.0
# or, with an image you built or saved yourself:
sudo bash install.sh --image nebulactrl:1.4.0 --image-archive nebulactrl-1.4.0.tar
```
The installer keeps `.env` and the database, loads or pulls the image, recreates only the containers whose definition changed, waits for `/readyz`, and upgrades the host's agent to the binary that ships with the new control plane. Your options are remembered in `/opt/nebulactrl/install.conf`.
- The server applies **forward-only** database migrations on startup, under a Postgres advisory lock. There is no automatic down migration.
- Startup order is safe: Postgres is kept, the server container is replaced, agents reconnect on their own (exponential backoff, up to 60 s) and the control plane **never stops remote containers** while it is down. Your services keep serving through a control-plane restart or upgrade, including those on the control-plane host (the proxy is the agent's, not the control plane's).
- Commands acknowledged before the restart keep their state; the agent redelivers unacknowledged results after reconnecting.
- The upgraded control plane serves new agent binaries immediately, and **agents upgrade themselves** when they reconnect (below).
## Agents upgrade themselves
An agent that is behind the control plane downloads the new binary from its control plane, verifies the SHA-256, replaces itself atomically, restarts when its running work is done and rolls back if the new binary does not start ([details](https://nebulactrl.com/docs/operations/upgrades)). This is automatic by default (Servers page → **Upgrade automatically**); **Upgrade agents** does the same one server at a time on request, and each server's page shows the version skew. Your services keep running.
## Upgrade other servers by hand
You need this once for an agent that predates self-upgrade, for a server that is offline during the upgrade, and when you switched the automatic upgrade off. Re-run the agent installer on the host (no token needed; the identity is kept):
```sh
curl -fsSL https://nebula.example.com/install-agent.sh | sudo sh -s -- --url https://nebula.example.com
```
It downloads the new binary, verifies it against the checksum the control plane publishes, restarts the service, and verifies the service stays up. Managed containers and the Traefik proxy keep running during the agent restart. The wire protocol is versioned; a control plane refuses an unknown **major** protocol version with close code 4400, so upgrade the control plane first and agents second. The server card shows each agent's version.
## Roll back the control plane
Because migrations only go forward, an older server binary may not work against a newer schema. The supported rollback is:
1. `cd /opt/nebulactrl && docker compose down` (do **not** pass `-v`).
2. Restore the pre-upgrade backup (Settings → Instance → History, trigger *Before upgrade*; or `/var/backups/nebulactrl/pre-upgrade-*.dump` from the installer) into a clean database ([restore](https://nebulactrl.com/docs/operations/backup-restore#restore)). Changes made after the dump (deployments, variable edits) are lost.
3. Run the installer with the previous `--version` (or image).
If the new version only changed code (no new migration, see `db/migrations` in the diff), re-running the installer with the previous version is enough.
## Roll back an agent
A failed self-upgrade rolls back by itself. After a good one the previous binary stays at `/usr/local/bin/nebulactrl-agent.prev`: move it over the binary and `systemctl restart nebulactrl-agent`. Or re-run the installer from an older control plane, or place an older binary at `/usr/local/bin/nebulactrl-agent`. The control plane never downgrades an agent, and an agent newer than the control plane is not upgraded. Use an agent whose protocol major version the control plane accepts.
## Roll back an application (not the platform)
That is a deployment rollback inside the UI/API/CLI: it re-deploys an earlier release's image and configuration. It does **not** restore data. See [operations: deployments](https://nebulactrl.com/docs/operations/deployments).
---
# Uninstall
Source: https://nebulactrl.com/docs/installing/uninstall
## The control-plane host
```sh
sudo bash /opt/nebulactrl/uninstall.sh # stops NebulaCtrl, keeps everything that holds data
curl -fsSL https://nebula.example.com/uninstall.sh | sudo bash -s -- --purge # the control plane serves the script too
```
The uninstaller prints what it will do and asks first (`--yes` skips the question; `--purge` and `--remove-workloads` ask you to type `delete`).
| Option | What it adds |
|---|---|
| *(none)* | Stops and removes the control-plane and Postgres containers and the **agent service and binary**. **Kept:** the database volume, `/opt/nebulactrl` (with the secret key), the agent identity, the local backups, and **your deployed apps, their volumes and the proxy, which keep running**. Re-run the installer to bring NebulaCtrl back with all its data |
| `--purge` | Also deletes the database volume, `/opt/nebulactrl` (**including `NEBULACTRL_SECRET_KEY`**) and the agent state. Backups stay |
| `--remove-workloads` | Also removes every container NebulaCtrl manages (your apps, the proxy, helpers), the `nc-vol-*` volumes (**your apps' data**), proxy certificates and `nebulactrl-*` networks |
| `--remove-backups` | Also deletes `/var/backups/nebulactrl` |
| `--revert-host` | Reverts what the installer added: its ufw/firewalld rules (the firewall itself stays on), the fail2ban jail, and `/etc/docker/daemon.json` restored from its backup |
It **never** uninstalls Docker, automatic updates or the hardening fixes, and never touches SSH. Before `--purge`, take a final backup if you may want the data back, and remember a dump is unreadable without the secret key.
Everything the uninstaller does is covered by the installer's test suite: default uninstall, reinstall over the kept data (same secrets, same data, the host re-enrolls with its kept identity), and `--purge --remove-workloads --remove-backups` leaving no container, network, volume or file behind.
## An additional server
```sh
# stops the service, removes the unit, env file and binary; keeps the identity and your containers
curl -fsSL https://nebula.example.com/install-agent.sh | sudo sh -s -- --uninstall
# additionally delete /var/lib/nebulactrl-agent (identity, build workspaces). The host must be re-enrolled to be used again.
curl -fsSL https://nebula.example.com/install-agent.sh | sudo sh -s -- --uninstall --purge
```
(If the control plane is already gone, take the agent installer from any other running control plane (`/install-agent.sh`), or remove the pieces by hand: `systemctl disable --now nebulactrl-agent; rm /etc/systemd/system/nebulactrl-agent.service /etc/nebulactrl-agent.env /usr/local/bin/nebulactrl-agent; systemctl daemon-reload`.)
Uninstalling the agent **does not remove** what it created: the proxy and your services keep running. To delete everything NebulaCtrl created on such a host (this **destroys the data in service volumes**; export what you need first):
```sh
docker ps -aq --filter label=nebulactrl.managed=true | xargs -r docker rm -f
docker images --filter label=nebulactrl.managed=true -q | xargs -r docker rmi -f
docker rm -f nebulactrl-traefik nebulactrl-pages
docker network ls --format '{{.Name}}' | grep '^nebulactrl-' | xargs -r docker network rm
docker volume ls -q | grep -E '^(nc-vol-|nebulactrl-)' | xargs -r docker volume rm
```
Review each list (`docker ps -a`, `docker volume ls`) first; the commands match by label or name prefix, not by what you consider yours. Deleting a service in the UI with *delete volumes* ticked removes that service's volumes only. Revoking the server in the control plane first is optional; it only stops the agent and cancels its pending commands.
Your agents' services keep running after the control plane is gone, but nothing manages them, and agents keep retrying the connection until they are uninstalled.
---
# Deployments
Source: https://nebulactrl.com/docs/operations/deployments
The behavior mirrors what Coolify documents for health checks, rolling updates, stop grace periods and rollbacks, with one deliberate difference: **health checks run from the agent, outside the container**, so images do not need `curl` or `wget`.
## Life of a deployment
```
created → queued → preparing → building → releasing → verifying → succeeded
↘ failed | canceled | rolled_back | rollback_failed
```
Image sources skip `building`. Every transition is recorded with a timestamp (deployment → step timeline). Only **one deployment per service can be active**; a second request gets `409 deployment_in_progress`. Deploy and rollback return `202` with the deployment; they never report success before the agent has finished.
A release is immutable and written *before* dispatch: the resolved source (image + digest, or git URL + commit SHA), a config snapshot and the encrypted, resolved variables.
## Strategies
The plan (`POST /services/{id}/deployments/plan`, shown before you deploy) names the strategy and why:
| Strategy | When | What happens |
|---|---|---|
| **rolling** | the service has **no volumes** and an HTTP port | New container starts and is health checked while the old one keeps serving; traffic switches only after the route is loaded and verified. |
| **recreate** | anything else (volumes, no HTTP port) | The old container is stopped first, then the new one starts. **There is downtime** for the time it takes to start and become healthy. The plan lists the reasons (`has volumes`, `no http port`), as Coolify does. |
### Rolling steps
1. Pull or build. If this fails the old container is untouched (`failed`).
2. Create and start the new container.
3. Health check it. Unhealthy = the deadline passes, the container exits, or it restarts. Then the new container is removed, the outcome is `failed` ("Previous release is still serving" plus the last 50 container log lines).
4. If the service has domains, push the full route table, now pointing at the new container, to Traefik. Traefik applies provider configuration asynchronously, so the agent waits for a per-generation marker router on the probe entrypoint to answer and reads the router statuses. If Traefik disabled any router (for example a bad upstream), the agent pushes the previous table again and the deployment is `failed`.
5. **Verify** through Traefik's internal probe entrypoint (port 8099, never published) using the service's first hostname and health path; any status below 500, 3 tries 2 s apart. If that fails, the previous routes are pushed again and the new container removed: `rolled_back`, or `rollback_failed` if the restore itself fails.
6. Retire: wait 2 s for drain, stop the old container with the **stop grace period**, remove it, prune old images.
### Recreate steps
Pull or build → stop the old container (kept) → start and health check the new one → on failure remove it and restart the old one (`rolled_back`, or `rollback_failed`) → on success load the route and remove the old container.
## Canceling a deployment
A deployment can be canceled at **any step** until the new release is live: `POST /api/v1/deployments/{id}/cancel` (scope `deployments:write`), the **Cancel deployment** button in the Deployments tab and the canvas menu, `nebulactrl deployments cancel `. The deployment ends as `canceled`, with the step it reached in `canceledAtStep` and "Canceled by " in its message. The request and the outcome are audited (`deployment.cancel`, `deployment.canceled` with the step).
- **Before the agent acknowledged the command** (queued, preparing, building with the command still `pending` or `sent`): the command is canceled and the deployment ends as `canceled` at once.
- **Once the agent runs it:** the control plane records the request (`cancelRequestedAt`) and sends a `cancel_deploy` command to the agent. The deployment keeps its status until the agent has aborted and unwound; the UI shows "Canceling…". An offline server receives the request when it reconnects.
What the agent does at each step (the previous release keeps serving unless noted):
| Step reached | Abort |
|---|---|
| **pull** | the pull is stopped; nothing was changed |
| **build** | the build gets `SIGINT` (and `SIGKILL` after 10 s), so BuildKit stops it; the build work directory is removed; no image is produced |
| **pre-deploy** | the command's container is removed (never left for a later redelivery to adopt) |
| **create / start / health / route** | the containers of the new release are removed, including one whose creation was interrupted; if the new route table had already been applied it is withdrawn first |
| **verify** | the previous routes are restored **first**, then the new containers are removed |
| **stop** (recreate only, the old release is stopping) | the stop is not interrupted; right after it, the old release is started again |
| a **recreate** deployment past "stop old" | the previous release is started again, exactly like an automatic rollback |
The result is `canceled` only when everything was restored. If the previous routes or the stopped previous release **cannot** be restored, the result is `rollback_failed` with the manual recovery steps, never `canceled`. A canceled deployment never becomes the service's current deployment and consumes no generation: the staged changes stay staged.
**Too late:** once verification has passed the new release is live and the point of no return is behind it. The agent then reports "Too late to cancel" in the step timeline and the deployment finishes (retire, then `succeeded`). A **post-deploy command** that is still running can be stopped: the deployment stays `succeeded`, with a warning that the command was canceled. A deployment that already finished is `409 cannot_cancel`. A cancel is not an agent shutdown: if the agent stops mid-deploy the command still runs again after the restart, as before.
## Health check semantics
Configured per service (**Settings → Health check**); the agent runs the check against the container's address on its environment network (`nebulactrl-env-`, or the `-int` network when the service blocks outbound internet), not the shared edge network. See [networking](https://nebulactrl.com/docs/operations/networking).
| Type | Passes when |
|---|---|
| `http` (default when a port is set) | `GET ` on the port returns status **200–399**. Default path `/`. |
| `tcp` | the TCP connection opens (default for managed databases) |
| `docker` | the image's own `HEALTHCHECK` reports healthy |
| `none` | the container has simply **run for 10 s without restarting**. The plan shows a warning: nothing proves it works. |
Timing defaults: interval 2 s, timeout 3 s, start period 0 s, **deadline 120 s**, success threshold **2 consecutive passes**. A container that exits or restarts during the deadline fails immediately. As in Coolify, a health check failure during deployment aborts the new release; it does not by itself stop a running one: after a deploy, a container that later becomes unhealthy is shown as unhealthy, not killed. Docker's restart policy (`unless-stopped`) restarts crashed containers.
## Stop grace period
`stopGraceSeconds` (default 30, 1–3600): how long Docker waits after SIGTERM before SIGKILL when a container is stopped for retire, restart or stop. Raise it for workloads that drain slowly.
## Variables and generations
Changing configuration, variables, domains or volumes does **not** redeploy. It increments the service's `generation`; the node shows a **Staged** chip and the environment's staged bar says "N changes to apply". **Review** lists the field-level changes (secret values masked). **Deploy** applies them: one deployment per changed service. Edits use optimistic concurrency: a stale `If-Match` returns `412 generation_conflict` and the UI reloads.
Secret variable values are never returned by any read endpoint, appear in logs only as `••••••`, and travel to the agent inside the command frame at send time, never in stored payloads.
## Rollback
*Roll back to this release* (deployment row, API `POST /deployments/{id}/rollback`, CLI `nebulactrl deployments rollback`) creates a **new deployment** from the old release's source and configuration snapshot. It is deployed with the normal strategy and checks.
> [!NOTE]
> **Application image and configuration only. Databases, volumes and external services are not rolled back.**
If the old release was a git build whose image has been pruned from the host (see below), the API returns `409 image_pruned`; use **Rebuild commit **, which deploys that commit again. Image releases re-pull `repo@digest` when missing (registries may have deleted it).
**Variable values outside the rollback window.** A release keeps the sealed environment it was deployed with only while it is among the newest `imageRetention` succeeded releases (or running, or in flight); an hourly job clears it from older ones ([secrets at rest](https://nebulactrl.com/docs/security/secrets-at-rest#release-secret-retention)). Rolling back to such a release deploys its old image and configuration with the service's **current** variables, says so in the timeline and in the rollback response, and fails (changing nothing) if the current variables do not resolve. Within the window, a rollback restores the old values.
Retention: after each successful deploy the agent keeps the newest `imageRetention` built images per service (default **5**) and removes the rest. The server's scheduled [Docker cleanup](https://nebulactrl.com/docs/operations/docker-cleanup) (daily, and when the disk alert is active) never removes the current or any of these rollback images, and prunes build cache older than 7 days.
## Recovering from `rollback_failed`
`rollback_failed` means the deployment failed *and* the agent could not restore the previous release. The service may be down or serving the wrong release. On the host:
1. `docker ps -a --filter label=nebulactrl.service=`: find which containers exist (names `nc---`, replica 0…N-1).
2. `docker logs ` on the newest one; the deployment's log in the UI has the failure reason.
3. If an old container exists and is stopped (recreate): `docker start `. If routes are wrong: in the UI, **Restart** the service or trigger a deploy of the last good release (rollback target) – the deploy re-applies the full route table.
4. If the host itself is the problem (disk full, Docker unhealthy, proxy error), fix it first; the server view shows the proxy status and disk gauge.
5. Redeploy and confirm `succeeded`. The reconciler also repairs a **missing** container automatically (at most 3 times per hour per service).
## Restart and stop
`restart` stops and starts the existing container (a brief interruption; not a rolling replace) with the configuration it was deployed with; staged changes are applied only by a deploy. `stop` stops it and keeps its configuration; the node shows stopped. Both honor the stop grace period.
## Moving a service to another server
Changing **Placement** is a staged change. Deploying creates a new deployment on the target; the old server's container is not auto-migrated and **volumes are not moved**. Plan data migration yourself.
---
# Builds
Source: https://nebulactrl.com/docs/operations/builds
By default, git services are built on the server that runs them ([ADR 0004](https://nebulactrl.com/docs/decisions/0004-build-on-target-host), [ADR 0006](https://nebulactrl.com/docs/decisions/0006-railpack-default-builder)). A team, project or service can instead build on a dedicated **build server** that pushes to a registry, and the server that runs the service pulls the image by digest: see [Build servers and registries](https://nebulactrl.com/docs/operations/build-servers). The build methods below are the same either way. **Settings → Source → Build method** chooses how.
| Build method | What happens |
|---|---|
| **Automatic** (default) | The repository's `Dockerfile` if there is one, otherwise Railpack. |
| **Dockerfile** | Always the Dockerfile (path configurable); the deployment fails if it is missing. |
| **Railpack** | Railpack, even if the repository has a Dockerfile (which is then ignored). |
| **Starter: Node.js, Python, Go, Ruby, PHP, static site** | A Dockerfile generated from the files in the build context. It is printed at the top of the build log so you can copy it into your repository. |
A Dockerfile always wins in Automatic mode. If you changed *Dockerfile path* away from `Dockerfile` and that file does not exist, the build fails with a clear message instead of falling back to Railpack.
## Preview the build plan
**Settings → Source → Preview build plan** (API: `POST /api/v1/services/{id}/build-plan`, needs `deployments:write`) asks the server that **will build** the service (its target, or the build server it resolves to) to clone the branch head and report:
- the method that applies and why ("No Dockerfile in the repository, so Railpack detects how to build it"),
- for Railpack: the detected providers (`node`, `python`, `staticfile`, …), the toolchain versions it would install, the build steps and the **start command**,
- Railpack's warnings and suggestions (for example "Add a `package-lock.json` for more deterministic installs"),
- whether a Dockerfile was found and, if not, which starter matches the project.
Nothing is built or deployed. That server must be online. The first plan on a host takes longer while Railpack's tooling downloads. The preview uses the **saved** configuration and the current build variables.
## Railpack
Pinned version: **Railpack 0.43.0**, frontend image `ghcr.io/railwayapp/railpack-frontend:v0.43.0@sha256:355ef61f…` (full digest in `pkg/agentprotocol/build.go`).
What Railpack detects: Node (npm, yarn, pnpm, bun), Python, Go, PHP, Ruby, Java, .NET, Deno, Rust, Elixir, static sites (a `Staticfile` or an `index.html`) and more; see the [Railpack docs](https://railpack.com/getting-started). Configure it with a `railpack.json` in the repository or with `RAILPACK_*` build variables, for example `RAILPACK_START_CMD`, `RAILPACK_NODE_VERSION`, `RAILPACK_PACKAGES`. Set build variables under **Variables** with scope *Build*; plain and secret values are both handed to Railpack as BuildKit secrets.
**The port.** Railpack does not know your port. Set the service's *Port*; the platform passes it as `PORT`, which Railpack's Node/Python/static start commands honour (the static site server listens on `$PORT`).
**Start command.** The default comes from Railpack (shown in the preview). To change it, prefer `RAILPACK_START_CMD`. A service-level *Start command* also works: Railpack images start with `bash -c`, so a multi-word command is passed as one quoted script.
**Requirements on the server.** Outbound access to `ghcr.io` (the frontend and Railpack's builder images), `registry-1.docker.io` and the usual package registries. Railpack's build graph needs BuildKit `mergeop`:
- Docker with the **containerd image store** (the default on fresh Docker 29 installs): builds run in the daemon.
- Otherwise the agent creates a `docker buildx` builder named `nebulactrl-buildkit` (a privileged BuildKit container, image pinned by digest) the first time a Railpack build needs it. This needs the `docker-buildx-plugin` package (included with Docker's own packages; Debian's `docker.io` lacks it). Without it Railpack builds fail with that explanation; Dockerfile and starter builds still work.
Check which applies: `docker info --format '{{json .DriverStatus}}'` mentions `io.containerd.snapshotter.v1` with the containerd store.
**Disk.** A first Railpack build pulls ~100–500 MB of builder images and leaves BuildKit cache. The server's [Docker cleanup](https://nebulactrl.com/docs/operations/docker-cleanup) prunes build cache older than 7 days daily (`docker builder prune --filter until=168h`, plus `docker buildx prune --builder nebulactrl-buildkit` when that builder exists) and keeps the pinned BuildKit and Railpack images. To reclaim everything Railpack brought: `docker buildx rm -f nebulactrl-buildkit` (removes the builder container and its cache), `docker image rm moby/buildkit:v0.34.1 ghcr.io/railwayapp/railpack-frontend:v0.43.0`. On a [build server](https://nebulactrl.com/docs/operations/build-servers) every project has its own builder, `nebulactrl-buildkit-`, and builds always go through it (`buildx build --push`), whatever the daemon's image store.
### Vite, Vite+ and Void apps
Checked against the pinned planner (Railpack 0.43.0), plan only:
| Project | What happens |
|---|---|
| Vite SPA (`vite` in `devDependencies`, `build: "vite build"`) | Detected as a static site, output `dist`, served by Caddy. No settings needed. |
| Vite+ project after `vp migrate` (adds the `vite` alias) | Same as a Vite SPA. |
| Vite+ project with only `vite-plus` (as `vp create` can produce) | **Not detected**: the plan has no start command and the container would not start. Add the build variable `RAILPACK_SPA_OUTPUT_DIR=dist`, or run `vp migrate`. |
| Void app | Only its `target: "node"` mode runs outside Cloudflare. Add a `start` script (`node dist/ssr/index.js`) or set `RAILPACK_START_CMD`; the app listens on `PORT`. Void's `db`, `kv`, `storage`, `auth`, `ai`, sandbox, WebSocket and email modules need Cloudflare and are unavailable; use a [managed database](https://nebulactrl.com/docs/operations/databases) instead. Not verified with a real build. |
## Starter Dockerfiles
Generated from the build context (the *Build context* path):
| Starter | Base image | Install | Start |
|---|---|---|---|
| `node` | `node:24-slim` | `npm ci` / `pnpm` / `yarn` by lockfile (else `npm install`), `build` script if present | `npm start` (or `main`, `server.js`, `index.js`) |
| `python` | `python:3.13-slim` | `requirements.txt`, `Pipfile` or `pyproject.toml` | `python main.py` / `app.py` / `server.py` |
| `go` | `golang:-alpine` → `alpine:3.22` | `go build` of the root package or the single `cmd/` | the binary |
| `ruby` | `ruby:3.4-slim` | `bundle install` | `bin/rails server` or `rackup` |
| `php` | `php:8.4-apache` (+ Composer) | `composer install --no-dev` if `composer.json` | Apache on `$PORT` (document root `public/` when it has an `index.php`) |
| `static` | `nginx:1.29-alpine` | none: serves prebuilt files from `index.html`'s directory (root, `public/`, `dist/` or `build/`) | nginx on `$PORT` |
`.git` is never copied into a starter image; your `.dockerignore` is honoured too. Set the *Start command* when the heuristics do not find one. Starters run no build for `static`: run your site generator in CI and commit the output, or use Railpack.
## Deploying without cache
Stale layers are the usual cause of "works on my machine, fails here". **Redeploy without cache** (inspector ⋯ menu, the Deployments tab and the canvas menu of a git service; API `POST /api/v1/services/{id}/deployments` with `{"noCache": true}`; `nebulactrl services deploy --no-cache`) builds **that one deployment** from scratch:
- Dockerfile, starter and Railpack builds run with `--no-cache --pull`: every layer is rebuilt and the base images are pulled again. This is the same on the target and on a [build server](https://nebulactrl.com/docs/operations/build-servers), where the build also ignores the registry cache (`--cache-from`).
- An image a build server already pushed for the same commit and settings is **not reused** (a normal deploy of an unchanged commit would reuse it). The fresh image becomes the reusable one for later deployments.
- The deployment records it: the timeline shows "Deploying without build cache", the deployment has `noCache: true` and the audit event `deployment.create` carries `noCache`. The next deployment builds normally; there is no setting that makes it permanent.
- `POST /environments/{id}/deploy-staged` and MCP `apply_changes` accept `noCache` too and apply it to the git services of that batch. Both only deploy services with staged changes, and the raw deploy operation is not exposed over MCP, so an AI client cannot rebuild an unchanged service without cache; use the web app, the CLI or REST for that.
- Image services have nothing to build: `noCache` on one is `422 validation_failed`. Rollbacks and reconcile redeploys never build. A server whose agent predates this option answers `422 agent_too_old` instead of quietly building with the cache; update the agent.
Limits: `--no-cache` ignores cached *layers*; BuildKit **cache mounts** (the package-manager caches Railpack mounts, such as the npm or pip download cache) are kept, because BuildKit does not treat them as layers. They speed up downloads but cannot make a build wrong. To drop them as well, run `docker builder prune -af` on the server (the agent also prunes build cache older than a week daily). `--pull` needs the registries of your base images to be reachable, and a `FROM` that names a local-only image fails with it.
## Git submodules
**Settings → Source → Git submodules** (config `submodules`: `auto`, `on` or `off`; `nebulactrl services update --submodules on`):
| Mode | Behaviour |
|---|---|
| **Automatic** (default) | Submodules are checked out recursively when the commit has a `.gitmodules` file. A submodule that cannot be fetched is a **warning** in the build log and the build continues without it, which is what happened before submodules were supported. |
| **Always** | Same checkout; a submodule that cannot be fetched **fails the build**. Use it once you rely on the submodule. |
| **Never** | The submodule folders stay empty. |
Automatic is the default because Coolify turns submodules on, and a repository that has them does not build without them; making it opt-in would keep that failure for everyone who does not find the setting. Because a warning is never worse than the old behaviour, an existing service cannot start failing after an upgrade. The checkout follows the commit's recorded submodule commits, nests up to five levels deep, and is part of the build-reuse key on build servers (changing the mode rebuilds).
**Credentials.** A `.gitmodules` file is untrusted input, so the repository's credentials only go to the repository's own host:
- An HTTPS repository with an access token: the token is used for submodules on the **same host** (and port) over https, and for relative URLs such as `../lib.git`. A submodule written as `git@github.com:org/lib.git` or `ssh://git@github.com/org/lib.git` on that same host is fetched over HTTPS with the token instead, so one token covers a repository and its private submodules on the same provider as long as the token can read them (a GitHub fine-grained token must list the submodule repositories).
- An SSH repository with a [deploy key](https://nebulactrl.com/docs/operations/ssh-deploy-keys): the key and its pinned host key are used for ssh submodules on the **same host and port**. Providers such as GitHub accept a deploy key for one repository only, so a private submodule needs the key added to that repository too, or a user/machine key that can read both.
- Submodules on **another host** are fetched anonymously over `https://`, `http://` or `git://`; a private one fails (a warning in Automatic, a failed build in Always). An `ssh://` submodule on another host is refused, since there is no key or pinned host key for it; change its URL to https on the parent's host or mirror it.
- Passwords in a submodule URL, `file://`, local paths and other schemes are refused.
Not supported: Git LFS (pointer files are checked out as text), shallow clones and per-submodule credentials. A server whose agent predates submodule support ignores the setting in Automatic, and a service set to *Always* is blocked with `agent_too_old` until the agent is updated.
## Upgrading the pinned Railpack
1. Check the release notes of the new version for breaking changes (provider behaviour changes are the usual ones).
2. Resolve the frontend digest without pulling: `docker buildx imagetools inspect ghcr.io/railwayapp/railpack-frontend:vX.Y.Z` and take the top-level `Digest`.
3. Update `RailpackVersion` and `RailpackFrontendImage` together in `pkg/agentprotocol/build.go` (and `BuildKitImage` if you also bump BuildKit; get its digest the same way).
4. Refresh `internal/agent/build/testdata/*-plan.json` / `*-info.json` by running `railpack prepare` from the new frontend image against a Node and a static example, run `go test ./internal/agent/build/`, then `./tests/run.sh -run TestRailpackBuilds -v` (it cleans up the images and cache it created).
5. Ship the new agent. Hosts pick up the new frontend image on their next Railpack build; images already built keep running.
## Troubleshooting
| Message | Cause and fix |
|---|---|
| `railpack could not plan this source` | Railpack recognised no language. Add a Dockerfile, choose a starter, or add `railpack.json`. The preview shows the reason. |
| `requested experimental feature mergeop has been disabled` | Should not surface: the agent retries through the managed builder. If it does, the buildx plugin is missing (`docker buildx version`). |
| `create the BuildKit builder (is the docker buildx plugin installed?)` | Install `docker-buildx-plugin`, or enable the containerd image store, or use a Dockerfile. |
| `dockerfilePath: "…" not found in repository` | Build method Dockerfile (or Automatic with a custom path) and the file is not in the repository at that commit. |
| `starter:static needs an index.html` | Nothing to serve at the root, `public/`, `dist/` or `build/`. |
| Preview: `409 server_offline` | The plan is made on the server that will build; bring it online (or switch the service to build on its target). |
| Preview: `504 build_plan_timeout` | The first plan on a host downloads tooling; try again. |
---
# Git providers and deploy on push
Source: https://nebulactrl.com/docs/operations/git-providers
NebulaCtrl connects to GitHub, GitLab, Gitea, Forgejo and Bitbucket. A connection lets you pick repositories instead of pasting URLs, deploys a service when you push to its branch, shows each deployment as a status on its commit and clones private repositories with short-lived credentials.
Set `NEBULACTRL_PUBLIC_URL` first: it is the address providers deliver webhooks to and redirect OAuth flows back to. Settings → Integrations → Git providers is where connections live (admin scope).
## Connect a provider
| Provider | How | Webhooks |
|---|---|---|
| GitHub (and GitHub Enterprise Server) | One click creates a **GitHub App** (manifest flow) owned by you or an organization. Permissions: contents read, metadata read, pull requests write, commit statuses write. Events: push, pull request. Install it on the repositories you want. | The App's own webhook delivers for every installed repository. |
| GitLab (gitlab.com or self-managed) | A personal/group/project access token with the `api` scope, or an OAuth application (scopes `api read_repository`; redirect URI `/api/v1/git/oauth/callback`). | Registered per repository through the API, with a secret token header. |
| Gitea, Forgejo | Always give the instance URL. An access token, or an OAuth2 application (same redirect URI). | Registered per repository through the API; signed with HMAC-SHA256. Gitea blocks private webhook targets by default: allow the control plane in `[webhook] ALLOWED_HOST_LIST`. |
| Bitbucket (Cloud and Data Center) | A signed push webhook only (optionally a repository access token to clone private repositories). No repository picker, no statuses, no previews. | You create it by hand with the URL and secret NebulaCtrl shows once; the secret can be rotated. |
Credentials, OAuth tokens (refreshed automatically), the GitHub App private key and webhook secrets are encrypted at rest and never returned by the API.
## Deploy on push
Link a git service to a repository in its Source settings (or pick the repository when you create the service). The link and its auto-deploy settings are operational: they apply immediately, never bump the generation and are never rolled back. The clone URL and branch stay in the service config (picking another repository stages that change like any edit).
- **Branch**: a push to `config.gitBranch` deploys. The build is pinned to the pushed commit; the deployment has trigger `git_push` and shows the commit sha, message and author in the Deployments lists.
- **Watch paths** (monorepos): gitignore-style globs relative to the repository root, e.g. `apps/api/**`, `packages/*/src`, `Dockerfile`. `**` crosses directories, a plain directory name watches everything below it, a leading `!` excludes (`!**/*.md`). A push deploys when a changed file matches (and is not excluded). Providers that cap or omit the file list (a GitHub push of over 2,048 commits, a new branch without commits, GitLab pushes with more than 20 commits, every Bitbucket push) cannot be filtered: they deploy.
- **Skip**: `[skip ci]`, `[ci skip]`, `[skip deploy]`, `[nebula skip]` in the commit subject.
- **Busy services**: a push during a running deployment is queued (the newest wins) and deploys when that deployment ends.
- Auto-deploy ships the service's current configuration, including staged changes, exactly like a manual deploy.
## Commit statuses
Every deployment of a linked service reports `nebulactrl/` on its commit: pending while it queues, builds and verifies, then success, or failure (failed, rolled back) / error (canceled), with a link to the deployment. Each state is sent once; failures are retried for several minutes. Reconcile and drain redeployments are not reported. Bitbucket has no status API.
## Webhook security
`POST /hooks/git/{provider}/{connectionId}` is unauthenticated but verified: GitHub, Gitea and Forgejo by HMAC-SHA256 of the body, GitLab by its secret token header, Bitbucket by `X-Hub-Signature: sha256=…`. A delivery that fails verification triggers nothing (HTTP 401). The delivery id is remembered for 24 hours: a repeat is rejected as a replay (HTTP 409). Every delivery is logged with redacted headers (Settings → Integrations → Deliveries; last 200, 14 days): processed, ignored, rejected, replayed or error, and what it started.
## Clone credentials
A linked service clones with credentials minted when the deploy command is sent: a GitHub App installation token restricted to that repository with read access to its contents (valid one hour), or the connection's access token. They travel as send-time secrets only; they are never stored in commands, releases or logs. A service without a link keeps using its own stored git token.
## API, CLI and MCP
`/git/connections*` (connections, repos, branches, deliveries, OAuth/manifest flows) and `/services/{id}/git`. `GET /services/{id}` carries `git` (provider, repo, auto-deploy, watch paths) next to `config.gitUrl` / `config.gitBranch`; MCP `service_get` returns the same. Deployments carry `commit` and trigger `git_push`.
## Removing a connection
Refused while services use it unless forced, which unlinks them. Webhooks NebulaCtrl registered are deleted; a GitHub App stays installed at GitHub. Webhooks of repositories no service uses any more are removed hourly.
---
# Deploy hooks
Source: https://nebulactrl.com/docs/operations/deploy-hooks
A deploy hook is a URL that deploys one service. Call it from a CI pipeline after
a build, from `curl`, or let Docker Hub or GHCR call it after every image push.
It is the same as pressing **Deploy**: the service's current configuration is
deployed, staged changes included, with the same health checks, rolling update
and rollback. The deployment shows **Via deploy hook** in the Deployments list.
```
POST https:///hooks/deploy//
```
The hook is **off** until you turn it on. It needs no login: the secret in the
URL is the credential.
## Turn it on
**Service → Settings → General → Deploy hook** (or the CLI):
1. Turn on **Enable deploy hook**. NebulaCtrl creates a secret and shows the
complete URL **once**. Copy it into your CI system's secret store.
2. Pick the **Set it up** tab for your CI or registry: curl, GitHub Actions,
GitLab CI, Docker Hub or GHCR.
```sh
nebulactrl services deploy-hook # state (never the secret)
nebulactrl services deploy-hook --enable # new secret, prints the URL once
nebulactrl services deploy-hook --rotate # replace the secret, prints the URL once
nebulactrl services deploy-hook --disable # off, secret deleted
```
`NEBULACTRL_PUBLIC_URL` must be set: it is the host of the URL (422
`public_url_required` otherwise).
Who may manage a hook: owners and admins, and developers or API tokens with
`deployments:write` on the project. Anyone with `projects:read` can see whether it
is on, when the secret was created and what the last call did, but never the URL.
### The secret
- 256 bits from the system random source (`ndh_` plus 43 URL-safe characters).
- Only its SHA-256 is stored. The URL is returned by exactly two requests (enable
and rotate) and never again: not in the UI, API, CLI or audit log (a backup holds the hash, which cannot be turned back into the URL).
- **Rotate** replaces the secret at once; the old URL answers 404 from then on.
- **Turn off** deletes the secret. Turning on again creates a new one; an old URL
never comes back.
- A wrong secret, a disabled hook, a deleted service and a malformed id all give
the same `404`, so a guess reveals nothing.
Treat the URL like a password: anyone who has it can deploy that service. Keep it
in a CI secret, not in the repository or a chat. Reverse proxies and CI systems
may log URLs, so rotate it if it might have leaked. For services in a
**production** environment the Settings section says so in a warning.
## What a call does
| Call | Result |
|---|---|
| plain `POST` (curl, CI) | Deploys the current configuration. A git service builds the head of its branch; an image service pulls its configured image again. |
| `?tag=` or `?digest=sha256:…` (image services) | Deploys that image, see below. |
| Docker Hub or GHCR payload | Deploys when the pushed repository and tag are this service's image; otherwise `202` ignored. |
Responses (all `202` unless noted, JSON):
```json
{"status": "deploying", "ignored": false, "deploymentId": "…"}
{"status": "queued", "ignored": false, "reason": "a deployment of this service is running; …"}
{"status": "ignored", "ignored": true, "reason": "pushed tag \"canary\" does not match the service's tag \"stable\""}
```
| Status | Meaning |
|---|---|
| `404` | wrong secret, hook off, service gone |
| `422` | an image reference outside the service's repository (`image_outside_repository`), a tag or digest on a git service, or the service cannot be deployed (for example its server is offline or revoked) |
| `429` | rate limit, with `Retry-After` |
`202 deploying` only says the deployment was created. Follow it with
`GET /api/v1/deployments/` or `nebulactrl deployments status `; a CI job
that must wait can use `nebulactrl services deploy --wait` with an API
token instead.
### A deployment is already running
A service has one active deployment. A hook call that arrives meanwhile is
**queued**, and the newest queued call replaces an older one, exactly like a git
push. When the running deployment ends, the queued call deploys (within about 10
seconds). A queued call whose image no longer belongs to the service (the image
was changed meanwhile) is dropped.
### Rate limit
6 calls per minute per hook with a burst of 3 (`429 rate_limited`, `Retry-After:
10`). Calls with a wrong secret do not use the budget.
### Audit
Every call with a valid secret writes an audit event `deployment.hook` (source,
outcome, image, deployment id, reason; actor "Deploy hook"). Turning the hook on or
off and rotating it write `deploy_hook.enable`, `deploy_hook.disable` and
`deploy_hook.rotate`. No event contains the secret.
## Deploy a specific image: `?tag=` and `?digest=`
For image services:
```sh
curl -fsS -X POST "$HOOK_URL?tag=v1.4.2"
curl -fsS -X POST "$HOOK_URL?digest=sha256:0123…"
```
The tag or digest is applied to the service's own repository: for
`ghcr.io/acme/api:stable`, `?tag=v1.4.2` deploys `ghcr.io/acme/api:v1.4.2`. A
full reference is accepted only when it names the same registry and repository
(`?tag=ghcr.io/acme/api:v1.4.2`); anything else is `422 image_outside_repository`,
so the URL can move a service between versions but never to someone else's image.
`tag` and `digest` together are refused.
**How it fits releases.** The override is **one release, not a configuration
change**: the release records the image reference you passed (in its source), the
service's configured image and its generation are untouched, and nothing becomes
a staged change. Everything else is as for a manual deploy: staged changes are
deployed too, health checks and rollback apply, and **Rollback** to that release
redeploys exactly that image. A later plain deploy (button, hook without `?tag=`)
goes back to the configured image. If you want the service to follow a new tag
permanently, change the image in Settings → Source.
## Registries
Docker Hub and GitHub call the hook with a JSON payload. NebulaCtrl deploys only
when the **pushed repository and tag** match the service's image; every other push
is answered `202` with `status: "ignored"` and the reason, so one webhook per
repository is safe. A service whose image has no tag matches `latest`; a service
pinned by digest never matches a tag push.
### Docker Hub
Repository → **Webhooks** → New webhook, URL = the hook URL.
Payload (from Docker's [webhook documentation](https://docs.docker.com/docker-hub/repos/manage/webhooks/)):
the pushed tag is `push_data.tag`, the repository `repository.repo_name`
(`namespace/name`). Docker Hub sends no signature, so the URL secret is the only
protection.
### GHCR (GitHub Packages)
Repository or organization → Settings → **Webhooks** → Add webhook: Payload URL =
the hook URL, content type `application/json`, **Secret empty**, events: **Packages**.
NebulaCtrl reads GitHub's [`package` event](https://docs.github.com/en/webhooks/webhook-events-and-payloads#package)
(and the older `registry_package` event): the repository is
`ghcr.io//`, the tag is
`package.package_version.container_metadata.tag.name`, and only the `published` and
`updated` actions deploy. A push without a tag (the per-platform manifests of a
multi-architecture image) is ignored; so is GitHub's `ping`. GitHub's optional
`X-Hub-Signature-256` is not verified: the URL is the secret.
Anything else that posts JSON (a body that is neither payload) is a plain call.
## CI examples
GitHub Actions, with the URL in the repository secret `NEBULACTRL_DEPLOY_HOOK`:
```yaml
- name: Deploy to NebulaCtrl
run: curl -fsS -X POST "${{ secrets.NEBULACTRL_DEPLOY_HOOK }}"
```
GitLab CI, with the URL in a masked variable:
```yaml
deploy:
stage: deploy
image: curlimages/curl:8
script:
- curl -fsS -X POST "$NEBULACTRL_DEPLOY_HOOK"
```
`curl -f` makes the job fail on `404`, `422` and `429`.
## API
`GET`, `PUT {"enabled": true|false}` and `POST …/rotate` on
`/api/v1/services/{serviceId}/deploy-hook`; the OpenAPI reference lists them under
**deploy-hooks**. `GET` needs `projects:read`, the others `deployments:write`.
`PUT enabled=true` and `rotate` return the `url` once. The hook endpoint itself
(`/hooks/deploy/…`) is not part of the authenticated API and has no CORS or CSRF
checks: it takes no cookies and no tokens.
---
# SSH deploy keys
Source: https://nebulactrl.com/docs/operations/ssh-deploy-keys
A git service can clone over SSH instead of HTTPS: **Settings → Source → Repository** accepts `ssh://git@host[:port]/org/repo.git` and the scp form `git@host:org/repo.git`. An SSH URL needs a **deploy key** and a **pinned host key**; both are managed per service under Source. They apply immediately and are never staged.
## Deploy key
- **Use SSH: generate a key** creates an Ed25519 key pair on the control plane. The private key is stored encrypted (AES-256-GCM, like every secret), is **write-only** (no endpoint returns it) and travels to the agent as a send-time secret, never in a command payload, release or log. The public key is shown with a copy button, its fingerprint, and, for github.com, gitlab.com, bitbucket.org, codeberg.org and gitea.com, a link to the repository's "add deploy key" page. Read-only access is enough.
- **Upload your own key** stores an unencrypted OpenSSH or PEM private key (Ed25519, ECDSA, or RSA of at least 2048 bits). Passphrase-protected keys are rejected.
- **Generate new key** / **Upload a key** on an existing key is a rotation: the old key stops being used on the next deploy. Add the new public key to the repository first.
- **Remove key** also drops the pinned host keys. A service with an SSH URL and no key fails its deploys with "has no deploy key".
- A pull-request preview of a service copies its key and pins (same repository). Previews from forks do not get them.
## Host key pinning (trust on first use)
The first fetch (a plan, a deploy, **Test connection**) reads the git host's SSH host key and records it per service and host (`host` or `[host]:port`). The control plane and the agent then only accept that key:
- The agent trusts nothing else: it is sent the pinned `known_hosts` line with every deploy and never falls back to `ssh-agent` or `~/.ssh/known_hosts`. A host that is not pinned fails the clone.
- If the host later presents a different key, the deploy fails with "SSH host key for … changed", the refused fingerprint is recorded and shown under **Host key** with **Accept new host key**. Accepting asks for confirmation and pins exactly the fingerprint you reviewed; if the server presents yet another key in between, nothing is pinned (409).
- Accepting is audited (`service.ssh_host.accept`), as are first-use pins (`service.ssh_host.pin`), refused keys (`service.ssh_host.mismatch`) and key changes (`service.ssh_key.create`, `.rotate`, `.delete`).
A mismatch seen only by the agent (for example, interception on the agent's network path only) fails that deploy with the same message in the build log; the control plane's own view of the key is unchanged, so the **Host key** row does not show it.
## Submodules
The deploy key and the pinned host key are also used for `ssh://` and `git@host:` **submodules on the same host and port** as the repository. A submodule on another host or port is not fetched (no key, no pinned host key for it). Hosts that scope a deploy key to one repository (GitHub) need the key added to the submodule repository as well, or a machine-user key that can read both. See [git submodules](https://nebulactrl.com/docs/operations/builds#git-submodules).
## API
`GET /services/{id}/ssh` (`projects:read`), `POST /services/{id}/ssh/key` (generate, or upload with `privateKey`; `rotate: true` to replace), `DELETE /services/{id}/ssh/key`, `POST /services/{id}/ssh/test`, `POST /services/{id}/ssh/host-keys/accept {fingerprint}` (all `projects:write`).
## Limitations
- One key per service; there is no team-level key shared by several services (upload the same key to each).
- One pinned key per host and service. SSH certificates, `@cert-authority` and hashed known_hosts entries are not supported.
- Only `ssh://` and scp-style URLs; passwords inside the URL are rejected. SSH through a proxy or jump host is not supported.
- Webhook-driven auto-deploy and commit statuses need a git connection (HTTPS API); an SSH service can still be deployed by hand, by CLI or API.
---
# Variables and secrets
Source: https://nebulactrl.com/docs/operations/variables
Variables are encrypted at rest (AES-256-GCM). A variable marked secret is write-only: no API, MCP tool or UI returns its value. Changes are staged and reach a container on the next deploy.
## Syntax
A value is plain text with `${{ … }}` expressions:
| Form | Meaning |
|---|---|
| `${{slug.KEY}}` | A variable, export or platform variable of another service in the same environment. |
| `${{KEY}}` | A variable (or platform variable) of the same service. |
| `${{shared.KEY}}` | A shared variable: the environment's value, else the project's. |
| `${{team.KEY}}` | A team variable: one value for every project of the team. |
| `${{vault:/#}}` | A secret in an external secret manager (Vault, OpenBao, Infisical), fetched when a deploy starts and never stored. See [secret managers](https://nebulactrl.com/docs/operations/secret-managers). |
| `$${{` | A literal `${{`. |
| `${{urlencode(x)}}`, `${{base64(x)}}` | Transforms of a reference or a quoted string (resolve time). |
| `${{secret(len, alphabet)}}` | A random string. `len` 1..1024 (default 32). `alphabet`: `alnum` (default), `hex`, `base64`, `base64url`, `digits`, `lower`, or a quoted literal of 2..128 distinct printable ASCII characters without `$ { }`. |
| `${{randomBytes(n, enc)}}` | `n` random bytes (1..1024) as `base64` (default), `hex` or `base64url`. |
| `${{randomInt(min, max)}}`, `${{uuid()}}` | A random integer (inclusive), a UUIDv4. |
Limits: 64 expressions per value, functions nest at most 3 deep, 64 KiB per value. Syntax errors return `422 invalid_template` with the offset. `shared`, `team` and `vault` are reserved: no service can use them as a slug. `vault.KEY` is a syntax error that points at the `vault:/#` form.
## Generators run once
Generators run in the write path only, with `crypto/rand`. The generated text is stored (encrypted) together with the template; a deploy never regenerates it. Writing the same template text again keeps the value and the version, so `nebulactrl vars set TOKEN='${{secret(32)}}'` is safe in CI. `secret`/`randomBytes` force the variable to be secret (an explicit `isSecret:false` is rejected with `secret_required`). Values shorter than 8 characters warn (`weak_secret`); values shorter than 6 characters are not masked in logs.
Rotation is explicit: `POST /services/{id}/variables/{key}/rotate {"dryRun":true}` returns the plan (consumers, deploy order, downtime, warnings, blockers); `dryRun:false` stages the new value and restages consumers. Nothing deploys. Database credentials and `*PASSWORD*` variables of volume-backed services cannot be rotated once deployed (`rotation_unsupported`): the image reads them only when it initialises its data directory. Use a second role or the database panel. `rotateEveryDays` sets a reminder only.
## Layers and restaging
Service variables, environment-shared variables, project-shared variables and team variables. A shared variable reaches a service only through an explicit `KEY=${{shared.KEY}}` row ("Share to services" stages it); a team variable the same way with `KEY=${{team.KEY}}`. Shared values may reference only other shared variables, team variables, external secrets, generators and transforms; team values only other team variables, external secrets, generators and transforms. Cycles are rejected at write time (`reference_cycle`); a reference to something that does not exist yet is a warning, and blocks a deploy (`unresolved_reference`).
When a write changes the resolved environment of other services (a provider's password, a shared value, a deleted environment override), those services are restaged: their generation advances and the staged changes show the row with `origin: inherited` and `via`.
### Team variables
`${{team.KEY}}` is the third shared layer, next to the project and the environment, and applies to every project of the team. Edit them in the UI under Shared variables → Whole team, or through `GET|PUT /team/shared-variables`, `DELETE /team/shared-variables/{key}`, `POST /team/shared-variables/{key}/rotate|share` and `GET …/history`. Same behaviour as the other layers: secrets are write-only, a change restages every service that resolves to the value (in any project), cycles are rejected, versions and audit entries (`shared_variable.set|delete|rotate` with `"layer":"team"`) keep fingerprints only. Only owners and admins may change them; a member or token restricted to some projects has no access, because the values reach every project.
## Platform variables
User keys named `PORT` or starting with `NEBULA_` are reserved. Release-scoped (referenceable): `PORT`, `NEBULA_SERVICE_NAME`, `NEBULA_ENVIRONMENT`, `NEBULA_PRIVATE_HOST`, `NEBULA_PUBLIC_DOMAIN`, `NEBULA_PUBLIC_URL` (always `https://`), `NEBULA_PROJECT_NAME`, `NEBULA_SERVICE_ID`, `NEBULA_ENVIRONMENT_NAME`. Known once the source is pinned (own service only): `NEBULA_GIT_COMMIT_SHA`, `NEBULA_GIT_BRANCH`, `NEBULA_IMAGE`. Per deployment: `NEBULA_DEPLOYMENT_ID`, `NEBULA_RELEASE_ID` (a rollback carries the new deployment's id). Per container (set by the agent): `NEBULA_REPLICA_INDEX`, `NEBULA_REPLICA_COUNT` (the count at the time the container was created).
Exports (for example a database's `DATABASE_URL`) are computed by the resolver, not stored, and never injected into the exporting service. A user variable with the same key wins.
**Ports depend on who asks when the provider is on another server.** `${{api.PORT}}` and every port-bearing export (`DATABASE_URL`, `DATABASE_PORT`, `PGPORT`, `REDIS_URL`, the `DATABASE_WRITE_*` and `DATABASE_READ_*` family of a [replica group](https://nebulactrl.com/docs/operations/database-replicas#writer-and-reader-variables)) resolve **per consumer**: to the provider's mesh port when the provider is exposed over the [mesh](https://nebulactrl.com/docs/operations/mesh#cross-server-service-links) and runs on a different server than the consumer, and to its container port otherwise. The host (`NEBULA_PRIVATE_HOST`, the slug) never changes. The mesh port equals the container port whenever that port is free on the provider's server, so most references keep their value. Editing a variable that references a service on another server never adds a mesh link by itself: the staged bar says "Mesh: adds link A → B (1 device)" and the deploy of the consumer adds it.
## Build-time variables (git services)
Scope `runtime` (default), `build` or `both`. Plain build/both values become `--build-arg`; secret ones (including values that derive from a secret) become BuildKit secrets (`--secret id=KEY,env=NCBS_KEY`, the value in the docker process environment, never argv): use `RUN --mount=type=secret,id=KEY,env=KEY …`. A secret is never offered as a build arg. Build scope needs a git source (`build_scope_requires_git`) and, for secrets, Docker 23+ on the host (`buildkit_required`; there is no silent fallback). Git SHA/branch variables are runtime only, to keep the layer cache.
## Suggestions
Suggestions come from two places: the template env files of the repository and the databases of the environment.
### Keys from `.env.example` (prefill)
The files `.env.example`, `.env.sample`, `.env.template` and `.env.dist` are read from the repository root and the build context (64 KiB and 500 keys at most). `.env`, `.env.local` and `.env.production` are never read. They are read **before the first deploy**:
- through the git provider's API when the service is linked to a GitHub, GitLab, Gitea or Forgejo connection, or the URL is a public repository on github.com or gitlab.com;
- otherwise (Bitbucket, other hosts, SSH, a private repository behind a token, a provider that fails) by a scan-only command on the service's server: a depth-1 fetch of the branch head into memory, nothing checked out, nothing built. A pasted token is sent at send time only and is never stored in the command or the database. An SSH repository can only be read once the service exists and has a deploy key.
Smart defaults per key: a database export of the environment becomes a reference (`DATABASE_URL=${{postgres.DATABASE_URL}}`); a secret-looking key with a placeholder becomes a generator (`${{secret(32)}}`); a safe example value is kept; a value pointing at localhost is cleared; placeholder text such as `changeme` or `` is never proposed as a value; platform keys (`PORT`, `NEBULA_*`) are left out and reported. The comment above a key is its hint.
- **Create dialog:** the Git repository dialog reads the repository as it is filled in and, when keys are found, offers a Variables step. Every key is checked; a key that needs your own value is a field. Creating the service stages the checked keys with it (`source: "suggested"`).
- **Variables tab:** a banner "N keys from .env.example aren't set" opens the same review sheet for bulk add. "Read again" fetches the files now.
- **Kept current:** the files are read again when a git service is created or its repository, branch, build context, server or token changes (a background job), and on every push to the tracked branch. A push that lists its changed files and touches no template file skips the read.
- **Deploy plan:** variables added from the example that still have no value produce the warning "3 variables from .env.example have no value" and list them. It never blocks a deploy; setting a value (any normal write) clears it.
API: `POST /services/{id}/env-template-scan` and `POST /environments/{id}/env-template-preview` (nothing is created or stored by the preview). A repository that cannot be read answers `status: unavailable` with the reason instead of an error.
### Databases
`GET /services/{id}/suggested-variables` also lists databases of the environment that nothing references yet. Accepting a suggestion is a normal staged write.
## History and audit
Every change appends a version (kept: 50 per key, 180 days). Non-secret versions keep their value (restorable as a new version); secrets keep only an HMAC fingerprint derived from the instance secret key (matched against every key of the [key ring](https://nebulactrl.com/docs/security/secrets-at-rest) after a rotation). Audit entries (`variable.set|delete|rotate|restore|share`, `shared_variable.*`) list keys, versions, non-secret values (truncated) and fingerprints, plus the affected services; never a secret value.
## Redaction
Logs are masked for every secret value, every secret substituted into a derived variable, and their URL-escaped and base64 forms (values of 6+ characters), on the server and the agent. Values fetched from a secret manager are masked the same way: the control plane keeps them in memory only (the last three deploys of a service, for at most 30 minutes) and re-fetches them on demand, once a minute at most.
CLI: `nebulactrl vars ls|set|rm|rotate|history`, `nebulactrl vars shared ls|set|rm|rotate|share `.
---
# Networking
Source: https://nebulactrl.com/docs/operations/networking
What can talk to what on an enrolled server, and the three controls you have: the private network every environment gets, opt-in **shared networks** that connect chosen services of different projects, and a per-service **Block outbound internet** switch.
For the threat model behind this (what is isolated and what is not) read the [network section of the threat model](https://nebulactrl.com/docs/security/threat-model).
## The layout on a server
| Network | Created for | Who is on it |
|---|---|---|
| `nebulactrl-edge` | the server | **Traefik**, the error-pages server and `cloudflared` (when a tunnel is on). No app, task or tailnet container is on it. |
| `nebulactrl-env-` | each environment, on the servers it runs services on | the environment's services (alias = the service slug), and Traefik while a route needs it |
| `nebulactrl-env--int` | an environment with a service that blocks outbound internet; Docker `internal`: no gateway | the blocked services, and every other service of the environment (same alias), and Traefik while a route needs it |
| `nebulactrl-shared-` | a team shared network, on one server | the services attached to it (alias `..`) |
| `nebulactrl-mesh` | a mesh-enabled server ([mesh](https://nebulactrl.com/docs/operations/mesh)) | the server's mesh node `nc-mesh-`, which also joins the environment networks of the services it exposes to other servers |
A mesh **link node** (`nc-meshl- `) joins the environment's network, and `-int` when the environment has one, under the **slugs of the services it reaches on another server**. Nothing else changes for the services themselves: see [Across servers](#across-servers).
All managed networks are named `nebulactrl-…` so they sort **after** `nebulactrl-edge`. Docker picks a container's default gateway network by name order, and when Traefik joined an environment network that sorted before the edge network, Docker moved its gateway and re-bound its published ports (a brief connection blip). On Docker Engine 28 and newer (API 1.48) the agent also gives the edge network the highest gateway priority on Traefik, the error-pages server and `cloudflared`.
Consequences:
- A container of project A cannot reach a container of project B on the same host: they share no network. Teams on one installation never share one either.
- Services never publish host ports; only Traefik does. Traefik joins the network of each environment (or `-int` network) that holds a routed service when the route is applied, and leaves it again when the last route is gone.
- The agent probes a new container's health on its address on its environment network (a containerised agent attaches itself to the networks it probes).
- Networks are created on demand by the first deployment that needs them, and the agent removes the ones that are empty (see [Address pool](#address-pool)). You never create a Docker network by hand.
Inside an environment nothing changes: a service reaches another by its slug (`postgres:5432`), exactly like before. `NEBULA_PRIVATE_HOST` is the slug.
## Across servers
Each server has its own networks, so a service on server A cannot reach one on server B by its slug. Once the [mesh](https://nebulactrl.com/docs/operations/mesh) is enabled on both servers, **services of the same environment** can: a consumer dials `:` as usual and a link node carries the connection over the tailnet. The environment boundary still holds: a link belongs to one environment, its access rules are generated for that environment's services only, and containers of another environment on the same server have no alias for it and no route to it. A service that blocks outbound internet still reaches the providers it is linked to, because the link node is on the `-int` network. The providers' ports are the container ports when free on their server, otherwise a mesh port in 15000-15999 (variable references follow).
## Shared networks
Use one when services of **different projects** (or environments) on the **same server** must reach each other: a shared Postgres for several projects, an internal API used by two products.
- A shared network belongs to the **team** and to **one server**. It never spans servers (the mesh does not carry shared networks) and never crosses teams: another team's network does not exist as far as you are concerned (404).
- **Settings → Networks → New network**: a name (unique in the team) and the server. Needs the `servers:write` scope (owners and admins). The page lists every network with its server and the services attached, each with the hostname to copy.
- **Service → Settings → Networking → Shared networks**: tick the networks to join. The list shows the networks on the service's server. The hostname the service gets on each is shown for copying: `..`, for example `api.production.shop`. Needs `deployments:write` on the service's project.
- Attaching and detaching are **staged** like every setting: the service shows *Staged* and **N changes to apply**, the review sheet lists `networks.`, discarding it puts the attachment back as it was, and the next deploy joins or leaves the network. Rolling back restores the networks the old release had (a network that was deleted since is simply left out).
- A service on a shared network is on its environment network too; it keeps reaching its environment by slug and other teams' services only through the shared network.
- A canvas card on a shared network shows a small network chip; hover it for the hostnames.
What the control plane refuses:
| Code | Meaning |
|---|---|
| `network_wrong_server` (422) | the service runs on another server than the network |
| `service_no_server` (422) | the service is not placed on a server yet |
| `egress_blocked` (422) | the service blocks outbound internet (see below): shared networks and outbound blocking are exclusive, because a shared bridge would give the service its internet back |
| `network_in_use` (409) | deleting a network that services are still attached to |
| `network_name_taken` (409) | a network with that name exists in the team |
Moving a service to another server **detaches** its shared networks (they cannot follow it); the change is staged and shows in the review sheet. Deleting a service frees its attachments.
Audit actions: `network.create`, `network.delete`, `network.attach`, `network.detach` (the service and network names, never anything secret).
## Block outbound internet
Service → Settings → **Networking** → *Block outbound internet*. It is part of the service's configuration, so it is **staged and released** like the port or the health check: it takes effect on the next deploy, and a rollback restores the value the old release had.
A blocked service joins only the environment's `-int` network. A Docker `internal` network has no gateway, so the container has **no route to the internet, the LAN or the host**. Every other service of the environment joins `-int` too under the same alias, so it still reaches the blocked service; the proxy reaches it over `-int` as well.
Defaults:
- **Managed databases: on.** A database needs no internet, and backups do not need one either: they stream through the agent. You can switch it off for one database (for example to install an extension on start-up) in its settings.
- **Apps and workers: off.** Most apps call something: package registries, payment or mail APIs, webhooks.
Things to know:
- **Calls out fail.** Anything that resolves or connects to the internet from that container fails: updates, webhooks, third-party APIs, `npm install` at start. Cron tasks and one-off commands run with the same network membership as the service. Builds run on the host and are not affected.
- **Deploy the others too.** A service deployed *before* a sibling started blocking outbound is not yet on the `-int` network; it joins it the next time it is deployed. The deploy plan of the service you changed says so ("Other services in this environment join the internal network the next time they are deployed"). Redeploy the services that need to reach it, or use **Deploy staged** on the environment.
- **The plan warns about outbound-looking variables.** If a service that blocks outbound has variables that look like they call the internet (a public URL, `SMTP_*`, `STRIPE_*`, `AWS_*`, `S3_*`, `SENTRY_*`, …), the plan lists their names. It is a hint from names and values, not a guarantee in either direction.
- **A blocked service cannot join shared networks** (`egress_blocked`). To share a database across projects, allow outbound for it, attach it, and rely on the shared network's membership for access control. A service that may reach the internet is not filtered at all.
- **A tailnet sidecar still works.** The sidecar keeps its own outbound access (it needs it to reach the tailnet's control server) and also joins `-int`, so it reaches the blocked service; only the service container is cut off.
- **A mesh link still works.** The link node joins `-int`, so a blocked consumer reaches the providers it is granted on other servers (and a blocked replica reaches its primary) and nothing else ([mesh](https://nebulactrl.com/docs/operations/mesh#what-runs-on-a-server)).
- Route services (external upstreams and TCP/UDP exposures) have no container, so the setting does not apply to them.
## Address pool
Docker hands out subnets of new networks from its *default address pools* (`172.16.0.0/12` split into /16s and `192.168.0.0/16` split into /20s): about 30 networks per host. Every environment needs one network, or two with an outbound block, so a host with many environments or previews failed with `all predefined address pools have been fully subnetted`.
The agent therefore gives every `nebulactrl-env-*`, `nebulactrl-env-*-int` and `nebulactrl-shared-*` network an explicit subnet out of its own pool, **`10.213.0.0/16` split into /24s: 256 networks** of up to 253 containers each. The edge network `nebulactrl-edge` is created with Docker's defaults and does not use the pool.
| Flag | Environment | Default |
|---|---|---|
| `--network-pool` | `NEBULACTRL_NETWORK_POOL` | `10.213.0.0/16` |
| `--network-prefix-len` | `NEBULACTRL_NETWORK_PREFIX_LEN` | `24` |
How a subnet is picked (one network at a time, under a lock):
- The **first free** subnet of the pool, lowest address first. A subnet freed by a removed network is used again.
- A subnet is free when it overlaps neither the IPAM subnet of **any** Docker network on the host (not only NebulaCtrl's) nor an address range of a network interface of the agent. Inside a container that means the daemon's networks are what counts.
- If Docker still refuses the pick as overlapping (a host route the agent cannot see, for example a VPN), the agent tries the next subnet. After 64 refusals it stops and reports it.
- A network that **already exists keeps its subnet**, whatever it is: upgrading the agent never recreates a network, so networks created before the pool existed stay where they are (and are swept when they empty, then come back from the pool).
- Only IPv4 pools are supported; the networks are IPv4.
When the pool is full the deployment fails with a message such as `network address pool exhausted: no free /24 subnet left in 10.213.0.0/16 for network nebulactrl-env-… (256 subnets …); widen the pool with --network-pool …`. Fix it by, in this order:
1. Removing unused networks: `docker network prune` removes the ones nothing uses; the agent's sweep removes empty `nebulactrl-env-*` and `nebulactrl-shared-*` networks (and leftovers of the old `nc-env-*` / `nc-shared-*` names) by itself after 60 seconds.
2. Using smaller subnets: `--network-prefix-len 26` gives a `/16` pool 1024 networks of 61 containers each.
3. Widening the pool: `--network-pool 10.208.0.0/12` is 4096 networks of /24.
Change the pool if `10.213.0.0/16` collides with something you reach from the host (a LAN, VPN or office network): pick a range none of them uses. Changing it affects only networks created afterwards.
Set them as environment variables in `/etc/nebulactrl-agent.env` (the systemd unit loads it) and restart the agent, for example `NEBULACTRL_NETWORK_POOL=10.208.0.0/12`. An invalid pool or prefix length stops the agent at start.
## Operating notes
- **See what is where:** `docker network ls --filter label=nebulactrl.managed=true` on the server; `docker network inspect nebulactrl-env-` lists the members. The environment id's first eight characters are in the URL of the environment's services (API: `environmentId`).
- **A service cannot reach a sibling after a change:** check that both were deployed since the change (see *Deploy the others too*). A service that only has a shared-network hostname must be on the network *and* the target must be too.
- **Traefik returns 502 for a service that works:** the proxy joins a service's network when its route is applied (every route carries the networks it needs). Redeploy the service; if the proxy itself was recreated, an agent reconnect re-applies every route.
- **A leftover network** after deleting everything: it is removed by the agent's next sweep (it runs every minute and on every reconcile), once nothing is connected to it and it is at least 60 seconds old. Networks with a container still connected are never removed.
- **API:** `GET/POST /networks`, `GET/DELETE /networks/{id}`, `PUT/DELETE /services/{id}/networks/{networkId}`; the setting is `config.blockEgress` of `PATCH /services/{id}`. Omitting `blockEgress` from a config PATCH keeps the current value.
## Limits
- No per-destination egress rules: a service is either allowed out entirely or not at all.
- Networks never span servers and there is no overlay network. Services of one environment on different servers can reach each other through the opt-in [mesh](https://nebulactrl.com/docs/operations/mesh) (TCP only, tagged tailnet devices that count against your plan); across environments and for anything else use a tunnel, a tailnet or a public domain.
- Docker networks are not a sandbox for hostile tenants: everything on a host shares its kernel and Docker daemon. See the [threat model](https://nebulactrl.com/docs/security/threat-model).
---
# Domains and proxy routes
Source: https://nebulactrl.com/docs/operations/proxy-routes
Every server runs its own Traefik (managed by the agent). This page covers what you can publish through it beyond "one hostname → one service": route services (external upstreams and TCP/UDP exposure), per-domain options, and how all of it deploys. Server-level settings (uploaded certificates, the upstream policy, the tailnet) are in [proxy-tailnet-certificates.md](https://nebulactrl.com/docs/operations/proxy-tailnet-certificates).
Everything here is **staged** like any other service change: it shows up as a pending change and takes effect on the next deploy. Writes need the `proxy:write` scope (session users hold it; MCP token presets never get it) in addition to `projects:write`. A server whose agent is too old to render these features answers `422 agent_too_old` instead of silently ignoring them.
## Route services
A route service (`kind: route`) is a canvas node without a container. It has a server, a protocol and one or more upstreams:
| Protocol | What it does |
|---|---|
| `http` | Publishes an upstream NebulaCtrl does not run (a NAS on the LAN, a port on the host, a tailnet device) under one or more hostnames, with the full set of domain options below. |
| `tcp` | Exposes `listenPort` of the server and forwards to the upstream, optionally restricted to client CIDRs and with the PROXY protocol. |
| `udp` | Same for UDP. There is no per-client restriction (see below). |
An upstream is either an external `host:port` or a managed service of the same environment (`serviceId` + `port`) on the **same server**; the latter resolves to that service's current containers, so "expose my Postgres on port 25432" keeps working across deploys of the database.
```
POST /environments/{environmentId}/routes create (Idempotency-Key supported)
GET|PATCH|DELETE /routes/{routeId} PATCH needs If-Match (service generation)
GET /servers/{serverId}/ports TCP/UDP ports held by route services
```
The protocol and the server of a route cannot change; delete and recreate it instead. `DELETE /services/{id}` does the same as `DELETE /routes/{id}`: the listen port is freed and, for a deployed route, the remaining table is pushed to the server in the same transaction, so the proxy stops publishing it. Restart, stop, logs, volumes and scaling do not apply to a route (`422 not_applicable`). Server drains skip route services.
Templates that publish a port (for example a game server) create the same kind of route service.
### Listen ports
TCP/UDP ports are static Traefik entrypoints on the host. A listen port is 1024-65535 and never `80`, `443` or the proxy's internal probe/admin ports (`422 invalid_listen_port`). A port is held per server **and protocol**; a second route asking for the same one gets `409 port_in_use` naming the holder, even before the first is deployed.
**Adding a new TCP/UDP port restarts the proxy on that server** (the agent recreates the Traefik container with the extra published port). Expect about a second of dropped connections, and HTTP is briefly unavailable too. The deploy plan says so (`New TCP port N restarts the proxy on ...`). If the new container fails to start (for example the host port is taken by something else), the agent puts the previous container and routes back and the deployment fails with a message naming the port. Changing only upstreams or CIDRs does not restart anything.
### Security notes for TCP/UDP
- **Docker bypasses ufw/firewalld.** Published ports are opened by Docker's own iptables rules, so a host firewall rule such as `ufw deny 25432` does *not* protect the route. Use `allowCidrs` (and `denyCidrs`) on the route, or a firewall in front of the server (cloud firewall, VPN).
- A TCP route needs either `allowCidrs` or an explicit `openToInternet: true`; the plan warns when it is open to everyone. `denyCidrs` can be added to either (see [IP rules](#ip-rules-allow-and-deny)).
- **UDP has no IP rules.** Traefik has no UDP middleware, so `allowCidrs`, `denyCidrs` and `proxyProtocol` are rejected for UDP (`422 invalid_config`). Put UDP behind a firewall or VPN if it must not be public.
- **Public database ports** are TCP routes with a mandatory allow-list: see [Managed databases](https://nebulactrl.com/docs/operations/databases#public-port).
### Upstream policy
External upstreams are checked against the server's upstream policy twice: by the control plane when you create or edit the route (`422 upstream_denied`) and by the agent when it applies the table (so a stale or hand-edited table cannot bypass it). By default:
- denied: unspecified addresses (`0.0.0.0`), loopback (`127.0.0.0/8`, `localhost`), link-local (`169.254.0.0/16`, which includes cloud metadata endpoints such as `169.254.169.254` and `metadata.google.internal`), the proxy's own containers (`nebulactrl-*`) and the proxy admin port;
- allowed: LAN addresses, CGNAT/tailnet addresses (`100.64.0.0/10`), public addresses, DNS names, and `host.docker.internal` (the Docker host, enabled by default per server).
Admins can allow loopback and link-local per server (Server → Proxy settings). The control plane validates against the policy the agent has *applied*, so a loosened policy takes effect for new routes after "Deploy proxy" on that server.
## Domain options
Every domain of a service or an http route service has a routing rule. Edit it with `PATCH /domains/{domainId}` (`If-Match` = the owning service's generation; the response carries the new ETag). A plain hostname change needs only `projects:write`; everything else needs `proxy:write`.
| Field | Meaning |
|---|---|
| `pathPrefix` | Serve only this path and below, e.g. `/api`. The same hostname can carry several domains with different prefixes; `(hostname, pathPrefix)` is unique (`409 domain_conflict`). |
| `stripPrefix` / `addPrefix` | Remove the prefix before forwarding / prepend a path before forwarding. |
| `kind: redirect` | Answer with a redirect to `redirectTo` (a hostname, not a URL) with `redirectCode` 301/302/307/308 (default 308; Traefik only distinguishes permanent from temporary, so 301/308 render a permanent redirect, which answers 301 to a GET and 308 to other methods, and 302/307 a temporary one: 302 for a GET, 307 otherwise), keeping path and query when `preservePath` (default). A redirect domain takes no options, auth or prefix rewriting. The target must differ from the domain itself and must not close a cycle with the workspace's other whole-host redirects (`422 redirect_loop`). The classic use is `www.example.com` → `example.com`. |
| `wwwRedirect` | `true` on a whole-host `serve` domain (no path prefix) keeps a **www ↔ apex redirect** for it: the counterpart hostname (`www.`, or the host without its `www.`) is routed as a whole-host `kind: redirect` domain with code **301** and `preservePath`, pointing at this domain. So `example.com` with the option on also answers `www.example.com` with a 301 to `https://example.com/…`; on `www.example.com` it redirects the apex to `www`. Both hostnames are routed, so the proxy requests a certificate for each (the redirect copies the domain's `tlsMode`, pinned certificate and DNS-01 provider; an uploaded certificate must cover both names, `422 certificate_mismatch`), and each needs its own DNS record: both rows show their DNS status and the "Point an A record for … to " guidance. The redirect is an ordinary domain row (it shows in the list, in pending changes and in release snapshots, and is rolled back or discarded like any domain). It follows the domain's rename and TLS settings; `false` removes it and deleting the domain deletes it. Domain views carry `wwwPartner` (the counterpart name; empty for wildcards, redirects and path-prefixed domains), `wwwRedirect` (the redirect exists) and, on the redirect row, `wwwRedirectFor`. `422 invalid_www_redirect` for an ineligible domain, `409 domain_conflict` when the counterpart is already taken. Needs `proxy:write`, like every redirect. UI: the **Redirect www.… to this domain** switch in the domain editor, and **Also route … and redirect it here (301)** when adding an apex or `www.` domain. CLI: `nebulactrl domains add --www-redirect`, `nebulactrl domains www on|off`. |
| `tlsMode` | `auto` (ACME when the server has an email and the name is public, otherwise the default or an uploaded certificate), `custom` (never ACME: an uploaded certificate selected by SNI, else the default one), `none` (plain HTTP only, no redirect). |
| `certificateId` | Pin an uploaded certificate (only with `tlsMode: custom`). It must exist on the service's server and cover the hostname, wildcards matching exactly one label (`422 certificate_mismatch`). Traefik still selects by SNI; the pin is validation and documentation. |
| `options` | The typed middleware set, validated as a whole (`422 invalid_options`): `forceHttps` (default true), `compress`, `noindex`, `securityHeaders` (`basic` or `strict`, which adds HSTS), `requestHeaders`/`responseHeaders`, `cors`, `redirects` (regex), `rateLimit`, `access` (IP allow and deny lists with optional trusted-proxy depth for CDNs, see [IP rules](#ip-rules-allow-and-deny)) and `basicAuth`. |
Headers that must not be forged (`Host`, `X-Forwarded-For`, `Set-Cookie`, hop-by-hop headers, ...) are rejected. Path prefixes are `/segment[/segment]` without trailing slash.
### IP rules: allow and deny
A domain (`options.access`) and a TCP route (`allowCidrs`, `denyCidrs`) take two lists of CIDRs or single addresses (IPv4 and IPv6, at most 200 each). They are evaluated **deny first**:
1. a client that matches a `denyCidrs` entry is refused, even when the allow list would admit it;
2. otherwise a client is admitted when it matches an `allowCidrs` entry;
3. an **empty allow list means everyone not denied** (so a deny list alone blocks a bad range and leaves the rest open). On a TCP route that is only accepted together with `openToInternet: true`; on a database public port the allow list is never empty.
Traefik has no deny middleware, so the agent renders both lists as **one `ipAllowList`**: the allow list (or every address when it is empty) with the denied ranges subtracted (the allowed ranges are split around them into CIDRs). The decision is identical to the ordered rules above. A rule set that admits nobody (for example `allow 10.0.0.0/8, deny 10.0.0.0/7`) is rejected (`422 invalid_options` for a domain, `invalid_config` for a route), as is a deny list that splits the allowed space into more than 4096 networks (for example more than 32 single IPv6 hosts carved out of `::/0`, or about 128 single IPv4 hosts out of `0.0.0.0/0`); use broader entries. The web UI shows the ordered effective rules next to the lists. `trustedProxyDepth` (domains) picks the client address behind a CDN, as before; rejected clients get `rejectStatus` (default 403) on a domain, and the connection is closed on a TCP route. The post-deploy verification probe skips access rules, as before.
On a domain locked to Cloudflare (the origin lock, see [Cloudflare](https://nebulactrl.com/docs/operations/cloudflare)) the rules are evaluated against the **real visitor address**: the entrypoints trust `X-Forwarded-For` only from Cloudflare's ranges, and `ipStrategy.depth: 1` picks the last entry, the address Cloudflare saw connect. Entries to its left are whatever the visitor sent and are never read, so `trustedProxyDepth` (access and rate limit) is ignored on such a domain. The lock itself is a separate `ipAllowList` on the direct peer address (Cloudflare's ranges, no `ipStrategy`), runs first and answers a peer outside the ranges with a plain `403 Forbidden`. Traefik's `ipStrategy` can only read the peer address or `X-Forwarded-For`, so `CF-Connecting-IP` is not used; Cloudflare puts the same address at the end of `X-Forwarded-For`. A request from a Cloudflare address without any `X-Forwarded-For` has no visitor address and is refused when a user rule is set.
### Basic auth
```
PUT /domains/{domainId}/auth-users [{username, password?}] replaces the user set
DELETE /domains/{domainId}/auth-users/{username}
```
Passwords are hashed with bcrypt (cost 10) and the hash is stored sealed; responses carry user names only. A user that already exists may omit the password to keep it. Enable the protection with `options.basicAuth: true` *after* adding users (`422 basic_auth_needs_users` otherwise); the last user cannot be removed while it is on. The hashes never appear in route tables, deploy payloads, audit entries or logs: they are attached to the command as `auth:` secrets when it is sent and the agent keeps them only in its own route table. A rule with basic auth enabled and no resolvable users is a hard error on the agent: it never renders an unprotected route.
### Pending changes
Domain edits appear in the pending changes as `domains.` (added/removed) and `domains..` for `stripPrefix`, `addPrefix`, `kind`, `redirectTo`, `redirectCode`, `preservePath`, `tlsMode`, `certificateId`, `options` (compact JSON) and `authUsers` (names). Discarding restores the released value, except `authUsers` (passwords are not kept).
## How a route deploys
A **regular service** deploys as before; its payload now also carries the per-domain rules and the server's applied proxy configuration. Domain edits therefore apply with the service's next deploy (there is no fast path that only reloads routes for a regular service: container names are tied to the current deployment).
A **route service** deploys *route-only*: the deployment has strategy `route_only`, nothing is built, pulled or started. The release snapshots the route config and its domains, and one `sync_routes` command carries the server's full route table (including this route, built from the new snapshot) and the proxy configuration. The agent applies it, reports `route` and `verify` events and a result like any deployment; for an http route it probes the hostname through the proxy and treats status < 500 as routed (set `verifySkip` for upstreams that legitimately answer 5xx). Verification checks routing, not access rules.
- Plan warnings: an external http route without basic auth or an IP allowlist ("this route has no access control" and "verification checks routing, not access rules"), TCP open to the internet, UDP without allowlist, Docker bypassing ufw, and the proxy restart for a new port.
- Plan blocking: `agent_too_old`, an http route without a domain (`route_needs_domain`), a managed upstream whose service is not deployed on the route's server (`upstream_not_deployed`).
- Rolling back a route re-sends the table of the old release. Deleting a deployed route pushes the remaining table.
- When a regular service is deployed, routes that target it are rewritten to its *new* container names in the table the agent applies after the health check, so exposed ports follow the deploy. *Deploy staged* orders routes after the services they point at.
- A reconnecting agent receives the full table (services, external routes, proxy configuration and the auth/certificate secrets) in its reconcile frame and re-applies it.
## Limitations
- Staged options apply on deploy; basic-auth users apply live with the next route push (any deploy, scale or route sync on that server) because they are resolved at send time from the current users.
- A new TCP/UDP port restarts the proxy briefly; there is no hot-add of listeners.
- A route can only target managed services on its own server; cross-server upstreams are external addresses.
- Deleting a managed service does not delete route services that target it: the route drops out of the table (no upstream) until the target is deployed again or the route is removed.
- Basic-auth passwords cannot be recovered or discarded back; set them again.
- The control plane cannot reach upstream addresses itself: reachability is verified from the server by the agent's post-deploy probe (http only).
---
# Import a Compose project
Source: https://nebulactrl.com/docs/operations/compose-import
**Import Docker Compose** has two modes. **Split into services** (this page) reads a `docker-compose.yml` once and stages one ordinary service per Compose service in an environment: the same services you could have created by hand, with normal deploy, rollback, variables, domains and logs; it is a one-time import, not Compose at run time. **Run as stack** keeps the file as it is and runs it with `docker compose` as one service: see [Compose stacks](https://nebulactrl.com/docs/operations/compose-stacks). Nothing deploys until you have reviewed the result and press **Deploy** (or choose "Stage and deploy").
Where: **Create → Import Docker Compose** on the canvas, the CLI `nebulactrl import compose`, or the API (`POST /environments/{id}/compose-imports/plan` and `/compose-imports`). It needs `projects:write` (`deployments:write` for deploy-right-away); reading the file from a git repository also needs a git connection or a public GitHub / GitLab.com repository.
## What the wizard does
1. **Source.** Paste the file, upload it (plus its `.env` and any files it references), or point at a file in a repository: a connected git provider (private repositories; not Bitbucket, which has no file API here) or the https URL of a public repository on github.com or gitlab.com. From a repository the importer also reads `.env`, the `env_file` entries and the bind-mounted files next to the Compose file (at most 40 reads, 1 MiB each).
2. **Review.** The plan shows, per Compose service, what it becomes; every row can be changed. Nothing is written yet. The plan is recomputed from the same input plus your choices, so what you review is what is imported.
3. **Stage.** One transaction creates the services, volumes, files, variables, domains, links, published ports and a canvas group named after the Compose project, and writes one audit event, `compose.import`. Replica counts, shared networks and the repository link follow once it is committed; a failure there becomes a warning, never a rollback of the import.
## How things map
| Compose | NebulaCtrl |
|---|---|
| `image` | image service (the tag is kept; a digest is resolved when it is deployed) |
| `build` (context, dockerfile) | git source built with the repository's Dockerfile. Context and Dockerfile are made relative to the repository root (the Dockerfile path is relative to the context in Compose and to the repository here). The repository is the one you imported from, the `git://`/`https://` context URL (`url#branch:subdir`), or a repository you give in the review. Build `args` and `target` are not applied |
| `command` | start command (shell form is split into words like Compose does) |
| `entrypoint` | the service's **entrypoint** Docker option (replaces the image's entrypoint and its default command, like Compose; `command` is passed to it). An empty entrypoint that would clear the image's is not applied and flagged |
| `environment`, `env_file`, `.env` | variables. Interpolation (`$VAR`, `${VAR:-default}`, `${VAR:?error}`, `$$`) is done by the importer against the `.env` contents; unset variables become empty strings and are listed |
| secrets | a key that looks like a credential (`PASSWORD`, `TOKEN`, `SECRET`, `*_KEY`, `DSN`, ...), a URL with a password or a long random token is flagged as a **secret** (encrypted, never shown again). You confirm or change every flag in the review |
| `ports` | the first HTTP-like container port becomes the service port, with a generated domain suggested (on when Compose published it); Traefik `Host()` labels give explicit hostnames. Other published ports can become **TCP/UDP routes**, off by default, with the Compose host port as the public port. Known database and broker ports are treated as plain TCP (no domain) |
| `PORT` variable | sets the service port; `PORT` and `NEBULA_*` are platform variables and are not imported |
| named `volumes`, anonymous volumes | service volumes (created empty: existing data in your Compose volumes is not copied) |
| bind-mounted **file** (≤ 64 KiB, at most 16 files and 256 KiB) | a config file written into the container before it starts. `*.sh` files get mode 0755. Never put secrets in them: they are stored in plain text |
| bind-mounted **directory** or host path | **rejected** with guidance. Tick "Use a named volume instead" to create a volume at that path; the files in the host directory are not copied |
| Docker socket, `/proc`, `ipc`, `network_mode`, `pid` of another container | never applied; listed per service as danger notes. (`privileged`, `devices`, `pid: host` and the other host-level options are applied or reported, see [Docker options and the target server](#docker-options-and-the-target-server)) |
| `depends_on`, `links` | deploy order (links on the canvas). `condition: service_completed_successfully` makes the dependency a **one-shot** service that must finish first |
| `healthcheck` | an exec health check running the Compose `test` inside the container (interval capped at 10 s, deadline from `start_period` and `retries`); `disable`/`NONE` turn it off. Without one, a service with a port gets a TCP check (not HTTP, so a 404 on `/` cannot block the deploy) |
| `deploy.resources.limits`, `mem_limit`, `cpus` | memory and CPU limits |
| `deploy.replicas`, `scale` | replicas, applied after the import and only for stateless services (no volumes, with a port; at most 10) |
| `stop_grace_period` | stop grace period |
| `cap_add`, `cap_drop`, `security_opt`, `read_only`, `pids_limit`, `hostname`, `dns`, `sysctls`, `user`, `group_add`, `devices`, `gpus` and `deploy.resources.reservations.devices`, `runtime`, `privileged`, `pid: host`, `ulimits`, `shm_size`, `extra_hosts`, `init`, `stop_signal`, `tmpfs` | [Docker options](https://nebulactrl.com/docs/operations/docker-options), judged against the target server: see [below](#docker-options-and-the-target-server). Each value is checked on its own; a refused one is listed with the reason and never takes the others down |
| `networks` | the environment network; every service of the environment reaches the others by name. Several Compose networks are merged (a warning says so). An `external` network attaches to the team's [shared network](https://nebulactrl.com/docs/operations/networking) of the same name when there is one |
| `labels` | ignored, except Traefik `Host()` rules and the load-balancer port |
| `profiles` | services behind a profile are not imported unless you switch them on |
| `extends` (same file), YAML anchors and merge keys | resolved. `extends` from another file and `include:` are not followed |
| `restart`, `container_name`, `logging`, `pull_policy`, `platform`, `tty`, ... | ignored (the platform decides) |
| anything else (`working_dir`, `secrets`, `configs`, `dns_search`, `userns_mode`, `uts`, ...) | listed per service as not applied |
## Docker options and the target server
Compose keys that set container options become the service's typed [Docker options](https://nebulactrl.com/docs/operations/docker-options). Two tiers apply, the same ones the settings page and the stack allow-list use:
| Tier | Compose keys | On a restricted server | On an unrestricted server |
|---|---|---|---|
| every server | `cap_add` (safe list), `cap_drop`, `security_opt: no-new-privileges`, `read_only`, `pids_limit`, `hostname`, `dns` (at most 3 IPs), `sysctls` (curated list only), `user`, `group_add` (numeric IDs), `ulimits`, `shm_size`, `extra_hosts`, `init`, `stop_signal`, `tmpfs`, `entrypoint` | applied | applied |
| unrestricted servers only | `devices`, `gpus` and `deploy.resources.reservations.devices` with `capabilities: [gpu]` (the NVIDIA driver; `count`, extra capabilities such as `compute`), `runtime` (other than `runc`), `privileged`, `pid: host`, `security_opt: seccomp=unconfined` / `apparmor=unconfined`, `cap_add: NET_ADMIN` / `SYS_ADMIN` | **not applied**, listed as "Needs an unrestricted server" in the review | applied, with a warning note per option so you review it |
The target is the server chosen in the review. Without a server (or while the choice changes) the plan assumes a restricted one; switching the server recomputes it. An instance admin turns a server unrestricted under **Servers → the server → Unrestricted**; importing again then applies the options, or you can set them in the service settings afterwards. Nothing is dropped silently: whatever is not applied is listed per service with its reason.
Never applied, on any server, and listed as not applied:
- `devices` given as CDI names (`vendor.com/gpu=all`) or outside `/dev`; a GPU `device_ids` selection (use a count); any device reservation without the `gpu` capability; a GPU driver other than `nvidia`; a second GPU request (the first one is used)
- `security_opt` seccomp or AppArmor profiles, SELinux labels and `systempaths`
- `sysctls` outside the curated list (for example `vm.max_map_count`: it changes the host and cannot be set from a container; declare it as a host requirement in the service settings instead)
- `group_add` names (`video`), `hostname` values that are not one DNS label (`mail.example.com`), `dns` entries that are not IP addresses, `user` values that are not `uid[:gid]`
- `pid: service:...` and `pid: container:...`
- `read_only: true` when the service has config files that are not on a volume (a read-only root filesystem cannot take them): put the file on a volume or turn it off
- `cap_drop` of a capability that is also in `cap_add`
`runtime: runc` is Docker's default and needs nothing. A managed database does not take container options; the review says so.
## References between services
Variables that point at another Compose service by name are rewritten to references so they keep working and show up as edges on the canvas:
- `DB_HOST=postgres` → `DB_HOST=${{postgres.NEBULA_PRIVATE_HOST}}`
- `http://api:3000/v1` → `http://${{api.NEBULA_PRIVATE_HOST}}:3000/v1`
- `redis://cache:6379/1`, `host=db port=5432`, `db:5432`, and a whole-value host name in a host-like variable (`*_HOST`, `*_URL`, `*_ADDR`, `*_SERVER`, ...).
Only unambiguous positions are rewritten. A bare word is never treated as a host in a variable that is not host-like (`POSTGRES_DB=app` stays), an e-mail address is not a host, a longer name (`db.example.com`) is not the service `db`, and a service never references itself. Every rewrite is listed in the review with its before and after (passwords masked); in the variables table you can keep a value as it was ("Keep original host names"). A reference also becomes a deploy-order link, unless it would close a cycle. The services' slugs are what references use; if a slug already exists in the environment the new service gets a numbered one and the references follow it.
A literal `${{` in a Compose value is escaped so it can never become a reference or a generator.
## Managed databases
An **official** database image (postgres, mysql, mariadb, redis, valkey, mongo, clickhouse; plain version tags, optionally `-alpine`, `-bookworm`, ...) with a version the [managed catalog](https://nebulactrl.com/docs/operations/databases) offers can become a managed database. It is **on by default** when eligible; switch it off per service to keep the image as it is. Redis and MongoDB (source-available licences) are off until you switch them on, which acknowledges the licence.
A service is not eligible when it overrides the command, mounts init scripts or other files, has volumes besides the data directory, sets variables the engine does not own, uses an unsupported version or image variant, or (MySQL/MariaDB) connects as `root`. The review says why.
When a service becomes managed:
- user and database name are kept (`POSTGRES_USER`, `POSTGRES_DB`, ...); the **password is generated** and the credential variables of the Compose service are not imported;
- the old password is replaced by `${{db.DATABASE_PASSWORD}}` (`REDIS_PASSWORD` for Valkey and Redis) in the places that use it as a password: the `user:password@` part of a URL, `password=` in a connection string, or the whole value of a password variable;
- a Redis URL without a password (`redis://cache:6379`) gains one: `redis://:${{cache.REDIS_PASSWORD}}@${{cache.NEBULA_PRIVATE_HOST}}:6379`; a variable that only holds the host needs `REDIS_PASSWORD` set by you (the review does not guess);
- the data volume is created empty. Restore a dump or a backup after the first deploy ([database backups](https://nebulactrl.com/docs/operations/database-backups)).
## Secrets and privacy
The plan response never contains a secret value: secret variables come back as `value: ""` with `secret: true`. The pasted source (including `.env`) is processed in memory and not stored; only the resulting variables are, encrypted. The audit event records counts and host names, never values. Config files and non-secret variables are stored in plain text like everywhere else.
## Blockers
The import refuses to run (`422 import_blocked`, listing the reasons) while any of these holds, and the review shows them next to the cause:
- a required `${VAR:?message}` variable has no value;
- a service with a `build` has no repository to build from (give one, or switch it to an image);
- a referenced `env_file` or bind-mounted file was not supplied;
- a hostname is already used by another service, a TCP/UDP port is taken, or the server is revoked or draining.
## CLI
```sh
# review, change nothing
nebulactrl import compose ./docker-compose.yml --project shop --env production --plan
# stage on server box1 (reads .env and referenced files from next to the file)
nebulactrl import compose ./docker-compose.yml --project shop --env production --server box1 \
--domain web=shop.example.com --no-managed cache --publish db=5432/tcp
# from a repository
nebulactrl import compose - --repo acme/shop --connection github-acme --branch main --path deploy/compose.yaml \
--project shop --env production --server box1
```
Per-service decisions are `=` (`--include`, `--exclude`, `--managed`, `--no-managed`, `--git url#branch`, `--image`, `--domain host|auto|off`, `--port`, `--publish port/proto`, `--convert-bind /path`) and `.` for variables (`--secret`, `--not-secret`, `--skip-var`, `--no-rewrite`). `--file '='` supplies a referenced file explicitly. `--deploy` deploys right away. Everything supports `--json`.
`--as-stack` imports the file as one [Compose stack](https://nebulactrl.com/docs/operations/compose-stacks) instead of splitting it (API: `mode: "stack"` on the plan and import requests; split stays the default). The plan lists the components, the suggested domains, the `.env` variables and the allow-list violations (a violation, or `build:`, blocks the import with `422 invalid_compose`; `--server` decides whether an unrestricted server lifts the restricted ones). In this mode `--secret`, `--not-secret` and `--skip-var` take the variable name (``), `--domain` and `--port` take the component name, and the flags that shape split services (`--include`, `--exclude`, `--managed`, `--no-managed`, `--publish`, `--git`, `--image`, `--convert-bind`, `--no-rewrite`) are refused.
```sh
nebulactrl import compose ./docker-compose.yml --project shop --env production --server box1 --as-stack --plan
```
## Limits
- It is an import, not a sync: changing the Compose file later does not update the services, and importing again creates new services (existing slugs get a numeric suffix).
- Volumes, bind-mounted directories and the data of existing containers are not migrated; shared named volumes become independent copies per service (a warning lists them).
- Services run on one server, on the environment network; Compose network separation is not kept.
- Build args and targets, `working_dir`, Compose secrets and configs and the other keys listed above are not applied.
- Only the first HTTP-like port gets a domain; other ports need an explicit TCP/UDP route.
- Reading from git uses the provider's file API (GitHub, GitLab, Gitea, Forgejo) or the public raw host (github.com, gitlab.com); there is no clone, so a file larger than 1 MiB or a repository path outside the Compose file's directory (`..`) cannot be read.
---
# Import from Coolify
Source: https://nebulactrl.com/docs/operations/import-coolify
**Import from Coolify** reads a Coolify v4 instance through its REST API and stages the same projects, environments, applications and databases here. It is a **one-time migration helper**: everything lands as ordinary, *staged* services you review and deploy yourself, and nothing is deployed unless you ask. **Data is never copied** (databases, volumes); the plan prints the follow-up steps, and [Restore from a dump file](https://nebulactrl.com/docs/operations/database-backups#restore-from-a-dump-file) copies a Postgres or MySQL database.
Where: **Create → Import from Coolify** on the canvas, the CLI `nebulactrl import coolify`, or the API (`POST /imports/coolify/plan` and `/imports/coolify/apply`). It needs `projects:write` on an unrestricted token or session (the import creates projects), plus `deployments:write` for `--deploy`. It is **not** available over MCP: a Coolify token and every variable value of another instance must not pass through a model.
## What you need in Coolify
1. **API access enabled** on the instance (Settings → Advanced → API access), reachable from the NebulaCtrl **control plane** (the control plane makes the requests, not your browser).
2. An **API token with the `read` and `read:sensitive` abilities** (Keys & Tokens → API tokens). `read` lists resources; `read:sensitive` is what makes Coolify return **variable values, custom Docker run options, pre/post-deployment commands and database passwords**. Without it the plan is blocked: Coolify answers with those fields hidden, and an import would silently drop them. The token owner must be a team admin or owner. ([Coolify: API permissions](https://coolify.io/docs/api/permissions), [authorization](https://coolify.io/docs/api/authorization).)
Use a separate token and revoke it after the migration. The token is sent in the request body to your NebulaCtrl server, used for that single request, and **never stored, logged, audited or returned**; the plan carries no variable values.
The control plane follows no redirects (so the token cannot be forwarded to another host) and refuses link-local addresses (cloud metadata); private LAN and tailnet addresses are allowed, because that is where Coolify usually lives. Errors never repeat Coolify's response body.
## The flow
1. **Connect / plan.** `nebulactrl import coolify --url https://coolify.example.com --plan` (the token comes from `$COOLIFY_TOKEN`, `--token-file`, or `--token`). The plan lists every resource, its variables **by key** (never values), the references that were rewritten, and everything that is not imported. Nothing is written.
2. **Decide.** Flags (or the wizard's controls) exclude resources, rename projects and services, choose an existing project, override ports and domains, flip secret flags, supply locked values and registry credentials. The plan is recomputed from Coolify on every call, so what you review is what is imported.
3. **Stage.** `nebulactrl import coolify --url … --server ` creates, in one transaction: the projects and environments, services, managed databases, volumes, variables, domains, deploy-order links, and one audit event `coolify.import` (counts only). Git links follow once it is committed; a failure there is a warning, never a rollback.
4. **Review and deploy** from the canvas (staged bar) or `nebulactrl services deploy --wait`. `--deploy` deploys everything right away, one environment at a time.
```sh
export COOLIFY_TOKEN=… # read + read:sensitive
export GHCR_TOKEN=… # a pull credential for ghcr.io (Coolify's own login cannot be exported)
# 1. review
nebulactrl import coolify --url https://coolify.example.com --plan
# 2. stage (nothing runs yet)
nebulactrl import coolify --url https://coolify.example.com --server homelab-1 \
--registry-user ghcr.io=xpertn-bot --registry-token-env ghcr.io=GHCR_TOKEN \
--git-connection github
```
Re-running is safe: a project whose name exists gets a ` (Coolify)` suffix (or use `--rename-project` / `--into`), a service slug that is taken becomes `name-2`, and a hostname already used by a NebulaCtrl service is left out with a warning. Existing services are never changed.
## How things map
### Projects and environments
A Coolify project becomes a NebulaCtrl project and each of its environments an environment (`production` / `prod` is flagged **production**). If a project of that name exists, the new one is named ` (Coolify)`; override with `--rename-project =`, merge with `--into =` (environments with the same name are reused, services are only added), or leave it out with `--skip-project`.
### Applications
| Coolify | NebulaCtrl |
|---|---|
| Docker image (`dockerimage`, or an image name without a repository) | **image service** with the image and tag. A missing tag becomes `latest` with a warning; `sha256:` tags become a digest. Registry credentials are not readable from Coolify: the plan lists every registry host that needs one and the services that use it; give `--registry-user host=user` and `--registry-token-env host=VAR` (stored per service, write-only, sent at deploy time like any registry credential) |
| Dockerfile (`dockerfile`) with a git repository | **git service**, build method *Dockerfile*. Coolify's *Dockerfile location* is relative to its *base directory*; here the path is relative to the repository root, so `base /apps/web` + `/Dockerfile` becomes Dockerfile path `apps/web/Dockerfile`, build context `apps/web` |
| Nixpacks, Railpack, static | **git service**, build method **Railpack** (Nixpacks' successor) with a warning. Coolify's install, build and start commands (and the publish directory of a static site) become the build variables `RAILPACK_INSTALL_CMD`, `RAILPACK_BUILD_CMD`, `RAILPACK_START_CMD` (`RAILPACK_SPA_OUTPUT_DIR`): check the build preview before you deploy. `NIXPACKS_*` variables are not read by Railpack |
| Docker Compose application, or a Dockerfile stored inside Coolify | **not imported**, listed with the reason (import the Compose file with `nebulactrl import compose --as-stack`; commit an inline Dockerfile to a repository) |
| Git repository | the clone URL comes from Coolify (`owner/name` becomes `https://github.com/owner/name`). The branch is kept; a commit Coolify pinned is reported (NebulaCtrl builds the head of the branch). Private repositories: Coolify's GitHub App and deploy keys **cannot be exported**; select a git connection (`--git-connection`) so GitHub/GitLab repositories are linked, or add an SSH deploy key in the service's Source settings |
| `ports_exposes` | the first port is the service **port** (the rest are noted). An application with **no domain, no health check, no published port and no other service pointing at it** becomes a **worker** without a port, with a note; override with `--port =` / `--kind =app` |
| `ports_mappings` (published host ports) | **not imported**: add a TCP/UDP route for the port |
| `fqdn` (every domain) | one **domain** per entry. `http://` entries keep plain HTTP allowed; an explicit `:port` is the target port; a path becomes a path prefix; `noindex_domains` become the *noindex* option; `gzip`/`compress` in the Traefik labels turns on *compression*; auto-generated `sslip.io`/`nip.io`/`traefik.me` hosts become a generated NebulaCtrl hostname. Invalid or already-used hosts are left out with a note. Redirects (www), basic auth, IP allow lists and other Traefik **labels are not imported**: they are listed, and set per domain under Domains → Rules |
| health check | enabled HTTP check → an **exec** check that runs Coolify's own request inside the container: `curl` (else `wget`) against `://:`, with the interval, timeout and start period; deadline = start period + retries × interval. Coolify ran its checks inside the container too, so the image has `curl` or `wget`, and frameworks with a host allow list (Laravel trusted hosts, Django `ALLOWED_HOSTS`, Rails host authorization) accept `localhost` where they refuse a probe from outside addressed to the container IP. A command check → an exec check. Disabled → **none** (the release counts as healthy once it ran 10 s without restarting), or the image's own `HEALTHCHECK` when Coolify found one. Methods other than GET and response-text checks are not supported and are noted; like Coolify's own check, any non-error status passes |
| `limits_memory`, `limits_cpus` | memory (MiB) and CPU limits. Swap, reservation, CPU set and CPU shares are not supported and listed |
| pre-/post-deployment command | the service's **pre-deploy** / **post-deploy** command. Coolify ran them with `sh -c`; a command with shell syntax keeps it by running under `sh -c`, a plain command becomes an argv. The pre-deploy command runs once per deployment in a one-shot container of the new image before the old release is touched |
| persistent storages | **named volumes** (the app-uuid prefix is removed). The data is **not** copied; host bind mounts and file storages are listed as not imported |
| scheduled tasks | listed as not imported (add them under Tasks → Cron) |
| preview-only variables | not imported |
### Custom Docker run options (`custom_docker_run_options`)
The option string is split with a real shell-word parser (quotes, escapes, line continuations; nothing is expanded or executed) and mapped to the typed [Docker options](https://nebulactrl.com/docs/operations/docker-options). A quoted `--entrypoint "docker-php-serversideup-entrypoint bun /var/www/html/bootstrap/ssr/ssr.js"` becomes the exec-form argv `["docker-php-serversideup-entrypoint","bun","/var/www/html/bootstrap/ssr/ssr.js"]` (Coolify splits it the same way); an entrypoint replaces the image's default command, so the start command stays empty.
| Applied | Needs an **unrestricted server** (flagged; applied only when the target server is unrestricted) | Listed as not imported |
|---|---|---|
| `--entrypoint`, `--cap-add`, `--cap-drop`, `--security-opt no-new-privileges`, `--shm-size`, `--ulimit nofile/memlock`, `--add-host`, `--init`, `--tmpfs`, `--hostname`, `--dns`, `--sysctl` (curated list), `--user`, `--group-add`, `--pids-limit`, `--read-only`, `--stop-signal`; `--memory` / `--cpus` when Coolify has no limit of its own | `--privileged`, `--device`, `--gpus`, `--runtime`, `--pid host`, `--security-opt seccomp=unconfined` / `apparmor=unconfined`, `--cap-add NET_ADMIN` / `SYS_ADMIN` | `--network`, `--publish`, `--volume`, `--env`, `--label`, `--restart`, `--ip`, `--cpuset-cpus`, health flags, other ulimits, `-it` and everything the parser does not recognise |
Each option is validated on its own, so one refusal never drops the others. Whatever is not applied is listed per service with the reason; options that need an unrestricted server say so ("An instance admin has to switch the target server to Unrestricted… import again, or set it in the service settings"). Pick the target server first so the plan shows what it accepts.
### Databases
Each database becomes a [managed database](https://nebulactrl.com/docs/operations/databases) with the **same engine** and, where the catalog offers it, the same version and flavor. Coolify's image decides the engine (a `redis` database running `valkey/valkey:8` is Valkey):
| Coolify image | Managed |
|---|---|
| `postgres:` | PostgreSQL `` (Alpine variants become the Debian image, with a note) |
| `postgis/postgis:-` | PostgreSQL with the **PostGIS** flavor (for example `postgis/postgis:18-3.6` stays 18 / PostGIS 3.6; a different PostGIS minor is noted) |
| `mysql:`, `mariadb:` | MySQL / MariaDB: the same version, else the same major, else the next offered (MySQL 8.0 → 8.4 LTS) |
| `valkey/valkey`, `redis`, `eqalpha/keydb`, `dragonflydb/dragonfly` | Valkey (Redis → Redis 8; KeyDB and Dragonfly → Valkey, Redis-compatible) |
| `mongo`, `clickhouse/clickhouse-server` | MongoDB, ClickHouse |
| SQLite, `timescale/…`, `supabase/postgres`, `bitnami/…`, any other image | **not imported**, with the reason; run them as an image service with the same image |
The user and database name are kept; **a new password is generated** and the old one is not imported. A version older than the catalog's oldest is moved to the next offered one (a logical dump restores into a newer version); newer than offered is not imported. Redis and MongoDB have source-available licences and are **off by default**: `--include ` is the acceptance. Public ports, custom config files, init scripts, SSL mode, Docker options and resource limits are listed as not carried over. **The data is not copied**; the plan prints, per database, the follow-up, for example:
```sh
# on the Coolify host
docker exec pg_dump -U expertn -Fc expertn > expertn-postgres.dump
# against NebulaCtrl
nebulactrl databases restore expertn-postgres --file expertn-postgres.dump
```
Key-value databases start empty (cache, queue and session data can usually be rebuilt; drain the queues first). MongoDB and ClickHouse need `mongodump`/`mongorestore` or the engine's own tools.
### Variables
Every variable is imported with its value, except those NebulaCtrl owns or Coolify injects: `PORT` and `NEBULA_*` (platform variables), `COOLIFY_*`, `SERVICE_FQDN_*`, `SERVICE_URL_*`, `SOURCE_COMMIT`, and preview-only variables. Shared-variable references (`{{environment.X}}`) are inlined with the value Coolify resolves, with a note. A literal `${{` in a value is escaped so it stays literal.
- **Secrets.** A variable is a secret when Coolify locked it (*shown once*), when its name looks like a credential (`*_KEY`, `*SECRET*`, `*PASSWORD*`, `*TOKEN*`, `*_DSN`, `API_KEY`, `*_KEK*`, …; public keys and `*_KEY_ID` are exempt), when its value is a URL with a password, or when it became a reference to a database password. The plan says why (`secretReason`) and you can flip any flag (`--secret`, `--not-secret`).
- **Locked variables.** Coolify never returns the value of a locked variable. They are listed with `?` and **not imported** until you supply the value with `--set-env .=` (or `--set`, or in the wizard), or set them on the service afterwards. A supplied value is stored as typed, literally: a `${{…}}` in it stays text. To point a variable at another service (`${{db.DATABASE_URL}}`), set it after the import with `nebulactrl vars set`.
- **Build-time flags.** *Available at build time* maps to the variable scope (`build`, or `both` together with runtime). Build scope exists for **git** services; on image services the variable stays a runtime variable, with a note. Secret build values reach the build as BuildKit secrets (Docker 23+ on the server).
- **References.** A value that points at a Coolify database or application **in the same environment** is rewritten to a NebulaCtrl reference, so it keeps working after the services get new names and passwords:
| In the Coolify value | Becomes |
|---|---|
| the database's internal hostname (its UUID), or an application's UUID / network alias | `${{db.NEBULA_PRIVATE_HOST}}` |
| the database password (whole value, in a URL's user info, or a long password inside text) | `${{db.DATABASE_PASSWORD}}` (`REDIS_PASSWORD` for Redis/Valkey) |
| the exact connection URL Coolify shows for the database | `${{db.DATABASE_URL}}` (`REDIS_URL`, `MONGO_URL`, `CLICKHOUSE_URL`) |
| user, database name or port as the whole value of a fitting key (`*_USER(NAME)`, `*_DB`/`*_DATABASE`/`*_NAME`, `*_PORT`), only for a database the service demonstrably uses | `${{db.DATABASE_USER}}`, `${{db.DATABASE_NAME}}`, `${{db.DATABASE_PORT}}` (`REDIS_PORT` for Redis/Valkey) |
| `http://:13714` | `http://${{ssr.NEBULA_PRIVATE_HOST}}:13714` |
Other text around the match is kept (`redis://default:${{cache.REDIS_PASSWORD}}@${{cache.NEBULA_PRIVATE_HOST}}:6379/1`). A reference to a resource that is **not imported**, or lives in **another environment**, is left as it is and flagged. `--no-rewrite .` keeps one value verbatim. Services that reference each other get a **deploy-order link**, so databases deploy first. The rewritten references are listed in the plan (key and new reference; never the old value).
### Coolify one-click services
A Coolify "service" is a Docker Compose stack from a template. It is **not imported automatically**; the plan lists each one (with its project) and the next step: save its Compose file from Coolify and run `nebulactrl import compose --as-stack`, or install the matching [template](https://nebulactrl.com/docs/operations/templates).
## What stays manual
Registry credentials (unless you pass them), private-repository access, the **data** of databases and volumes, DNS (still pointing at Coolify until you move it), Cloudflare/Traefik middlewares and dynamic route files (per-domain access rules, redirects, the external routes such as a Plex upstream: use a route service), scheduled tasks, port mappings, file mounts, locked variable values, backups and notifications.
## CLI reference
```
nebulactrl import coolify --url [--server ] [flags]
--url, --token-env (default COOLIFY_TOKEN), --token-file, --token connection (token is never printed)
--plan | --dry-run review only; exit code 1 when the plan has blockers
--deploy deploy right away (default: staged)
--server required to import
--git-connection link GitHub/GitLab repositories for builds
--skip-project, --rename-project, --into project choices
--exclude, --include, --rename, --port, --kind, --engine-version per application or database
--domain /=|off change or drop a domain
--secret, --not-secret, --skip-var, --no-rewrite . per variable
--set-env .=, --set .= values of locked variables
--registry-user =, --registry-token-env =
```
Resources are named by their Coolify name or UUID; ambiguous names are rejected with the candidates listed. `--json` prints the plan or the result (no values either way).
## Limits
- Coolify v4 only (API `/api/v1`, read from the `v4.x` OpenAPI); fields that are missing or typed differently in other versions are read leniently and degrade to a note.
- The importer reads, per resource, the application, its variables, storages and scheduled tasks, databases, one-click services and servers: a large instance takes a few seconds per resource.
- Coolify teams: a token sees one team; run the import once per team.
- Variables over 64 KiB and more than 500 variables per service are rejected by the variables API like any other write.
## Troubleshooting
| Message | Meaning |
|---|---|
| `Coolify refused the token` (`coolify_unauthorized`) | wrong or expired token, API access disabled on the instance, or Coolify's **Allowed IPs** (Settings → Advanced) exclude the control plane. On the same host the request arrives from the gateway of Coolify's Docker network (`docker network inspect coolify`, for example `10.0.1.1`): add that address for the import and remove it afterwards |
| `does not look like a Coolify v4 instance` | the URL is not the instance (use the address of the dashboard, not a proxied app) |
| `cannot reach Coolify at …` | the control plane cannot connect (firewall, tailnet, DNS); remember the control plane makes the request |
| `The Coolify token cannot read variable values` | the token lacks `read:sensitive` |
| `import_blocked` | the plan has blockers: run with `--plan` and read the `[blocked]` lines |
| a domain was "left out" | the hostname is already used by a NebulaCtrl service (a previous import) |
---
# Compose stacks
Source: https://nebulactrl.com/docs/operations/compose-stacks
A **stack** runs a Docker Compose file as it is. It is one service on the canvas: you paste or upload a `docker-compose.yml`, and the agent runs `docker compose up` for it on the server you choose. The services in the file are the stack's **components**.
Use a stack when you have a Compose file you do not want to take apart (init containers, `depends_on` conditions, sidecars that share a network namespace, a vendor's reference file). Use [Import a Docker Compose project](https://nebulactrl.com/docs/operations/compose-import) with **Split into services** when you want each part to be its own service with its own domains, variables, replicas, backups and rollbacks.
Where: **Create → Compose stack** on the canvas, **Create → Import Docker Compose → Run as stack**, or the API (`kind: "stack"`, `sourceType: "compose"`, `config.stack.compose`). Decision record: [ADR 0007](https://nebulactrl.com/docs/decisions/0007-compose-stacks).
## What a stack is
- One service: **one deploy, one release, one rollback, one set of staged changes.** Editing the compose file, the variables, the domains or the limits stages a change; **Deploy** applies it to the whole stack.
- Components are not services. They have no variables, domains or settings of their own; the stack's variables are shared by the whole file, and a domain points at one component and one container port.
- The compose project is named `nc-`. Containers are named `nc--` and carry the usual `nebulactrl.*` labels plus `nebulactrl.component`. Named volumes are `nc-_`.
- **Recreate only.** `docker compose up` replaces changed containers in place, so a deploy is not zero-downtime. Components restart on every deploy (the release's deployment ID is part of their labels).
- Requires the **Docker Compose plugin 2.24 or newer** on the server. The server page shows the version it found; without it a stack cannot be deployed there (`compose_unavailable`). The installer installs the plugin; on an existing host: `apt install docker-compose-plugin` (Debian/Ubuntu) or `dnf install docker-compose-plugin` (RHEL family), then restart the agent.
## Variables
The stack's variables (including project and environment shared variables, references and secrets) are used for **Compose interpolation**: write `${DB_PASSWORD}` in the file and set `DB_PASSWORD` in the Variables tab. A variable is only given to a component when the file passes it on (`environment: [DB_PASSWORD]` or `DB_PASSWORD: ${DB_PASSWORD}`), exactly as with `docker compose --env-file`.
- Values are sent to the agent with the deploy command and handed to `docker compose` in a **clean environment**: the agent's own environment (its enrollment token, its Docker settings) never reaches the file. `DOCKER_*` and `COMPOSE_*` keys are dropped, except `COMPOSE_PROFILES`.
- Nothing is written to disk by NebulaCtrl except the compose file itself (with the `${...}` placeholders) and the generated override (no values). Docker stores what a container was created with, as for every container.
- Secret values are masked in deployment logs.
- `env_file:` is not supported (a stack has no files next to the compose file): put the values in the stack's variables.
## What the agent changes: the override
The agent never edits your file. It generates a second file, the **override**, and runs `docker compose -f compose.yaml -f override.yaml`. The override:
| Does | Why |
|---|---|
| adds the managed labels (`nebulactrl.managed`, `.service`, `.deployment`, `.release`, `.environment`, `.component`, `.replica`) | status, logs, metrics, the orphan sweep and routing find the containers |
| sets `container_name: nc--` | stable names; also DNS names for Traefik |
| joins every component to the environment network with the component name as alias (and to the environment's internal network or shared networks like any service) | other services of the environment reach `web` by name; [network isolation](https://nebulactrl.com/docs/operations/networking) is unchanged. The project's own default network stays, so components still reach each other |
| removes `ports:` (host publishing) | nothing is bound on the host; HTTP is routed by Traefik through a domain |
| applies the per-component memory and CPU limits you set in the stack's settings (they replace `mem_limit`, `cpus` and `deploy.resources.limits`) | resource control from the UI |
| sets `restart: unless-stopped` for components that set no `restart` | like every service |
| pins images to the release's digests for a rollback or a reconcile redeploy | a rollback runs the images that were running, even for `:latest` tags |
If **Block outbound internet** is on for the stack, the components join only the environment's internal network (the compose file's own networks are replaced).
## Routing
A stack has no `Port`. Add domains like for any service, but pick the **component** and the **container port**; any port from 1 to 65535 is accepted, and the ports the file declares (`ports:` targets and `expose:`) are offered. Traefik routes to `nc--:` over the environment network, with all the usual domain options (redirects, path prefixes, basic auth, IP rules, TLS).
- `ports:` entries are **never published on the host**, whatever the file says. A published TCP/UDP port (a database, a game server) is therefore not reachable from outside; keep such components internal, or use the [public database port](https://nebulactrl.com/docs/operations/databases) of a managed database. HTTP-like ports become domains.
- After the containers are healthy the agent switches the routes and **verifies through the proxy** (`GET /` through the first domain of the stack must answer below 500). A failed verification restores the previous routes and the previous stack.
- Maintenance mode and traffic analytics are not available for stacks.
## Health, deploy and rollback
- Health is Compose's: the agent runs `docker compose up -d --wait` and waits for every component with a `healthcheck` to be healthy and every other component to be running (`depends_on` conditions work). The wait is bounded by **Wait timeout** (default 300 seconds, at most 3600). A component that exits with code 0 (an init or migration container) counts as healthy ("completed").
- Steps in the deployment view: **pull** (validate the file against the allow-list, normalise it, pull images), **create/start** (`up`), **health**, **route**, **verify**.
- **A failed `up`, health check or verification restores the previous release** (the previous compose file and override are re-applied) and the deployment ends `rolled_back`; if that fails too it is `rollback_failed` and the message says how to recover. The first deployment has nothing to restore: its containers are removed, volumes are kept, and the deployment is `failed`. The last 50 log lines of unhealthy components are attached.
- **Rollback** to an earlier release re-applies that release's compose file with the image digests recorded for it. It never touches data: volumes, databases and external services are not rolled back (the dialog says so).
- **Cancel** works at any step (see [Deployments](https://nebulactrl.com/docs/operations/deployments)).
- **Restart** runs `docker compose restart` for the whole stack, **Stop** stops every component (containers are kept), **Delete** runs `docker compose down --remove-orphans`; **Delete volumes** adds `--volumes`.
## What is checked: the allow-list
Docker is root on the host, so a compose file can ask for the host itself. By default NebulaCtrl refuses a file that does. The agent normalises the file with `docker compose config --format json` (anchors, `extends`, short syntaxes and variables resolved) and checks that model; the control plane checks the file as you wrote it, so mistakes show up as you edit and on save. A violation blocks the deploy (`422 invalid_compose`, with the list).
Refused unless the server is **unrestricted** (below):
- `privileged: true`, and privileged lifecycle hooks
- `network_mode` other than `none` or `service:` (so no `host`, `bridge`, `container:`); `pid`, `ipc`, `uts`, `userns_mode`, `cgroup` that name the host or a container outside the stack
- `devices`, `device_cgroup_rules`, `gpus`, GPU reservations, `runtime` other than `runc`, `cgroup_parent`, `storage_opt`, `isolation`, block-device limits
- `cap_add` beyond the [safe list](https://nebulactrl.com/docs/operations/docker-options#capabilities), including `NET_ADMIN` and `SYS_ADMIN` (the instance-wide "advanced capabilities" switch no longer exists: they need an unrestricted server like everything else here)
- `security_opt` other than `no-new-privileges`: `seccomp=unconfined` and `apparmor=unconfined` need an unrestricted server like the typed [Docker options](https://nebulactrl.com/docs/operations/docker-options); profiles and labels are refused too
- `sysctls` outside the curated list of [namespaced container sysctls](https://nebulactrl.com/docs/operations/docker-options) (the same list plain services may set; host-wide parameters such as `vm.max_map_count` are refused)
- **host bind mounts**, in short or long syntax, relative or absolute: this includes the Docker socket (`/var/run/docker.sock`). Named volumes (declared in the file), tmpfs and anonymous volumes are fine; `volumes_from: container:...` is not
- external volumes, custom volume or network `name:`s, volume `driver_opts` (`device`/`type: none`/`o: bind` mount host paths), non-local volume drivers: they could reach another project's data
- external networks, other network drivers (`host`, `macvlan`, `overlay`), network `driver_opts`: they would bypass the environment isolation
- `file:` secrets and configs, external secrets and configs (use `content:` or `environment:`)
- labels starting with `com.docker.`, `nebulactrl.` or `traefik.`
These work on every server, as they do for plain services: `cap_drop`, `no-new-privileges`, `read_only`, `pids_limit`, `hostname`, `dns`, curated `sysctls`, `user` and `group_add`. Everything marked above as needing an unrestricted server (`privileged`, `devices`, `gpus` and GPU reservations, `runtime` other than `runc`, `pid: host`, unconfined `seccomp` and `apparmor`, `cap_add` of `NET_ADMIN` or `SYS_ADMIN`) is the same set the typed Docker options gate behind the server's Unrestricted switch.
Not supported even on an unrestricted server (the stack model cannot carry them):
- `build:` (the agent runs images; build elsewhere, or use [Split into services](https://nebulactrl.com/docs/operations/compose-import) with a git source)
- `include`, `extends` with a `file:`, `env_file`, top-level `models`, `provider:` services (they need other files or run host-side plugins)
- `scale` or `deploy.replicas` above 1 (container names are fixed)
- service names that are not lower-case letters, digits, `-` or `_`, up to 63 characters (they become container names and DNS aliases)
A component whose name equals the slug of another service in the same environment (or of a component of another stack there) is refused (`stack_alias_conflict`): both would answer to the same name on the environment network.
## Unrestricted (per server)
An **instance admin** can switch the allow-list off for one server: **Servers → the server → Unrestricted**. It is the same switch that lets plain services use [devices, GPUs, a runtime, privileged mode and the other host-level Docker options](https://nebulactrl.com/docs/operations/docker-options#the-unrestricted-switch-per-server), because the two give identical power: anyone who can deploy a one-service stack on an unrestricted server can already ask for `privileged: true` and a host bind mount. The dialog warns that anyone who can deploy on that server can take it over (privileged mode, host networking, devices, the Docker socket, host bind mounts) and asks you to type the server's name. It is recorded in the audit log (`server.unrestricted.enable` / `.disable`, with the number of stacks and of services that use unrestricted-only options). The API is `PUT /servers/{id}/unrestricted` (it was `PUT /servers/{id}/compose` and the field `unrestrictedCompose`; both were renamed without an alias), the CLI `nebulactrl servers unrestricted`.
- The flag is read at **every deploy**, including rollbacks and reconcile redeploys. A rollback to a release that needed it fails on a server that is restricted again; so does the next deploy of such a stack. Switching it off does not stop running stacks.
- Only the allow-list is lifted. Ports are still never published on the host, the unsupported list above still applies, and containers still carry the managed labels and join the environment network (unless the file asks for `network_mode`).
- Keep unrestricted servers for stacks you wrote yourself. See the [threat model](https://nebulactrl.com/docs/security/threat-model).
## Logs, metrics, terminal
- **Logs:** all components interleaved, each line prefixed `component | `; filter by component in the UI (`?component=` in the API). History and drains keep the prefix.
- **Metrics:** the charts show the whole stack (CPU and memory summed over its containers); the last sample lists each component. The component list in the Overview shows the same numbers per component.
- **Terminal and files:** the shell and the file browser open in one component; the component's position in the sorted component list is the `replica` index.
## Import
**Create → Import Docker Compose** has two modes: **Split into services** (the one-time import described in [compose-import.md](https://nebulactrl.com/docs/operations/compose-import)) and **Run as stack**. Stack mode keeps the file as written (nothing is rewritten, `${VAR}` stays), turns the `.env` entries into the stack's variables (credential-looking keys are flagged secret), suggests a domain for components that publish an HTTP-like port (or carry a Traefik `Host()` label), shows the components and any allow-list violations, and stages one stack. Nothing deploys until you deploy. From the CLI: `nebulactrl import compose --as-stack`.
Directory and file bind mounts that the split importer can convert into config files or volumes are **not converted** in stack mode; they are violations: replace them with named volumes or `configs:` with `content:`.
## Known limits
- No rolling updates, replicas, autoscaling, maintenance mode, one-off tasks or cron tasks, volume backups (file-level) or preview environments for stacks.
- `build:` is not supported; images are pulled by tag at deploy time (`pull` happens on every deploy) and a rollback pins the recorded digests.
- Traffic analytics and request origins do not cover stacks.
- Components are not services: no per-component staging, deployments or variables.
- Only HTTP is routed. Published TCP/UDP ports are removed.
- A stack lives on one server; moving it creates a new deployment there and does not move volumes.
- Compose project directories are not backed up beyond the compose file kept in the release; named volumes are your data.
---
# Pull-request previews
Source: https://nebulactrl.com/docs/operations/previews
A preview is a real environment that NebulaCtrl creates for every open pull
request of a repository connected to the project, deploys with the pull
request's head commit, and deletes again when the pull request is closed or
merged. It needs a [git connection](https://nebulactrl.com/docs/operations/git-providers) (GitHub, GitLab, Gitea or
Forgejo) with webhooks, and at least one service of the project's base
environment linked to the repository.
Open it from **Project → Previews**. The page lists the open previews (status,
age, last push, URLs, delete) above the settings. The environment switcher
groups previews under the environment they were cloned from, labelled
`PR #12 · title`; a preview's canvas carries a banner with its pull request,
status and a delete button.
## What a preview is
| | |
|---|---|
| Environment | `environments.kind = 'preview'`, slug `pr-`, name `PR # · `, `parent_environment_id` = the base environment. Standard environment APIs work on it. |
| Services | A copy of the base environment's services. Services connected to the pull request's repository build the head commit on the PR branch; every other service is cloned as it is (image services run the same image, git services of other repositories build their own branch). Route services (external upstreams) are not cloned. |
| Databases | Created **fresh**: the same engine and version, new generated credentials, an empty data volume. Nothing from the base database (data, credentials, extra variables) is copied. References such as `${{postgres.DATABASE_URL}}` resolve to the preview's database. |
| Variables | Copied from the base services, including secret variables, so the preview runs like the base. Variables that contain generators (`${{secret(32)}}`) are generated again. The environment-level shared variables of the base environment are copied; project-level ones apply as before. |
| Volumes | Declared again, **empty**. |
| Domains | Each service that has a public domain in the base environment gets a generated `-pr-. ` (the server's base domain, else `.sslip.io`). If the name is taken, the project slug and then a counter are added. Custom domains are never copied (hostnames are unique). |
| Canvas | The base environment's layout, so the preview looks the same. |
The first deployment starts the databases and other providers first; a service
whose variables reference another service waits until that one succeeded. If a
deployment fails, services that depend on it are not started and the preview is
marked `failed` with the reason.
## Lifecycle
| Event | What happens |
|---|---|
| PR opened / reopened | The environment is cloned and deployed. A comment with the status and URLs is posted on the pull request and a commit status `nebulactrl/preview` (pending, then success or failure) is set on the head commit. |
| New push | The services built from the repository redeploy at the new head commit (pinned to the SHA). Other services keep running. A duplicate webhook delivery does nothing. |
| PR closed / merged | The preview is deleted: every service with its container, **volumes** and domains (`remove_service` with `deleteVolumes`), then the environment. The comment is updated to say so. |
| Over the limit | The least recently pushed running preview is **paused**: its services are stopped, volumes stay. The next push to its pull request resumes it (stopped services start again, the pushed commit is deployed). |
| Idle | A preview with no push for the configured idle days is deleted (checked every 10 minutes). Paused previews count as idle too. |
If a push arrives for a pull request without a preview (for example after an
idle deletion) one is created. The sticky comment is found by a marker
(``), so every project that previews a
repository gets one comment per pull request.
## Settings
`GET/PUT /api/v1/projects/{id}/previews/settings` (`projects:read` / `projects:write`):
| Setting | Default | Notes |
|---|---|---|
| `enabled` | off | |
| `baseEnvironmentId` | the production environment | A standard environment of the project. A base environment cannot be deleted while previews of it exist (`409 environment_has_previews`). |
| `forkPrs` | off | See [Fork pull requests](#fork-pull-requests). |
| `maxConcurrent` | 5 | 1–50 previews running at once. Past it the oldest is paused. |
| `idleDays` | 7 | 1–365. |
| `excludedServices` | none | Service slugs of the base environment that previews do not get. |
| `seedCommands` | none | `[{service, command, runOn}]`, below. |
### Seed commands
A preview's database starts empty. A seed command runs a one-off job (the same
mechanism as one-off commands in service settings) in a service of the preview
once **all deployments of the wave succeeded**, for example
`npm run db:migrate && npm run db:seed` in the app that owns the schema.
`command` is a shell command line run with `sh -c` in the service's current
image with its environment and networks. `runOn` is `create` (default: the first
deployment only) or `push` (also after every later deployment). Seed commands
run one after the other; a failing one (non-zero exit or timeout) marks the
preview `failed` and is shown in the comment, the commit status and the list.
Output is in the service's run history.
## Fork pull requests
Off by default: a fork's code is not yours, and a preview runs it. When an admin
turns `forkPrs` on, fork previews **never receive secrets**:
- no secret variable, and no variable whose value references a secret shared
variable or the vault (`${{shared.KEY}}`, `${{vault.…}}`);
- no secret environment-level shared variables;
- no git token, and no repository link: the clone uses the fork's public HTTPS
URL (the services that build the repository get `gitUrl` = the fork and the
PR branch). Private services of other repositories in the project cannot be
cloned for a fork preview;
- no registry credentials for private images.
Credentials of the preview's own databases are generated for the preview. Pull
requests from forks also never get commit statuses or comments through the
service's repository link; the comment and aggregate status of the preview
itself are still posted.
Pull-request previews of same-repository branches are trusted like a push to
that repository: whoever can push a branch can run code with the base
environment's secrets. Restrict who can push, or keep previews off for projects
whose secrets must not reach every contributor.
## Where previews build
Previews follow the [build placement](https://nebulactrl.com/docs/operations/build-servers#where-builds-run) of their services, with one difference: **a preview never builds on a shared build server** unless that server has **Allow previews** on. A preview's code is the pull request's (and for a fork, untrusted), so by default it is built on the preview's own target server, as it was before build servers existed, even when its base services build on a build server. When a build server does allow previews:
- the preview builds there like any deployment, with its own per-project BuildKit builder and cache, and its image is pushed to the registry under the usual repository and a unique `r-` tag (the repository name has the project and service, not the environment, so a preview's repository can be the base service's repository; deployments always pull the **digest**, never a tag);
- fork previews are still built from untrusted code on that server, with the registry push credential and the other projects' builders on the same host. Keep **Allow previews** off for any build server that matters, or dedicate a build server to previews and give it a registry credential that cannot touch your production repositories.
If a preview's services resolve to a build server but none allows previews, the build runs on the target and the preview's timeline says so ("No build server available; building on "); this never blocks the preview.
## Limits and costs
Each preview runs every service of the base environment (minus exclusions): size
`maxConcurrent` by memory, and exclude heavy services (a search cluster, a
worker) that previews do not need. A paused preview keeps its volumes (disk) but
no memory. Deleting a preview removes its volumes, including the preview
database.
## API
| | |
|---|---|
| `GET /projects/{id}/previews` | the project's previews: status (`creating`, `deploying`, `seeding`, `ready`, `failed`, `paused`, `deleting`), PR, head commit, URLs, `createdAt`, `lastPushAt` |
| `DELETE /previews/{id}` | start deleting a preview (202); needs `projects:write` |
| `GET/PUT /projects/{id}/previews/settings` | above |
Audit actions: `preview.settings.update`, `preview.create`, `preview.deploy`,
`preview.pause`, `preview.delete` (system actor for the automatic ones).
Notifications: `preview.created` and `preview.deleted`.
## Troubleshooting
- **No preview appears.** The base environment must contain a service linked to
the repository the pull request belongs to (Service → Source), previews must
be enabled, and the connection's webhook must deliver (Settings → Git →
Deliveries). Fork pull requests are ignored while `forkPrs` is off (logged as
"fork pull request ignored").
- **`failed` with "could not be deployed".** The message is the deployment's:
a missing server, an unresolved reference to an excluded service, a failed
build. Fix it and push again, or redeploy the service from the preview's
canvas.
- **Preview stuck deleting.** A service of the preview still has a running
deployment; the teardown retries every few seconds until it finishes.
- **Containers of a deleted preview remain.** `remove_service` is delivered like
any command; if the server was offline for longer than 15 minutes the command
expired and the reconciler removes the containers later (volumes may need a
manual `docker volume rm nc-vol--…`).
---
# Clone and move
Source: https://nebulactrl.com/docs/operations/clone-and-move
Three operations rearrange what you already run:
| | What it does | Where |
|---|---|---|
| **Clone a service** | A staged copy of one service, in the same or another environment. | Service menu → **Clone**, `nebulactrl services clone`, `POST /services/{id}/clone` |
| **Clone an environment** | A new, non-production environment with a staged copy of every service. The usual "staging from production". | Environment switcher → **Clone environment**, `nebulactrl envs clone`, `POST /environments/{id}/clone` |
| **Move a service** | Re-homes a service in another environment, staged until its next deploy. | Service menu → **Move to environment**, `nebulactrl services move`, `POST /services/{id}/move` |
Nothing runs because of a clone: the new services are staged like any new service
and start when you deploy them. Both clone operations and the move accept
`dryRun` (`--dry-run`): they report what they would do, including conflicts, and change
nothing.
You need write access (`projects:write`) to the source **and** to the target
environment, because a clone carries the source's secrets. A token restricted to
one project cannot clone out of it into another.
## What a clone copies
| | |
|---|---|
| Configuration | Image or repository, build settings, port, health check, start command, resource limits, [Docker options](https://nebulactrl.com/docs/operations/docker-options), pre- and post-deploy commands, files. A Tailnet setting is **not** copied (one tagged device per service). |
| Server | The source's server, or the one you pick (`serverId`). A clone on another server is checked like any new service (online, accepting workloads, Docker options allowed there). |
| Variables | All of them. **Secrets are copied sealed**: each value is opened and sealed again for its new row inside one transaction; no value appears in a response, an audit entry or a log, and the clone's ciphertext is not a copy of the source's. Variables with a generator (`${{secret(32)}}`, `uuid()`, …) are **generated again**, so a staging copy never shares a secret with production. |
| References | See [references](#references-and-slugs). |
| Credentials | The Git token, the registry login and the SSH deploy key (with its pinned host keys) are sealed again for the clone. A git clone is linked to the same repository with **auto-deploy off**; switch it on in the service's Git settings. |
| Volumes | The **definitions** (name, mount path) are copied; the volumes are **new and empty**. Data is not copied (see [volume data](#volume-data)). |
| Databases | A managed database is created **new and empty**: same engine, version and tuning, **new credentials**, an empty data volume. Nothing from the source database (data, credentials, extra variables) is copied. The result carries a note: [restore a dump](https://nebulactrl.com/docs/operations/database-backups#restore-from-a-dump-file) into it. A read replica is not cloned; add a replica to the cloned primary instead. |
| Domains | Never duplicated, because hostnames are unique. See [domains](#domains). |
| Links | A service clone in the **same** environment gets the links the source draws to other services (not the links other services draw to the source). An environment clone copies every link between the cloned services. A clone into another environment copies no link. |
| Environment extras | An environment clone also copies the environment's shared variables (sealed again, generated ones regenerated) and its canvas layout. |
Not copied: backup schedules, cron tasks, deploy hooks, shared-network
attachments (a staging copy must not join production's network), maintenance
mode, replica counts and autoscaling, release and deployment history. Route
services and Compose stacks are not cloned: an environment clone lists them as
skipped; cloning one on its own is refused with `not_applicable`.
### References and slugs
A **service clone** in the same environment gets a new slug (`api` becomes
`api-copy`). References *to the clone itself* in its own variables follow it
(`${{api.PORT}}` becomes `${{api-copy.PORT}}`); references to other services
stay, so the clone talks to the same siblings the source does.
An **environment clone** keeps every slug, so a reference such as
`${{postgres.DATABASE_URL}}` in the cloned `api` resolves to the **cloned**
`postgres`, with the clone's own credentials. The clone is self-contained: no
cloned service points back at production.
When a clone goes to **another environment**, the references of its variables are
checked there. The result lists every reference that does not resolve (a service
that is not in the target environment, a shared variable that does not exist),
so you can add the missing service or change the variable before deploying.
### Domains
A hostname can belong to one domain rule only, so a clone never gets its source's
domains. Choose:
| Mode | Result |
|---|---|
| `drop` (default) | The clone has no domain. Attach new ones in the Networking tab. |
| `suffix` | Each public domain is cloned with a suffix on its first label: `app.example.com` becomes `app-copy.example.com` (same environment), or `app-.example.com` (another environment, or an environment clone). Set `domainSuffix` to choose another. Path rules, port and the other options come along, a custom certificate does not (the clone asks for its own), and basic-auth users are copied (hashes sealed again), so a protected site stays protected. |
Generated domains (`--. `) are regenerated for the
clone's own name and environment. Redirects, apex domains (they cannot be
suffixed) and Compose component domains are skipped and listed.
A hostname that is already taken is a **conflict**: the whole clone fails with
`409 domain_conflict` (`details.hostnames`) and nothing is created. Choose another
suffix, or drop the domains. A dry run reports the conflicts without failing.
### Volume data
Cloning does not copy volume data. NebulaCtrl's backup and restore machinery is
per service: a volume backup goes to that service's own repository and restores
only into the same service, and database restore creates its sibling from a
backup of the source. Copying data would mean taking a backup, restoring it into
the clone and waiting for both, which is not a cheap side effect of a clone. To
bring data over:
- **Databases:** restore a dump into the cloned database (the database's
**Backups** tab → **Restore from a file**, or `nebulactrl databases restore`;
[restore from a dump file](https://nebulactrl.com/docs/operations/database-backups#restore-from-a-dump-file)), or restore a database backup **as a new
database** ([Database backups](https://nebulactrl.com/docs/operations/database-backups)) instead of cloning.
- **Other volumes:** back the source volume up and restore it into the clone with a
[volume backup](https://nebulactrl.com/docs/operations/backups) once the clone has its own destination, or copy the
files with the web terminal.
## Moving a service
A move changes the environment of a service. It is **staged**: the service keeps
running in its old environment until you deploy it. That deployment joins the new
environment's network and resolves its variables there. Domains, volumes, data,
variables and the release history stay with the service. The service's
**Pending changes** list the move as `environment: Project / old → Project / new`.
Before anything is written the move is checked:
| Check | Result |
|---|---|
| Variables of the service that reference services staying behind (`${{postgres.DATABASE_URL}}`), or project/environment shared variables that do not exist in the target | Listed as `outgoing`. They **break**: the next deploy fails with `unresolved_reference` until you fix them. |
| A variable that references a service the target environment also has (a `postgres` in both) | Listed as `rebound`: it would silently point at the other environment's service. |
| Other services of the old environment that reference the moved one | Listed as `incoming`. They break, and are marked as having staged changes so a deploy of the environment shows it. |
| Canvas links of the service | Removed (listed); draw them again in the new environment. |
| The target already has a service with the same slug | The moved service gets a free one (`api-2`); references to itself are rewritten. |
| A deployment of the service is running | `409 deployment_in_progress`. |
| The service is in a replica group or has a cross-server link | `409 move_blocked`: remove those first. |
| Route services, Compose stacks, the platform project, pull-request previews | Refused (`not_applicable`, `preview_environment`). |
A move that breaks references is refused with `409 move_blocked` (the plan is in
`details.plan`) unless you pass `force` (`--force`; the web app asks you to
confirm). Moving across **projects** of the team is allowed; the result warns that
project-level shared variables, git connections and team settings follow the new
project. Moving a service out of or into a **production** environment needs
approval for guarded clients (AI tokens), like other production changes.
Undo a move by moving the service back.
## In the web app
- **Clone a service:** open the service, then the **⋯ menu → Clone**. Pick the
environment (default: this one), a name, the server and the domain mode; **Check**
runs the dry run and shows conflicts, unresolved references and notes; **Clone**
creates the staged copy and opens it.
- **Move a service:** **⋯ menu → Move to environment**. The dialog shows the plan
(broken references, removed links, a new slug) before you confirm; the service
then shows a *Staged* change you apply with **Deploy**.
- **Clone an environment:** the environment switcher's **Clone environment**
asks for the new name, the domain mode and optionally one server for every
clone, shows the services and notes, and opens the new environment.
## CLI
```bash
# A staging copy of production; domains get -staging
nebulactrl envs clone production --project shop --name Staging --domains suffix
# Clone one service (staged); --dry-run shows what would happen
nebulactrl services clone shop/production/api --env staging --dry-run
nebulactrl services clone shop/production/api --env staging --domains suffix
# Move: check, then do it
nebulactrl services move shop/production/api-copy --env staging --dry-run
nebulactrl services move shop/production/api-copy --env staging --force
```
All three print JSON with `--json`.
## API and MCP
`POST /services/{serviceId}/clone`, `POST /environments/{environmentId}/clone` and
`POST /services/{serviceId}/move` (see the [API reference](https://nebulactrl.com/docs/reference/api)).
Both clones honour `Idempotency-Key`. Over MCP they are generic operations
(`find_operation` / `call_operation`): cloning is a creation like `createService`,
and `moveService` is production-guarded when the source or target environment is
production. Secrets never appear in any of the results.
## Audit
`service.clone`, `environment.clone` and `service.move` are recorded with the
source, the target and counts (services, forced, broken references), never with
values.
## Limits
- No data is copied (see [volume data](#volume-data)); databases start empty.
- Clones stay inside one team. A clone is never production, and never joins
shared networks, the tailnet, or the source's schedules.
- Route services, Compose stacks and read replicas are not cloned; routes,
stacks, replica-group members and services with cross-server links cannot be moved.
- Cloning a service whose source is on a server that is offline works (the clone
is only staged), but deploying the clone needs its own server online.
---
# Replicas and autoscaling
Source: https://nebulactrl.com/docs/operations/scaling
A stateless service can run on 1–10 replicas on its own server. Replicas are
containers of the same release; the server's Traefik load-balances across them
and health-checks every upstream.
**Eligible services:** no volumes, an HTTP port, and a kind other than
`database`. Anything else is fixed at one replica (`422 replicas_require_stateless`).
A deploy of a service that stopped being eligible (a volume was added, the port
removed) runs one replica, resets the count to 1 and turns autoscaling off.
## Manual scaling
`PUT /api/v1/services/{id}/scaling {"replicas": 3}` or
`nebulactrl services scale 3`. It applies immediately and is never a
staged change: a deployed service gets a `scale_service` command that reuses
the running release (no rebuild, no new deployment); an undeployed one stores
the setting for its first deploy. New replicas must pass the health check
before they receive traffic; removed replicas leave the upstream list first and
drain for 2 seconds. Scaling is refused while a deployment is running
(`deployment_in_progress`) or the service is stopped (`service_stopped`).
Deploys keep the current replica count. A rolling deploy starts N new
containers, waits until all are healthy, switches routing to the new set and
retires the old one; if any new replica fails, the old set keeps serving.
## Autoscaling
`nebulactrl services scale --autoscale 1:5:70` (min:max:target%), or
`PUT .../scaling {"autoscaling": {"enabled": true, "min": 1, "max": 5, "targetCpuPercent": 70}}`.
While enabled, the replica count is managed by the autoscaler and a manual
`replicas` value returns `409 autoscaling_enabled`.
A periodic job (every 30 s) judges each deployed service from the per-replica
CPU samples (percent of the replica's CPU limit, or of one core without a limit):
| Rule | Value |
|---|---|
| Scale out +1 | average above the target for 2 minutes |
| Scale in −1 | average below 35 % (never more than half the target) for 10 minutes |
| Cooldown | 3 minutes after scaling out, 10 minutes after scaling in |
| Bounds | min and max, within 1–10 |
Every sample bucket of the window must contain data; a gap restarts the clock.
**Capacity guard.** A scale-out is blocked when the server's free memory is
below one replica's memory (the configured memory limit, else the observed
average per replica) or the server has an active disk alert. The service then
shows `scaling.blockedReason`, e.g. "Scale-out blocked: agent1 has 0.4 GiB
free", until the condition clears. A failed `scale_service` command is shown
the same way ("Scale failed: …").
Each autoscale action is audited as `service.autoscale` (system actor, with the
reason, e.g. "CPU 84% > 70% for 2m"); manual changes as `service.scale`.
## Observability
`GET /services/{id}` reports `scaling.replicas` (desired), `scaling.ready`
(running and healthy) and `observed.replicas[]` per container. The metrics
endpoint sums CPU and memory over the replicas, adds `cpuPerReplica`, and the
newest point carries `replicas: [{index, cpuPercent, memBytes}]`. Runtime logs
merge all replicas, prefixed `[r]` when there is more than one.
Replicas share one server. Spreading replicas across servers is not supported.
---
# Tasks and cron jobs
Source: https://nebulactrl.com/docs/operations/tasks
Four ways to run a command next to a service. All of them run in a **throwaway
container of the service's own image**, on the service's server, with the
service's environment variables, on its environment network (so
`postgres:5432` resolves) and the edge network. They publish no ports, never
restart, use the service's memory and CPU limits, have a timeout, and the
container is removed when the command ends, whatever its outcome.
The command is an **argv array without a shell**. Use `["sh", "-c", "…"]` for
shell syntax. It replaces the image's entrypoint (like `docker exec`, and
unlike `docker run image args`), so an image with `ENTRYPOINT ["/app/server"]`
runs `["/app/server", "migrate"]` as written.
## Cron tasks
Per service, up to 20 tasks: `name`, `schedule`, `command`, `enabled`,
`timeoutSeconds` (default 1800), `concurrency` (`forbid` or `allow`).
- **Schedule**: five fields (`minute hour day-of-month month day-of-week`) or
`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. Parsed by the in-house
`internal/cronexpr`. The finest resolution is one minute.
- **Time zone**: each task has an IANA `timezone` (`Europe/Berlin`,
`America/New_York`, …; default `UTC`, and every task created before the field
existed stays `UTC`). The schedule is read as wall-clock time in that zone, so
"every day at 03:00" stays at 03:00 across daylight-saving changes (the UTC
instant moves by an hour). A wall time that does not exist (the spring-forward
gap) is skipped for that day; one that happens twice (the autumn hour) runs
once, the first time. An unknown zone is `422 invalid_timezone`. Set it with
`timezone` on `POST /services/{id}/tasks` and `PATCH /tasks/{id}` (changing it
recomputes the next run), `nebulactrl tasks create --timezone` /
`tasks update --timezone`, or the **Time zone** field of the task dialog, which
previews the next runs in the task's zone, in UTC and in your own zone. The
list shows the zone next to the schedule. `POST /task-schedules/preview` takes
`timezone` too. The control plane binary embeds the zone database, so zones
work on minimal images.
- A River periodic job evaluates due tasks every minute. A task that is due runs
**once**, however long the control plane was away: missed ticks are not
replayed.
- `forbid` (default): while the previous run of the task is still active the tick
is recorded as **skipped** ("the previous run is still running"). `allow`
starts another run. A streak of identical skips is recorded once, so it never
pushes real history out.
- A tick is also skipped, with the reason, when the service is stopped, has never
been deployed or its server is offline.
- **History**: the newest 50 finished runs of each task (and 50 one-off runs per
service) are kept with their output; older ones are deleted. Output is
redacted with the service's secret values and capped at 10,000 lines.
- **Run now** starts a run immediately, ignoring the schedule and `concurrency`.
- An acked run whose agent never answers is failed after `timeout + 10 minutes`
("the agent never reported a result").
- **Notifications**: a failed or timed-out **cron** run (and runs started by
features such as preview seeding) emits `task.failed`; runs a person started
from the UI, CLI or MCP do not, you are looking at them.
## One-off commands
`POST /api/v1/services/{id}/run {command, timeoutSeconds?}` (scope
`deployments:write`, audited as `service.run_command` with the command line).
Returns `202` with a run. Follow it with
`GET /task-runs/{runId}/logs?follow=true` (server-sent events; `event: end` carries
`{status, exitCode}`), or `GET /task-runs/{runId}` for the status and exit code.
`409 not_deployed` / `server_offline` when it cannot start.
- UI: the dock's **More** menu and ⌘K ("Run command in …"); the Tasks section of
the service settings also has the button, plus the run history drawer.
- CLI: `nebulactrl services run -- ` streams the output and
**exits with the command's exit code**. `--detach` returns the run id.
- MCP: `run_command` (needs `deployments:write`; waits up to `waitSeconds`,
default 20, returns at most 500 redacted lines, 64 KB) and `get_task_run`.
Docker exec level access is root-equivalent on that service's data, so the scope
is the same one that can deploy. Output is untrusted application text.
## Pre-deploy command
Service settings → Deploy → **Pre-deploy command** (`config.preDeployCommand`,
`preDeployTimeoutSeconds`, default 600, at most 3600).
Order of a deploy on the agent: pull or build the image → **pre-deploy** → stop
the old container (recreate only) → create and start the new one → health check →
route → verify. The pre-deploy container uses the **new image** and the deploy's
exact environment. Until it succeeds nothing of the previous release has been
touched, so for rolling **and** recreate deploys the old release keeps serving
while migrations run.
A non-zero exit (or a timeout) fails the deployment at the step `predeploy`; the
message carries the exit code and the last 20 output lines, and says that the
previous release is still serving. The output is in the deployment logs. The LED
strip keeps its three LEDs (Build, Release, Verify): `predeploy` lights Release
and the card shows a small "pre-deploy" label under it while it runs.
It runs for every deployment that creates a **new release** (manual, staged,
git push, preview). It does **not** run for rollbacks, reconcile redeploys or
server drains: those re-run an existing release and must not touch data.
Beware that migrations are not undone by a rollback.
## Post-deploy command
Service settings → Deploy → **Post-deploy command** (`config.postDeployCommand`,
`postDeployTimeoutSeconds`, default 600, at most 3600). It mirrors the pre-deploy
command, but runs **after** the swap, for work that needs the new release to be
live: cache warm-up, a search reindex, a smoke call.
Order of a deploy on the agent: pull or build → pre-deploy → create and start →
health check → route → verify → **retire the previous release** → **post-deploy**.
The post-deploy container is a one-shot container of the **new image** with the
deploy's exact environment, on the environment network (it reaches the live
service by its name, without an alias of its own). It uses the same memory and
CPU limits as the service and is removed when it ends.
**A failure only warns.** The new release is already serving, so a non-zero exit,
a timeout or a command that cannot start never fails or rolls back the
deployment. Instead:
- the deployment is `succeeded`, and its message ends with
`Warning: post-deploy command failed with exit code N`;
- the timeline shows the step **Post-deploy** with the status `warning` (a
triangle) and the sentence "The new release is live; the deployment was not
rolled back." The command's output is in the deployment logs;
- a notification goes out as `task.failed` with severity **warning** (title
"Post-deploy command failed on "). Channels that subscribe to task
failures at the `warning` level or lower receive it; the deployment itself is
still announced as `deploy.succeeded`.
Like the pre-deploy command it runs for every deployment that creates a **new
release** (manual, staged, git push, preview), not for rollbacks, reconcile
redeploys or drains, and a service that is itself a one-shot has none. The
deployment stays in `verifying` until the command ends (up to its timeout), so a
new deploy of the same service is refused as in progress until then.
## Operational notes
- The agent finds the image from the service's running container on the host,
else from the release (pulling it with the service's registry credentials).
- Containers are named `nc-task--` / `nc-predeploy--` / `nc-postdeploy--`
and carry `nebulactrl.task-run`, `nebulactrl.task-service`,
`nebulactrl.task-kind` labels, but **not** `nebulactrl.service`: they are never
replicas. The agent sweeps leftovers (exited ones older than 10 minutes, running
ones far past their deadline) on every reconcile.
- A redelivered `run_task` command (agent restarted mid-run) **adopts** the
existing container instead of running the command a second time. If the agent
shuts down while a task runs, the container is left running and adopted after
restart; the logs written during the gap are not replayed.
- Command payloads persisted in `agent_commands` hold the command line and the
variable **keys** only; values are attached at send time. Do not put secrets
into the command line itself (they would be stored), use variables.
- `GET /services/{id}/task-runs`, `/task-runs/{id}` and the output need
`projects:read`; creating, editing and deleting tasks needs `projects:write`;
running needs `deployments:write`.
---
# Web terminal and file browser
Source: https://nebulactrl.com/docs/operations/terminal
A shell and a file browser inside a running replica container, from the browser
(inspector header ⋯, the canvas dock's **More** menu, ⌘K) and from the CLI.
Nothing is installed in your image: the agent starts the shell with Docker's
exec API, over the connection the agent already holds to the control plane.
```
browser ⇄ WSS ⇄ control plane ⇄ agent WebSocket ⇄ Docker exec / archive API ⇄ container
```
This is a shell **inside a container**. A shell on the server itself is a separate,
opt-in feature with stricter rules: see the [host shell](https://nebulactrl.com/docs/security/host-shell).
## Who can use it
- **Owner, Admin and Developer** of the team. **Viewers never see it** (no
terminal, no files). A per-project restriction on the membership applies:
outside it the service does not exist (404).
- An API token needs the `deployments:write` scope **and** its creator must still
hold one of those roles. Browser sessions and tokens are treated the same.
- **Production** environments need an explicit confirmation: the UI asks with a
hold-to-confirm dialog, and the server refuses the session unless the open
message says `confirmProduction: true`. A script or the CLI must pass it
deliberately (`nebulactrl services shell --production`).
## The terminal
- The shell is `bash` when the image has it, else `sh`. Pick another command in
the dialog (for example `psql -U postgres`); it runs as an argv, with the
container's own user and working directory, and `TERM=xterm-256color`.
- Choose the **replica** when the service runs several. A stopped replica cannot
be opened.
- The terminal resizes with its panel, copies and pastes like any terminal, and
shows the exit code when the shell ends.
- **Limits.** 3 concurrent sessions per person (or token), shared across
services. A session with **no keyboard input for 15 minutes** is closed (a
warning appears one minute before). Closing the browser tab ends the session
and sends SIGHUP to the shell; a process that ignores SIGHUP (`nohup`) keeps
running in the container.
- Output is flow controlled: a runaway `cat /dev/urandom` slows down instead of
filling queues that also carry heartbeats and deployment events.
### What is recorded
| Event | Audit action | Metadata |
|---|---|---|
| a session opens | `terminal.open` | service, replica, container, **the executable only** (never its arguments, which may carry a password), production flag, server |
| it ends | `terminal.close` | duration in seconds, why it ended (`exit`, `client_closed`, `idle`, `agent_disconnected`, `error`), exit code |
**Keystrokes and output are never recorded, logged or stored**, anywhere. The
audit entry for a session is written before the shell starts: if the audit log is
unavailable the session is not opened.
Be aware that a shell in a container sees that container's environment,
including secret variables, and can change the container's filesystem. The
changes disappear on the next deployment, which creates a fresh container. Treat
terminal access like SSH access to that container.
## The file browser
- Lists directories (directories first, up to 2000 entries), previews text files
up to 1 MiB, downloads files of any size (streamed, never buffered whole) and
uploads **one file at a time, up to 50 MB**.
- Paths are absolute paths **inside the container**. The agent reads and writes
through the Docker API, so the host filesystem is out of reach; symlinks are
resolved inside the container's root.
- Listings use the image's `sh` (`stat`/`readlink` when present). Images without
a shell (distroless, `scratch`) fall back to reading the directory's archive,
which is slower on big trees and may report the listing as truncated.
- Uploaded files are created with mode `0644` and owned by root. Use the
terminal to `chown` them when the app runs as another user.
- **Production is read-only.** An Owner or Admin can allow writes per project
(project settings → Terminal & files, or
`PUT /api/v1/projects/{id}/terminal-settings {"allowProductionFileWrites": true}`).
Reads and downloads always work for the roles above.
- Every download or preview is audited as `file.read` (path, size), every upload
as `file.upload` (path, size). File contents are never recorded.
- Volumes: files written into a mounted volume persist, files elsewhere vanish
with the container on the next deployment. The browser shows both without
telling them apart; check the service's volume list.
## API
| Endpoint | Purpose |
|---|---|
| `GET /api/v1/services/{id}/terminal` | availability, replicas, production flag, whether file writes are allowed |
| `GET /api/v1/services/{id}/terminal/ws` | WebSocket (browser session, same origin). First message `{"type":"open","replica":0,"command":[…],"cols":80,"rows":24,"confirmProduction":false}`; binary frames are input/output; `{"type":"resize"}`, `{"type":"close"}`; the server sends `opened`, `idle_warning`, `exit`, `error`, `closed` |
| `GET /api/v1/services/{id}/files?replica=&path=` | list a directory |
| `GET /api/v1/services/{id}/files/content?replica=&path=&preview=` | download or preview a file |
| `POST /api/v1/services/{id}/files/upload?replica=&path=&name=` | upload the request body (`Content-Length` required) |
| `GET/PUT /api/v1/projects/{id}/terminal-settings` | the production-writes switch (admins) |
A cross-origin page cannot open a terminal with your session cookie: the WebSocket
handshake checks the `Origin` header against the request host and
`NEBULACTRL_PUBLIC_URL`. Behind a reverse proxy, forward `Upgrade` and
`Connection` headers and the original `Host`, and allow long-lived connections
(an idle terminal sends a ping every 30 seconds).
## CLI
```
nebulactrl services shell [--replica 0] [--production] [-- command args…]
```
Needs an interactive terminal on both ends. It opens the same WebSocket with
your token, puts your terminal in raw mode, forwards resizes and restores the
terminal when the session ends. The exit code is the shell's.
## Troubleshooting
| Symptom | Cause |
|---|---|
| "The server is offline" | the agent is not connected; terminals need the live connection and are never queued |
| "Replica N is not running" | the container exited or restarts in a loop; check the logs, then redeploy |
| "could not start a shell: … executable file not found" | the image has no `sh`. Open a command that exists (for example `/app/server`) or debug with the file browser |
| "You already have 3 terminal sessions open" | close one; sessions are per person across all services |
| "production" confirmation fails | the server did not receive `confirmProduction: true` (stale browser tab: reload) |
| the terminal drops after minutes behind a proxy | the proxy's idle timeout is shorter than the 30 s ping interval allows; raise it for `/api/v1/services/*/terminal/ws` |
---
# Docker options
Source: https://nebulactrl.com/docs/operations/docker-options
A service can set a fixed list of **typed** Docker container options. They exist for images that need them (a database that wants a bigger `/dev/shm`, a proxy that binds port 80 as a non-root user, RabbitMQ that stores its data by node name, a media server that needs the GPU) and for hardening (drop all capabilities, read-only root filesystem, a processes limit). There is no free-text `docker run` string: every option is a validated field, so nothing is silently ignored.
Where: the service's **Settings → Docker options**, the CLI (`nebulactrl services docker`), or API `docker` inside the service config (`POST /environments/{id}/services`, `PATCH /services/{id}`). It is release config, so a change is **staged** and applies with the next deploy, appears in the pending changes as `config.docker.` and is frozen into the release (a rollback restores the options of that release).
## Two tiers
The trust rule: **nobody below the instance admin can gain root on a host unless the instance admin made that server _unrestricted_.** Options therefore come in two tiers.
- **Every server.** Options that cannot hand over the host: they only restrict the container, or change something inside its own namespaces.
- **Unrestricted servers only.** Options that give a container the host's devices, GPUs, a different runtime or something equivalent to host root. An instance admin switches a server to unrestricted ([below](#the-unrestricted-switch-per-server)); on any other server these options are refused with `422 requires_unrestricted_server`, and the UI shows the group disabled with the reason.
## Options on every server
| Option | Field | Rules |
|---|---|---|
| Capabilities to add | `capAdd` | Names from the [safe list](#capabilities), with or without the `CAP_` prefix, stored upper-case, no duplicates |
| Capabilities to drop | `capDrop` | `ALL` or Linux capability names; applied before `capAdd`, so `capDrop: [ALL]` with `capAdd: [NET_BIND_SERVICE]` is the hardening pattern. A name in both lists is refused |
| No new privileges | `noNewPrivileges` | sets the `no-new-privileges` security option. Programs that rely on setuid helpers such as `sudo` stop working |
| Read-only root filesystem | `readOnlyRootfs` | writes go only to volumes and `tmpfs`. **Config files cannot be written to a read-only root** (Docker refuses `docker cp` into it, and a tmpfs is mounted over whatever was copied), so every config file of the service must sit under a volume mount; otherwise `422 invalid_config` |
| Processes limit | `pidsLimit` | 0 (no limit) or 1-4194304 processes and threads for the container (cgroup). This is the real per-container limit; see [`nproc`](#ulimits) |
| Hostname | `hostname` | one RFC 1123 label (lowercase letters, digits and dashes, at most 63 characters). **Empty means the service slug**, for every service: the hostname no longer changes with each deploy, which software that stores data by node name (RabbitMQ) needs. **With several replicas every replica gets `-`** (`web-0`, `web-1`, ...; an explicit `rabbit` becomes `rabbit-0`, `rabbit-1`, ...), so replicas never share a hostname; a single replica keeps the plain name. The name for an index is the same on every deploy, and the base name is cut if needed to keep the result at most 63 characters. Scaling up adds replicas without restarting the running ones, so a replica that was alone keeps its plain name until the next deploy renames it `-0` |
| DNS servers | `dns` | up to 3 resolver **IP addresses** (IPv4 or IPv6, no hostnames, no duplicates). Docker's embedded resolver still serves container names and forwards the rest to them |
| Sysctls | `sysctls` | up to 16 entries from the [curated list](#sysctls); the value is a number or numbers separated by single spaces |
| Host kernel requirements | `hostSysctls` | not applied: minimum values the **host** needs, for example `{"vm.max_map_count": 262144}`; the plan warns when a server has less ([below](#host-requirements)) |
| User | `user` | `uid[:gid]` as numbers (up to 2147483647) or user and group names |
| Supplementary groups | `groupAdd` | up to 8 numeric group IDs from 1 to 65535, for example the host's `render` group so a non-root process can open a GPU device |
| Resource limits | `ulimits` | `nofile` or `memlock`; `soft` and `hard` are `-1` (unlimited) or 0-2147483647; `soft` must not exceed `hard` unless `hard` is `-1`; each name once |
| Shared memory | `shmSizeMb` | `/dev/shm` size in MiB, 0 (Docker's default 64 MiB) to 65536 |
| Extra hosts | `extraHosts` | up to 20 `name:ip` (IPv4 or IPv6) or `name:host-gateway` entries |
| Labels | `labels` | up to 30; key `[A-Za-z0-9][A-Za-z0-9._/-]*` (127 characters at most), value at most 512 characters, no line breaks. **`com.docker.*`, `nebulactrl.*` and `traefik.*` are reserved** (case-insensitive): the managed labels and the proxy's own can never be set or overwritten |
| Init | `init` | run Docker's init (tini) as PID 1: reaps zombies and forwards signals |
| Stop signal | `stopSignal` | one of `SIGTERM`, `SIGINT`, `SIGQUIT`, `SIGHUP`, `SIGUSR1`, `SIGUSR2`, `SIGWINCH`, `SIGKILL`; the grace period (`stopGraceSeconds`) is unchanged |
| Entrypoint | `entrypoint` | replaces the image's `ENTRYPOINT`; an exec-form argv of up to 32 elements (each at most 4096 bytes, no NUL), the first one the program. The start command (`startCommand`) is passed to it as arguments. Like `docker run --entrypoint`, overriding the entrypoint **drops the image's default command**, so set `startCommand` when the program needs arguments. No shell is involved unless the first element is one. An empty entrypoint (clearing the image's) is not supported |
| tmpfs | `tmpfs` | up to 10 in-memory mounts: absolute, clean `path` (not `/`, not under `/proc`, `/sys`, `/dev`, `/run/docker`, not colliding with a config file mount), `sizeMb` (default 64, at most 65536), optional octal `mode` such as `1777` |
**Hardening is opt-in.** Nothing is switched on by default except the hostname: `no-new-privileges` breaks images that rely on setuid helpers and `capDrop: [ALL]` breaks images that chown their data directory at start, so you choose them per service. The Docker options section offers a one-click **Hardened** preset (`capDrop: [ALL]`, `noNewPrivileges`, `readOnlyRootfs`, `pidsLimit: 512` and a `/tmp` tmpfs) that you then adjust to what the image needs.
A violation is `422 invalid_config` with the reason. The agent validates the same rules again before it creates a container, against the server policy carried in the deploy payload, so an edited or old payload cannot slip past the control plane.
### Capabilities
Safe list (allowed on every server): `AUDIT_WRITE`, `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `FSETID`, `IPC_LOCK`, `KILL`, `MKNOD`, `NET_BIND_SERVICE`, `NET_RAW`, `SETFCAP`, `SETGID`, `SETPCAP`, `SETUID`, `SYS_CHROOT`, `SYS_NICE`, `SYS_PTRACE`. Docker grants most of these by default already; listing one is still useful after `capDrop: [ALL]` and for `IPC_LOCK` (with `memlock`), `NET_BIND_SERVICE`, `SYS_NICE` and `SYS_PTRACE`.
- `SYS_PTRACE` is allowed with a warning (the plan and the UI show it): processes in the container can inspect and tamper with each other.
- **`NET_ADMIN` and `SYS_ADMIN` need an unrestricted server.** They widen the container's power over the network stack and the kernel.
- Anything else is refused: `ALL`, `SYS_MODULE`, `SYS_RAWIO`, `SYS_TIME` and so on. (`capDrop` accepts `ALL` and every Linux capability name.)
### Sysctls
A container can only change **namespaced** kernel parameters. Docker supports the IPC namespace (`kernel.msg*`, `kernel.sem`, `kernel.shm*`, `fs.mqueue.*`) and the network namespace (`net.*`), and "does not support changing sysctls inside of a container that also modify the host system" ([docker run reference](https://docs.docker.com/reference/cli/docker/container/run/#sysctl)). Containers never use the host network or IPC namespace, so each entry below changes only the container's own namespace. `sysctls` accepts exactly:
- IPC: `kernel.msgmax`, `kernel.msgmnb`, `kernel.msgmni`, `kernel.sem`, `kernel.shm_rmid_forced`, `kernel.shmall`, `kernel.shmmax`, `kernel.shmmni`, `fs.mqueue.msg_default`, `fs.mqueue.msg_max`, `fs.mqueue.msgsize_default`, `fs.mqueue.msgsize_max`, `fs.mqueue.queues_max`.
- Network: `net.core.somaxconn` (listen backlog), `net.ipv4.ip_unprivileged_port_start` (bind below port 1024 without a capability), `net.ipv4.ip_forward`, `net.ipv4.conf.all.forwarding`, `net.ipv4.conf.default.forwarding`, `net.ipv4.conf.all.src_valid_mark` and `net.ipv4.conf.all.rp_filter`, `net.ipv4.conf.all.route_localnet` (VPN containers), `net.ipv4.ip_local_port_range`, `net.ipv4.ping_group_range`, `net.ipv4.tcp_fin_timeout`, `net.ipv4.tcp_keepalive_time`, `net.ipv4.tcp_keepalive_intvl`, `net.ipv4.tcp_keepalive_probes`, `net.ipv4.tcp_syncookies`, `net.ipv6.conf.all.disable_ipv6`, `net.ipv6.conf.all.forwarding`, `net.ipv6.conf.default.disable_ipv6`.
Host-wide parameters such as `vm.max_map_count` are refused in `sysctls` with a pointer to `hostSysctls`.
### Ulimits
`nofile` and `memlock` only. The **`nproc` ulimit is gone**: the Linux `RLIMIT_NPROC` is counted per real user ID across the whole host and is not enforced for UID 0, so it gave false assurance. Use `pidsLimit`, a cgroup limit that applies to the container whoever runs in it. Saved configs and release snapshots that carried an `nproc` entry had it removed by migration `0046`.
### Host requirements
A container cannot raise a host sysctl, so an application that needs one (Elasticsearch needs `vm.max_map_count` of at least 262144 for its mmap-based stores) declares it in `hostSysctls`. Nothing is applied. The agent reports the current values of `vm.max_map_count`, `fs.file-max`, `fs.inotify.max_user_watches`, `fs.inotify.max_user_instances`, `kernel.pid_max`, `net.core.rmem_max` and `net.core.wmem_max` with its facts (read again whenever it reconnects), and the **deployment plan** adds a warning for each shortfall with the exact commands:
```
sysctl -w vm.max_map_count=262144
echo 'vm.max_map_count = 262144' | sudo tee -a /etc/sysctl.d/99-nebulactrl.conf
```
The warning does not block the deploy (some applications run without it, slower or with errors). An agent that has not reported the value yet gets a warning that says so.
## Options on unrestricted servers only
| Option | Field | What it does |
|---|---|---|
| Devices | `devices` | up to 16 host device nodes: `hostPath` below `/dev` (for example `/dev/dri/renderD128`, `/dev/kvm`, `/dev/net/tun`), optional `containerPath` (default: the same path), `permissions` a subset of `rwm` (default `rwm`) |
| GPUs | `gpus` | `count` (`-1` for all, or 1-16), `driver` `nvidia`, optional `capabilities` (`compute`, `utility`, `video`, `graphics`, `display`, `compat32`). Needs the NVIDIA Container Toolkit on the host |
| Runtime | `runtime` | an OCI runtime configured in the host's Docker daemon, such as `runsc` (gVisor), `kata` or `sysbox-runc`. A sandboxing runtime raises isolation |
| Privileged | `privileged` | all capabilities, all devices, no seccomp or AppArmor profile: **equivalent to root on the host** |
| Host PID namespace | `pidHost` | the container sees and can signal every process on the host |
| Seccomp unconfined | `seccompUnconfined` | removes the default syscall filter |
| AppArmor unconfined | `apparmorUnconfined` | removes the default AppArmor profile |
| NET_ADMIN, SYS_ADMIN | `capAdd` | see [Capabilities](#capabilities) |
On a restricted server each of them is refused when you save the service, when you move it to that server, when a template that needs it is installed, and again at every deploy, rollback and reconcile redeploy (the plan reports the check as blocking: `requires_unrestricted_server`). Use them only for images you trust: a bug in the NVIDIA toolkit, a driver behind a device node or a kernel exploit without the seccomp filter is a way out of the container.
### The unrestricted switch (per server)
One switch per server controls both these options and [unrestricted Compose stacks](https://nebulactrl.com/docs/operations/compose-stacks#unrestricted-per-server): **Servers → the server → Unrestricted** (API `PUT /servers/{id}/unrestricted`, CLI `nebulactrl servers unrestricted`).
- **Instance admin only** (the earliest-created user, or an admin-scoped token acting for them). A team owner, a developer or a token without the admin scope gets 403.
- **Enabling needs the server's exact name typed**; the dialog warns that anyone who can deploy there can take the server over.
- **Audited** as `server.unrestricted.enable` or `server.unrestricted.disable`, with the server name, the number of stacks and the number of services that use unrestricted-only options.
- It is **read at every deploy, rollback and reconcile**. Disabling it does not stop running containers; the next deploy of a stack or service that needs it fails before anything changes, until the options are removed or the service is moved.
- It was called "Unrestricted Compose" and the instance-wide "advanced capabilities" switch existed next to it. Both are gone: this is the only switch (migration `0046` renamed `servers.unrestricted_compose` to `servers.unrestricted` and dropped the instance setting; the API field `unrestrictedCompose` is `unrestricted`, `PUT /servers/{id}/compose` is `PUT /servers/{id}/unrestricted`, and `PUT /instance/docker-options` no longer exists). A service that already carried `NET_ADMIN` or `SYS_ADMIN` now needs its server to be unrestricted.
## What is never possible
Not configurable on any server, not on any list: `--ip`/`--ip6` (rolling deploys start the new container while the old one runs, so two containers would need the same address; use the private DNS name), host networking on a plain service (routes dial the container on the environment network; a [stack](https://nebulactrl.com/docs/operations/compose-stacks) on an unrestricted server can use it), the restart policy and log options (the platform owns them; see [logs](https://nebulactrl.com/docs/operations/logs)), the host IPC namespace (use `shmSizeMb`), host bind mounts and the Docker socket, `--device-cgroup-rule`, and the host user, UTS or cgroup namespaces. Each container still joins only its environment network (and shared networks you attach); memory and CPU limits stay under NebulaCtrl's control.
## Interactions
- **Replicas and scaling:** every replica gets the same options. The non-secret runtime spec stored in the container's `nebulactrl.spec` label carries them and the server policy they were validated against, so a replica the agent adds on its own (a reconcile or autoscaler scale-up) is created identically. Scaling re-reads the server's unrestricted switch.
- **Managed databases** accept the options too (a larger `shmSizeMb` is the usual one for PostgreSQL). Their image, port and volume stay derived from the engine.
- **Route services** have no container: options do not apply (`422 invalid_config`).
- **Tasks** (one-off, cron, pre- and post-deploy commands) run in their own containers and do not inherit these options.
- **Templates** can set them in a `docker:` block with the same field names ([Templates](https://nebulactrl.com/docs/operations/templates)); a template that uses an unrestricted-only option declares `requiresUnrestricted: true` and installs only on an unrestricted server.
- **Compose import** maps only `cap_add`, `ulimits`, `shm_size`, `extra_hosts`, `init`, `stop_signal` and `tmpfs`; the other Compose keys are listed in [Compose import](https://nebulactrl.com/docs/operations/compose-import).
## Verifying
`docker inspect ` on the host shows the result: `Config.Hostname`, `Config.User`, `HostConfig.ReadonlyRootfs`, `HostConfig.CapAdd`, `HostConfig.CapDrop`, `HostConfig.SecurityOpt`, `HostConfig.PidsLimit`, `HostConfig.Sysctls`, `HostConfig.GroupAdd`, `HostConfig.Dns`, `HostConfig.Devices`, `HostConfig.DeviceRequests`, `HostConfig.Runtime`, `HostConfig.Privileged`, `HostConfig.PidMode`, `HostConfig.ShmSize`, `HostConfig.Ulimits`, `HostConfig.Init`, `HostConfig.Tmpfs`, `HostConfig.ExtraHosts`, `Config.StopSignal`, `Config.Entrypoint` and `Config.Labels`. The integration tests `TestPublicDatabasePortAndDockerOptions` and `TestHardeningDockerOptions` assert that on real Docker.
---
# Maintenance mode and error pages
Source: https://nebulactrl.com/docs/operations/maintenance-and-error-pages
Two things share one design: a **maintenance mode** you switch on per service, and **automatic error pages** the proxy shows when there is nothing healthy to forward to. Both are served by the agent's own tiny static server, look like the login screen (the animated ASCII "Eclipse" backdrop over a centred card), and are single self-contained HTML files: no external request, no web font, no tracking.
| Situation | Status | Page |
|---|---|---|
| Service is in maintenance mode | `503` + `Retry-After` | "Down for maintenance" (your title, message and expected-back time) |
| The service answers `502`, `503` or `504`, or has no healthy replica (stopped, crashed, restarting) | the same status | "Service unavailable" / "Service timed out" |
| A host that no service of this server serves | `404` | "Nothing here: No service is configured for this address." |
Every page is sent with `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow` and a strict Content-Security-Policy. Error and maintenance pages other than the 404 also carry `Retry-After` (the ETA of a maintenance window as an HTTP date, a fixed delay otherwise: 120 s without an ETA, 300 s once it has passed, 30 s for automatic error pages).
## Maintenance mode
Use it when you want visitors to see a clean page instead of a half-working app: a database migration, a manual upgrade, planned downtime.
- **Turn it on:** the service's Settings → **Maintenance**, the canvas card's **More ▸ Maintenance mode…** (and **End maintenance** once it is on), or the CLI:
```sh
nebulactrl services maintenance acme/production/web on --title "Upgrading the database" \
--message "Nothing is lost. This takes a few minutes." --eta 45m
nebulactrl services maintenance acme/production/web off
```
- **API:** `PUT /api/v1/services/{serviceId}/maintenance` with `{enabled, title?, message?, eta?}` (scope `deployments:write`, audited as `service.maintenance.on` / `service.maintenance.off`). `GET /api/v1/services/{serviceId}/maintenance/preview` returns the rendered page as HTML, optionally for unsaved text (`?draft=true&title=…&message=…&eta=…`); the settings form's **Preview page** opens it. The service resource carries `maintenance: {enabled, title, message, eta, since}`.
- **Text:** title up to 80 characters, message up to 600 (plain text, line breaks kept, always HTML-escaped), `eta` a future time shown in the visitor's local time and sent as `Retry-After`. Empty fields show the default copy. Switching off keeps the text for next time and clears the ETA.
- **While on:** every domain of the service answers `503` with the page. HTTP→HTTPS redirects still happen first, redirect-only domains still redirect, and basic-auth / IP allow rules are not applied to the page (a maintenance page must not need a login). The post-deploy **verification probe keeps reaching the real service**, so a deployment during maintenance still proves the new release works, and ends with the maintenance window intact.
- **Instant, no deploy.** The control plane sends the server its route table (`sync_routes`), and a stopped/offline server picks it up when it reconnects. A service that was never deployed stores the setting and applies it with its first deployment.
- **Operational, never staged.** Like replica counts it does not bump the service's generation, does not create a "changes to apply" entry, is not part of a release and is **never rolled back**. A rollback or a redeploy leaves maintenance exactly as it was.
- **Idempotent.** Repeating a request that changes nothing records no audit event and sends nothing.
- **Not for TCP/UDP routes** (`422 maintenance_unsupported`): there is no page to show on a raw port.
## Automatic error pages
The agent adds an `errors` middleware (status `502-504`) in front of every service router and a lowest-priority catch-all router on ports 80 and 443. Together they replace Traefik's bare `Bad Gateway` / `no available server` / `404 page not found` text:
- A stopped service, a crashed container, a failing upstream health check and a connection error all end in `502` or `503`; a timeout in `504`.
- A request for a host (or a path of a path-scoped domain) that no router matches gets the `404` page. The probe entrypoint is unaffected: an unrouted host is still a `404` there, so post-deploy verification can never pass by accident.
- The status code is the real one, so monitors, CDNs and API clients behave correctly.
**One caveat:** the middleware replaces *any* `502`/`503`/`504` the upstream itself sends, including an application's own JSON error body for a deliberate `503`. If an API must return its own `503` body, answer `429`/`500` instead or keep that status below the proxy.
## How it works
The agent starts a second infrastructure container next to Traefik, **`nebulactrl-pages`**, on the `nebulactrl-edge` network. It publishes nothing; only Traefik reaches it (`nc-pages` → `nebulactrl-pages:8080`).
- **Image:** `nginx:1.30.6-alpine-slim`, pinned by digest (the multi-arch index `sha256:dede8895648b461178c3bc604f7eed4423efe5220b2c4d839f5eb3d34ac3f8f7`), about 5 MB per platform. It is the official image, from the stable branch; it only serves static files and needs no build per service.
- **Hardened:** all capabilities dropped except `CHOWN`/`SETGID`/`SETUID`, 64 MiB memory limit, rotating 1 MB logs, `unless-stopped`.
- **Content** is rendered by the agent from templates embedded in the binary (`deploy/pages`), written into the container before its first start (like Traefik's bootstrap file) and fingerprinted in a label. The nginx configuration maps `/err/` to the error pages and `/m/` to a service's maintenance page, each with the right status and headers.
- **Changing the content recreates the container** (sub-second). The agent orders it so no page ever vanishes while a router still points at it: entering or editing maintenance, the page is rendered *before* the route push; leaving maintenance, the routers move back *first* and the page is dropped after. A recreate causes a short `502` for the other services currently in maintenance, then they recover.
- **It is excluded from the orphan sweep** (like the proxy and tailnet containers) and is repaired by the proxy health loop if someone removes it.
- **Failure is contained.** If the pages server cannot start (no disk, image pull blocked), route tables without maintenance still apply, with Traefik's own error pages and a warning in the agent log; a table that *needs* the pages server (a service in maintenance) is refused with `maintenance pages unavailable` and the previous routes stay live. The next apply retries.
An old agent without pages support ignores the maintenance flag; update the agent first.
## What the pages look like
The same engine as the sign-in screen draws the backdrop on a `` (two WebGL2 passes, about 20 fps, paused in hidden tabs, one static frame under `prefers-reduced-motion`). Without JavaScript, without WebGL2 or in forced-colors mode the page falls back to an inline CSS halftone orb, with the same card and text. Fonts are the system UI and monospace stacks. The visited hostname is shown in mono on the card (filled in by a few lines of script; the maintenance page also carries it in the HTML). A small violet mark sits above the card; there is no other branding.
### Rebuilding the assets
Sources live in `deploy/pages` (`page.html.tmpl`, `page.css`, `page.js`, `nginx.conf.tmpl`) and are embedded into both binaries. The backdrop engine is a generated, committed bundle, so `go build` never needs bun:
```sh
go generate ./deploy/pages # or: cd apps/web && bun run scripts/build-pages-engine.ts
```
It writes `deploy/pages/assets/eclipse.js` from the same sources as the login backdrop (`apps/web/src/lib/ascii`). The control plane embeds the same assets to serve the maintenance preview.
## Troubleshooting
- **Visitors still see the app after turning maintenance on.** Check the server is online (the change is a `sync_routes` command; see the service's server page) and `docker logs nebulactrl-traefik`. `docker ps` on the host should show `nebulactrl-pages` running.
- **The generic `404 page not found` text appears.** The agent runs without the pages server (see "Failure is contained" above) or the request reached another reverse proxy first.
- **`docker logs nebulactrl-pages`** shows nginx's error log; access logging is off.
---
# Managed databases
Source: https://nebulactrl.com/docs/operations/databases
A managed database is an ordinary service (`kind=database`) with a typed `database` section in its configuration. NebulaCtrl derives everything engine-specific from that section: image, port, start command, TCP health check, the data volume and its mount path, and the credential variables. You pick an engine and a version; nothing else needs to be configured to get a working database.
Databases are **single-container** (no HA, no automatic failover, no connection pooling; PostgreSQL alone has an [advanced standby replica](https://nebulactrl.com/docs/operations/database-replicas) option with a manual Promote) and always use the `recreate` strategy: a configuration change restarts the database and causes a short outage, which the deployment plan says up front.
## Engines
| Engine | Versions | Image | Licence | Notes |
|---|---|---|---|---|
| PostgreSQL | 18, 17, 16 | `postgres` | PostgreSQL | `postgis` flavor: `postgis/postgis` (GPL-2.0 image). PostgreSQL 18 uses a new data-directory layout, so its volume mounts at `/var/lib/postgresql`. |
| MySQL | 8.4 LTS, 9.7 | `mysql` | GPL-2.0 | |
| MariaDB | 11.8 LTS, 12.3 | `mariadb` | GPL-2.0 | |
| Valkey | 9, 8 | `valkey/valkey` | BSD-3-Clause | **Default key-value engine.** |
| Redis | 8 | `redis` | RSALv2 / SSPLv1 / AGPLv3 | Selectable, with a licence label; creation needs `acceptLicense`. |
| MongoDB | 8.0, 7.0 | `mongo` | SSPL-1.0 | Opt-in, with a licence label; creation needs `acceptLicense`. |
| ClickHouse | 26.8 LTS, 25.8 LTS | `clickhouse/clickhouse-server` | Apache-2.0 | Native-protocol port 9000. Backups are not supported yet. |
Image tags are major-version tags; every release records the resolved digest, so a deployment is reproducible even though the tag floats between patch releases. Redeploying a database picks up the newest patch release of its major version.
The catalog is data (`internal/databases/engines`): a unit test checks that every engine has exactly one default version, a licence, and a licence label when the licence is source-available.
## Creating a database
Canvas → **Create → Database** → pick an engine and version (and flavor, server and database name). Via the API: `POST /environments/{environmentId}/databases`; via the CLI: `nebulactrl databases create`. Creation is **staged** like every other change: it creates the service, the data volume and the generated credentials, and nothing runs until you deploy.
Credentials are ordinary secret variables on the database service, generated once with `crypto/rand` (32 alphanumeric characters) using the engine's own names:
| Engine | Variables |
|---|---|
| PostgreSQL | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` |
| MySQL / MariaDB | `MYSQL_*` / `MARIADB_*`: `ROOT_PASSWORD`, `USER`, `PASSWORD`, `DATABASE` |
| Valkey / Redis | `VALKEY_PASSWORD` / `REDIS_PASSWORD` (the server is started with `--requirepass`) |
| MongoDB | `MONGO_INITDB_ROOT_USERNAME`, `MONGO_INITDB_ROOT_PASSWORD`, `MONGO_INITDB_DATABASE` |
| ClickHouse | `CLICKHOUSE_USER`, `CLICKHOUSE_PASSWORD`, `CLICKHOUSE_DB` |
Secret values are never returned by any read. See *Reveal* below.
## Connecting other services
Databases export **derived connection variables**. They are computed from the engine and the credentials when a reference is resolved, are not stored, never injected into the database container and never produce a staged change:
| Engine | Exports |
|---|---|
| PostgreSQL | `DATABASE_URL`, `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_NAME`, `DATABASE_USER`, `DATABASE_PASSWORD`, `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` |
| MySQL / MariaDB | `DATABASE_URL`, `MYSQL_URL`, `DATABASE_HOST`, … `DATABASE_PASSWORD` |
| Valkey | `REDIS_URL`, `VALKEY_URL`, `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` |
| Redis | `REDIS_URL`, `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` |
| MongoDB | `MONGO_URL`, `DATABASE_URL`, `DATABASE_HOST`, … |
| ClickHouse | `CLICKHOUSE_URL`, `DATABASE_URL`, `DATABASE_HOST`, … |
In a consumer, set `DATABASE_URL=${{pg.DATABASE_URL}}` (where `pg` is the database's slug). Exports that contain the password are derived secrets: masked everywhere and redacted from logs. Connecting an edge on the canvas proposes exactly this one reference. A service on the **same server** reaches the database by its private hostname. A service on **another server** of the same environment reaches it through a [mesh link](https://nebulactrl.com/docs/operations/mesh#cross-server-service-links): the host is still the slug, and `DATABASE_PORT`, `PGPORT`, `DATABASE_URL` and the other port-bearing exports resolve to the mesh port **for that consumer** (the container port when that port is free on the database's server). The mesh must be enabled on both servers, and the link is added by deploying the consumer, which the plan states with the devices it adds. A PostgreSQL group with replicas also exports `DATABASE_WRITE_*` and `DATABASE_READ_*` ([writer and reader variables](https://nebulactrl.com/docs/operations/database-replicas#writer-and-reader-variables)).
## Reveal a connection string
Secrets stay masked in the UI, API and MCP. To connect from your laptop (`psql`) there is one explicit action: **Copy connection string**, `POST /services/{id}/database/reveal {"key":"DATABASE_URL"}`.
- Requires the `deployments:write` scope (admin included); browser sessions hold every scope. There is no MCP tool for it.
- Audited in the same transaction as `secret.reveal` (service and key, never the value).
- The response is `Cache-Control: no-store` and carries a 60-second `expiresAt`; the UI shows it briefly and does not keep it.
- The reveal response is not an idempotent operation, so the value never lands in stored idempotency bodies.
The internal hostname is only resolvable on the server's private network (and through a mesh link for consumers of the same environment on other servers). To connect from outside, use a tunnel or the TCP routes of the proxy.
## Rotating credentials
The official database images read their password variable **only when they initialise an empty data directory**. Editing `POSTGRES_PASSWORD` on a running database changes the variable and nothing else, so the database and its clients would silently disagree. NebulaCtrl therefore refuses edits of credential variables on a deployed database (`credential_managed`) and offers **Rotate credentials** (`POST /services/{id}/database/rotate-credentials`, `dryRun` for the plan):
- **PostgreSQL, MySQL, MariaDB, MongoDB, ClickHouse (`exec`):** the agent runs a typed command inside the container (`ALTER ROLE` / `ALTER USER` / `changeUserPassword`). The new password reaches the command through its stdin and environment, never argv, never the command payload in the database, and is masked in results. When the agent reports success the variable is updated. **The old password stops working immediately**, so every service using it fails to connect until it is redeployed.
- **Valkey, Redis (`redeploy`):** the server takes its password from the environment on every start. The variable is changed and the database restaged; it restarts on the next deploy.
- **Never deployed:** the new password simply becomes the initial one.
Every service in the environment whose variables reference `${{.…}}` is **staged** (its generation advances), so the next *Deploy staged changes* redeploys the consumers, databases first. Nothing is deployed automatically. Services that copied the password as plain text are not found: update them by hand. Rotation requires `projects:write` and `deployments:write`, runs one at a time and is blocked while the database is deploying or upgrading.
If the agent is offline the command waits; if it is never delivered the rotation fails with *Not delivered: server offline. No changes were made.*
## Version upgrades
`POST /services/{id}/database/upgrade/plan {"targetVersion":"18"}` returns a plan: method, steps, warnings, blockers and expected downtime. **Take a backup first** ([database backups](https://nebulactrl.com/docs/operations/database-backups)). Downgrades are blocked.
| Engine | Method | What happens |
|---|---|---|
| Valkey, Redis, MySQL, MariaDB, ClickHouse | `redeploy` | The image changes as a staged change; the next deploy upgrades the data files on start. Cannot be reverted without a backup. |
| PostgreSQL | `pg_upgrade` | The database is **stopped**. The agent runs [`tianon/postgres-upgrade`](https://github.com/tianon/docker-postgres-upgrade) (pinned per version pair, e.g. `17-to-18`) with the old volume as the source and a **new volume** as the target, then the service switches to the new volume and image as a staged change. **The old volume is kept untouched** on the server (delete it yourself when you are satisfied). Deploy the staged change to start the new version, then run `vacuumdb --all --analyze-in-stages`. If the conversion fails the old database is started again and nothing changed. |
| PostgreSQL PostGIS, MongoDB | `dump_restore` | Not automatic: restore a backup into a new database at the target version, switch the references, delete the old database. |
A never-deployed database just changes image. `pg_upgrade` copies the data (hard links are not used), so the host needs free disk space for a second copy. The helper needs the checksum setting of the old cluster; the agent retries once with the opposite `initdb` flag when `pg_upgrade` reports a mismatch (PostgreSQL 18 enables data checksums by default, earlier versions do not).
A PostgreSQL primary or replica **cannot be upgraded while the group has [read replicas](https://nebulactrl.com/docs/operations/database-replicas#interactions)** (`replicas_exist`): `pg_upgrade` carries neither the replication block of `pg_hba.conf` nor the replication slots. Remove the replicas, upgrade, and add them again. Scheduled and manual [backups](https://nebulactrl.com/docs/operations/database-backups) run on the primary only.
`tianon/postgres-upgrade` is an owner-approved exception (a personal Docker Hub namespace; its README calls itself a proof of concept). The upgrade path is covered by unit tests and was exercised by hand against a PostgreSQL 17 volume converted to 18.
## Observing a database
The database inspector's **Overview** shows the engine and version, its connection exports (names only) and, on **Refresh**, the version, database size and open connections that the agent reads with a read-only query inside the container. These are reported asynchronously (`POST /services/{id}/database/refresh`, then `GET /services/{id}/database`).
## Public port
A database can be published on a public port of its server, for a client that is not on the server (a laptop, a BI tool, a hosted job). It is off by default and always restricted by an IP allow-list.
| | |
|---|---|
| Where | the database's **Networking** settings: *Expose on a public port*; API `PUT /services/{id}/database/public-port` (`GET` reads it, `DELETE` closes it) |
| What it creates | one TCP route service (see [proxy routes](https://nebulactrl.com/docs/operations/proxy-routes)) on the database's server, marked as the database's public port. It shows on the canvas like other route services and is **staged**: deploy it to apply, like any change |
| Allow-list | **mandatory** (`422 allowlist_required`): CIDRs or single addresses. `0.0.0.0/0` or `::/0` is accepted only with `confirmOpen` set to the database slug (the UI asks you to type it, `422 open_confirmation_required`) |
| Deny list | optional, evaluated first, so it wins even over a confirmed `0.0.0.0/0` ([IP rules](https://nebulactrl.com/docs/operations/proxy-routes#ip-rules-allow-and-deny)) |
| Port | 1024-65535, never `80`, `443` or a proxy port; the UI suggests the engine's own port (5432 …) and moves to another one when it is taken (`409 port_in_use`) |
| Scopes | `projects:write` and `proxy:write` to change it, `projects:read` to read it |
**A new public port restarts the proxy on that server.** The proxy publishes each TCP port as a static entrypoint, so the agent recreates the Traefik container when the set of listening ports changes: about a second of dropped connections, and HTTP is briefly unavailable. The UI and the deployment plan warn about it, and `newEntrypoint` in the API says whether it applies. Changing only the allow or deny list of a port that is already published restarts nothing.
**TLS.** The proxy forwards raw TCP and never terminates TLS on this port, so a database that terminates TLS itself keeps doing so end to end. The managed databases do **not** enable TLS on their own, so by default the connection, password included, is not encrypted. Keep the allow-list to networks you trust, or tunnel the connection through a VPN or a [per-service tailnet](https://nebulactrl.com/docs/operations/service-tailnet).
**Docker bypasses host firewalls** (ufw, firewalld): the allow-list is the control, not a host firewall rule ([proxy routes](https://nebulactrl.com/docs/operations/proxy-routes#security-notes-for-tcpudp)). The proxy sees the client address Docker hands it, so a server behind another NAT or proxy sees that device's address, not the real client's.
**The external connection string** uses the server's **public IP** (set it in the server's settings; `422 no_public_address` otherwise) and the public port. Like the private exports it is only revealed on request: `POST /services/{id}/database/public-port/connection` (`deployments:write`), uncached, shown for 60 seconds and recorded in the audit log as `secret.reveal` with `public: true` (never the value).
## Advanced
- **[Standby replicas](https://nebulactrl.com/docs/operations/database-replicas)** (PostgreSQL only, advanced). A copy of the database that streams to another server and can answer read-only queries, so you can promote it by hand if the first server is lost. It is not a backup and not automatic failover, so keep your scheduled backups. On the same server it only helps with read load. In the database panel it sits in a collapsed **Advanced** section.
## Limits
- One container per database; no HA, no automatic failover and no connection pooling. PostgreSQL can have up to 5 asynchronous [standby replicas](https://nebulactrl.com/docs/operations/database-replicas) with a manual Promote; the other engines have none.
- A public port is a TCP route with an IP allow-list, not a managed TLS endpoint; there is no per-engine TLS setup (see [Public port](#public-port)). Closing it (`DELETE`, or removing the route) closes the listener on the server; the proxy is recreated only when its set of ports changes.
- Settings (`postgresql.conf`, `my.cnf`), init scripts, TLS and extension management are not exposed. Choose the PostGIS flavor for geospatial data.
- ClickHouse and MongoDB are lightly exercised; the integration tests cover PostgreSQL and Valkey.
---
# Postgres standby replicas and manual Promote
Source: https://nebulactrl.com/docs/operations/database-replicas
> **Advanced feature, PostgreSQL only.** Most databases do not need it. Start with [scheduled backups](https://nebulactrl.com/docs/operations/database-backups); add a standby replica only if one of the cases below is yours.
A **standby replica** is a second Postgres service in the same environment that follows the [managed database](https://nebulactrl.com/docs/operations/databases) with streaming replication and serves read-only queries. In the database panel it sits in a collapsed **Advanced** section, which opens by itself when the database already has replicas, is one, or is fenced. If the primary is lost, you can **Promote** a replica by hand and the platform repoints your apps.
## When to use it
- **A standby on another server.** A copy of the database streams to a second server over the [mesh](https://nebulactrl.com/docs/operations/mesh). If the first server is lost, you promote the copy by hand and the apps follow after you deploy the staged rewrite. This is the case the feature is made for.
- **Read offload.** Reports, exports or read-heavy queries run against `DATABASE_READ_URL` and leave the primary's CPU for writes. A replica on another server also spares the primary's disk and network.
## When not to use it
- **As a backup.** A replica is **not a backup**: a `DROP TABLE` or a bad `UPDATE` replicates within seconds. Keep [scheduled backups](https://nebulactrl.com/docs/operations/database-backups); there is no point-in-time recovery.
- **On the same server, for safety.** A replica on the primary's own server shares its disk and host, so it does not survive losing that server. It only helps with read load. The add dialog says so and still lets you do it.
- **As automatic failover.** NebulaCtrl does not watch the primary and does not switch anything by itself: there is no automatic failover, no leader election and no witness. Promote is a decision you make and confirm.
There is no synchronous replication and no cascading replicas. See [limits](#limits).
Scope: **PostgreSQL only** (16, 17 and 18, plain and PostGIS flavor). MySQL, MariaDB, Valkey, Redis, MongoDB and ClickHouse have no replicas, and their panels do not show this section.
## How it works
- A replica is an ordinary `kind=database` service of the primary's environment, with its own data volume and its own deployments, logs and cancel. Its engine, version, flavor, database name and user match the primary.
- Replication is **asynchronous** and physical (`pg_basebackup`, then a WAL stream with a replication slot).
- A primary has **at most 5 replicas** (`replicas_limit`). A replica cannot have replicas of its own (`replica_not_primary`).
- The primary needs **no restart and no configuration change**: Postgres' defaults already allow replication. Adding a replica creates a dedicated replication role `nebula_r_` on the primary, adds a managed block to `pg_hba.conf` for it, reloads the configuration and caps the WAL the primary keeps for a lost replica (see [Disk use on the primary](#disk-use-on-the-primary)). The role, the block and the slot are removed again when the replica is removed.
- The replica keeps the primary's setting of outbound traffic: **Block outbound internet stays on**. Its only route to the primary is the environment network (same server) or the mesh link.
## Add a replica
Primary's database panel → **Advanced → Standby replica → Add standby replica**, or:
```
POST /services/{primaryId}/database/replicas
{"name": "pg-replica", "serverId": "…", "acceptMeshChanges": false}
```
`"dryRun": true` returns the plan (`200`) without changing anything. Needs `projects:write` and `deployments:write`; the CLI is `nebulactrl databases replicas add`. The real request answers `202` with a **saga**, not a staged change:
1. **prepare**: the role, the `pg_hba.conf` block and the slot cap on the primary;
2. **link**: nothing on the same server; across servers, the exposure, link and grant on the [mesh](https://nebulactrl.com/docs/operations/mesh) and a connectivity check;
3. **clone**: one agent command runs `pg_basebackup` into the replica's new volume (large databases take a while: the default time limit is 6 hours; **cancel** deletes the half-written volume);
4. **deploy**: the replica service is deployed through the normal deploy path;
5. **streaming**.
The saga is re-entrant: a restart of the control plane or a disconnected agent resumes it. Replica states are `pending`, `preparing`, `cloning`, `deploying`, `streaming`, `lagging`, `broken`, `needs_rebuild`, `promoting`, `removing` and `failed`.
The replica copies the primary's memory and CPU limits, Docker options and stop grace period; edit them afterwards like any service. It is placed on the server you choose and **cannot be moved to another server afterwards** (`replica_pinned`: a move does not move volumes anyway); use [Rebuild](#rebuild) instead.
The replica's start command refuses to start with an **empty data directory** (it exits with code 3 and says "rebuild the replica"), because the stock entrypoint would otherwise initialise a fresh, empty primary that looks healthy.
### Same server and cross-server
| | Same server | Across servers |
|---|---|---|
| Path | the replica joins the environment network and connects to the primary's slug on port 5432 | the replica connects to a **link node** alias on the replica's server, which forwards over the mesh to the primary's server node and then to the primary |
| Needs | nothing extra | [mesh](https://nebulactrl.com/docs/operations/mesh) **enabled on both servers** (`mesh_server_required` blocks otherwise, with a one-click plan to enable it), `/dev/net/tun` on the replica's server (`mesh_no_tun`), an agent that supports it (`mesh_agent_too_old`), the mesh not paused, the coordinator reachable (`mesh_coordinator_unreachable` for a new replica) |
| Devices | none | adds the link node (and the server nodes if enabling the mesh), the plan says "adds N devices" and asks for `acceptMeshChanges` (`mesh_confirmation_required` without it) |
| Encryption | local | WireGuard between the link node and the server node; the last hop on each host is local |
| Removing the last replica | releases the role, slot and block | also releases the grant, then the link and the exposure |
The replication link and an app's [service link](https://nebulactrl.com/docs/operations/mesh#cross-server-service-links) are separate nodes with separate rules: a replica's link cannot reach an app port.
### Disk use on the primary
A replica that stops reading makes the primary keep WAL for its slot. NebulaCtrl sets `max_slot_wal_keep_size` to the smaller of **4 GiB** and **10 % of the server's disk**, so a dead replica cannot fill the primary's disk: past the cap the slot is invalidated (`wal_status = lost`), the replica becomes `needs_rebuild` and the primary carries on. The replica row shows the WAL retained by its slot.
## Writer and reader variables
Every member of a replica group (the primary and each replica) exports the same set of **derived variables**, in addition to the [usual ones](https://nebulactrl.com/docs/operations/databases#connecting-other-services). They are computed when a reference is resolved, are not stored and never produce a staged change on their own:
| Export | Value |
|---|---|
| `DATABASE_WRITE_URL`, `DATABASE_WRITE_HOST`, `DATABASE_WRITE_PORT` | the group's **current primary** |
| `DATABASE_READ_URL`, `DATABASE_READ_HOST`, `DATABASE_READ_PORT`, `DATABASE_READ_HOSTS` | the replicas that are `streaming` or `lagging` and **reachable by the consumer** |
Use them as `${{pg.DATABASE_WRITE_URL}}` and `${{pg.DATABASE_READ_URL}}`, where `pg` is the slug of any member. The URL and password exports are masked and redacted like `DATABASE_URL`.
Which replicas a consumer gets:
1. the replicas **on the consumer's server**, if any;
2. otherwise the replicas on other servers, reached through the mesh (the port is the mesh port, resolved per consumer);
3. otherwise **the writer**: with no usable replica, `DATABASE_READ_*` equals `DATABASE_WRITE_*`.
One replica gives a plain URL; several give a libpq multi-host URL, `postgres://user:pass@h1:5432,h2:5432/db` (a driver without multi-host support can use `DATABASE_READ_HOST`). `DATABASE_URL`, `PGHOST` and the others keep describing the service they are read from: on a replica that is the read-only server.
When a replica joins, leaves or changes **health class** (`streaming` or `lagging` against `broken`, `needs_rebuild` and the like; **not** its lag numbers), the services that reference any member are **staged**, never deployed. There is no reader proxy or pooler: the application picks the URL.
## Lag and health
The agent polls every 30 seconds, on the local socket, without any command traffic and without secrets. Lag comes from the **primary's** `pg_stat_replication` (bytes behind, and the replay lag in milliseconds); if that is unavailable it falls back to the replica's own received-versus-replayed difference and marks it stale. Time-based lag measured on the replica alone is not used, because it reads as unknown while the database is idle.
| State | Meaning |
|---|---|
| `streaming` | connected and replaying |
| `lagging` | more than 64 MiB or 60 seconds behind for 5 minutes |
| `broken` | the replica has not been connected to the primary for more than 2 minutes (the receiver stopped, the link is down or the primary is gone) |
| `needs_rebuild` | the slot was lost (`wal_status = lost`), or the replica follows a primary that was promoted over it |
Cards show the lag, the primary shows a `n replicas` chip and the replica `Replica of `. An observation older than 90 seconds is shown as "unknown". Notifications (see [notifications](https://nebulactrl.com/docs/operations/notifications)) fire on a **transition**: `database.replica.lagging` and `database.replica.broken`, de-duplicated.
Two Postgres behaviours to know: `hot_standby_feedback` is **off**, so long queries on a replica never bloat the primary, and `max_standby_streaming_delay` is 30 seconds, so **a long query on a replica can be cancelled** when it conflicts with replay. Retry it or use a dedicated longer-running copy.
`GET /services/{id}/database/replication` returns the status; `GET …/database` includes `replication` (`role`: `standalone`, `primary`, `replica` or `fenced`, the primary, the replicas with state and lag, and whether a rebuild is possible).
## Promote
**Promote** turns a replica into the new primary. It is **manual**. The dialog says so: "Manual. NebulaCtrl does not detect failures or switch automatically." Use it for planned maintenance or after you have decided the primary is gone.
`POST /services/{replicaId}/database/promote`, with `"dryRun": true` first for the plan, then the real request with `{"confirm": "", "acceptDataLoss": false}`. It needs `projects:write` and `deployments:write`, and a production environment needs the production approval like a deploy ([production guard](https://nebulactrl.com/docs/reference/mcp#production-approval)). Only one promotion per replica runs at a time (`promote_in_progress`). There is no MCP tool to promote.
### The plan
The dry run reports:
- the **mode**: `switchover` when the primary's server is online and its container is running, otherwise `forced`;
- the last observed lag and how old that observation is;
- the **consumers** whose variables reference the old primary, listed **per server**, with the links (and devices) the new primary needs for each consumer server;
- a warning `mesh_server_required` for a consumer on a server without the mesh: it cannot reach the new primary until the mesh is enabled there;
- the other replicas that will need a rebuild, and the backup schedules that will move;
- blockers: the replica is not `streaming`, `lagging` or `broken` (`replica_not_streaming`), its agent is offline (`replica_agent_offline`), or another promotion is running (`promote_in_progress`).
### Switchover (the old primary is reachable)
No committed data is lost.
1. **Freeze**: the old primary is set read-only and other sessions are ended.
2. **Stop and fence**: it is stopped cleanly, a fence is recorded and its restart policy is set to `no`.
3. **Final position**: the platform reads the last checkpoint from the **stopped** data directory and waits (5 minutes) until the replica has replayed it. If that times out, the old primary is restarted, unfrozen and unfenced, the promotion fails and **nothing was lost**.
4. **Promote** the replica (`pg_promote`) and clear its replica settings.
5. **Repoint** (below).
6. The old primary stays **fenced and stopped, with its data kept**.
### Forced (the old primary is unreachable or revoked)
The fence is recorded **first**, so the old primary cannot run as a primary again, then the replica is promoted. Nothing is read from the lost server. It needs `"acceptDataLoss": true` (`data_loss_not_accepted`, 422). **Everything the replica had not yet received is lost**: with asynchronous replication that is up to the replication lag, shown in the plan ("up to the replication lag, which is ` MiB` as of ``, or unknown, will be lost").
### Fencing
A fenced service is carried in every reconcile: the agent stops its container, sets its restart policy to `no` and refuses to start it on a deploy. There is **no "unfence"**, because running two writers is how data diverges. A fenced old primary can be [rebuilt as a replica](#rebuild) of the new primary or deleted. **Honest limit:** on a forced Promote, if the old primary's host comes back and Docker restarts the container (`unless-stopped`) before the agent reconnects and fences it, writes accepted in that window are lost; there is no merge, and rebuild is the only way back.
### Repoint: consumers on any server
In one transaction the platform:
- turns the replica into a standalone database, keeps its credentials (references to the old primary become literal copies of the same values) and clears its replica settings;
- marks the **other replicas `needs_rebuild`**, now following the new primary;
- **moves the backup schedules** to the new primary (the backup history stays with the old service);
- **stages a rewrite of the consumers**: in every consumer on **any server**, every `${{.KEY}}` reference to the old primary becomes `${{.KEY}}`. Nothing is deployed: the rewrites show in the staged bar, can be reviewed and discarded, and apply with **Deploy staged**;
- derives the grants the new primary needs for those consumers, adding the links the plan listed; the old primary's grants are dropped after the grace period.
A consumer that copied a connection string as plain text is not found; update it by hand. A consumer on a server **without the mesh** cannot reach a primary that moved to another server until the mesh is enabled there. A forced Promote while the old primary's server is dead depends on nothing from that server. It is audited as `database.promote` and `database.fence`.
## Rebuild
**Rebuild** (`POST /services/{id}/database/rebuild`, on a replica or a fenced primary; the CLI is `nebulactrl databases rebuild`) clones the database again from its primary into a **new data volume**; the old volume stays until you delete it. Use it for `needs_rebuild`, `broken` replicas whose slot is gone, a replica that followed a primary that was promoted over it, and the **old primary after a Promote**: its body may name the primary to follow (`followServiceId`), by default the replica that was promoted over it. There is no `pg_rewind`: rebuild is simpler and safe, at the cost of copying the whole database. Audited as `database.replica.rebuild`.
## Interactions
**Backups.** [Database backups](https://nebulactrl.com/docs/operations/database-backups) run on the **current primary only**. A schedule or a manual backup on a replica is refused (`backup_replica_unsupported`): a `pg_dump` on a standby can be cancelled by replay conflicts. A Promote moves the schedules with the role.
**Restore.** Restoring into a **new** database creates an ordinary standalone database, without replica settings. Restoring **in place** is refused on a replica (`replica_read_only`) and allowed on a primary with a warning: the restore replicates to every replica as ordinary writes.
**Upgrades.** A major version upgrade of a primary or a replica is **blocked while the group has replicas** (`replicas_exist`): `pg_upgrade` carries neither the `pg_hba.conf` block nor the replication slots. Remove the replicas, upgrade, add the replicas again. The minor version can drift when a floating major tag is redeployed: the plan warns, and you should redeploy the replicas first.
**Deleting** a primary that has replicas is refused (`replicas_exist`), and a replica cannot be deleted as a plain service (`replica_remove_required`): use **Remove replica** so its slot, role and `pg_hba.conf` block on the primary are dropped. **Credentials**: rotation on a replica is blocked (`replica_read_only`); rotate on the primary, the new password replicates and the replica's references stay consistent. A [public port](https://nebulactrl.com/docs/operations/databases#public-port) on a replica is allowed (it is a read-only server) and the plan says so. **Rollback** of a replica restores its configuration; the empty-data guard keeps a restored replica setting from doing harm on a promoted service.
**Removing a replica** stops it, drops its slot, role and block on the primary, deletes its service and then waits for the agent to remove its container and **data volume** before the replica is gone: the replica stays in `removing` until the agent reports the removal done, and a removal the agent reports as incomplete (a volume still in use, say) is sent again up to three times in all. A replica whose deploy is still running is stopped once that deploy is over, so it cannot stay connected to the primary; a drop the primary refuses (a slot still in use) is likewise sent again up to three times in all, because a slot left behind keeps the primary's WAL. An agent that is offline, or that does not answer within 10 minutes, does not hold the replica forever: the removal is given up with a warning in the control plane log, and a queued removal still runs when the agent returns. A drop that keeps failing or a primary that is offline is given up the same way: the warning names the slot, which you can drop on the primary with `SELECT pg_drop_replication_slot('')`.
**Servers.** A revoked server's replicas become `failed`; replicas whose primary was on it become `broken` and Promote is offered. [Draining](https://nebulactrl.com/docs/operations/draining-servers) a server moves services but not volumes, so a replica cannot be moved (`replica_pinned`).
## API, CLI and audit
| | |
|---|---|
| Add | `POST /services/{id}/database/replicas` |
| Remove | `DELETE /services/{id}/database/replica` (on the replica) |
| Promote | `POST /services/{id}/database/promote` |
| Rebuild | `POST /services/{id}/database/rebuild` |
| Status | `GET /services/{id}/database/replication` |
| CLI | `nebulactrl databases replicas add\|ls\|rm`, `nebulactrl databases promote [--yes] [--accept-data-loss]`, `nebulactrl databases rebuild [--follow]` |
| Audit | `database.replica.add`, `database.replica.remove`, `database.replica.rebuild`, `database.promote`, `database.fence` |
Errors: `replica_wrong_engine`, `replicas_limit`, `replica_not_primary`, `replicas_exist`, `replica_remove_required`, `replica_pinned`, `replica_read_only`, `replica_not_rebuildable`, `replica_not_streaming`, `replica_agent_offline`, `database_fenced`, `backup_replica_unsupported`, `mesh_server_required`, `mesh_confirmation_required`, `mesh_link_not_ready`, `primary_not_running`, `promote_in_progress`, `promote_confirmation_required`, `data_loss_not_accepted`, `agent_too_old`. The replication password is generated, sealed, delivered at send time only, and never appears in a payload, the audit log, the API or the logs. MCP has a read-only view (`get_database` shows replication); there is no tool to add, promote or rebuild.
## Limits
- **Manual only.** NebulaCtrl never promotes by itself, never detects that a primary is gone and has no witness or fencing device: it fences by stopping the container through the agent, which needs the agent to be reachable (or the host to stay down).
- **Asynchronous.** A forced Promote loses what the replica had not received. There is no synchronous option, no delayed replica, no cascading replica, no logical replication and **no point-in-time recovery**. Keep [backups](https://nebulactrl.com/docs/operations/database-backups).
- **Postgres only.** No other engine, no reader proxy or pooler, and no replica outside the primary's environment.
- At most **5 replicas** per primary; a replica is pinned to its server.
- Replicas across servers depend on the [mesh](https://nebulactrl.com/docs/operations/mesh): see its [failure modes](https://nebulactrl.com/docs/operations/mesh#failure-modes) and what was **not verified** there (Tailscale SaaS, NAT and relay fallback across real networks, hosts with a firewall, large databases).
- Split-brain after a real power loss, large-database clone times and clones over a real WAN were not exercised.
---
# Database backups
Source: https://nebulactrl.com/docs/operations/database-backups
NebulaCtrl backs up **managed databases** (the database panel's engines) on a schedule or on demand, keeps them in a destination you choose, prunes old ones, and restores them. This page is about your databases. Backing up the control plane itself (Postgres and `NEBULACTRL_SECRET_KEY`) is [backup-restore.md](https://nebulactrl.com/docs/operations/backup-restore).
## How a backup runs
The dump never touches the control plane's disk or memory.
1. The control plane records a backup run and queues a `db_backup` command for the agent of the database's server. The command names the destination and the object key; it carries **no credentials**. The destination's keys and the database password are attached to the frame at send time and live only in agent memory for the run.
2. The agent runs the engine's own dump tool **inside the database container** (an argv vector, no shell; credentials go through the exec environment, not the command line, except MongoDB whose tools only take them in the URI).
3. The dump is streamed: `dump stdout → (gzip) → SHA-256 → destination upload`. There is **no temp file**, so a 50 GB dump does not need 50 GB of spare disk. S3 uploads are multipart with 16 MiB parts, at most two in flight.
4. The exit status is checked **before** the stream may end. A dump that fails halfway makes the upload fail: the multipart upload is aborted (or the local temp file removed), no object and no manifest are left behind, and the run shows `failed` with the dump tool's own error.
5. After the upload the agent writes `.manifest.json` (engine, version, database, format, size, SHA-256, project/service names) and verifies the stored size. A restore into a fresh control plane can understand backups from the bucket alone.
Runs have a real state: `queued` → `running` (the agent acknowledged) → `succeeded` | `failed`. A run is `skipped` when it was not attempted (database stopped, not deployed, or the server offline) and `expired` when the command was never delivered. Nothing is ever reported as a success that did not happen.
| Engine | Tool (in the container) | Stored as | Restore |
|---|---|---|---|
| PostgreSQL | `pg_dump --format=custom --no-owner --no-acl` | `.dump` | `pg_restore --single-transaction --exit-on-error` |
| MySQL | `mysqldump --single-transaction --quick --routines --events --triggers` (as `root`) | `.sql.gz` | `mysql` |
| MariaDB | `mariadb-dump …` (as `root`) | `.sql.gz` | `mariadb` |
| MongoDB | `mongodump --archive --gzip` | `.archive.gz` | `mongorestore --archive --gzip` |
| Valkey / Redis | `valkey-cli --rdb -` / `redis-cli --rdb -` | `.rdb` | **not automated** (see below) |
| ClickHouse | not supported yet | | |
Key-value dumps are RDB snapshots. They are real backups, but loading an RDB means replacing the data file of a stopped server, which NebulaCtrl does not do yet: download the object and place it as `dump.rdb` in the data volume yourself.
## Destinations
Settings → **Backups** (admin scope). Two kinds:
- **S3-compatible** (AWS S3, Cloudflare R2, Backblaze B2, Wasabi, Hetzner Object Storage, SeaweedFS, Garage, …). Built on `aws-sdk-go-v2`. For a custom endpoint choose *path-style* if the store needs it and keep **compat mode** on (it is the default for custom endpoints): since 2025 the AWS SDK adds CRC32 checksums to every request, which several S3 implementations reject. Compat mode limits them to operations that require them. Use a **bucket-scoped key** with `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`, `s3:ListBucket` and `s3:AbortMultipartUpload`.
- **Local directory on the server** — flagged **not off-site**. Files are written under the agent's backup directory (`--backup-dir`, default `/backups`, `/var/lib/nebulactrl-agent/backups`), in the sub-directory you name. A destination can not point outside that directory; to use another disk mount it there or set `--backup-dir`. A backup on the database's own host does not survive the loss of that host, so use it as a first copy, not the only one. A local backup lives on one server and can only be restored onto a database on that same server.
Keys are sealed with `NEBULACTRL_SECRET_KEY` and are write-only in the API and UI (`accessKeySet: true`, never the value). Restoring the control plane without its secret key loses them.
**Test** writes, reads back and deletes a probe object from the agent of a server you pick (the agent is the one that talks to the destination). The result and any error stay on the destination.
A destination that schedules or stored backups use can not be deleted, and its endpoint, bucket, prefix and directory can not change while backups live in it.
## Schedules and retention
A database panel → **Backups** → *Add schedule*: a destination, a cron expression, a timezone and retention.
- Cron is the standard 5 fields (`minute hour day-of-month month day-of-week`) or `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. Ranges, lists, steps (`*/15`, `10-50/10`) and month/weekday names work. When both day fields are restricted a day matches if either does (classic cron). The parser is in-house (`internal/cronexpr`), with its next-run calculation tested across daylight-saving changes: a wall time that does not exist (spring forward) is skipped that day, a repeated one (fall back) runs once.
- At most one backup every **5 minutes**.
- A tick runs every minute. Missed runs are not replayed; after downtime a schedule runs once and moves on.
- **Missed backups are reported.** A schedule whose backup did not succeed within its window (the next due time after the last success plus a grace of a quarter of the interval, 15 minutes to 6 hours) raises `backup.missed` once, until the next success. It covers the case where nothing ran at all (control plane or server down, destination unreachable). See [Service alerts](https://nebulactrl.com/docs/operations/service-alerts#missed-backups).
- **Retention**: keep the newest *N* successful backups of the schedule (default 7) and/or delete those older than *D* days. `0` disables a limit. The newest backup is never pruned, and **locked** backups are never pruned and do not count towards *N*. Backups made on demand are never pruned automatically. Pruning deletes the object through the agent of the server that made it (an offline server retries after the next successful backup).
## Restore
Pick a backup → **Restore**.
- **To a new database (default).** NebulaCtrl creates a sibling database service next to the original (same engine, version and settings, its own volume, **fresh credentials**), deploys it empty, waits until it runs, then streams the dump into it. The original is untouched. Point your apps at the new database when you are satisfied, then delete the old one. For restores onto the same server this needs disk for both copies.
- **In place (advanced, destructive).** Type the database's slug to confirm. NebulaCtrl first takes a safety backup (marked *pre-restore*) to the same destination; only if that succeeds it restores over the database with `--clean` semantics. If the safety backup fails nothing is changed. NebulaCtrl can not stop your applications' writes: stop dependants first if consistency matters.
The agent verifies the object's SHA-256 against the recorded value while restoring; a mismatch fails the restore and reports it. Restore to a new database only happens after the new database's deployment succeeded; a failed deployment fails the restore and leaves the sibling for you to inspect or delete.
A backup is not proven until it restores. Restore the latest one into a new database now and then and look at the data.
## Restore from a dump file
Moving a database in from somewhere else (another server, Coolify, a laptop): **Database → Backups → Restore from a file**, or
```sh
nebulactrl databases restore shop/production/postgres --file shop.pgdump
nebulactrl databases restore shop/production/postgres --file shop.sql.gz --in-place --confirm postgres --destination s3
```
| Engine | Accepted files |
|---|---|
| PostgreSQL | `pg_dump -Fc` (custom), `-Ft` (tar) or plain SQL, each optionally `.gz` |
| MySQL, MariaDB | plain SQL from `mysqldump` / `mariadb-dump`, optionally `.gz` |
| MongoDB | `mongodump --archive`, optionally `.gz` |
The format is detected from the file's first bytes; the wrong engine, a `pg_dumpall` cluster dump, zstd/xz/zip, UTF-16 text and Redis/Valkey RDB files are refused with the reason, before anything is changed. Make a Postgres dump with `pg_dump -Fc -U > shop.pgdump`.
**How the file travels.** The upload goes through the control plane to the server's agent as a stream and is piped into the engine's own client (`pg_restore`, `psql`, `mysql`, `mongorestore`) inside the database container. Nothing is stored on the control plane or written to the agent's disk, and at most one 256 KiB window is in memory, so a 5 GB file works. The default limit is **5 GiB** (`NEBULACTRL_MAX_DUMP_UPLOAD_BYTES`); a larger file is refused at the start. Throughput is bounded by the window over the round trip to the agent. If you front the control plane with a proxy that caps request bodies (a Cloudflare-proxied hostname does at 100–500 MB), upload through a direct or DNS-only hostname.
**Targets.** As for backups, **a new database is the default**: a sibling is created, deployed empty, and the file is restored into it; the original is untouched. **In place** needs the database's slug and a backup destination for the safety backup (default: the one of its schedule); only after that backup succeeded can the file be sent. The restore waits in `awaiting_upload` until the target runs, then the client sends the file (the CLI and web app do this for you; the raw endpoint is below).
**What PostgreSQL does.** The file is restored into a *scratch database* created from `template0`, and swapped in only when it restored completely: the live database is renamed away, the scratch database takes its name, the old one is dropped (connections are ended at that moment). A broken or abandoned file therefore leaves the data exactly as it was, and PostGIS dumps work because their extensions and schemas are created by the dump itself in an empty database. `pg_restore` runs with `--no-owner --no-acl --role=`: every object ends up owned by the managed user and grants are dropped. For plain SQL the same is done on the stream: ownership, `GRANT`/`REVOKE`, role, tablespace, `CREATE/DROP/ALTER DATABASE` and `\connect` lines are skipped (the log counts them). In-place needs room for both copies while it runs.
**MySQL, MariaDB, MongoDB** are restored directly into the target database; they have no atomic swap. `CREATE/DROP DATABASE` and `USE` lines and `DEFINER=` clauses of a SQL dump are removed so the data lands in the managed database; `mongorestore` restores the databases the archive contains (`--drop` for in-place). A failed in-place restore can leave part of the dump there: restore the safety backup, or prefer a new database.
Progress and errors (the client's own messages, with credentials masked) are kept as the restore's log, shown in the dialog and in `GET /dump-restores/{id}`. The control plane records the SHA-256 of what it relayed. A restore whose transfer is lost (control plane restart, agent offline) is failed with "nothing was switched over"; start it again.
API: `POST /services/{id}/dump-restores` (202; `mode`, `name`, `serverId`, `confirmSlug`, `destinationId`, `fileName`, `sizeBytes`), `GET /dump-restores/{id}`, `GET /services/{id}/dump-restores`, `POST /dump-restores/{id}/cancel`, and the raw upload `PUT /api/v1/dump-restores/{id}/upload?fileName=` (body = the file, `Content-Length` required; 202 once the agent has the whole file; 409 `dump_restore_not_ready` until the status is `awaiting_upload`). Statuses: `provisioning`, `pre_backup`, `awaiting_upload`, `uploading`, `restoring`, `succeeded`, `failed`. Needs `deployments:write` (and `projects:write` for a new database). Audited as `backup.dump_restore.create|upload|succeeded|failed|cancel`. The create operation is not available over MCP (it needs a binary upload); reads are.
Limits: PostgreSQL 16+ images, agents of this release or newer (an older agent never answers and the upload is refused after 60 s), no `pg_dumpall` cluster dumps, no URL or object-storage import, no resumable uploads.
## API, audit, permissions
- Destinations: `GET/POST /backup-destinations`, `PATCH/DELETE /backup-destinations/{id}`, `POST …/test`. Admin scope to write.
- Per database: `GET/POST /services/{id}/backup-schedules`, `GET/POST /services/{id}/backups` (202), `GET /services/{id}/backup-restores`.
- `PATCH/DELETE /backup-schedules/{id}`, `GET/DELETE /backups/{id}`, `PUT /backups/{id}/lock`, `POST /backups/{id}/restore` (202), `GET /backup-restores/{id}`.
- Creating, restoring and deleting need `deployments:write` on the project; reading needs `projects:read`. Creates honour `Idempotency-Key`.
- Every change is audited (`backup.create`, `backup.restore`, `backup.delete`, `backup.lock`, `backup.schedule.*`, `backup.destination.*`); credential changes record only that they happened.
- Finished backups and restores are published to a notifier hook; channels (email, Slack, …) attach to it in Phase 6b.
## Limits
- Logical dumps only: no point-in-time recovery, no volume snapshots.
- No client-side encryption: protect the bucket and its key. A planned opt-in is `age` encryption; a lost key makes backups unreadable, so it will be explicit.
- Backups of one database are not coordinated with another's.
- Deleting a project or database removes its backup history rows but leaves the stored objects: delete them from the destination if you do not need them.
---
# Volume backups
Source: https://nebulactrl.com/docs/operations/backups
NebulaCtrl backs up the **named volumes of an ordinary service** (anything that is not a managed database): uploads, SQLite files, a Git server's repositories, an app's `data` directory. Managed databases keep their own logical dumps ([database backups](https://nebulactrl.com/docs/operations/database-backups)); the control plane's own backup is [backup-restore.md](https://nebulactrl.com/docs/operations/backup-restore).
A volume backup is **a file-level backup**. Out of the box it is *crash-consistent*: the files are exactly as they were at one instant, as if the power had been cut. Apps that journal their writes (SQLite, PostgreSQL, Valkey with AOF, most key-value stores) start from such a copy without trouble. For *application-consistent* data add a **before-backup command** (below).
## How a backup runs
The point-in-time copy is taken fast; the slow part, moving the data, happens afterwards while the service runs normally.
1. **Before-backup command** (optional). Run inside the service's container with a timeout, for example `sqlite3 /data/app.db ".backup '/data/.nc-backup/app.db'"` or `redis-cli BGSAVE`. It runs **before** the freeze, because Docker refuses to exec into a paused container. If it fails, the backup fails with "Nothing was backed up." and the service is never frozen (unless you chose *continue anyway*).
2. **Freeze.** The agent takes the service's per-service lock (so no deployment can recreate the container in the middle), then runs `docker pause` on the container. This uses the kernel's cgroup freezer: every process stops, **TCP connections stay open and nothing is reset**; clients see a latency spike, not an outage.
3. **Local copy.** A helper container copies the volumes into a temporary *staging volume* with `cp -a --reflink=auto`. On filesystems with copy-on-write (btrfs, XFS with reflink) this takes well under a second regardless of size; elsewhere it is a plain copy, bounded by the freeze budget.
4. **Thaw.** `docker unpause`. The freeze lasted only as long as step 3.
5. **After-backup command** (optional) runs now (for `live` mode: after the upload).
6. **Upload.** The helper runs **restic** on the staging copy and uploads into the service's repository in the background. restic is incremental and deduplicating: unchanged data costs nothing, and the data is encrypted on the server before it leaves it. The staging copy is deleted afterwards.
The service is only ever paused (or stopped) for step 3, **never for the upload**, and never longer than the freeze budget.
### Consistency modes
| Mode | What happens | Use it for |
|---|---|---|
| **Brief freeze** (default) | pause → copy → unpause. Requests stall for about a second on copy-on-write hosts. The default budget is **5 s** (1–60). | most services |
| **Stop during backup** | stop → copy → start. Connections are reset and the service is unavailable until it is back. | apps that cannot be paused safely: leases or heartbeats shorter than the freeze (Consul/etcd-style, Kafka, RabbitMQ), apps with multi-file invariants and no crash recovery |
| **Keep running, no freeze** | no pause, no copy: restic reads the live volumes. The backup can hold files from different moments. | static uploads, or together with a before-backup command that produces a consistent file |
**The freeze budget is a hard limit.** If the copy does not finish within it, the agent thaws the service, discards the copy and fails the backup with *"Snapshot took longer than 5 s; use Stop mode or a pre-backup hook"*. There is no silent fallback to another mode, and the service is never left paused. A timer unfreezes independently of the copy, retries the unpause, and reports it loudly if it cannot.
A service that is **not running** is backed up without any freeze (nothing is writing), whatever mode is set. If a deployment (or another action on the service) is in progress for two minutes, the backup is `skipped` and the service is not touched.
Hosts without copy-on-write need free disk for the staging copy (about as much as the volumes use). The agent checks this **before** freezing and fails early with the numbers instead of filling the disk.
## Set it up
Service → **Backups** tab (shown for services that have at least one volume):
- **Destination**: the same destinations as database backups (Settings → Backups): S3-compatible storage or a directory on the server. A directory is **not off-site**; it does not survive the loss of that server.
- **Consistency**, **freeze budget**, before/after commands (program and arguments; no shell unless you write `sh -c '…'`; optional user and timeout, default 5 min, and *if it fails: stop the backup / continue anyway*), and **excludes** (restic patterns such as `*.tmp`).
- **Retention**: keep the last *N* daily, weekly and monthly backups (defaults **7 / 4 / 6**). `0` removes that limit; all zeros keeps everything. **Locked backups are never removed.**
- **Schedule** (optional): a cron expression and timezone, at most one run every 5 minutes. A schedule that finds the server offline records a `skipped` run with the reason.
- **Missed backups are reported.** Like database schedules, an enabled volume schedule that produced no successful backup within its window raises `backup.missed` once per streak. See [Service alerts](https://nebulactrl.com/docs/operations/service-alerts#missed-backups).
*Back up now* uses the saved settings. All volumes of a service go into **one snapshot** (`/vol/`), so they are consistent with each other.
### Where the data goes
- One restic repository **per service and destination**, at `/volumes/`. Backups of different services never share a repository.
- The repository is encrypted (AES-256 with Poly1305) with a password NebulaCtrl generates on the first backup. It is stored sealed with `NEBULACTRL_SECRET_KEY` and reaches the agent only as a send-time secret in memory.
- **Download the recovery key** (Backups tab, admin scope, audited). Without the key, or without the secret key that seals it, the backups can not be read. Restoring the control plane without its secret key loses them, exactly as it loses destination credentials.
Restoring by hand without NebulaCtrl (restic ≥ 0.14):
```sh
export RESTIC_PASSWORD=''
export RESTIC_REPOSITORY='' # e.g. s3:https://s3.example.com/bucket/volumes/
# S3 also needs AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
restic snapshots
restic restore :/vol/data --target /path/to/new/volume
```
## Restore
Choose a backup → **Restore**, pick the volume.
- **Restore into a new volume (default).** The data goes into a **new** Docker volume (`nc-vol---r