Citadel Docs
Operations

TLS and secure Agent transport

Secure Citadel API, Agent, and Edge Agent communication.

Citadel transports browser sessions, Docker information, logs, configuration, and Agent commands. Use TLS whenever that traffic leaves one trusted machine or private network.

For the complete .env preparation workflow and application-setting reference, see Docker Compose configuration.

Citadel supports three Core transport modes:

ModeUse it forPublic traffic
ReverseProxyRecommended production setupTLS ends at your reverse proxy
DirectProduction without a reverse proxyTLS ends at Citadel Core
DisabledLocal development and isolated testsPlain HTTP

Request signing and Edge Agent enrollment authenticate Citadel components, but they do not encrypt traffic. Keep those protections enabled and add TLS.

TLS for regular Platform Agents and inbound Build Pool Agents is optional. Their default setup authenticates Core requests over HTTP to avoid requiring certificate management on every Docker host. HTTP does not protect Agent responses or traffic confidentiality, so use that default only on a trusted private network or VPN. The Core UI/API and Edge Agent transport still follow the Core transport mode selected below.

Before you start

Choose public addresses for:

  • the Citadel UI and API;
  • the Edge Agent gRPC endpoint.

When Core uses HTTPS through a proxy (ReverseProxy) or serves TLS itself (Direct), both CITADEL_PUBLIC_URL and CITADEL_EDGE_PUBLIC_GRPC_URL must be HTTPS URLs. These modes describe Core's TLS setup; Agent connection types are configured separately. Core validates both URLs at startup, even if the Edge listener is not published. Without Edge Agents, you can set both to the browser HTTPS URL and omit Edge port publishing and the gRPC proxy route. Include each configured hostname in CITADEL_HTTP_ALLOWED_HOSTS. Before using Edge Agents, configure a reachable gRPC endpoint and update the Edge URL to match.

Citadel Core keeps two internal listeners:

8000  UI, API, WebSocket live updates, OIDC, webhooks, and health
8001  Edge Agent bidirectional gRPC

The public addresses may use one hostname when your proxy routes both protocols, or separate ports/hostnames:

https://citadel.example.com
https://citadel.example.com:8444

Certificate DNS names or IP subject alternative names must match the addresses that clients actually use.

Use this mode when you already run Caddy, Traefik, nginx, HAProxy, or another TLS-capable proxy.

Choose the guide that matches where your proxy runs:

SetupGuide
Caddy in Docker, with automatic HTTPSReverse proxy with Caddy
Nginx in Docker, on a private proxy networkNginx in Docker
Nginx installed directly on the Docker hostNginx on the host

Preserve API error responses through the proxy so Citadel can display their details. For nginx, use proxy_intercept_errors off; in the Citadel proxy location. Do not replace API failures with a maintenance page or an HTML fallback.

The topology is:

Browser    -> HTTPS proxy -> Core HTTP/1 port 8000
Edge Agent -> HTTPS proxy -> Core HTTP/2 port 8001

Set the Core environment:

CITADEL_TRANSPORT_MODE=ReverseProxy
CITADEL_PUBLIC_URL=https://citadel.example.com
CITADEL_HTTP_LISTEN_PORT=8000
CITADEL_EDGE_GRPC_LISTEN_PORT=8001

CITADEL_EDGE_PUBLIC_GRPC_URL=https://citadel.example.com

CITADEL_HTTP_TRUSTED_PROXIES=172.30.0.2
CITADEL_HTTP_FORWARDED_LIMIT=1
CITADEL_HTTP_ALLOWED_HOSTS=citadel.example.com;localhost;127.0.0.1

CITADEL_CORE_AGENT_ALLOW_INSECURE=true

Set CITADEL_HTTP_TRUSTED_PROXIES to the stable address of the immediate proxy. Multiple values are comma-separated. Use CITADEL_HTTP_TRUSTED_PROXY_NETWORKS only for a dedicated, isolated proxy subnet whose containers cannot be joined by untrusted workloads. Configure at least one known proxy or known network.

