Overview
Every node runs the same binary and serves web, DNS and mail. One master (signing authority) and one primary (single-writer workload) at a time. Both move by a signed succession order, decided in advance.
Paths are under /api/v1; super_admin token unless marked recovery route.
TOKEN=$(ozpanel admin token --email admin@example.com --dangerous | tail -1) curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:9600/api/v1/cluster/state
Identity and transport
| Item | Value |
|---|---|
| Node UUID | Immutable. Evictions and certificates key on it. Names may be reused. |
| Registry | Replicated member table. Firewall peers, Galera peers, PostgreSQL allowlist, DNS node records derived from it. |
| Cluster CA | Created by the first node. 10 years. |
| Node certificate | Subject = UUID. 90 days, renewed 30 days early, hourly check. Reissued when it misses an address the node is reached on. |
| CA private key | Master plus every successor with a pinned rank. Withdrawn when a node leaves the ranked order. |
| Node channel | Port 9601, mutual TLS on node certs. Secrets, releases, handover, eviction, signed observations. SSH only during a join. |
| Mesh | tinc network ozcluster, port 656, over public addresses. Range 100.64.0.0/10 default; lowest free address per node. Replication rides it. |
Per-node settings
| Setting | Call | Notes |
|---|---|---|
| Region, label | PATCH /cluster/nodes/{name} | Region: lowercase, digits, dashes |
public_ip | same | Address DNS publishes when it differs from the interface |
floating_ip, real_ip | same, on the node | Also written to its config |
nat_egress_ip | same | Then measured and corrected by the node |
| Succession rank | POST /cluster/nodes/{name}/succession-rank | Master only |
| Preset primary, failback | POST /cluster/preset-primary, /cluster/nodes/{name}/failback | Master only |
| Connectivity targets | PUT /cluster/internet-probes | Cluster-wide |
Adding a node
- MachineBare Debian 13 or 12, root SSH by password. A machine with its own MariaDB datadir is refused: install-node.sh then join does not work. Join bare machines.
- WhereOn the master; other nodes answer 409. Mesh set up on the master.
- NetworkMaster reaches the new machine by SSH; it reaches the master on 656.
- ConsoleCluster → Operations: generic form for
POST /cluster/join. No join wizard.
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"node2","real_ip":"203.0.113.11","fqdn":"node2.example.com","ssh_password":"…"}' \
http://127.0.0.1:9600/api/v1/cluster/join
| Field | Default | Notes |
|---|---|---|
name | required | Node name |
real_ip | required | SSH address; recorded as interface and public |
ssh_password | required | Memory only, never stored |
fqdn | name | |
ssh_port | 22 | |
floating_ip | empty | Published address behind a hub |
new_password | none | Provider's forced first-login change |
201 with job_id; poll GET /cluster/join/{job}. Dry check: POST /cluster/preflight.
Steps
Master side: first_login · preflight · coordinate (UUID, registry, mesh address) · provision (app key, binary, config, CA cert) · enroll (key stays on the node) · mesh_up (ping both ways) · bootstrap_node (30 min) · verify (Galera size ≥ 2).
The new node's own plan: 46 steps, 20 before its panel starts, 26 inside it.
| Phase | Steps |
|---|---|
| 1 | As a first node (8) |
| 2 | As a first node with shared_storage_backend, plus mail, Redis, PgBouncer, allowlist, support services, apply_binds |
| 4 (16) | cluster_pki · copy_secrets · mesh_join · galera_join (never bootstraps) · shared_storage_ready · materialize_tenant_users · redis_replica · powerdns_join · mail_join (DKIM keys) · postgresql_join (repmgr standby) · caddy_setup · crowdsec_enroll · update_nftables_peers · update_galera_address · register_node · garbd_setup |
| 5 | dns_records · health_check |
A failed join
- Re-issueSame
POST /cluster/join, samename. UUID kept, mesh not re-invited, done steps skipped. - Not resumable
/cluster/bootstrap/{job}/resumeanswers 400 "not a bootstrap job" forjoin_*. - Master panel restartedJob failed (SSH passwords in memory). Re-issue.
After a join
- AutomaticAppended to succession (join order) · probes mirrored to its config · boot gate installed · tinc hooks · all gates re-derived · arbitrator converged.
- You decidePin a rank (
{"rank":2}) to set who takes over as primary · region, label, public IP · give singleton apps more than one node. - Verify
GET /cluster/membership-status,/cluster/planes,/cluster/interventions.
Roles
| Role | Does | Count |
|---|---|---|
| Master | CA key, signs declarations and releases, enrols, evicts, cluster-wide singletons | 1 |
| Primary | Single-writer workload | 1 |
| Successor | Standby, next by rank | The rest |
The declaration
One signed record: seq (higher wins) · primary_node · succession · max_lag_bytes · failback · kind (root, or succession signed by a successor's node key) · handover (outgoing primary's signed ack). No vote. Unverified = refused. The primary never leaves a live node without its ack.
GET /cluster/workload
POST /cluster/workload/declare {"primary":"node1","succession":["node2","node3"],"max_lag_bytes":0}
Role-bound planes
| Plane | Primary | Others |
|---|---|---|
| PostgreSQL (repmgr) | Writable | Streaming standby |
| PgBouncer | Own PostgreSQL | Declared primary |
| Redis | Master | Replica; clients ask Sentinel |
| JuiceFS metadata | Writer is itself | Writer is the primary |
| Galera arbitrator | Never | Highest-ranked live successor |
| Tenant cron singletons | Here | Withdrawn |
| Container apps, Caddy, DNS | Placement re-applied everywhere; Caddy and PowerDNS checked on every node | |
MariaDB (Galera) is multi-master. One role executor per node: one apply at a time, never older, measured after. GET /cluster/planes, /cluster/roles (180 s stale = unknown); role_transfer_stalled alert after 180 s.
Succession
Order: pinned rank ascending · then join order · then name. Down nodes keep their place and are skipped.
| Call | Notes |
|---|---|
GET /cluster/succession | Order, rank, up/down, effective_successor; warns when none is alive |
POST /cluster/nodes/{name}/succession-rank {"rank":N} | Inserts at N; null clears. Master only (403). New declaration at once. |
GET /cluster/master | Who, verified, do I hold the key |
POST /cluster/master/claim | 409 while master answers; ?force=true only when confirmed dead |
POST /cluster/master/import | ca_key, ca_cert, declare; key checked against the CA |
GET /cluster/arbiter | Derived home and reasoning; cannot be pinned (change ranks instead). Start now: POST /galera/garbd/start on that node. |
Master stops answering on every address → the effective successor holding the key claims it. Automatic.
Quorum
Data nodes weight 2, arbitrator (garbd) weight 1. Arbitrator only at an even data-node count, on the highest-ranked live successor, never the primary.
| Data nodes | Arbitrator | Votes | Survives |
|---|---|---|---|
| 2 | yes | 5, majority 3 | Loss of the primary |
| 3 | no | 6, majority 4 | Any single node |
| 4 | yes | 9, majority 5 | 2–2 split: arbitrator side wins |
Failover
| Signal | Rule |
|---|---|
| Mesh host-down | tinc hook wakes the liveness loop at once |
| Probe | Port 443 on mesh and public; 3 consecutive to change verdict |
| Heartbeat | Every 5 s; 20 s silence. Newer heartbeat vetoes a takeover. |
| Every address | Dead only when mesh, public and floating all fail |
| Witnesses | Signed observations from peers; any peer reaching it = alive (GET /cluster/nodes/{name}/witness) |
| Replication | WAL still arriving = alive |
| Own link | Connectivity targets prove it first. Down or unmeasurable: no takeover, no DNS withdrawal. |
PUT /cluster/internet-probes {"probes":[{"host":"198.51.100.5","port":443,"proto":"tcp","note":"isp"}]}
POST /cluster/internet-probes/test GET /cluster/connectivity GET /cluster/survive-status
Unset targets: two public resolvers on TCP 443.
Fences
- Self-fence3+ nodes: primary without a majority stops PostgreSQL. Backstop: 90 s unconfirmed, writes stop.
- GaleraSide without quorum refuses writes.
- YieldPostgreSQL primary seeing a verified, reachable other primary stops.
- Survive aloneAll peers silent: 30 s settle (≥ 2×
evs.inactive_timeout), own internet proven, majority with witnesses on 3+.
When the primary dies
- Verdict settles to down, corroborated.
- Master rechecks after 20 s (0 s if the mesh reported loss and its internet is proven).
- Master signs a declaration naming the effective successor. Dead master: master succession first.
- Every node applies it;
GET /cluster/planesshowscomplete.
| Service | Behaviour |
|---|---|
| Web | All nodes serve; dead node's address withdrawn from DNS |
| DNS | Every node authoritative. Records name live, ready nodes only; never emptied. |
| Readiness | Shared mounts, DB gate, Caddy, Dovecot, Postfix, PowerDNS active. 3 passes to change. |
| Every node; stops where Galera is non-Primary | |
| PostgreSQL | Promoted only with known lag ≤ max_lag_bytes; PgBouncers repointed |
| Redis | Follows the declared primary |
| Storage | JuiceFS metadata writer follows PostgreSQL |
| Apps | Singleton by lease (renew 10 s · self-stop 30 s · takeover 35 s); replicated on every candidate |
Timings: gate pass 2 min · idle liveness 60 s · mesh burst 2 s ×4 · observation age 90 s · transition expiry 10 min · failback retry 1/min. Drill, three nodes, primary cut: Redis 10 s, PostgreSQL promoted 72 s.
Handover and failback
POST /cluster/handover-to {"node":"node2"}
Any node. Refused before moving: 404 not a member · 409 down, already primary, planes disagree, transition running.
Sequence: seat + rank 1 → preflight → ready → old primary demotes with signed ack → target declares → promote → old primary rejoins as standby. Outcome: complete · partial · refused · failed. POST /cluster/handover (recovery route) makes the called node primary.
| Setting | Values | Default |
|---|---|---|
| Preset primary (seat) | POST /cluster/preset-primary {"node":"node1"} | — |
| Failback policy | manual | automatic, per node | manual |
| Manual failback | POST /cluster/failback/request on the seat | |
| State | GET /cluster/failback |
Automatic handback needs: seat with automatic policy · storage serving · no transition · planes agree · caught-up standby. Each refusal logged.
Boot gate
ozpanel boot-gate, PostgreSQL's ExecStartPre. Cached declaration plus any peer; only writes standby.signal; always exits 0. Only node in registry: not gated. Peer names this node: primary. Anything else: standby. Diverged Galera data archived to /var/backups/ozpanel/galera-diverged/.
Removing a node
POST /cluster/nodes/node3/evict {"reason":"decommissioned"}
DELETE /cluster/nodes/node3/evict # readmit
Refused: 403 not master · 409 itself, primary, master, or the only copy of a file on it (no override; unreadable storage needs "acknowledge_data_loss": true).
Phases: fence (tombstone, revoke certs, registry, DB accounts) → data (storage, repmgr, Sentinel, Galera) → records (DNS, apps, allowlists) → network (mesh, peers, firewall) → prove. Rerun resumes. GET /cluster/evictions. Readmitted nodes re-enrol; old certs stay revoked.
Console evict button sends no reason. Readmit: generic form only.
Addresses
- Publishedfloating, else public, else interface. Mesh never in DNS.
- Behind NAT
nat_egress_ipadmitted by peers; self-measured when all peers agree. No port forward: the node dials out. - Reach checkEvery 15 min peers dial each candidate address; unmeasured never reported reachable.
- Floating IP
POST /cluster/floating-ip{"floating_ip","mode":"automatic|manual","vps_ip","vps_password"|"tinc_invite","vps_ssh_port":22,"tinc_port":659,"tinc_network"}. Separate tinc network. Peers reached via the real gateway. Tunnel loss blackholes the default route. See Install.
Convergence
Config stored once, rendered on each node. Journal: gen, kind, scope, op, origin_node, made_by, made_at; no payloads, no secrets.
| Status | Means |
|---|---|
converged | At target generation |
behind | behind_by to apply; clears itself |
diverged | Applied but running differs; diverged_detail |
stale | No report in 90 s |
never_applied | Reconcile failing; needs a person |
GET /cluster/changes · /cluster/convergence · /cluster/gates · /cluster/updates/verify · /cluster/sync-replication.
Recovery
What needs a person
GET /cluster/interventions (recovery route). Unmeasured never counts as pass.
| Severity | Case |
|---|---|
| urgent | Galera not Primary · PostgreSQL primary while the declared one is unreachable · two arbitrators · app failed to start |
| soon | No successor · no arbitrator · app's nodes all down |
| note | No connectivity targets |
Recovery token works without quorum: ozpanel admin recovery-token; GET /cluster/recovery-token, POST /cluster/recovery-token/rotate. Recovery screen: /recovery, recovery grants.
Every node off, then back
- Wait for all nodes if you can.
- Every node up, MariaDB stopped:
POST /galera/recover-position. - Highest position only:
POST /galera/bootstrap({"force":true}if not marked safe). - Others join alone. Check interventions and planes.
Never bootstrap two nodes. Never force before recover-position everywhere.
Two of three really dead
- Confirm both off from the hosting console. Merely unreachable: stop.
- Survivor:
POST /cluster/promote-survivor {}with the recovery token. 409 = a peer is still connected. Refused without proven internet. - Read
steps: galera, redis, gluster (reports retired), postgres, dns, master. - Not master after ~5 min:
POST /cluster/master/claim. Primary still dead:POST /cluster/workload/declare.
Files need two of three storage nodes; they return with the second node. Old-primary writes after the survivor's position are discarded.
Tools
- ozpanel recoverDiagnose only;
--apply,--settle(75 s),--force,--config PATH. - GET /galera/survivorWould this node bootstrap alone, and why.
- Not working in the consolePostgreSQL promote, rejoin, switchover buttons (501). Use handover.
Drills
Three nodes, current release. Passed: A1 primary off and on (handback) · B1 primary cut from mesh · B2 master cut · B3 successor cut · B4 one link cut, nothing moves. Not run on this release: other single-node, two-node, whole-cluster and handover-fault drills. Tools: scripts/drill-cut.sh --cut A:B [--what mesh|all] [--minutes N] / --heal, scripts/drill-record.sh.
Cluster CLI
| Command | Does |
|---|---|
ozpanel identity csr|sign|install|show | Offline enrolment |
ozpanel mesh-join [--bundle PATH] | Join the mesh from the master's bundle |
ozpanel sync-mesh-peers --self NAME | Mesh firewall set from tinc host files, no database |
ozpanel boot-gate | PostgreSQL pre-start gate |
ozpanel galera-recover | Restart a failed MariaDB into the cluster, join-only |
ozpanel pull-release | Take the master's release before start |
ozpanel recover | Stranded-node diagnosis and recovery |
More endpoints: GET /cluster/mesh, /cluster/network, /cluster/ca, /cluster/identity, /cluster/certificates, /cluster/secrets · POST /cluster/secrets/pull, /cluster/gates/reconcile, /cluster/connectivity/reconcile, /repmgrd/rejoin · GET /galera/status, /galera/recovery, /galera/garbd/status. Full list: API.