Docker Compose Deployment
What you'll accomplish: Run the repo's actual docker-compose.yml, understand its read-only/tmpfs hardening and --version-based healthcheck, provision a carrier licence into the container, and optionally front it with the bundled Caddy reverse proxy.
Quick start
The actual docker-compose.yml at the repo root:
services:
hoziron:
build:
context: .
dockerfile: Dockerfile
container_name: hoziron
restart: unless-stopped
env_file:
- .env
environment:
- HOZIRON_HOME=/data
- HOZIRON_LOG=${HOZIRON_LOG:-info}
- HOZIRON_LOG_FORMAT=${HOZIRON_LOG_FORMAT:-text}
volumes:
- hoziron-data:/data
ports:
- "4200:4200"
- "4210:4210"
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "/usr/local/bin/hoziron-server", "--version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 512M
caddy:
image: caddy:2-alpine
container_name: hoziron-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
hoziron:
condition: service_healthy
profiles:
- proxy
volumes:
hoziron-data:
caddy-data:
caddy-config:
There are exactly two services: hoziron (the unified server) and an optional caddy reverse proxy gated behind the proxy Compose profile — it doesn't start with a plain docker compose up.
Set HOZIRON_LOG_FORMAT=json in .env (or the shell environment) for structured JSON log lines instead of the default text formatter — both stderr and the daily-rotating log file switch together.
Before starting, place a .env with your provider keys and a licence.json — see Carrier licence below — then:
docker compose up -d
docker compose ps # hoziron should show "healthy" after start_period
Why the healthcheck runs --version, not an HTTP probe
The runtime image is gcr.io/distroless/cc-debian12:nonroot — it has no shell, no curl, no wget. hoziron-server --version is the only in-container command Compose can exec to confirm the process is alive and not crash-looping; it doesn't confirm the HTTP listeners are actually accepting connections. For real HTTP health probing (what you want for a load balancer or orchestrator), hit http://<host>:4200/__server/health from outside the container.
Read-only root filesystem
read_only: true plus a tmpfs mount at /tmp means the container can't write anywhere except /data (the named volume) and /tmp (ephemeral, wiped on restart). This matches the distroless image's security posture — non-root UID 65532, no writable system paths. If you add config or scripts that expect to write outside /data, they'll fail; put that state under /data instead.
Carrier licence
Every surface shares one HozironPlatform, and platform init hard-fails without a valid licence at $HOZIRON_HOME/licence.json — which is /data/licence.json inside this container, since HOZIRON_HOME=/data. There's no bypass. docker compose up -d hoziron will CrashLoopBackOff-equivalent (Compose just keeps restarting it, per restart: unless-stopped) until the licence exists — that's expected.
The runtime image bundles both hoziron-server and the hoziron-cli client, so docker compose exec hoziron hoziron-cli licence status works directly against the running container — useful since the base image is distroless (no shell) and you can't install anything into it.
Licence trust is bound to your carrier ID, not to any machine or container identity, so there's no per-container resolution step needed before requesting the licence — send your carrier ID/name to Hoziron (see Bare metal § Carrier licence). Copy the resulting licence.json into the named volume:
docker compose up -d hoziron # will keep restarting until the licence exists
docker cp licence.json hoziron:/data/licence.json
docker compose restart hoziron
Verify the process is alive (this only confirms hoziron-server runs — not that the licence loaded):
docker compose exec hoziron hoziron-server --version
To confirm the licence itself loaded and is valid, check the API from outside the container (no CLI needed):
curl http://localhost:4200/licence/status # requires an auth header once auth.mode != "disabled"
Configuration
Mount a config.toml into /data (it's inside the read-write named volume, not a bind mount over the read-only root, since /data is the one writable path):
docker cp config.toml hoziron:/data/config.toml
docker compose restart hoziron
A minimal config.toml for this compose file:
[surfaces.api]
enabled = true
listen = "0.0.0.0:4200"
base_path = "/"
[surfaces.registry]
enabled = false
[auth]
mode = "local" # daemon refuses to boot with auth disabled on a non-loopback bind
[[provider]]
id = "anthropic"
driver = "anthropic"
api_key_env = "ANTHROPIC_API_KEY"
[[provider.model]]
id = "claude-sonnet-4-20250514"
[routing_gateway]
cloud_preference = [{ provider = "anthropic", model = "claude-sonnet-4-20250514" }]
ANTHROPIC_API_KEY (or whatever api_key_env you configure per provider) comes from .env, loaded via the compose file's env_file. This compose file's example config above doesn't set [surfaces.api.tls], so it serves plaintext HTTP on :4200 — the caddy profile is the intended way to put HTTPS in front of it in this setup. In-process TLS termination ([surfaces.api.tls], cert/key paths mounted under /data) is also genuinely supported if you'd rather terminate TLS inside the container instead of via Caddy — see TLS and networking.
Fronting with Caddy (the proxy profile)
The repo ships a Caddyfile at the root:
hoziron.example.com {
reverse_proxy hoziron:4200
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output stdout
format json
}
}
Replace hoziron.example.com with your real domain, then start with the profile enabled — Caddy automatically provisions and renews a Let's Encrypt certificate for that domain, so the host needs inbound 80/443 reachable from the internet (or use Caddy's internal CA for a private network):
docker compose --profile proxy up -d
Caddy only starts once hoziron reports healthy (depends_on: condition: service_healthy), which — per the healthcheck above — means the process responded to --version, not that the API is necessarily serving traffic yet.
Connecting to local models
If Ollama runs on the Docker host:
services:
hoziron:
extra_hosts:
- "host.docker.internal:host-gateway"
And in config.toml:
[[provider]]
id = "ollama"
driver = "ollama"
url = "http://host.docker.internal:11434/v1"
[[provider.model]]
id = "llama3.2"
Start Ollama with OLLAMA_HOST=0.0.0.0 ollama serve so it accepts connections from outside its own host network namespace.
Verify
docker compose up -d
docker compose ps # "healthy" after start_period (10s)
curl http://localhost:4200/__server/health # unified health, always on
curl http://localhost:4200/health # API-surface health (agents, providers, memory)
curl http://localhost:4200/metrics # Prometheus exposition, on whenever the API surface is active
Next steps
Related: