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: