Nginx on the host
Use a host-installed Nginx server for Citadel HTTPS, WebSockets, and Edge Agent gRPC.
Use this setup when Nginx already runs as a system service on the same Linux host as Citadel. Core stays in Docker; Nginx connects to its published loopback ports. For a containerized proxy, use Nginx in Docker.
Before you start
- Prepare the standard Compose installation.
- Install Nginx with HTTP/2 and gRPC support. The configuration below uses Nginx 1.25.1 or later.
- Point
citadel.example.comto this host and allow public traffic on ports80and443. Replace the example hostname throughout. - Obtain a valid certificate chain and matching private key using your
certificate provider. The example uses existing files under
/etc/letsencrypt/live/citadel.example.com/; change these paths if needed.
1. Configure Citadel
Core must trust the proxy's address as seen inside its container. With a Linux Docker bridge, host connections commonly arrive from the bridge gateway. From the installation directory, inspect the running Core container:
docker inspect "$(docker compose ps -q server)" \
--format '{{range $name, $network := .NetworkSettings.Networks}}{{$name}} gateway={{$network.Gateway}}{{println}}{{end}}'Use the installation's bridge gateway for CITADEL_HTTP_TRUSTED_PROXIES below; 172.19.0.1
is an example, not a fixed installation default. With
rootless Docker,
the source address also depends on the port-forwarding driver. If Core rejects
forwarded headers, confirm the actual peer using the diagnostic below. Do not
assume it is 127.0.0.1 just because Nginx's upstream uses loopback.
Update .env, keeping its other settings:
CITADEL_BIND_ADDRESS=127.0.0.1
CITADEL_HTTP_PORT=18000
CITADEL_EDGE_PORT=18001
CITADEL_TRANSPORT_MODE=ReverseProxy
CITADEL_PUBLIC_URL=https://citadel.example.com
CITADEL_HTTP_LISTEN_PORT=8000
CITADEL_EDGE_GRPC_LISTEN_PORT=8001
CITADEL_HTTP_TRUSTED_PROXIES=172.19.0.1
CITADEL_HTTP_FORWARDED_LIMIT=1
CITADEL_HTTP_ALLOWED_HOSTS=citadel.example.com;localhost;127.0.0.1
CITADEL_EDGE_PUBLIC_GRPC_URL=https://citadel.example.com
CITADEL_JWT_ISSUER=https://citadel.example.com
CITADEL_JWT_AUDIENCE=https://citadel.example.comClear existing CITADEL_TLS_CERTIFICATE_PATH,
CITADEL_TLS_PRIVATE_KEY_PATH, and
CITADEL_HTTP_TRUSTED_PROXY_NETWORKS values. Update explicit CORS origins
and OIDC callback URLs if configured; see
related URL settings.
Changing the JWT issuer or audience signs existing users out.
Download the Edge overlay so host Nginx can reach the gRPC listener, then apply the settings and confirm the private HTTP listener works:
curl -fSL https://raw.githubusercontent.com/Citadel-P/Citadel/main/deploy/install/compose.edge.yml -o compose.edge.yml
docker compose -f docker-compose.yml -f compose.edge.yml up -d --wait
curl --fail --header 'Host: citadel.example.com' http://127.0.0.1:18000/healthInclude both Compose files in later update commands.
If your installation uses different host ports, keep the .env values and
Nginx upstreams below consistent. Run Docker commands as the account that owns
the installation's Docker engine, including for rootless deployments.
2. Configure Nginx
Create /etc/nginx/conf.d/citadel.conf, or update your existing Citadel virtual
host. This file belongs inside Nginx's http block; the usual Linux package
configuration already includes conf.d/*.conf there.
map $http_upgrade $citadel_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name citadel.example.com;
return 301 https://citadel.example.com$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name citadel.example.com;
ssl_certificate /etc/letsencrypt/live/citadel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/citadel.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 0;
location ^~ /citadel.edge.v1.EdgeAgentService/ {
grpc_pass grpc://127.0.0.1:18001;
grpc_set_header Host $http_host;
grpc_set_header X-Forwarded-For $remote_addr;
grpc_set_header X-Forwarded-Proto $scheme;
grpc_read_timeout 3600s;
grpc_send_timeout 3600s;
grpc_intercept_errors off;
}
location / {
proxy_pass http://127.0.0.1:18000;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $citadel_connection_upgrade;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_intercept_errors off;
}
}The HTTP/2 directive
was introduced in Nginx 1.25.1. For an older distribution package, replace
listen 443 ssl; and http2 on; with listen 443 ssl http2;.
Browser traffic uses host port 18000; Edge gRPC uses 18001. Keep both bound
to loopback. The proxy preserves API error responses, supports WebSockets, and
uses long idle timeouts. Avoid an error_page maintenance fallback for these
locations. This example assumes Nginx is the public-facing proxy; review
forwarding trust if another proxy sits in front of it.
If a CDN, load balancer or gateway sits in front of Nginx, verify that it supports bidirectional streaming gRPC and forwards it to Nginx over HTTP/2 with validated TLS. Enable any provider-specific gRPC setting. Keep the HTTP/2 setting and dedicated gRPC location above even when the dashboard already works. Follow the Edge connectivity checks to distinguish CDN rejection, a missing proxy route and Agent authentication.
Nginx supplies HSTS because it terminates TLS; Core does not emit HSTS in
ReverseProxy mode. The HTTPS server's add_header applies to both locations,
including error responses. If you add location-level add_header directives,
repeat HSTS there because those directives override parent header inheritance
by default. If a CDN already sets HSTS, configure it in one place to avoid duplicates.
Nginx forwards Core's browser security headers by default. Preserve them without
adding duplicate add_header directives or hiding them with proxy_hide_header.
See Browser security headers
for the report-only CSP and public-response verification.
3. Validate and reload
sudo nginx -t
sudo systemctl reload nginxRepeat these commands after certificate renewal or configuration changes. Keep certificate issuance and renewal in your existing certificate-management process.
Open https://citadel.example.com, sign in, and check a container's Logs or
Stats. For an Edge Platform, use its generated Agent command with
CITADEL_CORE_URL=https://citadel.example.com and confirm it comes online.
For upstream errors, check Core health and /var/log/nginx/error.log. If only
Edge enrollment fails, check the service route and HTTP/2 support.
Diagnose a rejected proxy address
If Core reports Invalid forwarded headers, verify the peer instead of
trusting an entire Docker subnet. A temporary listener on the same bridge can
show the address used by host port forwarding. Replace citadel_default with
the network name printed in step 1, and use an unused loopback port:
docker run --rm --network citadel_default -p 127.0.0.1:18002:8000 \
python:3.13-alpine python -u -c '
import socket
with socket.socket() as listener:
listener.bind(("0.0.0.0", 8000))
listener.listen(1)
connection, peer = listener.accept()
with connection:
connection.recv(4096)
print("Proxy peer:", peer[0])
connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
'In another terminal on the same host, run curl http://127.0.0.1:18002/. The
listener prints the peer and exits; Docker removes the temporary container.
Set CITADEL_HTTP_TRUSTED_PROXIES to that address and recreate Core with docker compose -f docker-compose.yml -f compose.edge.yml up -d.
Verify it again if you recreate the Docker network or change rootless networking.
Check Script API access
CITADEL_PUBLIC_URL is the browser origin. Scripts call Core through
CITADEL_INTERNAL_BASE_URL, which defaults to http://127.0.0.1:8000 with this
listener configuration. Keep 127.0.0.1 in CITADEL_HTTP_ALLOWED_HOSTS so that
internal calls pass host validation; Core's private listener bindings remain in place.
Create a manual Script using an identity with Platform Read access:
const result = await citadel.api.listPlatforms();
console.log("Accessible Platforms:", result.platforms.map(platform => platform.name));Run it and confirm the Platform names (or an empty list) in the Action logs. A console-only Action does not test this connection. For HTTPS-only Core, follow Direct TLS Script setup.