Skip to main content

TLS & HTTPS

Overview​

UltraTorrent's containers speak plain HTTP and are designed to sit behind something that terminates TLS. That is deliberate: certificate handling belongs in one place, and every serious deployment already has a reverse proxy.

So "enabling HTTPS" means: put a proxy in front and give it a certificate. This page covers the certificate half; Reverse proxy covers the routing half.

You have three realistic options:

Use whenEffort
Let's Encrypt — HTTP-01The box is reachable from the internet on port 80Lowest
Let's Encrypt — DNS-01LAN-only, behind CGNAT, or you want a wildcardMedium — needs a DNS provider API token
Custom / internal CA certCorporate PKI, or a homelab with its own CAMedium — you must distribute the CA to clients
Watch this tutorial

Video coming soon.

Prerequisites​

  • A working Docker Compose install and a reverse proxy.
  • A domain name you control. A LAN-only IP address cannot get a public certificate — you need a hostname, even for an internal-only service (DNS-01 issues certificates for hostnames that never resolve publicly).
  • For HTTP-01: port 80 reachable from the internet, and DNS already pointing at the host.

Requirements​

Negligible — TLS termination costs a few MB of RAM. What it costs is attention: an expired certificate is an outage.

Ports​

PortNeeded for
80HTTP-01 ACME challenge and the HTTP→HTTPS redirect. Keep it open even after you have a certificate — renewal uses it
443HTTPS
—DNS-01 needs no inbound port at all

Volumes​

Whatever your proxy stores certificates in. Back it up:

ProxyCertificate store
Caddy (bundled proxy profile)the caddy_data volume
Traefikacme.json
NGINX + certbot/etc/letsencrypt
Nginx Proxy Managerits data/ and letsencrypt/ folders
HAProxyyour combined-PEM directory

Permissions​

Private keys must be readable only by the process that uses them:

sudo chmod 600 /etc/haproxy/certs/torrents.example.com.pem
sudo chown root:root /etc/haproxy/certs/torrents.example.com.pem

Never commit a key to git; never put one in .env.

How it fits together​

Note the last line: because TLS terminates at the proxy, the browser's WebSocket is automatically wss:// — you do not configure that anywhere. It only works if your proxy forwards the upgrade; see Reverse proxy.

Step-by-step​

Caddy obtains, installs and renews a Let's Encrypt certificate with zero configuration beyond naming your domain.

Edit deploy/Caddyfile (or your own Caddyfile) so the site label is your domain rather than :80:

