ozpanel sovereign hosting
Get ozpanel

Operator & API reference

Installing ozpanel

Requirements, DNS, both installers, bootstrap steps, resume, floating IP, config, updates.

Overview

One bare Debian machine becomes one cluster-capable node. Two installers, same sequence: config, bootstrap, first login.

  • scripts/install-node.shFrom your machine, a clean source checkout. Builds the release, copies it over SSH, drives the install.
  • ozpanel installOn the target, from a release binary already placed there.

No public download yet. Build with scripts/release.sh.

Requirements

ItemRequirementEnforced
OSDebian 13 or 12Refused otherwise
Architectureamd64 (one static binary)Refused otherwise
Accessroot; root SSH with password for the remote installerRefused otherwise
Memory3800 MB minimum, 8 GB comfortableozpanel install refuses below
DiskNo minimum; 20 GB ran a test nodePrinted only
MachineVM, bare metal or container. In a container: uid map from 0 covering 165536 ids, /dev/net/tun for tincWarned
Existing installNo /etc/ozpanel.jsonozpanel install refuses; use ozpanel bootstrap
  • Your machineClean git tree. sshpass ssh scp curl jq, the release toolchain. Reach TCP 22 and 9600 on the target.
  • Outbound from targetDebian mirrors, CrowdSec repository, GitHub (web terminal), public DNS on UDP 53, HTTPS (public IP check, ACME).
  • Bundled in the binaryCaddy, tinc, Roundcube, Rspamd, Garage, JuiceFS. ozpanel version lists them with sha256.

Ports

PortScopeService
22/tcpadminSSH
9600/tcpadminPanel and API, plain HTTP
80/tcp, 443/tcp+udppublicHTTP, ACME http-01, HTTPS, HTTP/3, panel under the node name
53/tcp+udppublicPowerDNS
25, 587, 465publicSMTP, submission (STARTTLS, implicit TLS)
143, 993, 110, 995publicIMAP, IMAPS, POP3, POP3S
9601, 656, 3306, 4567, 4568, 4444, 4569, 5432, 6379, 26379, 26380, 3901peers onlyAgent, tinc mesh, Galera, arbitrator, PostgreSQL, Redis, Sentinel, Garage RPC
659/tcp+udpoutboundFloating-IP tunnel to the hub (default)

Admin ports are open until the first management source is added:

ozpanel admin allow-ip 198.51.100.5 --label office --scope all   # all | console (default)

DNS records

Example: node panel.example.com, address 203.0.113.10, mail domain example.com.

WhenNameTypeValueSet by
Before installpanel.example.comA203.0.113.10 (floating address if used)Parent domain's DNS. ozpanel install checks it on public resolvers.
Afterpanel.example.comTXTv=spf1 a -allHELO SPF
After203.0.113.10PTRpanel.example.comHosting provider only
Aftersolo.example.comA203.0.113.10Second name-server name (default)
Per domainexample.comNSpanel.example.com, solo.example.comRegistrar
  • Name not ready--http-only and log in at http://203.0.113.10:9600, or --dns-from an existing panel.
  • Server identity checkGET /mail/identity; POST /mail/identity/publish publishes what the node can.
  • Other NS namesinit-config --nameservers, or PUT /dns/platform/nameservers {"nameservers":["ns1.example.com","ns2.example.com"]}. Glue needed only for names inside the zone they serve.
  • Per mail domainMX, SPF, DKIM, DMARC, autoconfig, SRV: one call. See Mail · Deliverability.

install-node.sh

scripts/install-node.sh --host 203.0.113.10 --fqdn panel.example.com --admin-email admin@example.com

Secrets not given are asked without echo. Nothing read from the environment.

