Skip to content
PaloNexus
Request access Request

Docker Compose

Kubernetes is the right home for PaloNexus in production, but evaluating it should not require standing up a cluster, installing Gateway API Custom Resource Definitions (CRDs), and learning Envoy Gateway. The Docker Compose stack brings the full control layer up on a laptop with one command, builds every image locally from the in-repo Dockerfiles, and ships a smoke test that proves the core decision contract: allow (200) / deny (403) / needs-approval (401).

Status: built and verified locally on arm64. The stack mirrors the overlays/dev posture — OpenID Connect (OIDC) sign-in is off (anonymous passthrough) but the registry-driven public-vs-private policy and the regulated-egress (needs-approval / task-based access control, TBAC) path are fully enforced, gated on the real agent-idp delegation authority.

platform/deploy/compose/ is a complete, non-Kubernetes stack:

ServiceImageRoleHost port
control-planebuilt from control-plane/Dockerfile (distroless/static)The decision point. :9191 decision (/authz), :8181 management (registry API, /metrics, /readyz).9191, 8181
agent-idpbuilt from agent-idp/Dockerfile (context = repo root)did:web issuer + human-approved time-boxed delegation authority. Answers /v1/delegations/check.8090 (internal)
model-brokerbuilt from model-broker/Dockerfile (LiteLLM)Holds the provider key; meters per-agent tokens back to the control-plane.8080
postgrespostgres:16-alpineDurable registry (db palonexus) and agent-idp store (db agentidp).5432 (internal)
lgtmgrafana/otel-lgtm:0.8.1Grafana + Prometheus + Tempo + Loki + OpenTelemetry (OTel) collector in one container.3000 (Grafana), 4317/4318 (OTLP)
registercurlimages/curl (one-shot)Waits for the control-plane, then registers the sample agent + its regulated tool so the needs-approval path is exercisable. Exits 0.

The diagram below maps the compose stack: the one-shot register seeds the registry through the control-plane management plane (:8181); smoke.sh exercises the decision plane (:9191) exactly as Envoy’s external-authorization (ext_authz) filter would; the control-plane calls agent-idp for the regulated-egress delegation check; Postgres holds both durable stores; and agent-idp ships Decentralized Identifier / Verifiable Credential (DID/VC) spans to the bundled LGTM observability backend (Loki, Grafana, Tempo, Mimir). The decision is read from the HTTP status: 200 allow, 403 deny, 401 needs-approval.

flowchart TD
  smoke[smoke.sh - ext_authz-equivalent caller] -->|X-Palonexus-Service / -Actor headers| dec
  subgraph cp["control-plane"]
    dec[":9191 decision /authz"]
    mgmt[":8181 management - registry API + /metrics"]
  end
  register[register one-shot - seeds agent + regulated tool] -->|POST /v1/registry/services| mgmt
  dec -->|regulated egress: /v1/delegations/check| idp[agent-idp :8090]
  mgmt --> pg[(Postgres - db palonexus + db agentidp)]
  dec --> pg
  idp --> pg
  broker[model-broker :8080 - LiteLLM] -->|meter per-agent tokens| mgmt
  idp -->|DID/VC spans OTLP| lgtm[LGTM + OTel :3000]
  dec -->|allow 200 / deny 403 / needs-approval 401| smoke

The Docker Compose topology: one decision point with two listeners, the delegation authority it calls for regulated egress, one Postgres serving two databases, and the smoke caller standing in for Envoy ext_authz.

In Kubernetes the enforcement point is Envoy Gateway’s SecurityPolicy.extAuth, which calls the control-plane /authz on :9191 for every request and, on a 200, forwards the request to the upstream. Compose has no Envoy Gateway — a faithful L7 data-plane proxy is out of scope for a laptop evaluation stack.