The proxy must:

  • send UI, API, WebSocket, OIDC, webhook, and health traffic to Core port 8000;
  • send Edge Agent gRPC traffic to Core port 8001 using HTTP/2/h2c;
  • support WebSocket upgrades;
  • preserve the public Host/HTTP2 :authority;
  • forward X-Forwarded-For and X-Forwarded-Proto;
  • disable buffering for long-lived gRPC and streaming requests;
  • use idle timeouts longer than long-lived Citadel connections.

Keep Core ports 8000 and 8001 on the private proxy network. If a proxy installed directly on the host requires published ports, bind them to 127.0.0.1 instead of every interface.

The installation's docker-compose.yml publishes the browser/API listener on host loopback port 18000. For a host proxy serving Edge Agents, also include the Edge overlay to publish port 18001. The installer does not include a reverse proxy. A containerized proxy needs a shared private Docker network and upstreams server:8000 and server:8001; its own localhost does not reach Core.

Configure the proxy, trusted proxy addresses, and public URLs before starting Core in ReverseProxy mode. Use the Compose commands in the host Nginx, containerized Nginx, or Caddy guide to include the required overlays.

The Edge Agent service name is:

citadel.edge.v1.EdgeAgentService

Use it when your proxy supports service-based gRPC routing. Consult your proxy's documentation for its exact HTTP/2 upstream syntax.

Direct Core TLS

Use direct mode when Citadel Core should load and serve your certificate. Direct mode serves HTTPS only; it does not open a separate HTTP redirect listener.

Place the certificate and key in ./tls, or set CITADEL_TLS_HOST_DIRECTORY to another directory. The direct-TLS Compose overlay mounts it read-only at /etc/citadel/tls.

This example includes Edge Agent access. Download both overlays into your installation directory (omit compose.edge.yml from downloads and commands if you do not need Edge Agents, but keep an HTTPS CITADEL_EDGE_PUBLIC_GRPC_URL):

curl -fSL https://raw.githubusercontent.com/Citadel-P/Citadel/main/deploy/compose.direct-tls.yml -o compose.direct-tls.yml
curl -fSL https://raw.githubusercontent.com/Citadel-P/Citadel/main/deploy/install/compose.edge.yml -o compose.edge.yml

Set:

CITADEL_BIND_ADDRESS=0.0.0.0
CITADEL_HTTP_PORT=443
CITADEL_EDGE_PORT=8443

CITADEL_TRANSPORT_MODE=Direct
CITADEL_PUBLIC_URL=https://citadel.example.com
CITADEL_INTERNAL_BASE_URL=https://citadel.example.com
CITADEL_HTTP_LISTEN_PORT=8000
CITADEL_EDGE_GRPC_LISTEN_PORT=8001

CITADEL_EDGE_PUBLIC_GRPC_URL=https://citadel.example.com:8443
CITADEL_HTTP_ALLOWED_HOSTS=citadel.example.com;localhost;127.0.0.1

CITADEL_TLS_CERTIFICATE_PATH=/etc/citadel/tls/core-fullchain.pem
CITADEL_TLS_PRIVATE_KEY_PATH=/etc/citadel/tls/core-key.pem

CITADEL_CORE_AGENT_ALLOW_INSECURE=true

From your installation directory, include the direct-TLS overlay to mount the certificates. The base Compose file publishes the browser port and checks listener health; the Edge overlay publishes the separate gRPC port:

docker compose \
  -f docker-compose.yml \
  -f compose.direct-tls.yml \
  -f compose.edge.yml \
  up -d

Include these overlays in later Compose commands too.

The health check uses HTTPS over loopback and skips certificate verification. Its only purpose is to verify that the local Core listener is ready; Core validates the configured certificate during startup.

The PEM certificate file should contain the leaf certificate and required intermediate certificates. The private key must match it.

The same Core certificate serves the API and Edge gRPC listeners. It must be valid for the hosts in both CITADEL_PUBLIC_URL and CITADEL_EDGE_PUBLIC_GRPC_URL.

Script API access

The browser origin (CITADEL_PUBLIC_URL) and Script API origin (CITADEL_INTERNAL_BASE_URL) serve different clients. With a reverse proxy, the internal default is cleartext loopback on the HTTP listener port; allow 127.0.0.1 explicitly in CITADEL_HTTP_ALLOWED_HOSTS. With Direct TLS, that listener accepts HTTPS only, so set CITADEL_INTERNAL_BASE_URL explicitly to https://citadel.example.com as above. Core's container must resolve and reach that endpoint, and the certificate must match its hostname.

Use a publicly trusted certificate with its intermediate chain for this setup. The Script Deno subprocess runs with a cleared, restricted environment: Core's Agent CA settings do not apply, and DENO_CERT and DENO_TLS_CA_STORE are not forwarded to it. The shared process runner may pass SSL_CERT_FILE/SSL_CERT_DIR, but that does not select Deno’s system trust store; Deno defaults to Mozilla roots. Custom/private-CA trust for Script API calls is not currently configurable through this path. Agent CA trust is a separate setting and does not establish Deno trust. Do not bypass certificate or hostname verification.

After installation, run a manual Action as an identity allowed to read Platforms:

const result = await citadel.api.listPlatforms();
console.log("Accessible Platforms:", result.platforms.map(platform => platform.name));

Confirm successful API output in the Action logs, including an empty list when no Platforms are accessible. 400 Invalid host indicates the internal hostname is absent from the allowed-host list. TLS failures require a reachable HTTPS origin with a matching, publicly trusted certificate and chain.

Local development

Use cleartext only when the network is intentionally isolated:

CITADEL_TRANSPORT_MODE=Disabled
CITADEL_PUBLIC_URL=http://localhost:18000
CITADEL_HTTP_LISTEN_PORT=8000
CITADEL_EDGE_GRPC_LISTEN_PORT=8001
CITADEL_EDGE_PUBLIC_GRPC_URL=http://localhost:18001
CITADEL_HTTP_ALLOWED_HOSTS=localhost;host.docker.internal

CITADEL_CORE_AGENT_ALLOW_INSECURE=true

The source-development Compose setup maps both listeners to loopback host ports 18000 and 18001. The published-image installer exposes only 18000 unless you include compose.edge.yml. This gRPC URL is local-only; remote Agents need a reachable, secured address. Citadel logs a warning while this mode is active. Do not expose this setup to an untrusted LAN or the internet.

Secure a regular Agent

Regular Platform Agents and inbound self-managed Build Pool Agents are gRPC servers. They authenticate Core requests over HTTP by default and require no certificate variables. To require encrypted connections instead, set CITADEL_CORE_AGENT_ALLOW_INSECURE=false on Core and configure direct TLS on the Agent's existing port 9000.

Copy <generated-agent-id> from this Platform’s Agent Setup command. Keep the same ID when changing its address or certificates. Example Agent environment:

CITADEL_AGENT_TLS_MODE=Direct
CITADEL_AGENT_PORT=9000
CITADEL_AGENT_TLS_CERTIFICATE_PATH=/etc/citadel/tls/agent-fullchain.pem
CITADEL_AGENT_TLS_PRIVATE_KEY_PATH=/etc/citadel/tls/agent-key.pem
CITADEL_CORE_PUBLIC_KEY=the-public-key-shown-by-citadel
CITADEL_AGENT_ID=<generated-agent-id>

Mount the files and publish the Agent. Replace AGENT_IMAGE with the complete image address from this Platform's generated installation command, and replace the example paths and public key with your own:

docker run -d \
  --name citadel-agent \
  --restart=always \
  --label com.citadel.system=true \
  --label com.citadel.system-role=agent \
  -p 9000:9000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /:/host:ro \
  -v /path/to/agent-tls:/etc/citadel/tls:ro \
  -e CITADEL_AGENT_TLS_MODE=Direct \
  -e CITADEL_AGENT_PORT=9000 \
  -e CITADEL_AGENT_TLS_CERTIFICATE_PATH=/etc/citadel/tls/agent-fullchain.pem \
  -e CITADEL_AGENT_TLS_PRIVATE_KEY_PATH=/etc/citadel/tls/agent-key.pem \
  -e CITADEL_CORE_PUBLIC_KEY="..." \
  -e CITADEL_AGENT_ID="<generated-agent-id>" \
  AGENT_IMAGE

Configure the Platform or inbound Build Pool endpoint with the exact DNS name or IP covered by the certificate:

https://docker-host.internal.example:9000

Core still signs every Agent request. TLS does not replace CITADEL_CORE_PUBLIC_KEY.

Private CA for regular Agents

If Agent certificates use a private CA, mount the CA's public certificate into Core:

services:
  server:
    volumes:
      - ./tls/agent-ca.pem:/etc/citadel/tls/agent-ca.pem:ro

Set:

CITADEL_CORE_AGENT_CA_CERTIFICATE_PATH=/etc/citadel/tls/agent-ca.pem

The bundle may contain multiple CA certificates. Citadel still validates the Agent hostname, validity dates, chain, and server-authentication usage.

Secure an Edge Agent

Edge Agents do not need server certificates because they open the connection to Core.

For a publicly trusted Core or reverse-proxy certificate:

CITADEL_CORE_URL=https://citadel.example.com

Use the URL generated by Citadel enrollment. It points to CITADEL_EDGE_PUBLIC_GRPC_URL, which may differ from the browser URL.

Private CA for Edge Agents

Mount only the CA public certificate:

-v /path/to/core-ca.pem:/etc/citadel/tls/core-ca.pem:ro

Add:

CITADEL_EDGE_CORE_CA_CERTIFICATE_PATH=/etc/citadel/tls/core-ca.pem

Do not mount a CA private key into an Agent.

For cleartext communication on a trusted private network, use:

CITADEL_CORE_URL=http://host.docker.internal:8001

The URL scheme selects the transport. http:// enables cleartext HTTP/2; https:// enables TLS. Citadel never bypasses certificate validation for an HTTPS URL.

Keep these values consistent with the public deployment:

CITADEL_JWT_ISSUER=https://citadel.example.com
CITADEL_JWT_AUDIENCE=https://citadel.example.com

When explicit CORS origins are needed, configure them by index:

CITADEL_HTTP_CORS_ORIGIN_0=https://citadel.example.com

Changing JWT issuer or audience invalidates existing access and refresh tokens. Users must sign in again.

OIDC providers must register Citadel's HTTPS callback:

https://citadel.example.com/api/v1/authentication/oidc/<provider-id>/callback

Certificate renewal

Citadel does not issue or renew certificates.

To renew a direct Core or regular Agent certificate:

  1. Replace the mounted certificate and key files.
  2. Restart the affected container.
  3. Verify its health and connection status.

Citadel warns at startup when a direct certificate expires within 30 days.

Troubleshooting

Core rejects proxy configuration

Confirm that CITADEL_HTTP_TRUSTED_PROXIES or CITADEL_HTTP_TRUSTED_PROXY_NETWORKS identifies the immediate proxy-to- Core connection. Do not enter public client networks.

OIDC redirects to HTTP

Confirm:

  • CITADEL_PUBLIC_URL uses HTTPS;
  • Core is in ReverseProxy mode;
  • the immediate proxy is trusted;
  • the proxy sends X-Forwarded-Proto: https.

Edge Agent reports an HTTP/2 error

Verify that CITADEL_CORE_URL points to the public Edge gRPC endpoint, not automatically to the UI endpoint. Confirm that the proxy forwards the Edge Agent gRPC service to Core port 8001 without HTTP/1.1 downgrade or buffering.

Certificate is not trusted

For a public certificate, verify that the full intermediate chain is served. For a private CA, mount the CA public certificate into the correct client:

  • Agent CA into Core for regular Agents;
  • Core/proxy CA into Edge Agents.

Certificate name mismatch

The hostname or IP in the configured URL must appear in the certificate subject alternative names. Changing only the URL scheme does not fix a name mismatch.