FlagDefaultNotes
--host IPrequiredTarget address
--fqdn NAMErequiredFirst label = node name
--ssh-port N22
--root-password PWaskedWith --first-login: the password set, asked twice
--first-login / --temp-passwordoff / askedChange the provider's forced temporary root password first
--admin-email / --admin-passwordnone / asked twiceFirst super-admin. Created even if the bootstrap fails.
--region RemptyFree text
--http-onlyoffCookies not Secure; use over http://IP:9600
--dns-from URLnonePublish this node's A record through an existing panel. Needs admin login.
--floating-ip IPnoneFloating IP at the handover. Needs --admin-email, --vps-ip.
--vps-ip / --vps-password / --vps-ssh-portnone / asked / 22The hub
--tinc-port / --tinc-invite URL659 / noneInvite = manual mode, no hub password
--keep-dir DIR./keep/<fqdn>-<date>-<time>Saved keys, see What to keep
--lib-dir DIR/usr/local/lib/ozpanelOnly this value accepted
--fresh-bootstrapoffRerun every step; default resumes the failed one
--new-host-keyoffMachine rebuilt, SSH host key changed. Without it a changed key is refused.
--manifest-url / --release-key FILEnoneInstall a published, signed release instead of building. No publish location yet.
  1. Refuse a dirty tree; build; refuse any ABSENT artifact.
  2. --first-login: change the temporary root password.
  3. Pre-flight: OS, arch, root, memory, disk, uid map.
  4. Copy to /usr/local/lib/ozpanel; ozpanel init-config (existing config kept).
  5. --dns-from: publish the A record.
  6. ozpanel bootstrap, one line per step. Prints the install screen URL.
  7. Prove the panel on 9600 runs the built binary.
  8. Create the first login.
  9. scripts/verify-node.sh from outside; save the keep directory.

Exit status non-zero if the bootstrap or the outside check failed.

ozpanel install

/usr/local/lib/ozpanel/ozpanel install --fqdn panel.example.com --admin-email admin@example.com
FlagDefaultNotes
--fqdn, --admin-email, --admin-passwordasked
--nodefirst label of FQDN
--region, --http-onlyempty, offAs above
--yes, -yoffNo confirmation; required without a terminal
--floating-ip / --hub-ipnoneHub IP required with a floating IP
--hub-password / --tinc-invitenoneOne of the two. Password never stored.
--hub-ssh-port, --tinc-port22, 659
--tinc-networkvps_<node>1–64 of letters, digits, _, -
--floating-statusPrint floating-IP state; install nothing

Checks before writing: root · Debian 12/13 · 3800 MB · no config · floating flags valid · FQDN resolves publicly to this machine (or the floating address).

Sequence: init-config → bootstrap (--pause-at-handover with a floating IP) → admin create → floating-IP job → bootstrap again.

CLI used at install

CommandFlags (default)Does
init-config--config (/etc/ozpanel.json) · --node · --fqdn · --region · --nameservers (fqdn, solo.<parent>) · --http-only · --floating-ip ("this node's floating IP — its public address when it sits behind NAT") · --force · --peer · --uuidWrites config (0640), app_key once (0600), the ozpanel unit, the ozpanel-recover timer (not enabled). --peer/--uuid are for joins.
bootstrap--pause-at-handover · --freshRuns or resumes the steps
first-login--host · --port (22) · --temp-password · --new-passwordProvider's forced password change, from your machine
admin create--email · --role (super_admin | domain_admin | domain_user) · --password (generated) · --tenantPanel user
admin passwd--email · --passwordReset, revoke sessions
admin token--email · --name (cli) · --ttl (1h) · --dangerousBearer token as that user
admin recovery-token--rotatePrint / replace the node's recovery token
admin recovery-grant--node N --allow diagnose|recover|force · --ttl (1h) · --reasonTime-boxed recovery permission
admin allow-ip--label · --scope all|consoleManagement source
admin console-restriction on|offConsole source restriction

Bootstrap steps

32 steps on a first node: 22 by the command, then migrations, then 10 by the running panel. Idempotent; each prints ASSERT lines.

Run bySteps
Command, phase 1validate · place_assets · set_hostname · install_base_packages · install_podman · system_tuning · host_hardening · install_nftables
Command, phase 2install_mariadb · shared_storage_backend (JuiceFS on Garage; GlusterFS never installed) · install_redis · install_powerdns · install_mail · install_caddy · install_postgresql · install_crowdsec · install_php · install_tinc · install_garage · install_juicefs
Commandcluster_pki (CA + node cert) · register_self
Panel jobapply_binds · mail_dataplane · postfix_services · rspamd_controller_auth · redis_service_accounts · shared_storage_ready (/mnt/cluster and every home on JuiceFS, proven writable) · pgbouncer_auth_query · db_access_allowlist · install_support_services (webmail; slowest) · bootstrap_health

