Reverse proxy with Caddy
Serve Citadel and Edge Agent connections over HTTPS using Docker Compose and Caddy.
This example adds Caddy to the standard Compose installation. One hostname serves the browser, API, and Edge Agent gRPC endpoint. Caddy handles certificates; Citadel's internal listeners remain on a private Docker network.
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, including its database password. Citadel generates and persists its secret keys automatically. - Replace
citadel.example.combelow with a hostname you control. Its DNS records must point to this Docker host, including any IPv6 record you publish. - Make TCP ports
80and443available to Caddy and reachable for certificate validation. This example assumes Caddy is the public-facing proxy. - Choose an unused Docker subnet. The example uses
172.30.250.0/29; change its addresses together if they overlap with your existing networks.
For private certificates or another proxy, use the transport guide.
1. Configure Citadel
Update these entries in the installation's .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.250.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. This example trusts only
Caddy's fixed address. Changing the JWT issuer or audience signs existing users
out; they can sign in again at the HTTPS address.
If you configured explicit CITADEL_HTTP_CORS_ORIGIN_* origins, update them for the HTTPS address.
For OIDC sign-in, also update the provider's registered callback URL; see
related URL settings.
2. Add Caddy
Create compose.caddy.yml beside docker-compose.yml:
services:
server:
networks:
default: {}
citadel_proxy:
ipv4_address: 172.30.250.3
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
citadel_proxy:
ipv4_address: 172.30.250.2
networks:
citadel_proxy:
ipam:
config:
- subnet: 172.30.250.0/29
volumes:
caddy_data:
caddy_config:The default network keeps Core connected to PostgreSQL. Core's published ports
remain bound to host loopback; Caddy reaches its container ports directly.
Keep the proxy network dedicated to these services and retain caddy_data
across upgrades so certificate state persists.
Create Caddyfile in the same directory:
citadel.example.com {
header Strict-Transport-Security "max-age=31536000"
@edge path /citadel.edge.v1.EdgeAgentService/*
handle @edge {
reverse_proxy h2c://server:8001
}
handle {
reverse_proxy server:8000
}
}The Edge route preserves the gRPC service path and uses cleartext HTTP/2 only inside the proxy network. Caddy's reverse proxy handles WebSocket upgrades and forwarding headers for browser traffic. Automatic HTTPS obtains and renews the public certificate.
Set HSTS at one HTTPS boundary. If a CDN already supplies it, configure that
boundary consistently instead of adding another HSTS header. This example does
not enable includeSubDomains or preload.
3. Validate and start
Run from the installation directory:
docker compose -f docker-compose.yml -f compose.caddy.yml config --quiet
docker compose -f docker-compose.yml -f compose.caddy.yml pull
docker compose -f docker-compose.yml -f compose.caddy.yml run --rm --no-deps caddy \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose -f docker-compose.yml -f compose.caddy.yml up -d
docker compose -f docker-compose.yml -f compose.caddy.yml psAlways include both Compose files in later update commands. Follow upgrade and rollback before changing an existing installation's images.
4. Check the connection
- Open
https://citadel.example.comand confirm the browser accepts its certificate. - Sign in and open a container's Logs or Stats to check live updates.
- If using Edge Agents, create or open an Edge Platform and use its generated
command with
CITADEL_CORE_URL=https://citadel.example.com. Confirm the Platform comes online; an existing Agent using the old URL needs its configuration updated and container recreated.
If the browser works but Edge enrollment fails, check the gRPC route and ensure
the Agent uses the HTTPS endpoint. If Core rejects forwarded headers, verify
that Caddy's address matches CITADEL_HTTP_TRUSTED_PROXIES.
For certificate issuance or upstream errors, inspect Caddy's logs:
docker compose -f docker-compose.yml -f compose.caddy.yml logs --tail=100 caddyCheck DNS, inbound ports, and Core health before retrying. See 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.