Nginx in Docker
Serve Citadel and Edge Agent connections over HTTPS using Docker Compose and Nginx.
This example adds Nginx to the standard Compose installation. One hostname serves the browser, API, and Edge Agent gRPC endpoint. TLS ends at Nginx; traffic to Core stays on a dedicated Docker network.
If Nginx already runs as a system service on the Docker host, use Nginx on the host instead.
The proxy reaches server:8001 on the private Docker network; the optional
Edge host-port overlay is not needed.
Before you start
- Prepare the installation's
docker-compose.ymland.env. - Point
citadel.example.comto your Docker host and make ports80and443available to Nginx. Replace this hostname throughout the example. - Place a valid PEM certificate chain and matching private key in
./tls/fullchain.pemand./tls/privkey.pem. Nginx does not obtain or renew certificates automatically; use your certificate provider's renewal process. - Choose an unused subnet. If
172.30.249.0/29overlaps your networks, change the subnet, container addresses, andCITADEL_HTTP_TRUSTED_PROXIEStogether.
For automatic certificate management, see Reverse proxy with Caddy.
1. Configure Citadel
Update these entries in .env, keeping its other settings:
CITADEL_BIND_ADDRESS=127.0.0.1
CITADEL_HTTP_PORT=18000
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.30.249.2
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 any existing CITADEL_TLS_CERTIFICATE_PATH,
CITADEL_TLS_PRIVATE_KEY_PATH, and
CITADEL_HTTP_TRUSTED_PROXY_NETWORKS values. Core trusts Nginx's fixed
address. Changing the JWT issuer or audience signs existing users out.
Update explicit CORS origins and OIDC callback URLs if configured; see related URL settings.
2. Add Nginx
Create compose.nginx.yml beside docker-compose.yml:
services:
server:
networks:
default: {}
citadel_proxy:
ipv4_address: 172.30.249.3
nginx:
image: nginx:stable-alpine
restart: unless-stopped
depends_on:
server:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./tls:/etc/nginx/tls:ro
networks:
citadel_proxy:
ipv4_address: 172.30.249.2
networks:
citadel_proxy:
ipam:
config:
- subnet: 172.30.249.0/29Core retains its default network for PostgreSQL. Keep the proxy network dedicated to Core and Nginx; Core's published ports remain bound to loopback.
Create nginx.conf in the same directory. The image loads this file inside its
http configuration, so it contains no outer http or events block:
map $http_upgrade $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/nginx/tls/fullchain.pem;
ssl_certificate_key /etc/nginx/tls/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://server:8001;
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://server:8000;
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 $connection_upgrade;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_intercept_errors off;
}
}The gRPC route
preserves the Edge service path and uses HTTP/2 to Core port 8001.
Browser traffic uses port 8000, with WebSocket upgrades and response buffering
disabled. Core enforces its own request size limits; Nginx adds no smaller limit.
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.
X-Forwarded-For contains the immediate client's address, matching Core's
one-hop trust configuration. This example assumes Nginx is the public-facing
proxy; review forwarding trust before placing another proxy in front of it.
Error interception
is disabled so API failures keep their status code and JSON details. Do not add
an error_page maintenance fallback to the Citadel locations.
3. Validate and start
Run from the installation directory:
docker compose -f docker-compose.yml -f compose.nginx.yml config --quiet
docker compose -f docker-compose.yml -f compose.nginx.yml pull
docker compose -f docker-compose.yml -f compose.nginx.yml up -d server
docker compose -f docker-compose.yml -f compose.nginx.yml run --rm --no-deps nginx nginx -t
docker compose -f docker-compose.yml -f compose.nginx.yml up -dCore must be running for Nginx to resolve server during validation. Always
include both Compose files in later update commands.
After configuration changes or certificate renewal, validate and reload Nginx:
docker compose -f docker-compose.yml -f compose.nginx.yml exec nginx nginx -t
docker compose -f docker-compose.yml -f compose.nginx.yml exec nginx nginx -s reload4. Check the connection
- Open
https://citadel.example.comand confirm its certificate is accepted. - Sign in and open a container's Logs or Stats to check live updates.
- For Edge Agents, use the Platform's generated command with
CITADEL_CORE_URL=https://citadel.example.comand confirm it comes online.
If Core rejects forwarded headers, check that CITADEL_HTTP_TRUSTED_PROXIES matches Nginx's
address. If browser access works but Edge enrollment fails, check the service
route and HTTP/2 listener. For TLS or upstream errors, inspect Nginx's logs:
docker compose -f docker-compose.yml -f compose.nginx.yml logs --tail=100 nginxSee transport troubleshooting for certificate trust and hostname errors.
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.