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:
| Mode | Use it for | Public traffic |
|---|---|---|
ReverseProxy | Recommended production setup | TLS ends at your reverse proxy |
Direct | Production without a reverse proxy | TLS ends at Citadel Core |
Disabled | Local development and isolated tests | Plain 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 gRPCThe public addresses may use one hostname when your proxy routes both protocols, or separate ports/hostnames:
https://citadel.example.com
https://citadel.example.com:8444Certificate DNS names or IP subject alternative names must match the addresses that clients actually use.
Recommended: reverse proxy
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:
| Setup | Guide |
|---|---|
| Caddy in Docker, with automatic HTTPS | Reverse proxy with Caddy |
| Nginx in Docker, on a private proxy network | Nginx in Docker |
| Nginx installed directly on the Docker host | Nginx 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 8001Set 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=trueSet 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
8001using HTTP/2/h2c; - support WebSocket upgrades;
- preserve the public
Host/HTTP2:authority; - forward
X-Forwarded-ForandX-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.EdgeAgentServiceUse 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.ymlSet:
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=trueFrom 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 -dInclude 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=trueThe 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_IMAGEConfigure the Platform or inbound Build Pool endpoint with the exact DNS name or IP covered by the certificate:
https://docker-host.internal.example:9000Core 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:roSet:
CITADEL_CORE_AGENT_CA_CERTIFICATE_PATH=/etc/citadel/tls/agent-ca.pemThe 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.comUse 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:roAdd:
CITADEL_EDGE_CORE_CA_CERTIFICATE_PATH=/etc/citadel/tls/core-ca.pemDo 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:8001The URL scheme selects the transport. http:// enables cleartext HTTP/2;
https:// enables TLS. Citadel never bypasses certificate validation for an
HTTPS URL.
Related URL settings
Keep these values consistent with the public deployment:
CITADEL_JWT_ISSUER=https://citadel.example.com
CITADEL_JWT_AUDIENCE=https://citadel.example.comWhen explicit CORS origins are needed, configure them by index:
CITADEL_HTTP_CORS_ORIGIN_0=https://citadel.example.comChanging 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>/callbackCertificate renewal
Citadel does not issue or renew certificates.
To renew a direct Core or regular Agent certificate:
- Replace the mounted certificate and key files.
- Restart the affected container.
- 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_URLuses HTTPS;- Core is in
ReverseProxymode; - 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.