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/devposture — 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 realagent-idpdelegation authority.
What the stack includes
Section titled “What the stack includes”platform/deploy/compose/ is a complete, non-Kubernetes stack:
| Service | Image | Role | Host port |
|---|---|---|---|
| control-plane | built from control-plane/Dockerfile (distroless/static) | The decision point. :9191 decision (/authz), :8181 management (registry API, /metrics, /readyz). | 9191, 8181 |
| agent-idp | built from agent-idp/Dockerfile (context = repo root) | did:web issuer + human-approved time-boxed delegation authority. Answers /v1/delegations/check. | 8090 (internal) |
| model-broker | built from model-broker/Dockerfile (LiteLLM) | Holds the provider key; meters per-agent tokens back to the control-plane. | 8080 |
| postgres | postgres:16-alpine | Durable registry (db palonexus) and agent-idp store (db agentidp). | 5432 (internal) |
| lgtm | grafana/otel-lgtm:0.8.1 | Grafana + Prometheus + Tempo + Loki + OpenTelemetry (OTel) collector in one container. | 3000 (Grafana), 4317/4318 (OTLP) |
| register | curlimages/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. | — |
Topology
Section titled “Topology”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.
How ext_authz is represented (read this)
Section titled “How ext_authz is represented (read this)”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.
Bring it up
Section titled “Bring it up”From platform/deploy/compose/:
cp .env.example .env # defaults work; set OPENAI_API_KEY for real model callsdocker compose up --build -d # builds 3 local images, pulls postgres + lgtmFirst build takes a few minutes (Go build + Python deps + the LiteLLM image).
Subsequent ups are instant. Check the stack:
docker compose psNAME SERVICE STATUSpalonexus-agent-idp-1 agent-idp Up (healthy)palonexus-control-plane-1 control-plane Uppalonexus-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, orwgetto self-probe with. Its readiness is proven externally instead: the one-shotregisterservice polls/readyzbefore it registers (so a cleanExited (0)means the control-plane answered), and./smoke.shasserts/authz. Probe it directly athttp://localhost:8181/readyzandhttp://localhost:9191/healthz.
Smoke test
Section titled “Smoke test”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:
./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:
# 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 # 401echo (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.
Environment
Section titled “Environment”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:
| Variable | Default | Consumed by | Purpose |
|---|---|---|---|
POSTGRES_USER | palonexus | postgres, control-plane, agent-idp | DB user for both databases. |
POSTGRES_PASSWORD | palonexus | postgres, control-plane, agent-idp | DB password. |
OPENAI_API_KEY | sk-dummy-replace-me | model-broker | Provider key — the one place it lives. Dummy lets the stack start; real key needed for model.invoke. |
LITELLM_MASTER_KEY | (empty) | model-broker | Optional LiteLLM admin / virtual-key surface. |
AGENT_IDENTITY_MODE | header | control-plane | header 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:
| Variable | Value in compose | Notes |
|---|---|---|
DECISION_ADDR / MGMT_ADDR | :9191 / :8181 | The two control-plane listeners. |
REGISTRY_BACKEND / REGISTRY_DB_URL | postgres / postgres://…@postgres:5432/palonexus?sslmode=disable | Durable registry. sslmode=disable because the local Postgres serves no TLS (CloudNativePG, CNPG, does in k8s). |
IDP_STORE_BACKEND / IDP_DB_URL | postgres / postgresql://…@postgres:5432/agentidp?sslmode=disable | Durable agent-idp store. |
AGENT_IDP_URL | http://agent-idp:8090 | Wires the delegation authority — this is what makes the 401 path real rather than a blanket fail-closed deny. |
OTEL_EXPORTER_OTLP_ENDPOINT | http://lgtm:4317 | agent-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. |
Observability
Section titled “Observability”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.
Turning on OIDC (workforce identity)
Section titled “Turning on OIDC (workforce identity)”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>/oidcOIDC_JWKS_URL: https://<your-oidc-issuer>/oidc/jwksOIDC_AUDIENCE: palonexusdocker compose up -d control-planeWith 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).
Persistence, reset, and teardown
Section titled “Persistence, reset, and teardown”Registry + agent-idp state lives in the pgdata volume; Grafana/Prometheus/Tempo
history in lgtmdata. Both survive docker compose restart and down (without
-v).
docker compose stop # pause, keep datadocker compose up -d # resumedocker compose down # remove containers, KEEP volumesdocker compose down -v # remove containers AND volumes (full reset)When to graduate to Kubernetes
Section titled “When to graduate to Kubernetes”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.