Skip to content

Install (Hetzner)

Provisioning checklist for spinning up the Fabric cloud control plane on Hetzner. Target: under 10 minutes from "click Buy" to "/healthz returns 200." Source-of-truth: ~/.openclaw/workspace/optimalOS/infra/hetzner/PROVISIONING-CHECKLIST.md.

This is the path Carlos walked on 2026-05-03/04 to bring https://fabric.optimal.miami live. It is also the path another operator would follow to host their own Fabric instance.

What you get

  • A CX22 or CX32 Debian 12 box in a Hetzner region you choose (Carlos uses Ashburn, US-East).
  • A Cloudflare-Tunnel-fronted hostname (no public ingress), with the tunnel running as a systemd unit on the box.
  • optimalos.service running the cloud-mode bundle on :3000.
  • An invite-gated single-tenant install ready for first-passphrase setup.

The Pi (or laptop, or future Mac mini) joins as a paired device after the box is up; see Pairing.

Pre-flight

  • Hetzner Cloud account with a payment method (about a minute if first time).
  • Cloudflare account with a zone you control (Carlos uses optimal.miami).
  • An ~/.ssh/id_ed25519.pub to paste into the Hetzner SSH-key form. Password auth is disabled by the bootstrap.
  • A Tailscale auth key, or willingness to interactive-auth from the box.
  • A Supabase project URL plus service-role key. Carlos uses the existing hbfalrpswysryltysonm instance shared with optimal.miami; managed-Supabase v1 per Decision-ledger #7.

Step 1. Buy the box (about 2 minutes)

  1. https://console.hetzner.cloud/ then New Project named optimalos-fabric (or your preferred slug).
  2. Add Server: pick a US-East region, Debian 12, CX22 (€3.79/mo, 2 vCPU, 4 GB RAM) or CX32 (€5.83/mo, 2 vCPU, 7.6 GB RAM). Carlos was upgraded to CX32 mid-deploy; CX22 is sufficient for v1.
  3. Paste your SSH key, name the box fabric-prod-1, and click Create.
  4. Note the public IPv4 address.

Step 2. Bootstrap (about 4 minutes)

bash
ssh root@<ipv4>          # accept host key
curl -fsSL <repo>/infra/hetzner/cloud-init.sh | sudo bash

The bootstrap script (committed at infra/hetzner/cloud-init.sh) sets up:

  • a non-root optimal user
  • bun runtime
  • caddy (installed but not used for the tunnel path; it stays available if you want a second hostname)
  • cloudflared package
  • ufw allowing port 22 from any source initially (locked down to Tailscale after Step 3)
  • /opt/optimalos/{app,workflows,operations,data} directories
  • a stub optimalos.service unit at /etc/systemd/system/optimalos.service

Wait for the "bootstrap complete" banner, then verify no errors:

bash
cat /var/log/optimalos-bootstrap.log | tail -40

Step 3. Tailscale join (about 1 minute)

bash
sudo tailscale up        # visit URL, approve on your tailnet
tailscale ip -4          # note the 100.x address
ssh root@<tailscale-ip>  # confirm SSH works over Tailscale from your laptop

Once Tailscale SSH works, you can disable the public IPv4 in the Hetzner console (optional). The bootstrap UFW rules already restrict port 22 to the Tailscale interface; the public IP can stay if you want a fallback.

Carlos's instance left public 22 open for the first 24 hours and has Tailscale lock-down queued in the kanban as a Phase 15 prep task.

Step 4. Cloudflare Tunnel (about 2 minutes)

bash
sudo cloudflared tunnel login                # paste URL into laptop browser, auth
sudo cloudflared tunnel create fabric-prod   # note the UUID
sudo cp /root/.cloudflared/<uuid>.json /etc/cloudflared/<uuid>.json
sudo nano /etc/cloudflared/config.yml        # replace both [TBD] UUID slots
sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared            # active

The shipped config.yml template ingresses fabric.optimal.miami to http://localhost:3000 with a 404 fallback. Adjust the hostname for your zone.

In the Cloudflare dashboard: DNS then add CNAME fabric.<your-zone> to <uuid>.cfargotunnel.com, proxy ON (orange cloud).

Step 5. Secrets and start (about 1 minute)

bash
sudo cp /opt/optimalos/secrets.env.example /opt/optimalos/secrets.env
# Generate a JWT signing key (decoded length must be at least 32 bytes for HS256):
openssl rand -base64 32   # paste into JWT_SIGNING_KEY=

# Pick an INVITE_PASSWORD (single-tenant gate per Decision-ledger #20a).
# Make it memorable but unguessable; the server logs a loud warning if blank.
sudo nano /opt/optimalos/secrets.env   # fill remaining [TBD] (Supabase URL + service key)

