Caddy
Overviewâ
Caddy is a reverse proxy that obtains and renews Let's Encrypt certificates on its
own: no separate ACME client, cron job, or manual reload is needed. The XLADA panel
and the subscription page only work behind a proxy with HTTPS â their middleware
drops the connection if a request arrives without the X-Forwarded-For and
X-Forwarded-Proto: https headers.
Caddy sets X-Forwarded-* itself. In the configuration below we still set them
explicitly: this way the behavior does not change if a CDN or load balancer
appears in front of Caddy, and X-Forwarded-Proto will not turn into http.
The examples use the domains panel.example.com and sub.example.com, with the
upstreams 127.0.0.1:3000 (panel) and 127.0.0.1:3010 (subscription page) â
these are the ports that the xpanel and xpanel-subpage containers publish on
the host loopback.
You can only continue with a registered domain: without one, Let's Encrypt will not issue a certificate.
DNSâ
Check the server IP address (a static one is preferable) and create records with your DNS provider or registrar:
| Type | Name | Value |
|---|---|---|
A | panel | Server IPv4 address |
A | sub | Server IPv4 address |
AAAA | panel | Server IPv6 address |
AAAA | sub | Server IPv6 address |
In Cloudflare, records are added under DNS â Records â Create record: type
A, panel or sub in the name field, and the server IP in the address field.
- Grey cloud â traffic goes directly to your server, and Caddy sees the real client IP. Choose this mode if Cloudflare is unavailable or gets in the way.
- Orange cloud â traffic passes through the Cloudflare network: the server IP
is hidden and attack protection works. Here you must specify the Cloudflare
ranges in
trusted_proxies staticin the Caddyfile, otherwise all clients will look like one Cloudflare address, and HWID limits, audit, and login brute-force protection will become meaningless.
Wait for the records to update: dig +short panel.example.com must return the
server address, and with the orange cloud, Cloudflare addresses.
Certificatesâ
Caddy obtains the certificate automatically as soon as the domain is listed in the
Caddyfile. Verifying the domain requires open ports 80 (HTTP-01) and 443
(TLS-ALPN-01); renewal is also automatic â about 30 days before expiry. In the
global block it is enough to specify email: notifications about certificate
problems will be sent to this address.
Full Caddyfileâ
Create the directory and the file:
sudo mkdir -p /opt/caddy && cd /opt/caddy
sudo nano Caddyfile
# âââ Global options ââââââââââââââââââââââââââââââââââââââââââââââââââ
{
# Email for Let's Encrypt notifications
email admin@example.com
# Real client IP behind Cloudflare. Cloudflare publishes the ranges;
# their list is worth checking against the current one periodically.
servers {
trusted_proxies static \
173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 \
141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 \
197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 \
104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 \
2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 \
2a06:98c0::/29 2c0f:f248::/32
client_ip_headers CF-Connecting-IP X-Forwarded-For
}
}
# âââ XLADA panel âââââââââââââââââââââââââââââââââââââââââââââââââââââ
panel.example.com {
encode zstd gzip
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Build assets: the hash in the file name allows caching them for a long time
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
reverse_proxy 127.0.0.1:3000 {
# The panel drops the connection without these two headers.
# We overwrite X-Forwarded-For so a client cannot spoof its IP.
header_up X-Forwarded-For {client_ip}
header_up X-Forwarded-Proto https
header_up X-Real-IP {client_ip}
header_up Host {host}
# WebSocket: Caddy handles Upgrade and Connection itself,
# no separate headers are needed. flush_interval -1 disables
# buffering â this matters for SSE and statistics streaming.
flush_interval -1
}
}
# âââ Subscription page âââââââââââââââââââââââââââââââââââââââââââââââ
sub.example.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Build assets: the hash in the file name allows caching them for a long time
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
reverse_proxy 127.0.0.1:3010 {
header_up X-Forwarded-For {client_ip}
header_up X-Forwarded-Proto https
header_up X-Real-IP {client_ip}
header_up Host {host}
flush_interval -1
}
}
# âââ Requests by IP and to unknown domains âââââââââââââââââââââââââââ
:443 {
tls internal
respond 204
}
Key directives:
| Directive | Purpose |
|---|---|
trusted_proxies static | trust Cloudflare headers from these subnets |
client_ip_headers | take the real IP from CF-Connecting-IP |
header_up X-Forwarded-For | pass the real client IP to the panel |
header_up X-Forwarded-Proto https | the panel accepts only https |
flush_interval -1 | streaming without buffering |
@assets and header | long cache for build assets from /assets/ |
:443 with tls internal | cuts off scanners hitting the IP |
Do not add extra HTTP authentication to the sub.example.com domain: the
subscription page is requested by client apps that cannot enter a login and
password. Protect panel login with its own authentication and TOTP.
Running in Dockerâ
services:
caddy:
image: caddy:2.9
container_name: caddy
restart: unless-stopped
# host mode is needed so Caddy can see 127.0.0.1:3000 and 127.0.0.1:3010
network_mode: host
environment:
- TZ=UTC
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./data:/data # ACME certificates and keys
- ./config:/config # Caddy service state
cd /opt/caddy
docker compose up -d
docker compose logs -f -t
Host mode is chosen deliberately: only this way does the Caddy container see the
loopback ports of the panel and the subscription page, while the certificates stay
on the host. Caddy itself listens on ports 80 and 443, and 3000, 3010 and
3001 are not exposed.
Validation and reloadâ
# Check the syntax without restarting
caddy validate --config /opt/caddy/Caddyfile
# Bring the file to canonical form
caddy fmt --overwrite /opt/caddy/Caddyfile
# Graceful reload: connections are not dropped
systemctl reload caddy
# In Docker, the same commands run inside the container
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
What to look for in the logsâ
journalctl -u caddy -f # host install
docker compose logs -f caddy # Docker run
certificate obtained successfullyâ certificate issued;serving initial configurationâ configuration accepted;tls.obtainoracmeâ check DNS, ports80and443, Let's Encrypt limits;dial tcp 127.0.0.1:3000: connect: connection refusedâ the panel is not running.
Make sure both sites respond:
curl -I https://panel.example.com
curl -I https://sub.example.com
The response must start with HTTP/2 200. The real client IP is conveniently
checked in the panel audit: if it shows Cloudflare addresses, then
trusted_proxies is not enabled.