An install_nftables "no policy applied" warning is expected; the panel applies it at start.

Following and resuming

Completed steps stay done. A failed step stops the job with its ASSERT lines.

WayHow
Install screenhttps://<fqdn>/admin/install, before any login exists. Sign in with ssh root@<node> ozpanel admin recovery-token. Both halves, the failure, the log, Resume.
Remote installerRerun the same install-node.sh command
On the nodeozpanel bootstrap (follows running · reopens paused or failed · full run if none)
APIPOST /cluster/bootstrap/<job>/resume (409 while running) · GET /install/state, /install/log, POST /install/resume
One stepPOST /cluster/steps/<step>; list: GET /cluster/steps
Start overozpanel bootstrap --fresh / --fresh-bootstrap

Never rerun ozpanel install over an existing config. Use ozpanel bootstrap; the binary prints that too. Panel down: journalctl -u ozpanel -n 50.

Floating IP through a hub

A hub holds the address; tinc carries it to the node. It becomes the node's public identity: A record, mail source, certificates. Own address stays node.real_ip.

scripts/install-node.sh --host 203.0.113.10 --fqdn panel.example.com --admin-email admin@example.com \
  --floating-ip 203.0.113.11 --vps-ip 198.51.100.5
ozpanel install --fqdn panel.example.com --admin-email admin@example.com \
  --floating-ip 203.0.113.11 --hub-ip 198.51.100.5 --hub-password '…'
  • Modesautomatic (hub root password, built over SSH) · manual (tinc invitation from the hub).
  • WhenBoth installers: at the handover, before panel-side steps, so apply_binds and the node certificate use the floating address.
  • JobPOST /cluster/floating-ip: tinc_vps_cleanup · tinc_vps_create · tinc_vps_invite · tinc_join · tinc_configure · tinc_test · tinc_switch (SSH drops) · tinc_verify.
  • Checkozpanel install --floating-status, GET /cluster/floating-ip: in_force: true, outbound_ip = floating address.
  • Tunnel downDefault route blackholed; no mail from the wrong address. Older nodes' tinc-down scripts rewritten to match.
  • Failed jobNot resumable (hub password in memory only). Node keeps its own address; submit again.
  • ConsoleCluster → Floating IP form.

First login

  • https://panel.example.comPanel under the node name.
  • http://203.0.113.10:9600Direct; login over HTTP only with --http-only.
  • /admin/installInstall screen, recovery-token login.
  • /docsSwagger UI; API base /api/v1.
ozpanel admin create --email admin@example.com          # password generated, printed once
TOK=$(ozpanel admin token --email admin@example.com --name check --ttl 30m)
curl -s -H "Authorization: Bearer $TOK" http://127.0.0.1:9600/api/v1/ozpanel/health

What to keep

install-node.sh copies these to the keep directory (0600). After ozpanel install, copy them yourself.

FileIsIf lost
/etc/ozpanel/secrets/app_keyEncrypts stored credentialsRelay passwords, 2FA, registrar keys unreadable
/etc/ozpanel/pki/cluster-ca.keyIssuer key; master onlyNo new nodes, no signed releases
/etc/ozpanel/pki/cluster-ca.crt, node.crt, node.keyTrust anchor, node identityNode cert re-issuable
/etc/ozpanel.json, /etc/ozpanel/secrets/Config, service credentialsNeeded to restore this node's data

Checking it worked

CheckCallGood
LivenessGET /ping (no token)pong: true
Buildozpanel versionNo ABSENT
RequirementsGET /cluster/requirementsincomplete: 0
ConfigGET /ozpanel/config-checksummary.failing: 0
HealthGET /ozpanel/health[/{module}]healthy (else degraded, critical)
Needs a personGET /cluster/interventions"nothing needs a person"; unmeasured never counts as pass
Mail identityGET /mail/identitymissing: 0
From outsidescripts/verify-node.sh --host 203.0.113.10143/110/587 refuse cleartext auth before TLS; 9600 pings

Config file

/etc/ozpanel.json, 0640, written by init-config. Restart ozpanel after an edit. Secrets: one file each in secrets_dir.

