How to serve CyberChef, IT-Tools, and Draw.io publicly with Caddy, without storing visitor data on your server. Remove Authelia from specific subdomains, add hardening headers, verify the result, and keep the rest of your homelab behind authentication.
Most homelab guides stop at "put it behind Authelia." That's correct for apps with server-side state: Home Assistant, Nextcloud, Portainer. But a class of tools is fully client-side and stateless — the browser does all the work, and the server is just a dumb byte pump. For those, authentication is theater. Every login wall adds friction, log-overhead, and a false sense of security while the actual attack surface barely changes.
This guide strips that theater for CyberChef, IT-Tools, and Draw.io. The result: public endpoints that refuse to log, remember, or persist anything, with the rest of your homelab staying exactly where you left it — behind Authelia.
A tool is zero-server-state when ALL of these are true:
CyberChef, IT-Tools, and Draw.io all hit this bar. They're single-page apps that execute logic client-side; the backend only serves static assets. If you're extending this list, read the app's own docs before declaring it stateless.
Because there's no server-side state, the main risks aren't data leaks — they're supply-chain and metadata exposure:
:latest.If the tool has a database, uploads, logins, or secrets in URL paths, this pattern does not apply. Nextcloud, Vaultwarden, and Home Assistant belong behind Authelia or VPN. The test above is non-negotiable.
The goal is: three subdomains bypass Authelia. everything else keeps it.
chef.example.com # CyberChef — no Authelia, hardened headers
it.example.com # IT-Tools — no Authelia, hardened headers
draw.example.com # Draw.io — no Authelia, hardened headers
home.example.com # Home Assistant — Authelia still active
cloud.example.com # Nextcloud — Authelia still active
# 1. Add the rules in §4 to your Caddyfile
# 2. Reload Caddy
caddy reload
# 3. Verify headers
curl -I https://chef.example.com | grep -E 'strict-transport|csp|permissions|frame|x-frame|x-content'
# 4. Confirm no cookies/session are set
curl -I https://it.example.com | egrep -i 'set-cookie|www-authenticate' || echo 'clean'
If your Caddyfile currently pushes all traffic through Authelia, add a bypass list. There are two clean ways; pick one.
Option A — inline in each site block (simplest):
chef.example.com {
reverse_proxy 10.0.0.10:8000
# No forward_auth = Authelia bypassed for this subdomain only
import static_security_headers
}
it.example.com {
reverse_proxy 10.0.0.20:8080
import static_security_headers
}
draw.example.com {
reverse_proxy 10.0.0.30:8080
import static_security_headers
}
Option B — centralized skip list (cleaner at scale):
(authelia_basics) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
(static_security_headers) {
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src * data: blob:; font-src * data:; connect-src 'self' ws: wss:; frame-ancestors 'self'; base-uri 'self'; form-action 'self'"
header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), accelerometer=(), gyroscope=()"
header X-Frame-Options "SAMEORIGIN"
header X-Content-Type-Options "nosniff"
header Referrer-Policy "no-referrer"
header Cache-Control "public, max-age=3600, must-revalidate"
}
(public_docker_sites) {
@public_sites host chef.example.com it.example.com draw.example.com
handle @public_sites {
reverse_proxy {http.reverse_proxy}
import static_security_headers
}
handle {
import authelia_basics
reverse_proxy {http.reverse_proxy}
}
}
* {
import public_docker_sites
}
If you use Docker Compose labels instead of a Caddyfile, the equivalent is an HTTP_AUTH_BYPASS=true environment label or removing the authelia matcher on the Caddy proxy for those specific services.
The snippet above has a full block. Here's the rationale so you can tighten or loosen it later:
| Header | Value | Why |
|---|---|---|
Strict-Transport-Security | max-age=31536000; includeSubDomains; preload | Browsers will never downgrade to HTTP. Submit to the preload list once you're confident. |
Content-Security-Policy | See snippet | Locks down where scripts, styles, images, and connections can come from. 'unsafe-inline' and 'unsafe-eval' are included because CyberChef/Draw.io need them — expect a JS console warning about bundle safety; document what it blocks and accept it. |
Permissions-Policy | All denied | Removes access to geolocation, camera, microphone, USB, and payment APIs. The page has no legitimate need for these. |
X-Frame-Options | SAMEORIGIN | Prevents clickjacking via embedding. Necessary because Draw.io can be framed by a malicious parent. |
X-Content-Type-Options | nosniff | Browsers trust your Content-Type. Without it, an attacker uploads a crafted HTML file disguised as JS. |
Referrer-Policy | no-referrer | No referer string leaves the browser. Protects downstream resource URLs. |
Cache-Control | public, max-age=3600 | Static assets cache aggressively; HTML stays fresh. Tune to your taste. |
The 'unsafe-inline' and 'unsafe-eval' relaxations exist because WebContainer-based tools like CyberChef and Draw.io use dynamic code generation. A stricter CSP breaks them. The added frame-ancestors 'self' and X-Frame-Options compensate — you're not opening an embedding hole just to make the app work.
Never run :latest on public-facing containers. Pin by digest so supply-chain surprises don't become zero-days.
name: public-tools
networks:
public:
internal:
services:
cyberchef:
image: ghcr.io/gchq/cyberchef:10.19.3
container_name: cyberchef
restart: unless-stopped
networks: [public]
ports: ["127.0.0.1:8000:80"]
read_only: true
tmpfs:
- /tmp
it-tools:
image: corentinth/it-tools:2025.2.14
container_name: it-tools
restart: unless-stopped
networks: [public]
ports: ["127.0.0.1:8080:80"]
read_only: true
drawio:
image: jgraph/drawio:24.7.19
container_name: drawio
restart: unless-stopped
networks: [public]
ports: ["127.0.0.1:8081:80"]
read_only: true
tmpfs:
- /tmp
Key choices:
127.0.0.1 bindings mean only Caddy on the host can reach them. Even a compromised neighboring container can't reach these ports directly.read_only + tmpfs minimizes writable surface. If the container escapes the overlay, there's little to mutate.restart: unless-stopped keeps them alive across reboots without a supervisor.The whole point of this guide is surgical removal. Verify Authelia is still protecting every other subdomain.
# Should challenge with a 401 or redirect to /auth
curl -I https://home.example.com | head -20
curl -I https://cloud.example.com | head -20
# Should NOT challenge
curl -I https://chef.example.com | head -20
curl -I https://it.example.com | head -20
curl -I https://draw.example.com | head -20
If Authelia still prompts on the public tools, your host matcher is still in the global block — pull it into the handle branch instead.
"Edit /opt/stacks/caddy/Caddyfile: for chef, it-*, and draw* subdomains replace forward_auth with the shared static_security_headers snippet from /snippets/security-headers.conf. Reload and verify with curl. Revert if any non-public subdomain loses auth."
Set-Cookie, no WWW-Authenticate, headers present.443 to it — reduces lateral movement if a container is compromised.These cover the Hermes-based setup that manages the stack above.
Install, model selection, gateway, memory, skills — the zero-to-useful path.
Cost routing, memory hygiene, skill curation, profiles, config knobs.
Telegram-controlled automations, entities, climate, media — through Hermes.
Filesystem, memory, web, fetch — zero-cost MCP without hosting.
Also see the blog: Practical Homelab AI: Hermes, Docker, Caddy & the Overhaul
Filesystem, memory, web, fetch — zero-cost MCP without hosting anything.
Related: Practical Homelab AI blog post · All guides: lemonlink.eu/guides