Security & TLS

Terminate TLS in front of a self-hosted Future AGI instance, close its plaintext ports, keep its secrets in a manager, and isolate code evals.

Nothing in the stack terminates TLS: the UI and the API serve plain HTTP. In production you put a reverse proxy in front of the stack to handle certificates, point the app at the HTTPS origins, and keep the plaintext ports off the network. This page covers that for Standalone and Distributed, plus where production secrets should live and how code evals are isolated. On Kubernetes, TLS sits on the Gateway API route or Ingress that exposes the Helm chart: see Install for production.

Terminate TLS with a reverse proxy

Run Caddy, nginx, or Traefik on the host in front of the stack. Caddy is the shortest path because it issues and renews Let’s Encrypt certificates on its own.

Run the proxy

Put a Caddyfile next to your compose file, with a host name for each port people or SDKs reach. The full port list is in Requirements.

# Caddyfile
app.example.com     { reverse_proxy 127.0.0.1:3000 }   # UI
api.example.com     { reverse_proxy 127.0.0.1:8000 }   # API
files.example.com   { reverse_proxy 127.0.0.1:9005 }   # object storage
traces.example.com  { reverse_proxy 127.0.0.1:4318 }   # trace collector (OTLP/HTTP)
gateway.example.com { reverse_proxy 127.0.0.1:8090 }   # LLM gateway

Object storage and the collector listen on 127.0.0.1 only, so browsers and SDKs on other machines reach them only through the proxy. Browsers open every stored file (uploads, exports, audio and image previews) at <MINIO_URL>/<bucket>/<key>: pass the path through unchanged. Leave out the traces host if every SDK runs on the Docker host, and the gateway host if only this host calls the LLM gateway. Applications on other machines then call the gateway at https://gateway.example.com instead of http://<host>:8090, so their API keys no longer cross the network in plain text.

Run Caddy as a service, so TLS survives the end of your SSH session and a reboot. Caddy’s apt and dnf packages install the caddy systemd service, which reads /etc/caddy/Caddyfile:

sudo cp Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy

Every host name has to resolve to this machine before Caddy can complete the Let’s Encrypt challenge.

Point the app at the HTTPS origins

Set the public URLs in .env:

VITE_HOST_API=https://api.example.com
BASE_URL=https://api.example.com
FRONTEND_URL=https://app.example.com
APP_URL=https://app.example.com
MINIO_URL=https://files.example.com
FI_COLLECTOR_PUBLIC_URL=https://traces.example.com

With the production overlay, BASE_URL goes in the root .env and the other five in deploy/.env.production: no compose file passes BASE_URL on, so the overlay’s file cannot set it. See Which file a key goes in.

The UI reads VITE_HOST_API on start and writes it into its config.js, so no rebuild is needed. BASE_URL is the API’s own public URL (SSO callbacks, CSRF origins), APP_URL builds invite and password-reset links, and the setup screen’s SSL/TLS certificate check reads FRONTEND_URL and VITE_HOST_API. What each one breaks when it is wrong, and the matching ALLOWED_HOSTS and CORS_ALLOWED_ORIGINS, are in the Checklist.

Close the plaintext ports

Once the proxy is live, the plaintext ports still answer, and anyone who can reach them bypasses TLS. These listen on every interface:

SetupPorts on all interfaces
StandaloneUI 3000, API 8000, LLM gateway 8090
DistributedUI 3000, API 8000, LLM gateway 8090, model serving 8080; the Temporal UI 8085 and the PeerDB UI 3001 with their profiles

Block them at a firewall so only the proxy on the host reaches them; clients on other machines reach the LLM gateway through the proxy’s gateway host. On Linux, Docker writes its own iptables rules for published ports, so a ufw or firewalld rule on the host does not stop them: filter in Docker’s DOCKER-USER chain or at your cloud provider’s firewall. The data stores, object storage and the collector already listen on 127.0.0.1 only.

Note

FRONTEND_PORT and BACKEND_PORT take a port number only. ./bin/install checks the stack on http://localhost:<port> and an unset APP_URL becomes localhost:<FRONTEND_PORT>, so a value such as 127.0.0.1:3000 breaks both.

Apply and verify

Recreate the containers so the new URLs take:

docker compose up -d
curl -sI https://app.example.com | head -1     # expect HTTP/2 200

With the production overlay, never run a plain docker compose or ./bin/install: they read the wrong files and settings. Run the overlay’s command instead, as in Start Distributed with the production overlay:

