Direct exposure
Public IP, 80/443 reachable. Caddy requests its own certificates. See Installation.
Installation assumes your server has a public IP with 80/443 reachable, so Caddy can get its own certificates automatically. Plenty of real servers don’t have that — a machine on a university lab network, an office server behind NAT, anything without direct inbound access. This page is for that case.
Caddy normally terminates TLS itself, requesting certificates directly from Let’s Encrypt — which only works if the internet can actually reach your server to complete that verification. Behind a tunnel, an outbound-only connection from your server carries traffic in instead, and the tunnel provider’s edge terminates TLS. Caddy still does the routing, just over plain HTTP internally.
Direct exposure
Public IP, 80/443 reachable. Caddy requests its own certificates. See Installation.
Tunnel (this page)
No inbound access needed. An outbound connection from your server carries traffic in; the tunnel’s edge handles TLS.
These docs use Cloudflare Tunnel since it’s free with no bandwidth limits, but the same shape works with any outbound tunnel — the only Huell-specific pieces are deploy/Caddyfile.tunnel and the optional cloudflared service in docker-compose.yml.
This section gets the dashboard itself live. It’s all you need to do up front — bringing a project’s docs online later is a separate, much shorter section below, not part of this walkthrough.
Point your domain at Cloudflare
Your domain’s DNS needs to be on Cloudflare (free tier is enough). If it’s registered elsewhere, add it as a zone and update your registrar’s nameservers to Cloudflare’s.
Install cloudflared on your server
No root needed — it’s a standalone binary:
mkdir -p ~/bincurl -L -o ~/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64chmod +x ~/bin/cloudflaredAuthenticate and create a tunnel
cloudflared tunnel logincloudflared tunnel create huellThe login step prints a URL — open it in a browser on any device and authorize your domain. tunnel create writes credentials to ~/.cloudflared/, and prints a tunnel ID you’ll need in a couple steps — keep it visible.
Check whether you have Total TLS
Cloudflare’s free certificate covers your domain plus one subdomain level (*.example.com) — Huell’s project URLs (<slug>.docs.example.com) are one level deeper than that, so they need Total TLS (SSL/TLS → Edge Certificates → Total TLS in the Cloudflare dashboard). Many orgs already have this on — check there first. If it’s not on and your plan doesn’t include it for free, it’s a paid add-on. This decides which of the two config.yml versions you write two steps down — note which one applies to you before continuing.
Route the dashboard's hostname
The only DNS route you need before starting anything — this is not project-specific:
cloudflared tunnel route dns huell docs.example.comPlace tunnel credentials where Docker can read them
mkdir -p deploy/cloudflaredcp ~/.cloudflared/cert.pem ~/.cloudflared/*.json deploy/cloudflared/chmod 644 deploy/cloudflared/*.jsonYour tunnel ID is the filename you just copied — ls deploy/cloudflared/*.json shows <tunnel-id>.json. (cloudflared tunnel list also shows it, if you need it again later.)
Write deploy/cloudflared/config.yml — service targets are Docker Compose service names, not localhost, since cloudflared runs as a container on the same network as caddy. Use whichever matches step 4:
Total TLS on — one wildcard line covers every project, forever, with nothing more to do here later:
protocol: http2tunnel: <your-tunnel-id>credentials-file: /etc/cloudflared/<your-tunnel-id>.jsoningress: - hostname: docs.example.com service: http://caddy:80 - hostname: "*.docs.example.com" service: http://caddy:80 - service: http_status:404Total TLS off — just the dashboard for now. Project lines get added one at a time, later, in the section below:
protocol: http2tunnel: <your-tunnel-id>credentials-file: /etc/cloudflared/<your-tunnel-id>.jsoningress: - hostname: docs.example.com service: http://caddy:80 - service: http_status:404Set the tunnel-specific env vars
In .env, alongside the usual configuration:
DASHBOARD_HOST=docs.example.comAUTH_URL=https://docs.example.comCADDYFILE=Caddyfile.tunnel# only if Total TLS is off — omit entirely if it's onDOCS_SUBDOMAIN_SEPARATOR=-Build, migrate, and start
docker compose builddocker compose run --rm worker pnpm --filter @huell/db migratedocker compose --profile tunnel up -dThe dashboard should now be reachable at DASHBOARD_URL. Nothing about projects yet — that’s next, and only once you’ve actually created one.
Skip this whole section if Total TLS is on — the wildcard route already set up covers every project automatically, forever, with zero further steps, ever. This only applies with Total TLS off, and only after a project actually exists (create it in the dashboard first — this section has nothing to do until then).
Once DOCS_SUBDOMAIN_SEPARATOR=- is set and a project exists, its page in the dashboard shows the exact command for it — for example:
cloudflared tunnel route dns huell vortexdb-docs.example.comRun that, then add a matching line to deploy/cloudflared/config.yml:
- hostname: vortexdb-docs.example.com service: http://caddy:80And restart cloudflared to pick it up:
docker compose restart cloudflaredRepeat this (route → config line → restart) once per project.
If the server runs other projects too (a shared lab or club server), two things to check first:
ss -tlnp before starting anything — docker-compose.yml binds 80/443 on the host by default, which is usually free even on a busy shared box since most other projects don’t need those specifically, but verify.