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
| Item | Requirement | Enforced |
|---|---|---|
| OS | Debian 13 or 12 | Refused otherwise |
| Architecture | amd64 (one static binary) | Refused otherwise |
| Access | root; root SSH with password for the remote installer | Refused otherwise |
| Memory | 3800 MB minimum, 8 GB comfortable | ozpanel install refuses below |
| Disk | No minimum; 20 GB ran a test node | Printed only |
| Machine | VM, bare metal or container. In a container: uid map from 0 covering 165536 ids, /dev/net/tun for tinc | Warned |
| Existing install | No /etc/ozpanel.json | ozpanel 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 versionlists them with sha256.
Ports
| Port | Scope | Service |
|---|---|---|
| 22/tcp | admin | SSH |
| 9600/tcp | admin | Panel and API, plain HTTP |
| 80/tcp, 443/tcp+udp | public | HTTP, ACME http-01, HTTPS, HTTP/3, panel under the node name |
| 53/tcp+udp | public | PowerDNS |
| 25, 587, 465 | public | SMTP, submission (STARTTLS, implicit TLS) |
| 143, 993, 110, 995 | public | IMAP, IMAPS, POP3, POP3S |
| 9601, 656, 3306, 4567, 4568, 4444, 4569, 5432, 6379, 26379, 26380, 3901 | peers only | Agent, tinc mesh, Galera, arbitrator, PostgreSQL, Redis, Sentinel, Garage RPC |
| 659/tcp+udp | outbound | Floating-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.
| When | Name | Type | Value | Set by |
|---|---|---|---|---|
| Before install | panel.example.com | A | 203.0.113.10 (floating address if used) | Parent domain's DNS. ozpanel install checks it on public resolvers. |
| After | panel.example.com | TXT | v=spf1 a -all | HELO SPF |
| After | 203.0.113.10 | PTR | panel.example.com | Hosting provider only |
| After | solo.example.com | A | 203.0.113.10 | Second name-server name (default) |
| Per domain | example.com | NS | panel.example.com, solo.example.com | Registrar |
- Name not ready
--http-onlyand log in athttp://203.0.113.10:9600, or--dns-froman existing panel. - Server identity check
GET /mail/identity;POST /mail/identity/publishpublishes what the node can. - Other NS names
init-config --nameservers, orPUT /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.
| Flag | Default | Notes |
|---|---|---|
--host IP | required | Target address |
--fqdn NAME | required | First label = node name |
--ssh-port N | 22 | |
--root-password PW | asked | With --first-login: the password set, asked twice |
--first-login / --temp-password | off / asked | Change the provider's forced temporary root password first |
--admin-email / --admin-password | none / asked twice | First super-admin. Created even if the bootstrap fails. |
--region R | empty | Free text |
--http-only | off | Cookies not Secure; use over http://IP:9600 |
--dns-from URL | none | Publish this node's A record through an existing panel. Needs admin login. |
--floating-ip IP | none | Floating IP at the handover. Needs --admin-email, --vps-ip. |
--vps-ip / --vps-password / --vps-ssh-port | none / asked / 22 | The hub |
--tinc-port / --tinc-invite URL | 659 / none | Invite = manual mode, no hub password |
--keep-dir DIR | ./keep/<fqdn>-<date>-<time> | Saved keys, see What to keep |
--lib-dir DIR | /usr/local/lib/ozpanel | Only this value accepted |
--fresh-bootstrap | off | Rerun every step; default resumes the failed one |
--new-host-key | off | Machine rebuilt, SSH host key changed. Without it a changed key is refused. |
--manifest-url / --release-key FILE | none | Install a published, signed release instead of building. No publish location yet. |
- Refuse a dirty tree; build; refuse any
ABSENTartifact. --first-login: change the temporary root password.- Pre-flight: OS, arch, root, memory, disk, uid map.
- Copy to
/usr/local/lib/ozpanel;ozpanel init-config(existing config kept). --dns-from: publish the A record.ozpanel bootstrap, one line per step. Prints the install screen URL.- Prove the panel on 9600 runs the built binary.
- Create the first login.
scripts/verify-node.shfrom 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
| Flag | Default | Notes |
|---|---|---|
--fqdn, --admin-email, --admin-password | asked | |
--node | first label of FQDN | |
--region, --http-only | empty, off | As above |
--yes, -y | off | No confirmation; required without a terminal |
--floating-ip / --hub-ip | none | Hub IP required with a floating IP |
--hub-password / --tinc-invite | none | One of the two. Password never stored. |
--hub-ssh-port, --tinc-port | 22, 659 | |
--tinc-network | vps_<node> | 1–64 of letters, digits, _, - |
--floating-status | Print 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
| Command | Flags (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 · --uuid | Writes config (0640), app_key once (0600), the ozpanel unit, the ozpanel-recover timer (not enabled). --peer/--uuid are for joins. |
bootstrap | --pause-at-handover · --fresh | Runs or resumes the steps |
first-login | --host · --port (22) · --temp-password · --new-password | Provider's forced password change, from your machine |
admin create | --email · --role (super_admin | domain_admin | domain_user) · --password (generated) · --tenant | Panel user |
admin passwd | --email · --password | Reset, revoke sessions |
admin token | --email · --name (cli) · --ttl (1h) · --dangerous | Bearer token as that user |
admin recovery-token | --rotate | Print / replace the node's recovery token |
admin recovery-grant | --node N --allow diagnose|recover|force · --ttl (1h) · --reason | Time-boxed recovery permission |
admin allow-ip | --label · --scope all|console | Management source |
admin console-restriction on|off | Console 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 by | Steps |
|---|---|
| Command, phase 1 | validate · place_assets · set_hostname · install_base_packages · install_podman · system_tuning · host_hardening · install_nftables |
| Command, phase 2 | install_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 |
| Command | cluster_pki (CA + node cert) · register_self |
| Panel job | apply_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.
| Way | How |
|---|---|
| Install screen | https://<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 installer | Rerun the same install-node.sh command |
| On the node | ozpanel bootstrap (follows running · reopens paused or failed · full run if none) |
| API | POST /cluster/bootstrap/<job>/resume (409 while running) · GET /install/state, /install/log, POST /install/resume |
| One step | POST /cluster/steps/<step>; list: GET /cluster/steps |
| Start over | ozpanel 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_bindsand the node certificate use the floating address. - Job
POST /cluster/floating-ip:tinc_vps_cleanup·tinc_vps_create·tinc_vps_invite·tinc_join·tinc_configure·tinc_test·tinc_switch(SSH drops) ·tinc_verify. - Check
ozpanel 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.
| File | Is | If lost |
|---|---|---|
/etc/ozpanel/secrets/app_key | Encrypts stored credentials | Relay passwords, 2FA, registrar keys unreadable |
/etc/ozpanel/pki/cluster-ca.key | Issuer key; master only | No new nodes, no signed releases |
/etc/ozpanel/pki/cluster-ca.crt, node.crt, node.key | Trust anchor, node identity | Node cert re-issuable |
/etc/ozpanel.json, /etc/ozpanel/secrets/ | Config, service credentials | Needed to restore this node's data |
Checking it worked
| Check | Call | Good |
|---|---|---|
| Liveness | GET /ping (no token) | pong: true |
| Build | ozpanel version | No ABSENT |
| Requirements | GET /cluster/requirements | incomplete: 0 |
| Config | GET /ozpanel/config-check | summary.failing: 0 |
| Health | GET /ozpanel/health[/{module}] | healthy (else degraded, critical) |
| Needs a person | GET /cluster/interventions | "nothing needs a person"; unmeasured never counts as pass |
| Mail identity | GET /mail/identity | missing: 0 |
| From outside | scripts/verify-node.sh --host 203.0.113.10 | 143/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.
| Key | Default | Notes |
|---|---|---|
config_version | 1 | Anything else refused |
secrets_dir | /etc/ozpanel/secrets | |
managed_services | unset | Units the services module may control |
node.name / uuid / fqdn / region | label / minted / flag / empty | Never edit or copy the uuid |
node.real_ip | detected | Physical address, never a tunnel |
node.floating_ip + addressing_mode | empty, direct | Set together by the floating-IP job |
node.nameservers | fqdn, solo.<parent> | |
node.tenant_uid_base / _max | detected | Pin only for identical numbering |
node.network.public_interface / public_ips | unset | List wins over interface |
node.network.peer_interface / peer_ips | ozcluster / unset | Falls back to real_ip |
node.network.admin_ips / include_loopback | follows public / unset | |
panel.listen | :9600 | |
panel.base_domain | node FQDN | Host in reset and invite links |
panel.web_dir | /usr/local/lib/ozpanel/web | |
panel.hostmaster_email | hostmaster@<domain> | |
panel.cookie_secure | true | false = --http-only |
cluster.db_hosts / db_name / db_user | 127.0.0.1 / ozpanel / per-node account | Password: secret db_password |
cluster.peers | [] | Non-empty = joiner |
cluster.survive_after_seconds / survive_interval_seconds | built-in | Silence before taking over alone |
cluster.internet_probes | unset | host, port, proto tcp|icmp, note. Empty = never assume primary alone. |
cluster.boot_gate_wait_seconds | built-in | How long a booting node looks for a peer |
services.*_url | PowerDNS :8081, Caddy :2019, SOGo :20000, Rspamd :11334 | Local 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
| Flag | Default | Notes |
|---|---|---|
--host | required | Master's FQDN. Non-master refused; bare IP fails TLS. |
--panel | https://HOST | |
--root-password | asked | SSH/scp to the master |
--token-file F | Preferred. Dangerous-scoped token, file 0600. | |
--token T | Works; visible in the process list during the deploy | |
--admin-email / --admin-password | / asked | Instead of a token |
--api-over-ssh | off | API calls on the master over SSH. Needs a token. |
--single-node | off | Lone install; registry must confirm no other nodes |
--skip-consoles | off | Skip the master's console check |
- Master and clean tree checked; one release built.
- Staged at
/usr/local/lib/ozpanel/updates/ozpanel; signed (POST /cluster/updates/sign, CA holder only). - Applied:
POST /cluster/self-update; unsigned refused. Master proven. - Rolled out:
POST /cluster/updates/publish, one member at a time; each verifies, swaps, restarts. - Migrations last, by the primary, Galera appliers quiesced (
POST /cluster/updates/migrateby hand). - Measured:
GET /cluster/updates/verify; non-zero exit on any difference.
- ConvergencePublish · master's push to returning nodes ·
ozpanel pull-releasebefore every start (--timeout 60s,--restart-if-pulled, always exits 0). - SafetySignature checked with the CA cert · staged binary must run
version· previous kept asozpanel.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 originalapp_keyto read stored credentials. - init-config --forceNew identity. Never on a serving cluster member.
- UninstallNot available. Reinstall the OS.