docker compose --env-file deploy/.env.production \
  -f docker-compose.distributed.yml -f deploy/docker-compose.production.yml \
  up -d --no-build --wait --wait-timeout 1200

If the browser still calls the old API address, the container that serves the UI kept its old config.js: run docker compose up -d --force-recreate app (Distributed: frontend). Then open the setup screen again: the SSL/TLS certificate row should pass.

Keep secrets out of .env

These files hold the production secrets in plain text:

SetupFileSecrets
Standalone.envSECRET_KEY, PG_PASSWORD, MINIO_ROOT_PASSWORD, AGENTCC_INTERNAL_API_KEY, AGENTCC_ADMIN_TOKEN, CH_PASSWORD, INTEGRATION_ENCRYPTION_KEY, AGENTCC_WEBHOOK_SECRET, REDIS_PASSWORD, PROPERTY_CATALOG_API_PASSWORD, PROPERTY_CATALOG_CONSUMER_PASSWORD
Distributed.envThe same, without REDIS_PASSWORD
Distributed with the production overlaydeploy/.env.productionThe same. deploy/setup.sh fills in all but CH_PASSWORD, AGENTCC_WEBHOOK_SECRET and INTEGRATION_ENCRYPTION_KEY, which you add: see Start Distributed with the production overlay
HelmA Kubernetes Secret the chart creates, or yours through secrets.existingSecretSee Configure

Provider keys you add (OPENAI_API_KEY and the like) and MAILGUN_API_KEY sit in the same file. ./bin/install creates .env readable only by your user; keep it that way.

For anything past a single trial host, keep the source of truth in a dedicated manager instead of a file on disk:

  • AWS Secrets Manager
  • HashiCorp Vault
  • GCP Secret Manager

Replace the published defaults first: the Checklist lists them and what breaks when one changes later. Then inject the values at deploy time. Compose gives a variable exported in your shell precedence over .env when it fills the compose files’ ${VAR} references. deploy/setup.sh is the exception: it clears exported secrets before Compose reads deploy/.env.production, so with the overlay, write the values into that file. Whichever way you inject them, PG_PASSWORD, INTEGRATION_ENCRYPTION_KEY and CH_PASSWORD must keep the values the data was created with.

Isolate the code executor

Custom code evals run code your users write. How it is isolated depends on the setup:

  • Standalone: Python evals run inside the app container as an unprivileged user, with an empty environment, resource limits and a private temporary directory. That user cannot read the app’s data or mounted secrets. On Docker hosts with Linux 6.7 or later and Landlock, evals may connect only to TCP ports 80 and 443 (CODE_EXECUTOR_EGRESS_PORTS); on older kernels they can reach the Temporal dev server, Postgres and ClickHouse over the Docker network. This suits installs where everyone who can write a code eval is trusted with the data. Otherwise set COMPOSE_PROFILES=sandbox in .env and run ./bin/install again: every run then gets its own nsjail jail with a read-only view of the filesystem. The sandbox profile needs privileged containers, and JavaScript evals run only with it.
  • Distributed: code evals always run in the nsjail code-executor, which needs privileged: true. It listens on 127.0.0.1:8060 only and runs any code it is sent, without authentication: never publish that port. CODE_EXECUTOR_LOCAL_FALLBACK=true runs code evals inside the worker, next to the platform’s secrets, when code-executor cannot be reached; use it only on a platform that cannot run code-executor and where everyone who can write a code eval is trusted.
  • Helm: the sandbox is off by default (codeExecutor.enabled), and custom code evals are refused until you turn it on on nodes that allow privileged pods. With networkPolicy.enabled, the chart blocks the sandbox from every private, link-local and metadata address.

nsjail still allows network access, in both the sandbox profile and Distributed. On any install that holds sensitive data, keep code-eval authorship to people you trust with the databases.

Private provider URLs

AGENTCC_ALLOW_PRIVATE_PROVIDER_URLS (default false) lets the providers an organization adds use base URLs on private networks, such as a local Ollama or vLLM server. With it on, anyone who can add a provider can point the gateway and the API at any address on those networks, the databases included. Leave it off unless everyone who can add a provider is trusted. Loopback, link-local and cloud metadata addresses stay refused either way. Setup and examples are in Private and local provider URLs.

Dive deeper

Was this page helpful?

Questions & Discussion