Skip to content

Tunnel deployment

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:

Terminal window
mkdir -p ~/bin
curl -L -o ~/bin/cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
chmod +x ~/bin/cloudflared

Authenticate and create a tunnel

Terminal window
cloudflared tunnel login
cloudflared tunnel create huell

The 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:

Terminal window
cloudflared tunnel route dns huell docs.example.com

Place tunnel credentials where Docker can read them

Terminal window
mkdir -p deploy/cloudflared
cp ~/.cloudflared/cert.pem ~/.cloudflared/*.json deploy/cloudflared/
chmod 644 deploy/cloudflared/*.json

Your 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: http2
tunnel: <your-tunnel-id>
credentials-file: /etc/cloudflared/<your-tunnel-id>.json
ingress:
- hostname: docs.example.com
service: http://caddy:80
- hostname: "*.docs.example.com"
service: http://caddy:80
- service: http_status:404

Total TLS off — just the dashboard for now. Project lines get added one at a time, later, in the section below:

protocol: http2
tunnel: <your-tunnel-id>
credentials-file: /etc/cloudflared/<your-tunnel-id>.json
ingress:
- hostname: docs.example.com
service: http://caddy:80
- service: http_status:404

Set the tunnel-specific env vars

In .env, alongside the usual configuration:

Terminal window
DASHBOARD_HOST=docs.example.com
AUTH_URL=https://docs.example.com
CADDYFILE=Caddyfile.tunnel
# only if Total TLS is off — omit entirely if it's on
DOCS_SUBDOMAIN_SEPARATOR=-

Build, migrate, and start

Terminal window
docker compose build
docker compose run --rm worker pnpm --filter @huell/db migrate
docker compose --profile tunnel up -d

The 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:

Terminal window
cloudflared tunnel route dns huell vortexdb-docs.example.com

Run that, then add a matching line to deploy/cloudflared/config.yml:

- hostname: vortexdb-docs.example.com
service: http://caddy:80

And restart cloudflared to pick it up:

Terminal window
docker compose restart cloudflared

Repeat 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:

  • Port collisions: 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.
  • Tunnel ownership: a personal Cloudflare account works fine for testing, but for anything meant to outlive one person’s involvement, create the tunnel under a shared/org-owned Cloudflare account instead — otherwise the whole deployment depends on one person’s account staying active.