Network egress
Where Elizon may send requests, the policy host allowlist, and how to make a Docker Compose deployment default-deny.
Requests Elizon sends out on behalf of an Agent, a workflow or a Connection are checked twice (the exceptions are listed under Not covered by the allowlist yet):
- The deployment floor. The same for every Organization on the deployment, and configured by the operator. Private, loopback, link-local and metadata addresses and the platform's own service names (
postgres,redis, …) are refused unless the operator opened the private network withEGRESS_ALLOW_PRIVATE_HOSTS=true. The address is checked after DNS and again on every redirect hop. - The policy allowlist. Per Organization, Project, Agent, Team or Workflow: the hosts a call may reach (
egress.allowed_hostsin a policy's rules). A host that is not listed is refused by name, before it is resolved, with the reasonpolicy_egress_denied.
The allowlist decides where a call may go. What a call may carry is a separate rule: data rules on outbound workflow nodes. For policies as a whole, see Policies.
The host allowlist
An entry is one of:
| Entry | Matches |
|---|---|
api.example.com | That host only |
*.example.com | Every subdomain of example.com, at any depth. Not example.com itself (list it as well), and never evil-example.com |
203.0.113.7 | That IPv4 address |
[2001:db8::1] | That IPv6 address (in brackets) |
A URL, a path, a port, a bare * or a wildcard anywhere but the first label is refused when the policy is saved. Matching ignores case and a trailing dot.
An empty list means no allowlist: only the deployment floor applies. When several policies apply (the Organization's, the Project's and the ones attached to the Agent or Workflow), their lists are intersected: a host must be allowed by every one of them. Narrowing a parent's *.example.com to api.example.com is a restriction and is accepted; adding a host the parent does not cover is refused at save. Two lists that share no host leave nothing allowed, never everything.
A policy in monitor mode refuses nothing: a host it would refuse is reached, and the would-be refusal is written to the policy decision log.
Where it is enforced
| Path | Checked |
|---|---|
| Workflow nodes: HTTP Request, FTP / SFTP, Send Email (SMTP), app nodes, MCP Tool, Connection calls | The run's Workflow, Project and Organization policies |
| Email Trigger (IMAP) and its connection test, App Trigger polls, the MCP tool refresh on the canvas | The Workflow's policies |
| Agent tools: MCP, Swagger and app tools | The Agent's policies, also when it runs as a Team member or in a workflow AI Agent node (the Team's or Workflow's list does not apply) |
| Tool setup: MCP connect, OAuth and refresh, Swagger spec import, Connection test, the Package wizard's tool test | The Project's policies |
| Model providers called by ai-workers (OpenAI, Anthropic, Gemini, Mistral, Ollama, Azure, Bedrock, …) | The run's policies (a Team run: the Team's; a workflow: the Workflow's), on every request before it is sent |
| Voice calls: speech-to-text and text-to-speech providers, and the direct Gemini model | The voice Agent's policies, when the call is set up |
| Embedding calls when a knowledge base is indexed or searched (OpenAI, Gemini, Ollama and OpenAI-compatible) | The knowledge base's Project policies (folding in the Organization's), on every request before it is sent |
Every refusal is written to the policy decision log with boundary = egress, rule_family = egress and action = deny. A refused request is never sent: no DNS lookup, no connection. A redirect to an unlisted host is refused at that hop. A voice call whose provider is refused does not start, a run whose model provider is refused ends with policy_denied, and a knowledge base document whose embedding provider is refused shows the reason (policy_egress_denied, the host and the rule) on its status.
Not covered by the allowlist yet
These requests are checked against the deployment floor only, not the policy allowlist:
- OAuth sign-in and token refresh for app Connections (the provider's fixed token endpoints).
- Testing an LLM Configuration or provider key in Settings, and SIP trunk provider (Infobip) account calls.
- Fetches by the knowledge and crawler services.
Making a Docker Compose deployment default-deny
The allowlist is enforced inside Elizon's own services. For defence in depth, a self-hosted deployment can also make the container network itself default-deny, so that a container with no business on the internet has no route to it at all.
The building block is a Docker network declared internal: true: containers attached only to it can reach each other but nothing outside. Elizon's outbound proxy, ssrf-proxy (Squid), stays the private-address floor: it refuses private, loopback and link-local destinations and passes everything else. It does not read policies; the per-Organization allowlist is enforced in-process as described above.
-
Add an override file next to your Compose file, for example
docker-compose.egress.yml. It turns the stack's own network (elizonininfra/docker/docker-compose.community.yml) into an internal one, and adds a second, ordinary network only to the services that must reach the internet or publish a port:networks: elizon: internal: true # no route out for anything attached only to it elizon-egress: {} # an ordinary bridge with a route out services: # The proxy itself, and the services that dial out directly. ssrf-proxy: { networks: [elizon, elizon-egress] } api-gateway: { networks: [elizon, elizon-egress] } infisical: { networks: [elizon, elizon-egress] } # Services that publish a port to the host need a non-internal network for it. web: { networks: [elizon, elizon-egress] } temporal: { networks: [elizon, elizon-egress] } temporal-ui: { networks: [elizon, elizon-egress] }Compose merges a service's
networksacross files, so an override can add a network to a service but cannot take one away. That is why the recipe makes the shared network itself internal instead of listing a new internal network for every service. Every service left out of the override (postgres,redis,minio,ai-workers,ai-workers-celery,marketplace-api, …) keeps onlyelizonand so has no route out;ai-workersstill reaches model providers throughssrf-proxy(itsHTTP_PROXY/HTTPS_PROXYalready point there). If your own Compose file has other services that dial out (a voice worker, a knowledge or crawler service, LiveKit), addelizon-egressto them the same way. -
Start with both files (your Compose file first):
docker compose -f docker-compose.community.yml -f docker-compose.egress.yml up -d. -
Check it. From
ai-workers, a request that bypasses the proxy must fail, and one through it must succeed:docker compose exec ai-workers python -c "import httpx; httpx.get('https://example.com', proxy=None, trust_env=False)" # fails: no route docker compose exec ai-workers python -c "import httpx; print(httpx.get('https://example.com').status_code)" # 200, via ssrf-proxy
Why some services keep a route out:
- api-gateway sends tool, workflow-node and Connection requests itself. Its egress guard resolves each host once and connects to the vetted address, so it does not go through an HTTP proxy; the floor and the policy allowlist are applied in-process.
- voice-agent-worker streams audio to the speech providers and media to LiveKit; WebSocket and media traffic does not fit an HTTP proxy. Its provider hosts are checked against the allowlist when a call is set up.
- infisical reaches its own upstream services when configured to.
- knowledge-service and crawler-service (where deployed) fetch the documents and sites you add to a knowledge base.
- livekit and its egress recorder carry call media.
Squid's configuration (infra/docker/squid/squid.conf) is unchanged by any of this: it remains a private-range denylist, not an allowlist.
Kubernetes
Default-deny for a Kubernetes deployment (NetworkPolicy) is out of scope for this release; it belongs with the Kubernetes deployment work. The policy allowlist applies on every deployment, whatever the network does.