Skip to main content
Some organizations cannot send prompt metadata to a vendor-run service — a defense contractor, a hospital group, a bank’s model-risk function. For them, governance that requires a SaaS control plane is governance they cannot buy. So the control plane runs in your network. Same image, same schema, same governance loop as the hosted product. Postgres and Redis are the only dependencies, and neither the SDK nor the backend calls out to us.

Evaluate it in fifteen minutes

Then open http://localhost:8008. bootstrap is interactive by default and asks for an email, a name, an organization, and a password — press enter at the password prompt and it generates a strong one and prints it once.
bootstrap needs no mail server. Every other route into the platform sends something — a verification code at signup, a link in an invitation — which is right for the hosted product and wrong for a machine you brought up ten minutes ago. Without this command a fresh deployment is running and unreachable.

The four settings that actually matter

Everything else has a working default. These four do not, and three of them fail quietly. generate-secrets.sh produces the two JWT secrets, the field encryption key, and a Postgres password, each from a cryptographically secure source. Do not reuse a value across two deployments, and do not copy one out of a README — a signing key someone else has read is a signing key that mints sessions for any user in your deployment.
Set FIELD_ENCRYPTION_KEY before you store the first provider credential. There is no recovery path afterward: the plaintext was never kept anywhere, so a rotated JWT_SECRET_KEY turns every stored credential into unreadable bytes.

Ask the deployment whether it is healthy

Thirteen checks, grouped, each one either silent or explaining what you lose and what to do:
It exits non-zero when something is genuinely broken, so it composes with a CI gate or a deploy step. Warnings alone are not a failure — an air-gapped deployment correctly has several. Add --strict if you want them to be, and --json for monitoring. The same checks run at every boot and log a one-line summary, so a deployment that drifts into a degraded state says so in its own logs rather than waiting to be asked.

What works with no internet access

The governance loop is local. Air-gapped, you keep:
  • Every deterministic rule: pii_scan, deny_regex, allow_model, max_prompt_chars, injection_scan, the tool and MCP rules, rate and budget limits.
  • PII detection and masking, including the NER model — it ships baked into the image rather than downloading at startup.
  • The full audit trail, the dashboard, SSO, SCIM, and evidence export.
You lose the parts that are a model call by definition:
  • semantic_guard rules and the LLM-backed anomaly judge, unless you point LLM_BASE_URL at a model you host yourself. Any OpenAI- compatible endpoint works — vLLM, Ollama, a private Azure OpenAI deployment.
  • SaaS connectors (Google Workspace, Microsoft 365, Okta, Slack), which need to reach those vendors by construction.
  • Outbound email, unless you have an internal SMTP relay. In-app notifications still work.

Sending governance into your own tracing

Egis knows something your tracing does not — that a call was refused, or that four card numbers were masked out of it — and by default the only place to see it is the Egis dashboard. Set an OTLP endpoint and every governed call also becomes a span in your own stack:
Spans follow the OpenTelemetry GenAI semantic conventions, so they render as model calls in Datadog, Honeycomb, Grafana Tempo, or anything else that speaks OTLP — rather than as an opaque vendor event nobody has a view for. The part that makes this worth turning on is the parenting. The SDK stamps the ambient W3C trace and span id onto every event, so the governance span becomes a child of the span the call actually happened inside. A blocked call shows up inline in the trace your engineer was already looking at, between the two spans it sat between, marked as an error with the policy’s reason on it. Today that same engineer sees an unexplained gap.
Nothing on a span carries content. Prompt text, response text, matched values, and sanitized excerpts are all absent by construction — a collector is a third party, and the attributes are counts, verdicts, model names, and durations. The one string that comes close is the rule’s own name, which you wrote.
Install the exporter with the otel extra:
Without it the setting is a no-op and the boot log says so by name, rather than silently doing nothing.

Going to production

The compose file is a reference, not a production topology. Before it carries real traffic:
  • Terminate TLS in front of the service and set PUBLIC_BASE_URL to the https:// origin. Authorization codes and session cookies cross that URL.
  • Use managed Postgres if you have one, and point DATABASE_URL at it. The compose Postgres has no backup story; yours does.
  • Do not scale to zero. The workers are in-process asyncio loops that do real work on a timer — retention sweeps, rollups, intelligence refresh. A platform that suspends an idle container silently stops all of them.
  • Set REDIS_URL before adding a second replica, for the reasons in the table above.
  • Run doctor after every config change. It is cheap and it is the only thing that will tell you about the quiet failures.
Upgrades are docker compose pull && docker compose up -d. The migrate job runs to completion before the service starts, so the control plane never comes up against a schema it does not understand — which otherwise surfaces as a 500 on one page and nowhere else.