sudo chmod 600 /opt/optimalos/secrets.env
sudo chown optimal:optimal /opt/optimalos/secrets.env

Build and ship the Bun bundle (from your laptop, against your optimalOS checkout):

bash
cd ~/path/to/optimalOS
pnpm build:cloud
rsync -avz --delete -e "ssh -i ~/.ssh/id_ed25519" \
  dist/ root@<ip>:/opt/optimalos/app/
ssh root@<ip> "
  chown -R optimal:optimal /opt/optimalos/app && \
  find /opt/optimalos/app -type f -exec chmod 644 {} \; && \
  find /opt/optimalos/app -type d -exec chmod 755 {} \;"

Start it:

bash
sudo systemctl start optimalos
sudo systemctl status optimalos    # active (running)

Step 6. Apply vault migrations

The Bun server runs at /healthz immediately, but vault routes will 500 until the Supabase tables exist. Three idempotent migrations (mirrored at ~/.optimalos/transfers/2026050*.sql):

FileAdds
20260503215150_fabric_vault_phase_10a_2.sqlvault_entries, vault_recipients, vault_access_log
20260503221013_fabric_devices_phase_10b_3.sqldevices, pairing_tokens, FK on vault_recipients.device_id
20260503235721_fabric_vault_canary_phase_10a_7.sqlcanary_ciphertext column on vault_recipients

Two paths; pick whichever works (migrations are idempotent so re-runs are safe). Full procedure including the schema_migrations stamp lives in Runbook.

Step 7. Verify (about 30 seconds)

bash
curl -s -o /dev/null -w "healthz HTTP %{http_code}\n" \
  https://fabric.<your-zone>/healthz
curl -s -o /dev/null -w "vault/setup HTTP %{http_code}\n" \
  https://fabric.<your-zone>/vault/setup
curl -s https://fabric.<your-zone>/api/version | head -c 200; echo
dig +short fabric.<your-zone> CNAME

Expected: 200, 200, a JSON version blob, and the cfargotunnel CNAME. If anything is wrong, see Runbook then Common breakages.

Step 8. First passphrase ceremony

In any modern browser (iPad Safari is the canonical mobile target):

  1. Open https://fabric.<your-zone>/
  2. Sign in with INVITE_PASSWORD from /opt/optimalos/secrets.env
  3. Navigate to /vault/setup
  4. Walk the four-step wizard: passphrase, passkey, recovery phrase, challenge

Total time: about 3 seconds for crypto plus however long you spend writing the recovery phrase down. Two vault_recipients rows (browser plus recovery) appear when you finish.

A full walkthrough including what each step does and where to look when it breaks is in Quickstart.

Rollback and kill switch

If anything is wrong and you want to take the box out of service:

Soft rollback (stop services, keep box):

bash
sudo systemctl stop optimalos cloudflared caddy
sudo systemctl disable optimalos cloudflared caddy
# Cloudflare dashboard: delete the fabric.<your-zone> CNAME

Hard rollback (delete the Hetzner box entirely):

  1. Hetzner console then fabric-prod-1 then Delete.
  2. Cloudflare dashboard then Tunnels then fabric-prod then Delete tunnel (also removes the cfargotunnel record).
  3. Cloudflare DNS: delete the fabric.<your-zone> CNAME.

Hetzner billing settles within minutes (hourly billing).

Repoint to Pi (emergency restore):

CF DNS: optimal.miami CNAME → a6f4487b-dd02-4702-9b8c-2ccea348b2a7.cfargotunnel.com

That UUID is the Pi tunnel from /etc/cloudflared/config.yml on the Pi. Carlos uses this if Hetzner needs to come down for any reason and the Pi has to absorb traffic.

After install

  • Pair your first device with Pairing.
  • Add your first credential per Quickstart then Add your first credential.
  • Lock down public 22 on UFW once Tailscale is verified working.
  • Backup /opt/optimalos/secrets.env to your password manager. If the box is destroyed and JWT_SIGNING_KEY is lost, every browser session is invalidated (not catastrophic, but worth a note).

Source

  • Provisioning checklist: ~/.openclaw/workspace/optimalOS/infra/hetzner/PROVISIONING-CHECKLIST.md
  • Bootstrap script: ~/.openclaw/workspace/optimalOS/infra/hetzner/cloud-init.sh
  • Caddyfile and cloudflared/config.yml templates: ~/.openclaw/workspace/optimalOS/infra/hetzner/
  • Hetzner handoff (load-bearing on session start): ~/.optimalos/transfers/fabric-hetzner-handoff-2026-05-04.md
  • Bootstrap zip (8 files for hands-off provisioning): ~/.optimalos/transfers/fabric-hetzner-bootstrap.zip

Built by Carlos Lenis in Miami