Instead, the control-plane /authz decision endpoint is exposed directly on localhost:9191, and the smoke test calls it exactly the way Envoy’s ext_authz filter would: by setting the X-Palonexus-Service (ingress target) and X-Palonexus-Actor / X-Palonexus-Action / X-Palonexus-Resource (agent egress) headers and reading the HTTP status as the verdict. This is the same handler, the same headers, and the same verdicts that run in production — what compose omits is only the proxy hop that forwards an allowed request onward. For end-to-end request forwarding through Envoy, use the Kustomize self-hosting path.

From platform/deploy/compose/:

Terminal window
cp .env.example .env # defaults work; set OPENAI_API_KEY for real model calls
docker compose up --build -d # builds 3 local images, pulls postgres + lgtm

First build takes a few minutes (Go build + Python deps + the LiteLLM image). Subsequent ups are instant. Check the stack:

Terminal window
docker compose ps
NAME SERVICE STATUS
palonexus-agent-idp-1 agent-idp Up (healthy)
palonexus-control-plane-1 control-plane Up
palonexus-lgtm-1 lgtm Up (healthy)
palonexus-model-broker-1 model-broker Up (healthy)
palonexus-postgres-1 postgres Up (healthy)
palonexus-register-1 register Exited (0)

Why control-plane has no health column: its image is distroless/static — no shell, curl, or wget to self-probe with. Its readiness is proven externally instead: the one-shot register service polls /readyz before it registers (so a clean Exited (0) means the control-plane answered), and ./smoke.sh asserts /authz. Probe it directly at http://localhost:8181/readyz and http://localhost:9191/healthz.

The acceptance criterion for this stack is the decision trio. smoke.sh runs it against the live control-plane and exits non-zero on any wrong verdict:

