# nebula.toml reference

Every key of `nebula.toml`. How the file is used (validate, plan, apply, git, drift) is in the [nebula.toml guide](https://nebulactrl.com/docs/operations/project-config.md); the machine-readable form is the JSON Schema at `/schema/nebula.toml.json`, which carries the same descriptions and examples.

## Conventions

- TOML, UTF-8, `schema = 1`. Unknown keys, wrong types and TOML datetimes are errors.
- `snake_case` everywhere. Units live in the key name: `_seconds`, `_mb`, `_days`, `_minutes`, `_percent`. No `"30s"` or `"512mb"` strings.
- Services are a map keyed by slug (`[services.web]`). Collections are maps keyed by their identity: variable name, hostname, volume name, task name, backup key, file path. Arrays are for ordered or atomic lists only (argv, upstreams, CIDRs, watch paths, redirects).
- *name* means a name resolved in the workspace (server, git connection, registry, backup destination, network, certificate). An unknown or ambiguous name is a problem, never a guess.
- **staged** keys take effect on the next deploy and show in the staged bar. **immediate** keys take effect at once, like the dashboard control they map to.
- An absent key is not owned and not changed on an existing service, whatever its default says. The default applies when the service is created.

## Root

| Key | Type | Notes |
|---|---|---|
| `schema` | integer | Required. Only `1`. |
| `"$schema"` | string | Ignored; for editors. |
| `project` | string | Optional project slug; must match the target project. |
| `server` | string (name) | Default server. Precedence: service > environment > root. |
| `variables` | table | Project-layer shared variables, read as `${{shared.KEY}}`. |
| `secrets` | table `{ keys, scope }` | Shared secret names, no values. |
| `services` | table | One table per service, keyed by slug. |
| `environments` | table | Optional overrides per environment slug. |

`auto_apply`, `is_production`, tokens and credentials are not keys.

## Services

Exactly one source key decides the kind:

| Source key | Kind |
|---|---|
| `image` | An image service (`app`, or `worker` without a `port`). |
| `git` | A service built from a repository. |
| `database` | A managed database. |
| `route` | An external proxy route; no container. |
| `stack` | A compose stack. |

Common keys: `name` (immediate), `server` (staged; required for `route`), `only_in` (environments the service exists in), `depends_on` (deploy order, staged), `networks` (attach existing shared networks, staged), `unset` (environment blocks only).

| Section | image, git | database | route | stack |
|---|---|---|---|---|
| `port`, `command`, `health_check`, `one_shot` | yes | derived | no | no |
| `stop_grace_seconds`, `memory_mb`, `cpus`, `image_retention`, `block_egress`, `docker` | yes | yes | no | no |
| `pre_deploy`, `post_deploy`, `files`, `volumes`, `scale`, `tasks` | yes | no | no | no |
| `variables`, `secrets` | yes | yes | no | yes |
| `domains` | yes (needs a port) | no | yes (http routes) | yes (`component`) |
| `backups` | no | yes | no | no |
| `uptime` | yes | yes | yes | yes |

A key not allowed for the kind is `not_allowed_for_kind`.

### Runtime keys

| Key | Type | Default | Bounds |
|---|---|---|---|
| `image` | string | required for `image` | `repo[:tag][@digest]`, no registry credentials |
| `port` | integer | `0` | 0-65535; `0` means no HTTP |
| `command` | argv | image default | `[]` clears |
| `one_shot` | bool | `false` | Runs to completion on every deploy; excludes `pre_deploy` and `post_deploy` |
| `stop_grace_seconds` | integer | `30` | 1-3600 |
| `memory_mb` | integer | `0` (no limit) | `0` or at least 6 |
| `cpus` | float | `0` (no limit) | at least 0; the only float in the format |
| `image_retention` | integer | `5` | 1-50 |
| `block_egress` | bool | `false` (`true` for a new database) | No outbound internet |
| `generate_domain` | bool | `false` | Attach the platform-generated hostname when the service has none |

`health_check`: `type` (`http`, `tcp`, `docker`, `exec`, `none`), `path` (http, starts with `/`), `port`, `interval_seconds` (2), `timeout_seconds` (3), `start_period_seconds` (0), `deadline_seconds` (120, 1-3600), `success_threshold` (2), `command` (exec; first element `CMD` or `CMD-SHELL`). Each key is owned on its own.

`pre_deploy` and `post_deploy`: `command` (argv, required) and `timeout_seconds` (600, 1-3600).

### `git` and `build`

| Key | Type | Notes |
|---|---|---|
| `git.url` | string | Public repository, or one whose token or deploy key is already stored. Userinfo in the URL is rejected. Exclusive with `connection` and `repo`. |
| `git.connection` | string (name) | Git connection; credentials stay in the connection. immediate |
| `git.repo` | string | `owner/name`. immediate |
| `git.branch` | string | Required. |
| `git.submodules` | `auto`, `on`, `off` | Default `auto`. |
| `git.auto_deploy` | bool | Default `true` on a new link; needs `connection`. immediate |
| `git.watch_paths` | array, at most 50 | Replaces the list; globs and `!` exclusions. immediate |
| `build.type` | `auto`, `dockerfile`, `railpack`, `starter:<node\|python\|go\|ruby\|php\|static>` | Default `auto`. |
| `build.dockerfile`, `build.context` | relative path | Defaults `Dockerfile` and `.`. No absolute paths, no `..`. |
| `build.location` | `inherit`, `target`, `build_server` | Default `inherit`. |
| `build.server`, `build.registry` | string (name) | Optional. |

### Variables and secrets

A variable value is a string in the variable grammar: a plain value, `"${{PORT}}"`, `"${{db.DATABASE_URL}}"`, `"${{shared.KEY}}"`, `"${{team.KEY}}"`, `"${{vault:integration/path#key}}"`, a generator (`"${{secret(32)}}"`, `randomInt`, `uuid`, `randomBytes`), or a transform (`"${{urlencode(db.PASSWORD)}}"`). `"$${{"` writes a literal `${{`. Non-string values are `wrong_type`. Names match `^[A-Za-z_][A-Za-z0-9_]{0,254}$`; `PORT` and `NEBULA_*` are reserved.

The table form carries metadata: `KEY = { value = "...", scope = "build", comment = "...", rotate_every_days = 90, public = false }`. `scope` is `runtime` (default), `build` or `both`; `public = true` asserts a literal is not a credential. `secrets = { keys = ["NAME"], scope = "runtime" }` declares names that are secret, with no value in the file. A variable containing a generator is a secret; `secret = false` with one is rejected.

### `domains`

`[services.<s>.domains."<host>[/<path-prefix>]"]`; a table may be empty. Keys (all staged unless noted): `target_port`, `component` (stack, required there), `strip_prefix`, `add_prefix`, `kind` (`serve`, `redirect`), `redirect_to`, `redirect_code` (301, 302, 307, 308; default 308), `preserve_path`, `tls` (`auto`, `custom`, `none`), `certificate` (name), `dns_resolver`, `www_redirect`, `options` (the whole table replaces the live option set), and `cloudflare` (`manage`, `proxied`, `origin_lock`; immediate, needs a Cloudflare connection).

`options`: `force_https`, `compress`, `noindex`, `security_headers` (`basic`, `strict`), `request_headers`, `response_headers`, `cors` (`origins`, `methods`, `headers`, `expose_headers`, `credentials`, `max_age_seconds`), `redirects` (array of `regex`, `replacement`, `permanent`), `rate_limit` (`average`, `period_seconds`, `burst`, `trusted_proxy_depth`), `access` (`allow_cidrs`, `deny_cidrs`, `trusted_proxy_depth`, `reject_status`), `basic_auth` (enables only; users and passwords are never in the file), `large_uploads`.

### `database` and `backups`

`database` is create-only: `engine` (required), `version`, `flavor`, `name`, `username`, `accept_license` (required `true` for source-available engines). A difference on an existing database is `immutable_field`.

`backups.<key>` (key matches `^[a-z0-9][a-z0-9_-]{0,62}$`; immediate): `destination` (name, required), `cron` (required; at most one run per 5 minutes), `timezone` (`UTC`), `enabled` (`true`), `retention_count` (7, 0-1000), `retention_days` (0, 0-3650). Schedules created in the dashboard have no key and are never touched.

### `route`

`protocol` (`http`, `tcp`, `udp`; create-only), `upstreams` (1-20 inline tables, each `{ address = "host:port" }` or `{ service = "<slug>", port = 1234 }`), `scheme`, `insecure_skip_verify`, `server_name`, `pass_host`, `health_path`, `verify_skip`, `listen_port` (1024-65535, tcp and udp), `allow_cidrs`, `deny_cidrs`, `open_to_internet` (required when `allow_cidrs` is empty on tcp and udp), `proxy_protocol` (0, 1, 2). Needs `server`. The public port of a database is not a file key.

### `stack`

Exactly one of `compose` (up to 256 KiB) and `compose_file` (relative path, sent as an attachment); `wait_seconds` (300, 1-3600); `resources.<component>` with `memory_mb` and `cpus`.

### `volumes`, `files`, `scale`, `uptime`, `tasks`, `docker`

- `volumes`: `name = "/mount/path"`; names match `^[a-z0-9][a-z0-9_.-]{0,62}$`, paths are absolute. Staged. Detaching never deletes data. A service with volumes cannot have `scale.replicas` above 1.
- `files."/path"`: `mode` (octal string, `0644`) and exactly one of `content` or `source` (an attachment). At most 16 files, 64 KiB each, 256 KiB in all. The whole set is one unit.
- `scale` (immediate): `replicas` (1-10), `autoscaling.enabled`, `autoscaling.min`, `autoscaling.max`, `autoscaling.target_cpu_percent` (20-95).
- `uptime` (immediate, admin scope): `enabled`, `alerts`, `failure_threshold` (3, 1-20), `recovery_threshold` (2, 1-20), `reminder_minutes` (0, 0-1440).
- `tasks.<name>` (immediate, at most 20): `schedule` (required cron), `timezone` (`UTC`), `command` (argv, required), `enabled`, `timeout_seconds` (1800, 1-86400), `concurrency` (`forbid`, `allow`).
- `docker`: staged, each key owned individually. Allowed everywhere: `cap_add`, `cap_drop`, `no_new_privileges`, `read_only_rootfs`, `pids_limit`, `hostname`, `dns`, `sysctls`, `host_sysctls`, `user`, `group_add`, `ulimits`, `shm_size_mb`, `extra_hosts`, `labels`, `init`, `stop_signal`, `tmpfs`, `entrypoint`. Only on unrestricted servers: `devices`, `gpus`, `runtime`, `privileged`, `pid_host`, `seccomp_unconfined`, `apparmor_unconfined` and the capabilities `NET_ADMIN` and `SYS_ADMIN`.

## Environments

`[environments.<slug>]` overrides one environment: `server`, `variables` and `secrets` (the environment layer of shared variables), and `services.<slug>` with the same keys as a service plus `unset`. The file never creates, renames or deletes environments.

### The merge rule

For service `S` in environment `E` the resolved definition is `merge(services.S, environments.E.services.S)`:

1. Tables merge by key, recursively.
2. Scalars and arrays replace the lower value as a whole. An override restates a full list.
3. Leaves replace as a unit: a variable entry, `domains.<host>.options`, `files.<path>`, a `route.upstreams` item, `resources.<component>`.
4. `unset` (environment blocks only) removes TOML key paths after the merge, for example `["variables.FEATURE_X", 'domains."shop.acme.com"', "tasks.digest", "scale"]`. A removed key is not owned in that environment; nothing live is deleted.
5. `only_in` filters services before merging. A service defined only in an environment block must be complete.
6. Error positions refer to the line that supplied the winning value.

## Limits

File 256 KiB; attachments at most 8, 256 KiB each, 1 MiB in all; 64 services; 500 variables per service and per shared layer; 50 domains per service; 16 files per service; 20 tasks per service; 50 watch paths; 20 upstreams; 32 environment blocks; table nesting depth 10; any string 64 KiB.

## Problems

Each problem is `{ severity, code, message, path, line, column, hint, suggestions, environment }`, shown as `nebula.toml:19:1: error[unknown_key] services.web.healthcheck: unknown key "healthcheck"; did you mean "health_check"?`. The codes and what to do about each are in the [troubleshooting table of the guide](https://nebulactrl.com/docs/operations/project-config.md#troubleshooting-by-code).

## Examples

A web app from git, a worker, Postgres with nightly backups, a cron task, a proxied domain, an external route, an uptime monitor and staging overrides:


```toml
#:schema https://nebula.example.com/schema/nebula.toml.json
schema  = 1
project = "acme-shop"          # must equal the target project's slug when --project / the binding names one
server  = "fra-1"              # default server (by name) for every service; override per service or environment

# Project-wide shared variables. Services read them as ${{shared.APP_NAME}}.
[variables]
APP_NAME = "Acme Shop"

# ───────────────────────── web: built from git ─────────────────────────
[services.web]
name               = "Web"
port               = 3000                 # no port => the service is a worker (no HTTP health check, no domain)
stop_grace_seconds = 30
depends_on         = ["db"]               # deploy order; references in variables add edges automatically

[services.web.git]
connection  = "github-acme"               # git connection NAME (Team settings → Integrations); never a token
repo        = "acme/shop"
branch      = "main"
auto_deploy = true                        # immediate
watch_paths = ["apps/web/**", "packages/**", "!**/*.md"]   # monorepo filter; replaces the whole list

[services.web.build]
type       = "dockerfile"                 # auto | dockerfile | railpack | starter:<node|python|go|ruby|php|static>
dockerfile = "apps/web/Dockerfile"
context    = "."

[services.web.health_check]
type             = "http"
path             = "/healthz"
deadline_seconds = 90

[services.web.pre_deploy]                 # runs before the new release starts; non-zero exit fails the deploy
command         = ["bun", "run", "db:migrate"]
timeout_seconds = 300

[services.web.variables]
NODE_ENV     = "production"
APP_URL      = "https://shop.acme.com"
DATABASE_URL = "${{db.DATABASE_URL}}"     # reference to another service's exported variable
SESSION_SECRET = "${{secret(48)}}"        # generator: runs once, same text again keeps the value
SITE_NAME    = "${{shared.APP_NAME}}"
BUILD_FLAG   = { value = "1", scope = "build", comment = "read by the bundler" }   # table form for metadata

[services.web.secrets]                    # names only; the values are set in the dashboard / CLI / secret manager
keys = ["STRIPE_SECRET_KEY"]

[services.web.domains."shop.acme.com"]    # key = hostname, or hostname/path-prefix
www_redirect = true

[services.web.domains."shop.acme.com".cloudflare]   # immediate; needs a Cloudflare connection
proxied = true

[services.web.domains."shop.acme.com".options]      # replaces the domain's whole option set
compress         = true
security_headers = "strict"

[services.web.scale]                      # immediate
replicas = 2

[services.web.uptime]                     # immediate
failure_threshold = 3
reminder_minutes  = 60

[services.web.tasks.digest]               # cron task, immediate; runs in the service's image
schedule        = "0 7 * * 1-5"
timezone        = "Europe/Berlin"
command         = ["bun", "run", "jobs:digest"]
timeout_seconds = 900

# ───────────────────────── worker: same repo, no port ─────────────────────────
[services.worker]
command    = ["bun", "run", "worker"]
memory_mb  = 512
depends_on = ["db"]

[services.worker.git]
connection  = "github-acme"
repo        = "acme/shop"
branch      = "main"
watch_paths = ["apps/worker/**", "packages/**"]

[services.worker.build]
type       = "dockerfile"
dockerfile = "apps/worker/Dockerfile"
context    = "."

[services.worker.variables]
DATABASE_URL      = "${{db.DATABASE_URL}}"
QUEUE_CONCURRENCY = "8"

# ───────────────────────── db: managed Postgres with nightly backups ─────────────────────────
[services.db.database]                    # create-only keys: engine, version, name, username
engine   = "postgres"
version  = "17"
name     = "shop"
username = "shop"

[services.db.backups.nightly]             # immediate; key = stable identity of the schedule
destination     = "offsite-s3"            # backup destination NAME
cron            = "0 2 * * *"
timezone        = "Europe/Berlin"
retention_count = 14
retention_days  = 30

[services.db.uptime]
reminder_minutes = 30

# ───────────────────────── printer: external route (no container) ─────────────────────────
[services.printer]
name    = "Office printer"
only_in = ["production"]                  # this service exists in the file only for these environments

[services.printer.route]
protocol    = "http"
upstreams   = [{ address = "192.168.1.20:631" }]     # or { service = "web", port = 3000 } for a managed service
health_path = "/"

[services.printer.domains."print.acme.com".options.access]
allow_cidrs = ["203.0.113.0/24"]

# ───────────────────────── staging overrides ─────────────────────────
[environments.staging]
server = "fra-staging"

[environments.staging.variables]          # environment-layer shared variables (override the project layer)
APP_NAME = "Acme Shop (staging)"

[environments.staging.services.web]
unset = ['domains."shop.acme.com"', "scale", "uptime"]   # TOML key paths; only allowed in environment blocks

[environments.staging.services.web.git]
branch = "develop"

[environments.staging.services.web.variables]
NODE_ENV = "staging"
APP_URL  = "https://shop.staging.acme.com"

[environments.staging.services.web.domains."shop.staging.acme.com".cloudflare]
proxied = false

[environments.staging.services.worker.git]
branch = "develop"

[environments.staging.services.db]
unset = ["backups.nightly"]
```

A single image:


```toml
#:schema https://nebula.example.com/schema/nebula.toml.json
schema = 1
server = "fra-1"

[services.whoami]
image = "traefik/whoami:v1.10"
port  = 80

[services.whoami.health_check]
type = "http"
path = "/health"

[services.whoami.variables]
GREETING = "hello"

[services.whoami.domains."whoami.acme.com"]

[services.whoami.scale]
replicas = 2
```

A compose stack:


```toml
#:schema https://nebula.example.com/schema/nebula.toml.json
schema = 1
server = "fra-1"

[services.shop]
name = "Shop stack"

[services.shop.stack]
wait_seconds = 300
compose = '''
services:
  web:
    image: nginx:1.27-alpine
    environment:
      GREETING: ${GREETING}
  cache:
    image: valkey/valkey:8
'''

[services.shop.stack.resources.cache]
memory_mb = 256

[services.shop.variables]
GREETING = "hello"

[services.shop.domains."shop.acme.com"]
component   = "web"
target_port = 80
```
