cluster.yaml
cluster.yaml describes the whole cluster in one file: its networks, drives, nodes, dashboard, failover timers, sign-in, reverse proxy, Cloudflare DNS, self-update, git backup and AI access. Every node holds a copy at /etc/clouduler/cluster.yaml. A daemon refuses to start if the file does not parse or validate.
This page lists every key. Most keys apply live, without a restart. The Reload column in each table tells you which ones do not.
Once a cluster exists, the authoritative copy of the config lives in the cluster's replicated state (raft). Every node rewrites its local cluster.yaml from that copy. If you edit the file by hand, the next sync reverts it, and a daemon restart does not help: your edit runs for a few seconds, then it is overwritten.
To apply a file edit, run clouduler config reload. Or skip the file and use the Settings pages in the web UI.
How the config is replicated
- The config is stored in raft as one YAML string plus a generation number. The first stored config is generation 1.
- The web UI (Apply),
clouduler config reload,clouduler node remove, the self-update settings and the network and drive rename buttons all write to raft. - Every node watches raft. When the generation changes it parses the new YAML, hands it to the subsystems (proxy, DNS, drives, scheduler, auth) and rewrites its own file if the text differs. The check runs every
failover.probe_interval(5 seconds by default). - At boot a daemon reads its local file first, then the first watcher tick overwrites the file with the raft copy if they differ.
- A fresh cluster seeds raft from the founding node's file, but only while raft has no config at all.
See Live config for what takes effect immediately and what needs a restart.
Editing the config
- Web UI
- CLI / config
Open Settings. The tabs that edit cluster.yaml are General, Network, Storage and Sign-in and backup. Updates and API keys and AI clients are separate screens.
- Change fields on any page. Changes are staged in a draft. The header button Apply changes stays disabled until the draft differs from the running config.
- Open Changes to apply to see the diff. Validate config checks the draft without applying it, Discard edits throws it away.
- Set Confirm within (seconds) (default 60, minimum 5).
- Press Apply changes. The UI validates first, then submits.
- If your edit touched
networks,nodes,web,proxyorcloudflare, a rollback timer starts. The banner Config change waiting for you counts down and the header button turns into Confirm config. Press it once you can still reach the dashboard. Roll back now restores the previous config immediately.
Edits to failover, secrets, auth, drives, self_update, backup, mcp and cluster.name apply at once with no countdown.
Only admins can apply. Viewers can read.
| Page | Where | Keys |
|---|---|---|
| Cluster | /settings/general | cluster.name |
| Failover | /settings/general?page=failover | failover.* |
| Networks | /settings/network?page=networks | networks.* and the rename button |
| Dashboard | /settings/network?page=web | web.* |
| Reverse proxy | /settings/network?page=proxy | proxy.enabled, ports, proxy.acme.*, proxy.dns_server.* |
| Cloudflare DNS | /settings/network?page=cloudflare | cloudflare.zones and the API token |
| Drives | /settings/storage?page=drives | drives.* |
| Sign-in | /settings/security?page=auth | auth.* |
| Service login | /settings/security?page=service-login | proxy.auth.* |
| Cluster key | /settings/security?page=secrets | secrets.generate_if_missing |
| AI access | /settings/security?page=mcp | mcp.enabled |
| Git backup | /settings/security?page=git-backup | backup.* |
| Updates | /settings/self-update | self_update.* |
| A node's page | /nodes/<name> | nodes.<name>.* (its Save button has its own Rollback after (seconds) field) |
Validate a file without contacting the cluster:
clouduler --config /etc/clouduler/cluster.yaml check
cluster homelab
3 nodes, 3 managers, quorum 2
network internal (internal) 10.10.0.0/24 int.example.com
network lan (private) 192.168.1.0/24 lan.example.com
network wan (public) example.com
web https://cloud.int.example.com:8443
Push the on-disk file into the cluster so every node picks it up:
clouduler config reload
clouduler config reload --via boreas # submit through a specific node
On success it prints reloaded config into the cluster, generation N. A rejection prints cluster rejected the config: ....
Global flags: --config <PATH> (env CLOUDULER_CONFIG, default /etc/clouduler/cluster.yaml) and --data-dir <PATH> (env CLOUDULER_DATA).
clouduler check does not print the failover margin warning described below. The UI shows it as a banner on the Failover page.
Annotated example
A three-node cluster with an internal network, a home LAN, a public network and Cloudflare. The comments below are lost the first time you save through the UI or config reload, so treat this as a reading aid.
cluster:
name: homelab # shown in the UI and in alerts
networks:
internal: # exactly one internal network, used for raft, drives, overlay
type: internal
subnet: 10.10.0.0/24
domain: int.example.com # names under it resolve to node addresses
lan: # an extra subnetted LAN
type: private
subnet: 192.168.1.0/24
domain: lan.example.com
publish_dns: false # keep these names out of Cloudflare
wan: # reachable from the internet, no subnet
type: public
domain: example.com
vpn: # outbound-only, kill-switched; no domain allowed
type: egress
drives:
fast:
mode: replicated # every implementing node keeps a copy
sync_interval: 5m
bulk:
mode: served # one node serves it, others mount it over NFS
served_by: [atlas]
nodes:
atlas:
id: 00000000-0000-4000-8000-000000000001 # minted by `clouduler init`
role: manager
network:
internal: 10.10.0.1
lan: 192.168.1.10
wan:
ipv4: 203.0.113.5 # publishes an A record
open_ports: # ports the router forwards to this node
- {port: 25, protocol: tcp}
vpn: wg0 # host interface the egress network leaves through
drives:
fast: /srv/clouduler/fast
bulk: /srv/clouduler/bulk
labels:
disk: ssd
boreas:
id: 00000000-0000-4000-8000-000000000002
role: manager
network:
internal: 10.10.0.2
lan: 192.168.1.11
drives:
fast: /srv/clouduler/fast
cirrus:
id: 00000000-0000-4000-8000-000000000003
role: manager
last_resort: true # only schedule here when nothing else fits
network:
internal: 10.10.0.3
lan: 192.168.1.12
drives:
fast: /srv/clouduler/fast
data_dir: /var/lib/clouduler
web:
address: cloud # becomes cloud.int.example.com
network: internal
port: 8443
through_proxy: true
backend_port: 8444
failover:
node_timeout: 60s
fence_timeout: 15s
probe_interval: 5s
stop_grace: 30s # 15s + 30s < 60s, so no margin warning
secrets:
key_file: /etc/clouduler/secrets.key
generate_if_missing: true
node_key_file: /etc/clouduler/node.key
auth:
api_keys: true
session_ttl: 12h
oidc:
issuer: https://id.example.com/application/o/clouduler/
client_id: clouduler
scopes: [openid, profile, email, groups]
admin_claim: groups
admin_value: clouduler-admins
username_claim: preferred_username
cloudflare:
zones:
- example.com # every network domain must sit inside a zone
proxy:
enabled: true
http_port: 80
https_port: 443
dns_publish: all-nodes # or owning-node
dns_server:
enabled: false
acme:
enabled: true
directory_url: https://acme-staging-v02.api.letsencrypt.org/directory # staging while testing
auth:
host: login
networks: [lan]
issuer: https://id.example.com/application/o/apps/
client_id: clouduler-apps
palette: grape
backup:
age_recipient: age1examplepublickeyexamplepublickeyexamplepublickeyexample00
branch: main
mcp:
enabled: true
self_update is not shown: it is written through the Updates page. Only cluster, networks, nodes and web are required. Everything else has a default.
Options
Reload is live when the value takes effect without restarting the daemon, and restart when it is read once at boot. See Live config.
cluster
cluster.namerequiredstringno defaultnetworks
Network names are referenced by nodes, web.network, proxy.auth.networks and the networks: lists of stacks. Use the rename button in Settings > Network instead of renaming a key by hand: it also rewrites deployed stacks and the redirect, static-route and WireGuard stores. See Networking.
networksrequiredmapno defaultnetworks.<name>.typerequiredenumno defaultCIDRno defaultbare domainno defaultbooldefault truedrives
durationdefault 15menumdefault replicatedlist of node namesdefault []nodes
nodesrequiredmapno defaultnodes.<name>.idrequiredUUIDno defaultnodes.<name>.rolerequiredenumno defaultmapdefault {}IP addressno defaultmapno defaultlist of {port, protocol}default []interface name or mapno defaultnoneno defaultmap drive to absolute pathdefault {}map string to stringdefault {}booldefault trueportdefault 7373absolute pathdefault cluster data_dirpathdefault docker defaultbooldefault inherits secrets.generate_if_missingdata_dir and web
absolute pathdefault /var/lib/cloudulerweb.addressrequiredstringno defaultweb.networkrequirednetwork nameno defaultportdefault 8443booldefault trueportdefault 8444failover
durationdefault 60sdurationdefault 15sdurationdefault 5sdurationdefault 30ssecrets
absolute pathdefault /etc/clouduler/secrets.keybooldefault trueabsolute pathdefault /etc/clouduler/node.keyauth
The OIDC client secret is not in the file. Set it under Settings > Sign-in and backup > Sign-in. See Access and login.
booldefault truedurationdefault 12hobjectdefault absenthttps URLno defaultlistdefault [openid, profile, email]stringdefault groupsstringdefault nonestringdefault preferred_usernamecloudflare
The API token is a sealed secret set from Settings > Network > Cloudflare DNS. See Proxy and TLS and DNS publishing.
objectdefault absentlist of zone namesno defaultproxy
portdefault 80portdefault 443enumdefault all-nodesbooldefault falselist of network namesdefault [] (every subnetted network the node has an address on)portdefault 53booldefault falseURLdefault Let's Encrypt productionstringdefault noneproxy.auth
objectdefault absentlabel or FQDNno defaultlist of network namesno defaulthttps URLno defaultstringno defaultlistdefault [openid, profile, email, groups]stringdefault groupsstringdefault preferred_usernamedurationdefault 12henumdefault grapehex colourdefault nonelist of network namesdefault []durationdefault 30dself_update
objectdefault absentURLno defaultbooldefault truedurationdefault 24hbackup
objectdefault absent (off)SSH URLno defaultage1... public keyno defaultlegacydefault n/amcp
booldefault trueValidation errors you may see
Validation stops at the first failure, so you see exactly one error, printed as Error: invalid config at <path>: <message>.
| Error | Meaning |
|---|---|
cluster.name: must not be empty | Give the cluster a name. |
networks.internal: a internal network needs a subnet | Subnetted kinds need subnet. The article is wrong in the message for every kind. |
networks.vpn.domain: a egress network is not addressable and must not declare a domain | Remove domain from egress, wireguard and nat networks. |
networks.wan: a public network is always published to dns; publish_dns can only be turned off on internal and private networks | Use a private network for names you want to hide. |
nodes.n1.network.internal: 10.20.0.1 is outside the internal subnet 10.10.0.0/24 | Node IP not inside the network's subnet. |
nodes.n1.network.wan: a public network takes an ipv4, ipv6 and/or domain block | A public network needs a block, not a bare IP. |
web.backend_port: must differ from web.port | Pick two different ports. |
proxy.https_port: web.port already listens on 8443 | Proxy and dashboard ports clash. |
web.network: no network named nope | Reference to a network that does not exist. |
failover.fence_timeout: must be shorter than node_timeout (10s), or a container can run on two nodes at once | Fix the timers. |
failover.probe_interval: must be shorter than fence_timeout (15s), or a node cannot be missed before it fences | Fix the timers. |
failover.stop_grace: must be at most 600s | Also rejected: zero. |
auth.api_keys: cannot be disabled without an oidc provider, that would lock everyone out | Configure OIDC first. |
drives.fast.served_by: only a served drive has a serving node, set mode: served | Add mode: served. |
networks.internal.domain: int.example.com is not covered by any configured cloudflare zone | Add the parent zone to cloudflare.zones. |
proxy.acme.enabled: certificate issuance is DNS-01 only, so it needs a cloudflare section | Add cloudflare. |
backup.repo_url: only ssh urls are supported (e.g. git@host:org/repo.git), not http(s) | Use an SSH URL. |
backup.age_recipient: must be an age X25519 recipient, starting with age1 | Paste an age1... public key. |
data_dir: must be an absolute path | Same for secrets.key_file, nodes.<name>.drives.<drive> and friends. |
An edit that would make two deployed stacks share an overlay network is refused with this config would make stacks A and B share the overlay network OVERLAY: rename one of the stacks or networks. Two network names whose derived bridge subnet or routing table collide are also refused; rename one of them.