Terminal window
./smoke.sh
== PaloNexus compose smoke (http://localhost:9191/authz) ==
PASS allow (public 'echo'): 200 (want 200)
PASS deny (private 'orders'): 403 (want 403)
PASS needs-approval (regulated egress): 401 (want 401) [X-Palonexus-Needs-Approval: true]
== 3 passed, 0 failed ==

What each line proves, and the exact call:

Terminal window
# ALLOW 200 — public service, anonymous: the registry marks 'echo' public.
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'X-Palonexus-Service: echo' http://localhost:9191/authz # 200
# DENY 403 — private service, anonymous: 'orders' requires auth, none presented.
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'X-Palonexus-Service: orders' http://localhost:9191/authz # 403
# NEEDS-APPROVAL 401 — agent egress to a regulated tool with no human-approved
# delegation. The control-plane asks agent-idp /v1/delegations/check, gets a
# deny, and returns 401 + X-Palonexus-Needs-Approval so the agent's middleware
# interrupts for approval rather than failing hard.
curl -s -D - -o /dev/null \
-H 'X-Palonexus-Actor: northstar-devops-incident-agent' \
-H 'X-Palonexus-Service: runbooks-operator' \
-H 'X-Palonexus-Action: runbooks:read' \
-H 'X-Palonexus-Resource: runbooks-api:/runbooks/db-failover' \
http://localhost:9191/authz # 401

echo (public) and orders (private) are self-seeded by the control-plane at startup. The sample agent (northstar-devops-incident-agent, with runbooks-operator on its egress allowlist) and the regulated tool (runbooks-operator, dataClass: regulated) are registered by the one-shot register service via the management API — both persisted in Postgres, so they survive restarts.

The stack comes up healthy with every value at its default; only a real OPENAI_API_KEY is needed for the model-broker to make live completions. Minimum-viable .env:

VariableDefaultConsumed byPurpose
POSTGRES_USERpalonexuspostgres, control-plane, agent-idpDB user for both databases.
POSTGRES_PASSWORDpalonexuspostgres, control-plane, agent-idpDB password.
OPENAI_API_KEYsk-dummy-replace-memodel-brokerProvider key — the one place it lives. Dummy lets the stack start; real key needed for model.invoke.
LITELLM_MASTER_KEY(empty)model-brokerOptional LiteLLM admin / virtual-key surface.
AGENT_IDENTITY_MODEheadercontrol-planeheader trusts X-Palonexus-Actor (verifiable presentation, VP, optional; demo); vc requires a verified VP (production).

The compose file wires the rest to match the control-plane env contract and the Persistence backends:

VariableValue in composeNotes
DECISION_ADDR / MGMT_ADDR:9191 / :8181The two control-plane listeners.
REGISTRY_BACKEND / REGISTRY_DB_URLpostgres / postgres://…@postgres:5432/palonexus?sslmode=disableDurable registry. sslmode=disable because the local Postgres serves no TLS (CloudNativePG, CNPG, does in k8s).
IDP_STORE_BACKEND / IDP_DB_URLpostgres / postgresql://…@postgres:5432/agentidp?sslmode=disableDurable agent-idp store.
AGENT_IDP_URLhttp://agent-idp:8090Wires the delegation authority — this is what makes the 401 path real rather than a blanket fail-closed deny.
OTEL_EXPORTER_OTLP_ENDPOINThttp://lgtm:4317agent-idp ships DID/VC spans to LGTM (degrades to stdout if absent).
OPA_URL(unset)Inline policy only in compose — no Open Policy Agent (OPA) container. The org-wide Rego veto is a k8s add-on.
OIDC_ISSUER / OIDC_JWKS_URL / OIDC_AUDIENCE(unset)Anonymous passthrough (dev posture). See below to turn workforce identity on.

Grafana is at http://localhost:3000 (anonymous Viewer). agent-idp exports DID/VC delegation spans over OTLP to the bundled collector; explore them in Tempo. The control-plane exposes Prometheus metrics at http://localhost:8181/metrics (decision counts by allow/deny/rule, latency) — point a scrape at it or query ad hoc. See Observability for what good looks like.

The default stack is anonymous-passthrough, identical to overlays/dev. To enforce real workforce identity, set OIDC_ISSUER / OIDC_JWKS_URL (plus OIDC_AUDIENCE) on the control-plane service to point at the organization’s Logto tenant (the supported IdP), then recreate it. The core is IdP-agnostic by architecture — any standard OIDC issuer (Okta, Microsoft Entra ID, Auth0, Keycloak, Dex, …) verifies through the same three env vars:

# docker-compose.yml → services.control-plane.environment
# (point these at the Logto tenant's OIDC endpoints)
OIDC_ISSUER: https://<your-oidc-issuer>/oidc
OIDC_JWKS_URL: https://<your-oidc-issuer>/oidc/jwks
OIDC_AUDIENCE: palonexus
Terminal window
docker compose up -d control-plane

With OIDC on, the deny smoke line returns 401 (invalid/absent credential) instead of 403, and authenticated callers carrying the right scope are allowed — the registry still decides public-vs-private. No IdP is bundled in this stack — point OIDC_ISSUER/OIDC_JWKS_URL at an existing issuer. The optional seed-logto seeder loads a sample identity model into a Logto tenant for evaluation and testing (Logto needs its own Postgres + migrations and is heavier than an evaluation laptop warrants); run it alongside to exercise the flows with sample workforce identities — or wire the organization’s IdP directly (see IdP Support Model).

Registry + agent-idp state lives in the pgdata volume; Grafana/Prometheus/Tempo history in lgtmdata. Both survive docker compose restart and down (without -v).

Terminal window
docker compose stop # pause, keep data
docker compose up -d # resume
docker compose down # remove containers, KEEP volumes
docker compose down -v # remove containers AND volumes (full reset)

Compose is for evaluation and local development. Move to the Kustomize self-hosting path for: real Envoy Gateway request forwarding (not just the decision), the OPA org-wide veto, network-enforced egress (proxy-only NetworkPolicies + the admission webhook), mutual TLS (mTLS) on the decision plane, and HA. Every env var is identical across both — only the orchestration changes.