Agent endpoint is rejected as insecure

Use an https:// Agent endpoint, or set CITADEL_CORE_AGENT_ALLOW_INSECURE=true when the Agent is reachable only through a trusted private network or VPN.

Citadel does not provide a certificate-validation bypass.

Request and login limits

Core limits each client IP to 600 requests per minute by default, configurable with CITADEL_HTTP_REQUESTS_PER_MINUTE. Login, initial setup and public MFA requests also share a 20-attempt per-client minute budget. Password verification allows five attempts per account per minute; a successful verification resets that account's budget. Username and email aliases share the same account limit. These bounded, in-memory limits reset when Core restarts. Authentication failures return generic errors and throttled requests return HTTP 429 with Retry-After.

Forwarded client addresses are used only behind configured trusted proxies, walking the forwarded chain from the nearest hop. Configure the proxy to append or replace X-Forwarded-For correctly. Clients behind one NAT share an IP budget. A global ceiling of 100 times the configured client budget also protects Core from aggregate overload. /health liveness probes are exempt from these traffic budgets. /ready dependency probes use the normal limits and allow at most four concurrent checks; excess concurrent probes receive HTTP 503 without querying PostgreSQL or Docker.

Browser security headers

Core adds X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and Referrer-Policy: strict-origin-when-cross-origin to responses on its main HTTP listener, including the bundled frontend and API errors. The Edge gRPC listener omits these browser headers and the report-only CSP below. Both listeners retain HSTS in direct TLS mode. Embedding Citadel in an iframe is intentionally blocked. Streaming bodies and WebSocket upgrades are not buffered or rewritten.

The initial Content-Security-Policy-Report-Only policy reports violations in browser developer tools; it does not block scripts, workers or connections. No reporting endpoint is configured, so violation details are not uploaded to a collector. Monaco's bundled workers are allowed through worker-src 'self' blob:. Inline styles remain allowed for Monaco, charts, Radix positioning and terminal rendering. connect-src includes the page's own origin, CITADEL_PUBLIC_URL, and that URL's explicit ws:// or wss:// equivalent for realtime, logs and terminal sessions. Configure the public URL to match the externally reachable API.

This is observation, not an enforced XSS protection policy. For example, the current inline theme bootstrap can produce a script violation while still running normally. Before enforcing CSP, check Monaco editing and JSON/YAML diagnostics, live logs, terminal input, reconnects, and light/dark themes in a production frontend build. Resolve unexpected violations rather than adding broad script or connection allowances. See MDN's CSP guide.

Vite development headers are unchanged. If a separate host serves the frontend, configure these headers there too: API response headers do not protect a document served by another server. For a separately built frontend using VITE_API_BASE_URL, include its API origin and WebSocket equivalent in that host's policy. Reverse proxies should preserve Core's headers; any existing enforced proxy CSP still applies.

In ReverseProxy mode, Core does not emit Strict-Transport-Security. Configure HSTS at the HTTPS reverse proxy or CDN. The Nginx guides set Strict-Transport-Security: max-age=31536000 on HTTPS responses, including errors. This tells browsers to use HTTPS for the host for one year; it does not include subdomains or request preloading. If a CDN manages HSTS, avoid adding a duplicate header at the origin proxy.

After deploying, inspect the public frontend response through the full proxy chain:

curl --silent --show-error --dump-header - --output /dev/null https://citadel.example.com/

Expect X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Content-Security-Policy-Report-Only, and Strict-Transport-Security. If headers are missing or duplicated, compare the response from Core with the public response and check proxy/CDN header rules. Nginx forwards Core's browser headers by default; it needs no matching add_header directives for them. See Nginx's response header forwarding and add_header documentation.

Cookie-based logout requires an Origin matching the configured public origin or an explicitly allowed CORS origin. Requests with a missing or foreign Origin are rejected before revoking the session, including when secure cookies use SameSite=None. API clients that use the refresh cookie must supply the trusted Origin on logout. An explicitly configured Host allowlist is enforced even when TLS mode is Disabled.

On this page