KeyDefaultNotes
config_version1Anything else refused
secrets_dir/etc/ozpanel/secrets
managed_servicesunsetUnits the services module may control
node.name / uuid / fqdn / regionlabel / minted / flag / emptyNever edit or copy the uuid
node.real_ipdetectedPhysical address, never a tunnel
node.floating_ip + addressing_modeempty, directSet together by the floating-IP job
node.nameserversfqdn, solo.<parent>
node.tenant_uid_base / _maxdetectedPin only for identical numbering
node.network.public_interface / public_ipsunsetList wins over interface
node.network.peer_interface / peer_ipsozcluster / unsetFalls back to real_ip
node.network.admin_ips / include_loopbackfollows public / unset
panel.listen:9600
panel.base_domainnode FQDNHost in reset and invite links
panel.web_dir/usr/local/lib/ozpanel/web
panel.hostmaster_emailhostmaster@<domain>
panel.cookie_securetruefalse = --http-only
cluster.db_hosts / db_name / db_user127.0.0.1 / ozpanel / per-node accountPassword: secret db_password
cluster.peers[]Non-empty = joiner
cluster.survive_after_seconds / survive_interval_secondsbuilt-inSilence before taking over alone
cluster.internet_probesunsethost, port, proto tcp|icmp, note. Empty = never assume primary alone.
cluster.boot_gate_wait_secondsbuilt-inHow long a booting node looks for a peer
services.*_urlPowerDNS :8081, Caddy :2019, SOGo :20000, Rspamd :11334Local service endpoints

Also created: /usr/local/bin/ozpanel symlink · /etc/ozpanel/pki/ · ozpanel.service (always restarts, 5 s to 2 min backoff) · ozpanel-recover.timer (enable with systemctl enable --now ozpanel-recover.timer).

Updating

One artifact: binary with migrations, templates, consoles, API reference and the six bundled programs. Deployed to the master only; members take it over a signed channel.

# find the master
curl -s -H "Authorization: Bearer $TOK" http://127.0.0.1:9600/api/v1/cluster/roles \
  | jq -r '.data.nodes[] | select(.authority=="master_holder") | .node'
# token into a 0600 file, then deploy from a clean checkout
ozpanel admin token --email admin@example.com --name deploy --ttl 30m --dangerous > deploy.tok
scripts/deploy-node.sh --host panel.example.com --token-file deploy.tok
FlagDefaultNotes
--hostrequiredMaster's FQDN. Non-master refused; bare IP fails TLS.
--panelhttps://HOST
--root-passwordaskedSSH/scp to the master
--token-file FPreferred. Dangerous-scoped token, file 0600.
--token TWorks; visible in the process list during the deploy
--admin-email / --admin-password/ askedInstead of a token
--api-over-sshoffAPI calls on the master over SSH. Needs a token.
--single-nodeoffLone install; registry must confirm no other nodes
--skip-consolesoffSkip the master's console check
  1. Master and clean tree checked; one release built.
  2. Staged at /usr/local/lib/ozpanel/updates/ozpanel; signed (POST /cluster/updates/sign, CA holder only).
  3. Applied: POST /cluster/self-update; unsigned refused. Master proven.
  4. Rolled out: POST /cluster/updates/publish, one member at a time; each verifies, swaps, restarts.
  5. Migrations last, by the primary, Galera appliers quiesced (POST /cluster/updates/migrate by hand).
  6. Measured: GET /cluster/updates/verify; non-zero exit on any difference.
  • ConvergencePublish · master's push to returning nodes · ozpanel pull-release before every start (--timeout 60s, --restart-if-pulled, always exits 0).
  • SafetySignature checked with the CA cert · staged binary must run version · previous kept as ozpanel.bak (manual rollback) · publish refuses a disagreeing cluster unless ?force=1.
  • Hand-copied binaryReplaced on next start. Only the signed channel persists.
  • ConsoleCluster → Updates: sign, self-update, publish, rollout.

Reinstalling

  • Rerun installerIdempotent. Keeps config and app_key.
  • Rebuilt machine--new-host-key; new keep directory per run; reuse the original app_key to read stored credentials.
  • init-config --forceNew identity. Never on a serving cluster member.
  • UninstallNot available. Reinstall the OS.

Next: The cluster