torrents.example.com {
encode gzip

@api path /api/* /ws/*
handle @api {
reverse_proxy backend:4000
}

handle {
# nginx-unprivileged listens on 8080 — not 80.
reverse_proxy frontend:8080
}
}
docker compose --profile proxy up -d
docker compose logs -f proxy # watch the certificate get issued

Requirements: DNS for torrents.example.com already points at this host, and ports 80 and 443 are reachable. Caddy handles the HTTP→HTTPS redirect and renewal itself.

Wildcard / DNS-01 with Caddy needs a DNS-provider plugin, which means a custom Caddy image:

FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
*.example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
reverse_proxy frontend:8080
}

Verification​

The chain is complete and the dates are right:

openssl s_client -connect torrents.example.com:443 -servername torrents.example.com < /dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
subject=CN = torrents.example.com
issuer=C = US, O = Let's Encrypt, CN = R11
notBefore=Jul 12 09:14:02 2026 GMT
notAfter=Oct 10 09:14:01 2026 GMT

No chain gaps (the classic "works in Chrome, fails on Android" bug):

openssl s_client -connect torrents.example.com:443 -servername torrents.example.com < /dev/null 2>&1 | grep -i "verify"
Verify return code: 0 (ok)

HTTP redirects to HTTPS:

curl -sI http://torrents.example.com | head -1
# HTTP/1.1 301 Moved Permanently

The app works over TLS, WebSocket included:

curl -s https://torrents.example.com/api/system/live

Then open the UI and confirm a download's progress bar updates live — that proves wss:// is tunnelling correctly through your TLS terminator.

Chrome&#39;s site information popover on the UltraTorrent UI, reporting &quot;Connection is secure&quot;

Where did the padlock go?

Chrome 117 and later replaced the padlock icon with a site information icon (the two sliders). Click it: a working certificate reads "Connection is secure", and Connection is secure → Certificate is valid shows the issuer. A misconfigured chain says "Not secure" here even when the page still loads.

Reverse proxy​

TLS and routing are two halves of one job. Configure the routing (and the mandatory WebSocket upgrade) in Reverse proxy.

Updates​

Certificates are independent of UltraTorrent upgrades — rebuilding the stack does not touch them, as long as your proxy's certificate store is a volume or a host path outside the repo.

After any certificate renewal, the proxy must reload. Caddy and Traefik do it themselves. NGINX and HAProxy need a deploy hook (see the certbot tab above).

Backups​

Back up the certificate store (caddy_data, acme.json, /etc/letsencrypt, your PEM directory) alongside your .env and database dump. Losing it is recoverable — but Let's Encrypt rate-limits duplicate certificates (5 per identical name set per week), so repeatedly losing it will lock you out of re-issuance for days.

Troubleshooting​

SymptomCauseFix
ACME fails: "Timeout during connect" on HTTP-01Port 80 is not reachable from the internetOpen 80 on the firewall and the router. Cloud hosts: check the security group. Or switch to DNS-01, which needs no inbound port
ACME fails: "Invalid response … 404"Something else is answering on port 80, or the proxy is not serving /.well-known/acme-challenge/Make the proxy the only thing on port 80
"too many certificates already issued"Let's Encrypt rate limit — usually from a loop of failed retriesWait it out (a week), and test against --staging / the ACME staging directory next time
Certificate valid in Chrome, untrusted on Android/iOSMissing intermediate — you served the leaf onlyServe the fullchain: cat certificate.crt intermediate.crt > fullchain.pem
ERR_SSL_PROTOCOL_ERRORYou are hitting an HTTP-only port over HTTPS (e.g. https://host:8080)The container speaks plain HTTP. Go through the proxy's 443
Padlock is valid, but the UI never updatesTLS is fine; the WebSocket upgrade is being droppedSee Reverse proxy → verification
Mixed-content warnings in the consoleSomething is requesting http:// from an https:// pageThe SPA uses relative URLs by default. Check CORS_ORIGIN and any custom VITE_API_URL build arg
Traefik ignores acme.jsonWrong file permissionschmod 600 acme.json
Certificate expiredRenewal ran but the proxy never reloadedAdd a deploy hook that reloads the proxy; verify with certbot renew --dry-run
Browser warning on a mkcert certificateThe device does not trust your root CAInstall the root CA on that device — or move to DNS-01 with a real domain

Best practices​

  • Automate renewal, then prove it works — certbot renew --dry-run — long before day 89.
  • Prefer Caddy or Traefik if you have no strong opinion: they issue and renew certificates with no cron job to forget.
  • Use DNS-01 for LAN-only installs. A public certificate for a private hostname, no inbound ports, no browser warnings, no CA to distribute.
  • Always serve the full chain, never the bare leaf.
  • Keep port 80 open for redirects and HTTP-01 renewals, even once HTTPS works.
  • TLS 1.2 minimum, TLS 1.3 preferred.
  • Back up the certificate store with everything else.
  • Never terminate TLS inside the app containers — they are built to sit behind a proxy.
  • Public deployment? TLS is table stakes, not the whole story. Also read Security.

FAQ​

Can I get a certificate for a bare IP address? Not from Let's Encrypt. Use a hostname (DNS-01 works even if it only resolves on your LAN).

Do I need to change anything in UltraTorrent for HTTPS? Only CORS_ORIGIN in .env, to your public https:// origin. The SPA uses relative URLs, so wss:// follows automatically.

Can I terminate TLS on the frontend container directly? No — it is a plain-HTTP nginx by design. Use a proxy.

Self-signed for a quick test? Fine for a lab, but every device will warn, and some tooling will refuse. mkcert is a better version of the same idea.

Does the bundled Caddy profile renew automatically? Yes, as long as it keeps running and caddy_data persists.

Checklist​

  • A hostname exists and (for HTTP-01) resolves to the host
  • The reverse proxy is working over plain HTTP first
  • Ports 80 and 443 reachable (HTTP-01) — or a DNS API token in place (DNS-01)
  • Certificate issued, fullchain served
  • openssl s_client reports Verify return code: 0 (ok)
  • HTTP redirects to HTTPS
  • Renewal automated and dry-run tested
  • Proxy reloads on renewal (deploy hook, for NGINX/HAProxy)
  • CORS_ORIGIN updated to the https:// origin; backend recreated
  • The live UI still updates over wss://
  • Certificate store included in backups
  • Private key is 0600, owned by the proxy's user, and not in git

See also​