Citadel Docs
Operations

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.com to this host and allow public traffic on ports 80 and 443. 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.com

Clear 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/health

Include 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 nginx

Repeat 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.

On this page