Connected Installation
Connected Installation¶
The supported self-hosted install: one Linux host with internet access, Docker
Compose, served over HTTPS. install.sh takes a clean host to a healthy stack
with a first admin, and is safe to re-run.
Air-gapped installs are not supported at launch. For a short walk-through of the same install, see the Quick start.
Support matrix¶
| Item | Supported |
|---|---|
| Operating system | Ubuntu 24.04 LTS (recommended); Ubuntu 22.04 LTS, RHEL 9, Rocky Linux 9 and Debian 12 are supported, with a warning from the installer |
| CPU architecture | x86_64 (amd64) only |
| Container runtime | Docker Engine 28 or later |
| Compose | Docker Compose v2.28 or later |
| Installer tools | bash, python3, openssl, cosign v2 (required: the installer verifies every image signature; v3 is supported once a release confirms it verifies these signatures, see CI/CD) |
| Access | A non-root account in the docker group, on a local (unix-socket) Docker context, that owns the install directory or can create it with sudo |
install.sh stops on an unsupported architecture, Docker Engine or Compose
version, or a missing cosign, and warns on an operating system other than Ubuntu 24.04. Sizing,
ports and the outbound allow-list are in the partner requirements document
(Requirements).
What you receive¶
| File | Purpose |
|---|---|
license.json |
The Ed25519-signed license for this deployment's ID (ADR-008), downloaded by a partner administrator from the partner portal after the first run (Request a license). Verified on the host; no license server. |
pull-credentials.json |
A read-only registry token for this deployment, created on your firm's registry machine account and delivered by the partner portal (ADR-045): {"registry","username","token"}. It reads the private ghcr.io/huitzo-ai/{backend,worker,dashboard,backup} images. Keep it in your firm's secret manager. |
Release assets¶
Each release is published at https://get.huitzo.com/self-hosted/<version>/
(/self-hosted/stable/ redirects to the current stable release). The version is
the platform release number that /health reports. Images are built for
x86_64 (amd64) only.
| Asset | Purpose |
|---|---|
install.sh |
The installer. |
docker-compose.yml |
The stack, every Huitzo image pinned by the released digest. |
.env.template |
The template install.sh fills on the first run. |
Caddyfile |
The HTTPS profile's proxy configuration. |
backup.sh |
The backup script. |
values.yaml |
Helm values with the released digests. Helm is unsupported at launch. |
RELEASE-NOTES.md |
Version, image digests and this pre-run check. |
SHA256SUMS |
SHA-256 of every asset above. |
SHA256SUMS.cosign.bundle |
The keyless cosign signature over SHA256SUMS. |
Download one release into an empty directory:
# pseudocode: operator commands
v=<version>; base=https://get.huitzo.com/self-hosted/$v
for f in install.sh docker-compose.yml .env.template Caddyfile backup.sh \
values.yaml RELEASE-NOTES.md SHA256SUMS SHA256SUMS.cosign.bundle; do
curl -fsSLO "$base/$f"
done
Pre-run check¶
install.sh cannot vouch for itself, so check the assets before running it:
the signature over SHA256SUMS must come from Huitzo's release workflow on
main, then every file must match its checksum.
# pseudocode: operator commands, in the download directory
cosign verify-blob SHA256SUMS --bundle SHA256SUMS.cosign.bundle \
--certificate-identity https://github.com/Huitzo-Inc/huitzo/.github/workflows/customer-release.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
sha256sum -c SHA256SUMS
chmod +x install.sh backup.sh
Stop if either command fails. Any other signing identity (another workflow,
branch or repository) fails verify-blob.
Install¶
A license names the deployment ID it is for, and a deployment ID exists only after the first boot (ADR-008 D3). A first install therefore runs twice:
# pseudocode: operator commands; adjust paths and values
# 1. No license yet: setup mode on this host only; prints the deployment ID.
./install.sh \
--pull-credentials pull-credentials.json \
--domain huitzo.example.com \
--acme-email [email protected] \
--admin-email [email protected]
# 2. Request a license for the printed deployment ID, then:
/opt/huitzo/install.sh --license license.json
| Flag | Meaning |
|---|---|
--license FILE |
The signed license for this deployment ID. Must be a regular, readable file. Omit it on the first run (setup mode). |
--pull-credentials FILE |
Registry login, used for this run only. Needed whenever an image must be verified or pulled; a re-run whose digests were all verified earlier and are still present locally (renewal, restore) can omit it. |
--prefix DIR |
Install directory. Default /opt/huitzo, except that the copy of install.sh inside an existing install directory defaults to that directory. |
--domain HOST |
Turn on the HTTPS profile for this hostname. |
--acme-email EMAIL |
Let's Encrypt account email (HTTP-01). |
--tls-cert FILE --tls-key FILE |
Bring your own certificate instead of Let's Encrypt. |
--tls-internal |
Caddy's own CA instead of Let's Encrypt. Testing only: browsers do not trust it. |
--external-tls |
You run the TLS proxy yourself; no Caddy. |
--admin-email EMAIL |
The first admin. Required on the first run. |
--allow-grace |
Accept a license in GRACE (expired, inside its grace window) at install time. |
--no-up |
Run through placing the license (assets, .env, registry login, image pull, license preflight, license placement), then stop; nothing is started. |
--backups-only |
For a restore onto a new host: after the license step, migrate the empty database and run step 11 only. No backend, workers, beat, dashboard or admin start, so no deployment ID is minted. |
The script runs these steps in order and stops at the first failure:
- Checks the support matrix.
- Copies the release assets into the install directory (
/opt/huitzo):docker-compose.yml,Caddyfile,.env.template,install.shandbackup.sh, replaced on every run, plus emptybranding/andcerts/directories. It never touches an existing.env, except for the two one-time upgrades in the next step. - Creates
.envon the first run (Environment file). An existing.envwith the singleHUITZO_DB_PASSWORDof an older release is split into per-role passwords after step 7 and before step 8 (Upgrading). An existing.envwithout the database backup secrets gets them appended once (Backup secrets on an existing install). - Logs in to the registry. The credentials path is passed to Python as an
argument and the token goes to
docker loginon stdin, into a temporary Docker configuration directory that the image verification and pull use and that is deleted when the script exits; your own Docker configuration and logins are never touched (ADR-045). - Verifies the cosign signature of every Huitzo image in the compose file
(
migrate,backend,worker,beat,dashboard,backup,backup-restore, and any otherghcr.io/huitzo-aiimage) with the exact identity of the pre-run check and the annotationrelease=<version>of this release (an older release's digest is refused), then pulls the digest-pinned images. An image without a digest, a signature from any other identity, or a missing cosign stops the install. The verified digests are recorded with the signer identity in.verified-images; a later run skips the verification and pull only for a recorded digest whose image is still present locally. - Runs the license preflight on the file you passed. An
INVALID license stops the install with the live
license/license.jsonunchanged and no service started; a usable one is written overlicense/license.jsonin place. Without--licenseand with no license placed yet, the run continues in setup mode. - With the HTTPS profile and a license, starts Caddy on ports 80 and 443 and records the certificate key pin (HTTPS profile). Nothing of the application is reachable through it yet. Setup mode skips this step.
- Starts the backend, the workers and beat (the
setup status checks that it ticks):
migrateruns the schema migrations and sets the database role password first. When a license was placed but the backend still reports setup mode (it booted before the file existed), the backend is restarted once; if it is still in setup mode, the run stops and prints the deployment ID and the reason. - Creates the first admin from inside the backend container.
- Starts the dashboard on
127.0.0.1:3000(the route Caddy forwards to), restarting it when the backend (re)started after it so its proxy follows the new backend, and prints the URL and what to back up. In setup mode it starts the dashboard only when its port is bound to127.0.0.1, then prints the deployment ID instead (after step 11) and exits. - Prepares and starts the database backup service: it
creates or updates the backup roles, generates the manifest signing key
once, and starts
backup. It runs after the dashboard, so a failure here stops the run with an error but never leaves the Hub without its proxy.
Setup mode¶
Without a license the backend boots in setup mode
(ADR-008 setup mode):
it serves the health probes, /api/v1/setup/*, the license status and reload
routes, and answers every other route with 402 LICENSE_INVALID; the workers
run nothing. install.sh without --license uses it to prepare the
deployment before a license exists:
- it generates
.env, pulls the images and starts the backend and workers; - it creates the first admin with the one-time setup token;
- it starts the dashboard only when its port is bound to
127.0.0.1(127.0.0.1:3000by default) and keeps Caddy down, stopping one left running by an earlier install, so nothing listens beyond this host; - it prints the deployment ID (
hzd_plus 26 characters, shown in groups of four for reading aloud; the license request takes it without spaces) and exits.
The ID is also on GET /api/v1/setup/status (no sign-in needed), on the
dashboard's setup page (/setup) and on the admin license card. Request the
license for it, then run install.sh --license license.json, which checks the
file against this deployment's ID, places it and starts the HTTPS profile.
The backend also enters setup mode when the license names another deployment
ID (including a license issued before deployment IDs existed, whose free-text
deployment_id never matches), is not active yet (not_before in the
future), or carries an issued_at more than 24 hours ahead of the host clock.
Any other unusable license (expired past grace, tampered, signed by an
untrusted key) still stops the backend from booting, and its log prints the
local deployment ID. A matching license leaves setup mode on the next re-read
(at most hourly), on POST /api/v1/license/reload (it needs an admin or owner
token; signing in is refused in setup mode, but the bootstrap-admin response
carries one), or on a backend restart, which is what
install.sh --license does. A reload, each backend boot and a re-read that
finds a usable license all tell the workers to re-read before they refuse
work. The customer compose runs one backend; with several backend replicas
(Kubernetes), a reload re-reads the file only on the replica that serves the
call, and the others follow at their next hourly re-read or a restart.
Serving this host only is a property of install.sh, not of setup mode
itself. Starting the stack by hand without a license (for example
docker compose --profile https up -d) publishes the setup routes through
Caddy: bootstrap-admin is then reachable remotely, still behind the one-time
setup token and its failure limits. Prefer install.sh, or keep the https
profile down until the license is installed.
Remote plain HTTP is unsupported¶
Session cookies are always Secure, so a browser on another machine that
reaches the dashboard over plain HTTP is signed out on every page reload.
install.sh therefore accepts exactly three access modes:
| Mode | Flags | Dashboard port 3000 |
|---|---|---|
| HTTPS profile (Caddy) | --domain |
127.0.0.1:3000 |
| Your own TLS proxy | --external-tls |
HUITZO_DASHBOARD_PORT from .env (default 127.0.0.1:3000) |
| This host only | neither | 127.0.0.1:3000 |
In every mode except --external-tls the script refuses to start when .env
publishes port 3000 on anything other than 127.0.0.1. Docker-published ports bypass host
firewalls such as ufw and firewalld, so if your proxy runs on another machine,
block port 3000 in the network or security group.
Environment file¶
On the first run install.sh writes .env from .env.template with mode
0600 and fills the required secrets, the metrics token and the database
backup secrets with random values (32 bytes each; hex, except the
control-plane key, which is base64):
| Variable | Holds |
|---|---|
HUITZO_JWT_SECRET_KEY |
Session signing key |
HUITZO_DB_SUPERUSER_PASSWORD |
Password of the huitzo database superuser (roles) |
HUITZO_DB_APP_PASSWORD |
Password of the huitzo_app runtime role (roles) |
HUITZO_DB_BACKUP_PASSWORD |
Password of the huitzo_backup role the backup service dumps with (roles) |
HUITZO_DB_BACKUP_AUDIT_PASSWORD |
Password of the huitzo_backup_audit role the backup service writes its audit rows with (roles) |
HUITZO_BACKUP_CTL_HMAC_KEY |
Key the backend and the backup service authenticate each other's requests with (Backup service) |
HUITZO_BACKUP_TICKET_KEY |
Backend-only key that protects backup download tickets (Backup service) |
HUITZO_REDIS_PASSWORD |
Redis password |
HUITZO_AUDIT_PEPPER |
Audit-log redaction pepper |
HUITZO_SECRETS_ENCRYPTION_KEY |
Encryption key for stored secrets such as API keys |
HUITZO_METRICS_TOKEN |
Bearer token for the backend's /metrics endpoint (Observability) |
Hex keeps every password safe inside the database and Redis URLs. The access-mode
flags are written to .env too (HUITZO_TLS_MODE, HUITZO_DOMAIN,
HUITZO_ACME_EMAIL, COMPOSE_PROFILES, and HUITZO_EDGE_TRUSTED_PROXIES with
the https profile; see HTTPS profile). It never writes
HUITZO_BACKUP_AGE_RECIPIENTS: the encryption keys are yours to create, off
this host (Encryption keys). On later runs the
existing .env is the source of truth: it stays byte-identical, and the script
stops if --domain, --acme-email or the TLS mode flags contradict it. To
change them, edit .env and re-run.
Losing .env loses HUITZO_SECRETS_ENCRYPTION_KEY, and with it every stored
secret. Back it up (Backups).
Metrics token on an existing install¶
An .env written before install.sh generated HUITZO_METRICS_TOKEN has no
such line, so /metrics answers 503. Re-running install.sh does not add it,
because the script never rewrites an existing .env. Generate a value, add the
line to .env, and recreate the backend:
cd /opt/huitzo
python3 -c 'import secrets; print(secrets.token_hex(32))'
# add the printed value to .env as HUITZO_METRICS_TOKEN=<value>
docker compose up -d
Scraping is covered in Observability.
Backup secrets on an existing install¶
An .env written by a release without the backup service lacks some of
HUITZO_DB_BACKUP_PASSWORD, HUITZO_DB_BACKUP_AUDIT_PASSWORD,
HUITZO_BACKUP_CTL_HMAC_KEY, HUITZO_BACKUP_TICKET_KEY and the empty
HUITZO_BACKUP_AGE_RECIPIENTS= line. The first run of a newer install.sh
copies .env to .env.bak (mode 0600), then appends exactly the missing
lines with fresh random values, in one atomic rewrite; every existing line
stays as it was. A later run finds them all and leaves .env byte-identical.
Back up the new .env and delete .env.bak once that backup exists.
Database role passwords¶
Each database role has its own password in .env:
| Role | Variable | Who uses it |
|---|---|---|
huitzo (superuser, POSTGRES_USER) |
HUITZO_DB_SUPERUSER_PASSWORD |
postgres (the first initialisation), the one-shot migrate service, which runs the migrations, and the run-only backup-restore service (Restore) |
huitzo_app (LOGIN NOSUPERUSER NOBYPASSRLS) |
HUITZO_DB_APP_PASSWORD |
backend, worker and beat |
huitzo_backup (LOGIN BYPASSRLS, read-only, pg_read_all_data) |
HUITZO_DB_BACKUP_PASSWORD |
the backup service and backup-restore only |
huitzo_backup_audit (LOGIN, INSERT on the audit log only) |
HUITZO_DB_BACKUP_AUDIT_PASSWORD |
the backup service and backup-restore only |
install.sh creates both backup roles, or brings them back to their exact
shape and password, on every run without --no-up, after migrate: in a
one-off migrate container it runs huitzo-db db-backup-role with the two
passwords in owner-only files that the container deletes on exit, so they are
never printed or put on a command line
(roles). To rotate one,
put a new value in .env and re-run install.sh.
The huitzo_app password no longer opens the superuser, and backend, worker
and beat never receive the superuser URL
(Which service holds the admin URL); separate passwords
are what make that placement a boundary. The bundle has no separate huitzo_admin role: the
migration admin is the superuser.
After alembic upgrade head, the migrate service sets the huitzo_app
password from HUITZO_DB_APP_PASSWORD on every run. It sends a SCRAM-SHA-256
verifier computed in the container, never the cleartext, quoted by
PostgreSQL's format('%L'); nothing secret is logged. Every password must be
printable ASCII.
To rotate the huitzo_app password, put a new value
(python3 -c 'import secrets; print(secrets.token_hex(32))') in
HUITZO_DB_APP_PASSWORD in .env and run docker compose up -d from the
install directory: migrate moves the role to it before the backend and
workers reconnect.
To rotate the superuser password, from the install directory:
docker compose exec postgres psql -U huitzo -d huitzo -c "\password huitzo"and enter the new value.- Put the same value in
HUITZO_DB_SUPERUSER_PASSWORDin.env. docker compose up -d.
Upgrading an install with one database password¶
Releases before the split kept one HUITZO_DB_PASSWORD for both roles. The
first run of a newer install.sh over such an install splits it after the
HTTPS proxy step, which needs no database, and before it starts the backend,
workers and beat. A failure before list item 1 below leaves the previous
release's backend, workers and beat running and the database unchanged; from
item 1 until the backend, workers and beat start again,
the application is down.
- It stops the previous release's
backend,workerandbeatand copies.envto.env.bak(mode0600), so none of them is left with a password the database no longer accepts. - It generates the three new passwords and keeps them in
.env.db-rotation(mode0600) until the switch, so an interrupted run reuses them. - In a one-off
migratecontainer, connected ashuitzowith the old password, it runsALTER ROLE huitzo_app PASSWORD …andALTER ROLE huitzo PASSWORD …in one transaction (SCRAM verifiers, as above). If the old password is refused because an earlier run already changed it, it connects with the new one instead. - Only then does it replace the
HUITZO_DB_PASSWORDline in.envwith the three new variables and delete.env.db-rotation.
A failure while the roles move (item 3) leaves .env unchanged and matching
the database. A failure in the final rewrite (item 4) leaves .env on the old
password while the roles already use the new ones, kept in .env.db-rotation,
which the next run reuses. Either way the application stays stopped: re-run
install.sh. A later run finds no HUITZO_DB_PASSWORD and changes nothing.
With --no-up the split waits for the next run without it, because it needs
the database running. No password is printed.
After the upgrade, back up the new .env (Backups) and delete
.env.bak once that backup exists: .env.bak holds the old password, which
no role accepts any more, and every other secret.
Development compose is never for customers¶
deploy/compose/docker-compose.yml in the repository is the development and
internal stack. It publishes PostgreSQL on port 5432 with the fixed
credentials huitzo/huitzo and Redis on 6379. Never run it for a customer
or on a host reachable from a network you do not control: install with
install.sh and the release assets, whose compose file publishes no database
port and generates every password.
Which service holds the admin URL¶
Only the run-only migrate service receives HUITZO_DATABASE_ADMIN_URL.
backend, worker and beat get HUITZO_DATABASE_URL (the huitzo_app
role) and nothing else for the database. They run pack code: pipeline stages,
nested command bodies and the fast queue run in-process, and without the sandbox
helper an isolated pack child runs as the same user as its parent and can read
the parent's environment from /proc/<ppid>/environ
(ADR-039 §f,
Sandbox UID),
so a variable in their environment is readable by any installed pack.
No runtime code path reads the admin URL. It is read only by alembic
(apps/backend/alembic/env.py and the extension chains), by the app_role
step that migrate runs, by huitzo-db db-backup-role, which install.sh runs
in a one-off migrate container (backup service), and by the break-glass
rerun-aad-rotation
command, which therefore runs in a one-off migrate container. With the
variable unset, settings fall back to HUITZO_DATABASE_URL, so a command run
in backend connects as huitzo_app. The run-only backup-restore service
holds the superuser credential under its own name, HUITZO_DATABASE_RESTORE_URL
(Restore); it runs no pack code and up -d never starts it.
This is a boundary because the two roles have separate passwords
(HUITZO_DB_SUPERUSER_PASSWORD and HUITZO_DB_APP_PASSWORD,
above): the app role's URL carries nothing that opens the
superuser. An install that still has the single HUITZO_DB_PASSWORD of an older
release gets the boundary when install.sh splits it
(upgrade).
The same rule holds in the Helm chart: the migration Job and the backend's
migration init container receive the admin URL; the backend, worker and beat
Deployments do not. There it protects the admin credential as long as
HUITZO_DATABASE_URL names huitzo_app with its own password, as the chart's
example Secret does (deploy/k8s/README.md).
License preflight¶
Before it replaces anything, install.sh checks the license file you passed
with the release backend image itself, with no network:
# pseudocode: what install.sh runs (settings validation gets throwaway secrets)
docker run --rm --network none
-e HUITZO_DEPLOYMENT_MODE, HUITZO_DATABASE_URL (never contacted), HUITZO_LICENSE_PATH,
HUITZO_LICENSE_DATA_DIR=/app/license-data,
HUITZO_JWT_SECRET_KEY, HUITZO_AUDIT_PEPPER, HUITZO_SECRETS_ENCRYPTION_KEY (random)
-v <your license file>:/etc/huitzo/license/license.json:ro
-v huitzo-customer_huitzo-license-data:/app/license-data:ro # only once the volume exists
<backend image> python -c 'import sys; from backend.licensing.verify import main; sys.exit(main())'
Once the deployment has booted, the preflight also reads its deployment ID
from the huitzo-license-data volume (read-only) and refuses a license issued
for another ID, naming both. Before the first boot there is no ID yet: it
prints deployment ID: not generated yet and checks everything else.
The entry is called with python -c because the release image ships compiled
modules, which python -m cannot run.
It prints the state (VALID, GRACE or INVALID), tier, customer, licensed
deployment, this deployment's ID, expiry date and days left. An INVALID license
(expired past grace, tampered, signed by a key this release does not trust such
as the dev key, issued for another deployment ID, or malformed) prints the
reason and stops the install with a non-zero exit, before the live
license/license.json is touched. The file is staged with mode 0644 first,
so the image's user can read it whatever its mode on your host. A GRACE
license (already expired, still inside its grace window) is refused too,
printing the state, expiry date and days past expiry, unless you pass
--allow-grace; the entry exits 0 for VALID, 1 for INVALID and 2 for GRACE.
HTTPS profile¶
--domain turns on the https compose profile: a Caddy service publishing
ports 80 and 443 in front of the dashboard, which then listens on
127.0.0.1:3000 only. Caddy reads HUITZO_DOMAIN, HUITZO_ACME_EMAIL and
HUITZO_TLS_MODE from .env:
HUITZO_TLS_MODE |
Certificate |
|---|---|
acme |
Let's Encrypt over HTTP-01. Needs a public DNS name and port 80 reachable from the internet. |
byo |
Your certificate and key, copied to certs/cert.pem and certs/key.pem in the install directory. |
internal |
Caddy's own CA. Testing and CI only. |
Keep the key across renewals. Huitzo CLI runners pin the SPKI hash of the
Hub's TLS key. Caddy is configured with reuse_private_keys, so a renewal
issues a new certificate for the same key and the pin stays valid. The key
lives in the caddy-data volume; back it up with the rest (Backups).
The pin. After Caddy serves its first certificate, install.sh computes the
leaf key's SPKI SHA-256 and writes it to hub-pin.env in the install directory
as HUITZO_RUNNER_HUB_CERT_SPKI_SHA256. The backend reads that file and ships
the pin, root-signed, in the runner install manifest. Caddy starts, and the pin
is recorded, before the backend, so the backend boots with it. On a re-run the
script keeps hub-pin.env as long as it contains the served pin (a rotation
file with two pins stays as it is), and warns and rewrites it when the served
key is not in it.
With a byo certificate, replace the files by re-running install.sh with
--tls-cert and --tls-key: it restarts Caddy when they change (a change
left by an interrupted or --no-up run is applied on the next run) and records
the new pin (with a warning when the key changed; plan that as a
key rotation). With --external-tls, write the pin yourself:
# pseudocode: pin = sha256(DER SubjectPublicKeyInfo of the served leaf)
openssl s_client -connect <host>:443 -servername <host> -> leaf certificate
openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER | sha256sum -> <hex>
hub-pin.env: HUITZO_RUNNER_HUB_CERT_SPKI_SHA256='["sha256:<hex>"]'
(from the install directory) docker compose up -d backend
Per-client sign-in limits behind Caddy. Caddy has the fixed address
HUITZO_CADDY_ADDRESS (172.16.231.10) on huitzo-network, whose subnet is
HUITZO_NETWORK_SUBNET (172.16.231.0/24); containers without a fixed address
get one from HUITZO_NETWORK_DYNAMIC_RANGE (172.16.231.128/25). On the first
run with --domain, install.sh writes HUITZO_EDGE_TRUSTED_PROXIES with
Caddy's address to .env, so the dashboard trusts Caddy alone (one hop) and
each user keeps their own sign-in rate-limit bucket. A later run never edits
.env, so with an .env written before the variable existed it warns: add
HUITZO_EDGE_TRUSTED_PROXIES=172.16.231.10 and run
docker compose down && docker compose up -d from the install directory,
which also recreates the network with the fixed subnet.
- Load balancer in front of Caddy. The shipped Caddyfile trusts no
upstream. Caddy replaces the load balancer's
X-Forwarded-For, so all users behind that load balancer share its bucket. --external-tls. The variable stays empty. Set it to the address your proxy reaches the dashboard from. For a proxy on this host that is thehuitzo-networkgateway, the first address ofHUITZO_NETWORK_SUBNET(172.16.231.1by default), when Docker's userland proxy carries the loopback-published port (the default); check the peer in the dashboard log. Trusting the gateway trusts every process on this host that can reach port 3000.- Subnet collision. If
172.16.231.0/24overlaps a network this host or its users reach,upfails with a pool overlap, or that network loses its route to the host. Runinstall.shwith--no-upfirst, set the three network variables andHUITZO_EDGE_TRUSTED_PROXIESin.envto another private range, then run it again. - Checked on every run.
install.shstops before starting anything when the subnet is not inside10.0.0.0/8,172.16.0.0/12or192.168.0.0/16(the backend's default trusted ranges), the dynamic range is not inside it, or Caddy's address is outside the subnet, inside the dynamic range, or the subnet's network, gateway or broadcast address. With thehttpsprofile it warns whenHUITZO_EDGE_TRUSTED_PROXIESis empty or does not list Caddy.
See client IP behind an HTTPS proxy.
Rotating the key¶
Rotating the key is a manual step: put the new and the old pin in
hub-pin.env ('["sha256:<new>","sha256:<old>"]'), run
docker compose up -d backend from the install directory, let
runners refresh their manifest, then swap the key and drop the old pin. See
Runner Cert-Pin Rotation.
First admin¶
The setup API creates the first admin while no user exists, and only with the
one-time setup token: the backend writes it at first boot to
setup-token on the huitzo-license-data volume (mode 0600, readable only from
the host), never returns it from an API and deletes it once the first admin
exists. install.sh reads it with docker compose exec and calls
POST /api/v1/setup/bootstrap-admin from inside the backend container, on the
backend's own loopback, before the dashboard (the only route to the backend)
starts. It uses --admin-email and a generated password, printed once before
the call so a lost response never hides it. Sign in and change it. If the
deployment already has a user, the step is skipped.
A missing or wrong token gets 403. Wrong tokens are counted per client and
across the deployment; past the limit they get 429, while the correct token
still succeeds. The dashboard's setup page (/setup) asks for the token too.
Doing it by hand, while the dashboard is still stopped:
# pseudocode: run from the install directory
docker compose exec backend cat /app/license-data/setup-token
docker compose exec backend GET http://127.0.0.1:8000/api/v1/setup/status
docker compose exec backend POST http://127.0.0.1:8000/api/v1/setup/bootstrap-admin
{"email": "<admin email>", "password": "<8 to 72 characters>",
"setup_token": "<the token>"}
Backups¶
Releases with Hub-managed backups. This section describes the release that adds the
backupservice and the/admin/backupspage; its release notes say so. On an older release,backup.sh --db-onlystill takes a plainpg_dump(huitzo_db_*.sql.gz): store it encrypted, and restore it with step 3 of the older procedure in that release's copy of this page.
Database backups are managed by the Hub. A backup service in the compose
project takes them on a schedule, encrypts each one with
age to the public keys you configure, signs its
manifest, and keeps them in its own volume. Platform admins see them, start
one, verify one or download one on /admin/backups. Restoring is a command on
the host, never a button.
Encryption keys¶
Backups are encrypted before they touch the disk. Before the first backup:
- On a machine other than this host, create an age X25519 identity with
the
agetools. Keep the private identity there, and a second copy offline or in a password manager, encrypted with a passphrase. - Put the public recipient (
age1…) in.envasHUITZO_BACKUP_AGE_RECIPIENTS. List several, separated by commas, to let more than one key holder decrypt. - Re-run
install.sh(ordocker compose up -d backup).
The age tools' key generator writes the identity file and prints its public
key (age1…); age -p makes the passphrase-encrypted copy. Run both on your
workstation, never on the Huitzo host; install.sh prints the exact commands
while no recipient is set.
install.sh never creates an age identity and never asks for one: it only
reads the public recipients. It checks each one is an age1… recipient and
stops on anything else, including a pasted private identity
(AGE-SECRET-KEY-…). While the list is empty it says so on every run.
With no recipient, the backup service refuses to take backups and alerts:
it never writes an unencrypted dump. Turning the schedule on in the Hub does
not change that: every scheduled backup fails, visibly, until a recipient is
set. Losing every private identity loses every backup.
Also record the backup service's signer public key off the host
(/admin/backups shows its id; huitzo-db db-backup-list --json prints it).
You need it to restore on a different host.
The backup service¶
| Service | Runs | Holds |
|---|---|---|
backup |
Always (huitzo-db db-backup-serve), as UID 10950, read-only root filesystem, no capabilities |
The huitzo-backups volume (mode 0700, UID 10950), the huitzo-backup-keys volume read-only, both backup role passwords, the age recipients and the control-plane key |
backup-restore |
Only on docker compose run (profile restore; up -d never starts it) |
The same except the control-plane key (it is not on backup-ctl), plus the superuser URL HUITZO_DATABASE_RESTORE_URL and Redis for the serving check (Restore) |
Networks, all but huitzo-network internal (no route out):
| Network | Members |
|---|---|
db-network |
postgres, migrate, backend, worker and beat |
backup-db |
postgres, backup and backup-restore only, so no worker can even resolve backup. postgres is on these two networks only: dashboard, caddy and redis cannot reach it |
backup-ctl |
backup, backend and dashboard only: the backend sends requests to backup:8081, and the dashboard's proxy streams downloads from it. Never the workers |
backup-redis |
redis and backup-restore only: the restore's serving check reads beat's heartbeat |
huitzo-network |
Everything else; neither backup service is a member |
The workers and beat get no backup setting, volume or network: pack code runs in the workers, so nothing there may read a backup or its credentials.
On each run without --no-up (step 11, after the dashboard; with
--backups-only, right after migrate), install.sh:
- Runs
huitzo-db db-backup-rolein a one-offmigratecontainer (roles). - Generates the Ed25519 manifest signing key into the
huitzo-backup-keysvolume once, in a throwawaybackupcontainer with no network (mode0400, UID10950), and prints its public key: record that off the host. A key that exists is never replaced. - Starts
backupand waits for it to be healthy.
Downloads from /admin/backups are on in this bundle
(Schedule and retention). The dashboard's proxy serves
them from an internal location that only a backend download response can
reach.
What a complete backup holds¶
| Volume or file | Holds | Captured by |
|---|---|---|
postgres-data |
Users, runs, audit records, settings, the deployment ID | The Hub's database backups (encrypted, signed). A file copy of a running database is not consistent |
huitzo-backups |
The encrypted database backups themselves | Copy them off the host (below) |
huitzo-backup-keys |
The key the backup service signs manifests with |
Not needed to restore: record its public key off the host instead (Encryption keys) |
huitzo-files |
Uploaded files | backup.sh archive |
huitzo-packs |
Installed Intelligence Packs | backup.sh archive |
huitzo-dashboards |
Installed dashboards | backup.sh archive |
huitzo-license-data |
The deployment root key that CLIs pin, and the volume copy of the deployment ID | backup.sh archive |
redis-data |
Queued work (optional) | backup.sh archive, after a Redis SAVE |
.env |
Every secret, including HUITZO_SECRETS_ENCRYPTION_KEY |
backup.sh copy |
license/license.json |
The license file | backup.sh copy |
caddy-data |
HTTPS profile only: the TLS key runners pin | backup.sh archive, when the volume exists |
hub-pin.env |
HTTPS profile only: the recorded runner pin | backup.sh copy, when present |
certs/cert.pem, certs/key.pem |
byo certificate only: the key runners pin |
backup.sh copy, when present |
Without .env, encrypted data such as stored API keys cannot be recovered.
The files and volumes stay with backup.sh until the Hub takes them over in a
later release.
Schedule and retention¶
On /admin/backups a platform admin turns the schedule on (hourly by default)
and sets how many scheduled, daily and pre-upgrade backups to keep. The daily
backup is the first verified scheduled backup of each UTC day. .env sets the
floors no admin can go below (HUITZO_BACKUP_RETENTION_MIN_SCHEDULED,
HUITZO_BACKUP_RETENTION_MIN_DAILY, HUITZO_BACKUP_RETENTION_MIN_PREDEPLOY),
and the newest verified backup is never deleted. A deleted backup stays
recoverable in the volume's .trash/ for 72 hours.
Downloads from the Hub are on in this bundle
(HUITZO_BACKUP_DOWNLOAD_ENABLED=true); set it to false in .env to turn
them off. A download is the encrypted file, <id>.dump.age.
backup.sh¶
install.sh places backup.sh in the install directory:
# pseudocode: operator commands; adjust paths and values
/opt/huitzo/backup.sh # database backup + files, to ~/huitzo-backups
/opt/huitzo/backup.sh --output /mnt/backups # another destination
/opt/huitzo/backup.sh --db-only # a Hub database backup now, nothing else
/opt/huitzo/backup.sh --files-only # volumes and files, no database
/srv/huitzo/backup.sh --install-dir /srv/huitzo # a non-default --prefix
--db-only asks the backup service for a backup now, so the result is
encrypted, signed and listed in the Hub like any other; it writes no
database file of its own. The full run does the same and then archives the
files and volumes; when the database backup fails (for example while
HUITZO_BACKUP_AGE_RECIPIENTS is empty) it still archives the files, then
exits non-zero. --db-only needs no output directory and exits non-zero on a
failed backup. backup.sh targets the huitzo-customer compose project
in the install directory (default /opt/huitzo), creates every file
owner-only (mode 0600), and writes a SHA-256 checksum per file. The files
archive holds .env and keys in the clear: store it encrypted.
Copying backups off the host¶
Backups live on this host, so losing the host or its disk loses them. Copy
huitzo-backups (the .dump.age, .manifest.json and .chunks files of each backup) to another
machine or a NAS on a schedule. This is your own job: the Hub does not copy
backups anywhere in this release.
For a NAS:
- Encryption first. Copy only the encrypted
.dump.agefiles with their.manifest.jsonand.chunksfiles, never abackup.shfiles archive in the clear, and never thereports/directory. - Make the copy hard to erase. Prefer a copy the NAS pulls from this host, or a write-once target (NAS snapshots, S3 Object Lock), so that someone who takes over this host cannot delete the copies as well.
- A NAS copy is trusted for storage only. Restoring from it still checks the signature and every chunk of the backup, so a file swapped on the NAS is refused.
- NFS: export only to this host's IP;
all_squashwithanonuid/anongidset to a dedicated account; prefer NFSv4 with Kerberos (sec=krb5p). - SMB: SMB3 with encryption required.
- Mount with
nofail, so a missing NAS never stops the host from booting. - Copy, don't point the
backupservice's volume at the NAS: its locking and free-space checks assume a local filesystem.
Restore¶
Test a restore on a separate host before go-live. The database restore is
huitzo-db db-restore, in two steps: --prepare checks the backup's
signature and checksum, the deployment ID, the PostgreSQL and schema versions,
and that the app is stopped, then takes a backup of the current database
(skipped for a database that has never booted, such as one prepared with
--backups-only);
--apply reads the decrypted backup on its input, restores it into a scratch
database, checks it, and swaps it in. Decrypt wherever the private key is and
pipe the result in over ssh, so the key never has to be on the host. Before
pg_restore reads any part of the decrypted stream, --apply checks it
against the chunk checksums the backup service signed, so a backup file
swapped somewhere along the way is refused.
# pseudocode: restore onto a fresh host from a backup.sh set + a Hub database backup
1. mkdir /opt/huitzo; copy the backed-up .env, hub-pin.env and certs/key.pem,
certs/cert.pem (when present) into it, dropping backup.sh's
huitzo_file_<name>_<timestamp> naming (huitzo_file_certs_key.pem_* becomes
certs/key.pem); keep .env and key.pem at mode 0600. Add the old host's
signer public key to HUITZO_BACKUP_TRUSTED_SIGNERS in .env. Download the
release assets of the same or a newer version and run
./install.sh --license <backed-up license.json> --pull-credentials <file> --no-up:
it places the compose file, Caddyfile and backup.sh, keeps the restored
.env, places the license with mode 0644, verifies and pulls the images, and
starts nothing (so no new deployment ID is minted).
2. For each huitzo_vol_<name>_*.tar.gz:
docker volume create huitzo-customer_<name>
docker run --rm -i -v huitzo-customer_<name>:/data <postgres image> tar xzf - -C /data
< huitzo_vol_<name>_*.tar.gz
Not huitzo-backups: the backup service creates that volume in step 3.
3. ./install.sh with the step 1 flags, --backups-only in place of --no-up:
migrate brings the empty database to head (it creates huitzo_app and
huitzo_auth, which the dump grants to), then the backup roles with their
.env passwords, the signing key and the backup service start, in a
huitzo-backups volume the backup service owns (UID 10950, 0700); the
backend never does, so no deployment ID is minted. From the install
directory, copy the backup's three files in as the backup service's user,
so they are its own (a root-owned copy is refused):
tar cf - <id>.dump.age <id>.manifest.json <id>.chunks
| docker compose run --rm --no-deps -T backup
sh -c 'cd /var/lib/huitzo/backups && tar xf - && chmod 0400 <id>.*'
docker compose run --rm backup-restore huitzo-db db-restore <id> --prepare
(backup-restore starts redis for its serving check)
Then, from the machine that holds the private key:
ssh <host> 'docker compose … exec -T backup huitzo-db db-backup-cat <id>'
| age -d -i <identity>
| ssh <host> 'docker compose … run --rm -T backup-restore huitzo-db db-restore <id> --apply <approval>'
4. Run install.sh with the same flags: it keeps the restored .env, migrate is a
no-op at head and sets the huitzo_app password, and the first-admin step is
skipped because users exist.
After the restore every user signs in again: --apply deletes all sessions,
and re-applies revocations made after the backup was taken when the old
database is still there to read them from. On the same host, only the two
db-restore commands of step 3 are needed: stop worker, beat and
backend, run them, then docker compose up -d.
Deployment ID on restore¶
The deployment ID is stored twice: in the database (authoritative) and in the
huitzo-license-data volume
(ADR-008 D3).
- Both copies restored (a new host included): the ID is unchanged and the license stays valid. Restoring onto new hardware needs no reissue.
- One copy restored: the backend restores the other from it at boot and logs a warning.
- Copies that disagree (for example the database of one install and the
volume of another): the backend refuses to boot and logs both IDs and the
recovery steps: restore both copies from the same backup, or remove the
wrong
deployment-idfile from the volume so it is rewritten from the database. - A fresh install gets a new ID, so it needs a reissued license
(
reason: reissueon the request). - Two running copies of one install break the license agreement's one-deployment rule; that is a contract matter, not a technical check.
- A backup from another deployment:
huitzo-db db-restorecompares the backup's deployment ID with the target database's and refuses a mismatch unless you pass--foreign-deployment <the backup's ID>and type that ID at the prompt. A database that has never booted has no ID yet, so restoring onto a host prepared with--backups-onlyneeds no flag.
Clean-VM check in CI¶
The clean-vm-install workflow repeats this install on a fresh GitHub-hosted
Ubuntu 24.04 runner. Its candidate images are built on a self-hosted runner and
handed over as a workflow artifact.
A pull request runs it when one of these changes:
deploy/customer/, backup.sh, a backend, worker or dashboard Dockerfile, the
dashboard's nginx or docker files, or the workflow itself. The full tier
always runs it (CI tiers), and covers
the migrations, the license trust code and the worker tasks. The full tier
runs in the nightly Heavy Gate, on
manual dispatch and on ci:heavy pull requests; the Heavy Gate also runs
before every staging deploy and customer release. It
builds candidate images from
the branch with a CI-only license key that only CI jobs hold, assembles the
release assets with those digests, and checks: the bad licenses (expired, tampered, dev-signed, a
directory, a missing file) stop the install before anything starts; a first run
without a license reaches setup mode, creates the admin with the
setup token, prints the deployment ID that setup/status reports, listens on
127.0.0.1 only and answers 402 outside the setup routes; a license for another
deployment ID is refused by the preflight; a license signed for the VM's own ID
then installs the HTTPS profile; the stack
is healthy with the HTTPS profile (internal certificates and a hosts entry);
port 3000 listens on 127.0.0.1 only; the first admin signs in over HTTPS from
the host's non-loopback address and a reload keeps the session; through Caddy,
10 clients with their own addresses sign in 5 times each within a minute with
no 429, while a 6th attempt from one client gets 429 even with a forged
X-Forwarded-For (per-client buckets); a re-run leaves
.env unchanged; a file the backend writes is readable by the worker; a forced
certificate renewal keeps the runner pin; beat sends the scheduled-dispatch task
within 120 s of up and a user schedule due the next minute creates exactly one
execution (Celery beat); an expired-past-grace license makes the license gate
refuse the next dispatch tick, so a schedule due afterwards is never claimed and creates none; no container restarts;
docker compose restart keeps the data; the backup service is healthy, a
worker cannot resolve backup, and the dashboard cannot resolve postgres; and backup.sh (with a throwaway age
recipient whose identity is discarded) takes a Hub database backup that
db-backup-verify accepts, and captures every volume plus .env and
license/license.json. It does not cover Let's Encrypt, your own
certificate, --external-tls or registry login, and its cosign is a stand-in
that records the identity install.sh asks for (the branch candidates are
unsigned). The release run of customer-release.yml covers the published
assets: the pre-run check, registry login with a read-only
account, real cosign verification of the released digests, setup mode and the
release version on /health
(CI/CD).
After install¶
- License status:
GET /api/v1/license/status(authenticated) orhuitzo license status. - Renewal:
/opt/huitzo/install.sh --license new-license.json --no-upruns the license preflight on the new file and, if it is usable, writes it overlicense/license.jsonin place. A renewal or reissue always names this deployment's ID. Running services re-read it on their next check;POST /api/v1/license/reloadapplies it at once (no restart). See License Hard-Gate. - Token expiry: the GHCR token expires with the license plus its grace, or after one year, whichever is sooner (ADR-045). Huitzo re-issues it alongside license renewal.
Issuance of these files is a Huitzo-side operation: see the License Issuance Runbook.
Support and contacts¶
| Need | Contact |
|---|---|
| License requests and renewals | partners.huitzo.ai (partner administrators) |
| Install questions, licensing questions, anything not covered here | Your Huitzo partner contact, or [email protected] |
| Legal notices | [email protected] |
| Security vulnerabilities | [email protected] |