ozpanel sovereign hosting
Get ozpanel

Operator & API reference

The cluster

Identity, roles, joining, succession, failover, handover, eviction, addresses, convergence, recovery.

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

ItemValue
Node UUIDImmutable. Evictions and certificates key on it. Names may be reused.
RegistryReplicated member table. Firewall peers, Galera peers, PostgreSQL allowlist, DNS node records derived from it.
Cluster CACreated by the first node. 10 years.
Node certificateSubject = UUID. 90 days, renewed 30 days early, hourly check. Reissued when it misses an address the node is reached on.
CA private keyMaster plus every successor with a pinned rank. Withdrawn when a node leaves the ranked order.
Node channelPort 9601, mutual TLS on node certs. Secrets, releases, handover, eviction, signed observations. SSH only during a join.
Meshtinc 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

SettingCallNotes
Region, labelPATCH /cluster/nodes/{name}Region: lowercase, digits, dashes
public_ipsameAddress DNS publishes when it differs from the interface
floating_ip, real_ipsame, on the nodeAlso written to its config
nat_egress_ipsameThen measured and corrected by the node
Succession rankPOST /cluster/nodes/{name}/succession-rankMaster only
Preset primary, failbackPOST /cluster/preset-primary, /cluster/nodes/{name}/failbackMaster only
Connectivity targetsPUT /cluster/internet-probesCluster-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
FieldDefaultNotes
namerequiredNode name
real_iprequiredSSH address; recorded as interface and public
ssh_passwordrequiredMemory only, never stored
fqdnname
ssh_port22
floating_ipemptyPublished address behind a hub
new_passwordnoneProvider'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.

PhaseSteps
1As a first node (8)
2As 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
5dns_records · health_check

A failed join

  • Re-issueSame POST /cluster/join, same name. UUID kept, mesh not re-invited, done steps skipped.
  • Not resumable/cluster/bootstrap/{job}/resume answers 400 "not a bootstrap job" for join_*.
  • 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.
  • VerifyGET /cluster/membership-status, /cluster/planes, /cluster/interventions.

Roles

RoleDoesCount
MasterCA key, signs declarations and releases, enrols, evicts, cluster-wide singletons1
PrimarySingle-writer workload1
SuccessorStandby, next by rankThe 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

PlanePrimaryOthers
PostgreSQL (repmgr)WritableStreaming standby
PgBouncerOwn PostgreSQLDeclared primary
RedisMasterReplica; clients ask Sentinel
JuiceFS metadataWriter is itselfWriter is the primary
Galera arbitratorNeverHighest-ranked live successor
Tenant cron singletonsHereWithdrawn
Container apps, Caddy, DNSPlacement 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.

CallNotes
GET /cluster/successionOrder, 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/masterWho, verified, do I hold the key
POST /cluster/master/claim409 while master answers; ?force=true only when confirmed dead
POST /cluster/master/importca_key, ca_cert, declare; key checked against the CA
GET /cluster/arbiterDerived 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 nodesArbitratorVotesSurvives
2yes5, majority 3Loss of the primary
3no6, majority 4Any single node
4yes9, majority 52–2 split: arbitrator side wins

Failover

SignalRule
Mesh host-downtinc hook wakes the liveness loop at once
ProbePort 443 on mesh and public; 3 consecutive to change verdict
HeartbeatEvery 5 s; 20 s silence. Newer heartbeat vetoes a takeover.
Every addressDead only when mesh, public and floating all fail
WitnessesSigned observations from peers; any peer reaching it = alive (GET /cluster/nodes/{name}/witness)
ReplicationWAL still arriving = alive
Own linkConnectivity 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

  1. Verdict settles to down, corroborated.
  2. Master rechecks after 20 s (0 s if the mesh reported loss and its internet is proven).
  3. Master signs a declaration naming the effective successor. Dead master: master succession first.
  4. Every node applies it; GET /cluster/planes shows complete.
ServiceBehaviour
WebAll nodes serve; dead node's address withdrawn from DNS
DNSEvery node authoritative. Records name live, ready nodes only; never emptied.
ReadinessShared mounts, DB gate, Caddy, Dovecot, Postfix, PowerDNS active. 3 passes to change.
MailEvery node; stops where Galera is non-Primary
PostgreSQLPromoted only with known lag ≤ max_lag_bytes; PgBouncers repointed
RedisFollows the declared primary
StorageJuiceFS metadata writer follows PostgreSQL
AppsSingleton 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.

SettingValuesDefault
Preset primary (seat)POST /cluster/preset-primary {"node":"node1"}—
Failback policymanual | automatic, per nodemanual
Manual failbackPOST /cluster/failback/request on the seat
StateGET /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 NATnat_egress_ip admitted 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 IPPOST /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.

StatusMeans
convergedAt target generation
behindbehind_by to apply; clears itself
divergedApplied but running differs; diverged_detail
staleNo report in 90 s
never_appliedReconcile 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.

SeverityCase
urgentGalera not Primary · PostgreSQL primary while the declared one is unreachable · two arbitrators · app failed to start
soonNo successor · no arbitrator · app's nodes all down
noteNo 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

  1. Wait for all nodes if you can.
  2. Every node up, MariaDB stopped: POST /galera/recover-position.
  3. Highest position only: POST /galera/bootstrap ({"force":true} if not marked safe).
  4. Others join alone. Check interventions and planes.

Never bootstrap two nodes. Never force before recover-position everywhere.

Two of three really dead

  1. Confirm both off from the hosting console. Merely unreachable: stop.
  2. Survivor: POST /cluster/promote-survivor {} with the recovery token. 409 = a peer is still connected. Refused without proven internet.
  3. Read steps: galera, redis, gluster (reports retired), postgres, dns, master.
  4. 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

CommandDoes
ozpanel identity csr|sign|install|showOffline enrolment
ozpanel mesh-join [--bundle PATH]Join the mesh from the master's bundle
ozpanel sync-mesh-peers --self NAMEMesh firewall set from tinc host files, no database
ozpanel boot-gatePostgreSQL pre-start gate
ozpanel galera-recoverRestart a failed MariaDB into the cluster, join-only
ozpanel pull-releaseTake the master's release before start
ozpanel recoverStranded-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.

Previous: Installing ozpanel Next: Web hosting and Caddy