Why TLS made my homelab actually usable
Before I set this up, every internal service had the same problem. I’d open Grafana, Vaultwarden, Gitea, Uptime Kuma — and every single one greeted me with a browser warning. “Your connection is not private.” Click through. Click through again. Sometimes the browser just refused entirely.
It’s a small thing until it isn’t. You stop checking services because the friction adds up. You paste URLs into Slack and the preview breaks. You try to access something from your phone and the cert error is non-dismissable. Self-signed certs are a tax you pay every single day.
I fixed it once and haven’t thought about it since.
What I actually wanted
Eight services. One cert. No manual renewal. No clicking through warnings. Valid padlock in every browser, on every device, including mobile.
The solution is a wildcard cert for *.lab.securebytes.net — one cert that covers every subdomain. grafana.lab.securebytes.net, vault.lab.securebytes.net, git.lab.securebytes.net — all valid, all automatic.
Why DNS-01 and not HTTP-01
The standard way Let’s Encrypt validates a cert is HTTP-01 — it hits a URL on your server to prove you control it. That works fine for public-facing servers. It doesn’t work for internal services that aren’t reachable from the internet, and it definitely doesn’t work for wildcard certs.
DNS-01 is different. Instead of hitting your server, Let’s Encrypt asks you to create a TXT record in your DNS zone. You prove control of the domain by controlling its DNS, not by serving a file. That works for internal services, and it’s the only method that issues wildcards.
I run my DNS through Cloudflare, so the whole flow is automated — acme.sh talks to the Cloudflare API, creates the TXT record, gets the cert, deletes the record. Zero manual steps.
Step 1: scope the API token
Don’t use your global Cloudflare API key. Create a token scoped to exactly what acme.sh needs.
In the Cloudflare dashboard: My Profile → API Tokens → Create Token.
Use the “Edit zone DNS” template, then scope it down:
- Permissions:
Zone → DNS → Edit - Zone Resources:
Specific zone → securebytes.net
That token can only edit DNS records for my zone. If it leaks, the blast radius is one domain’s DNS — not my entire Cloudflare account.
export CF_Token="your_token_here"
export CF_Account_ID="your_account_id"
Step 2: install acme.sh
acme.sh is a single shell script. No Python, no Go, no Docker required — just bash. That matters on a small LXC where you don’t want a runtime footprint.
curl https://get.acme.sh | sh -s email=you@example.com
That installs to ~/.acme.sh/ and registers a cron job for auto-renewal. You’re done with setup.
Step 3: issue the wildcard cert
export CF_Token="your_scoped_token"
~/.acme.sh/acme.sh --issue \
--dns dns_cf \
-d lab.securebytes.net \
-d *.lab.securebytes.net \
--server letsencrypt
The --dns dns_cf flag tells acme.sh to use the Cloudflare plugin. The two -d flags request both the apex (lab.securebytes.net) and the wildcard (*.lab.securebytes.net) — the wildcard alone doesn’t cover the apex.
acme.sh creates the TXT record, waits for propagation, validates, pulls the cert. On first run this takes about 2 minutes. After that, renewals run silently via cron every 60 days.
Step 4: nginx as the reverse proxy
One nginx instance handles all routing. Each service gets a vhost that terminates TLS and proxies to the internal IP.
server {
listen 443 ssl;
server_name grafana.lab.securebytes.net;
ssl_certificate /root/.acme.sh/lab.securebytes.net/fullchain.cer;
ssl_certificate_key /root/.acme.sh/lab.securebytes.net/lab.securebytes.net.key;
location / {
proxy_pass http://10.10.10.5:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
listen 80;
server_name grafana.lab.securebytes.net;
return 301 https://$host$request_uri;
}
Same cert path for every vhost. Add a new service, copy the block, change the server_name and proxy_pass. That’s it.
What this actually changes
Every internal service now has a valid cert. No browser warnings. No click-throughs. Links work in Slack previews. Mobile browsers don’t complain. Password manager autofill works correctly because the origin is consistent.
More practically: I stopped avoiding services. When checking on something is frictionless, you actually check on it. The monitoring I set up started getting used the way monitoring is supposed to be used.
Renewal is fully automated. I’ve had this running for months and haven’t touched the cert once. The cron job runs, acme.sh renews, nginx picks up the new cert on reload. The whole thing is invisible until it isn’t — which is the point.
The full setup, including the nginx vhost templates and the acme.sh deploy hook that reloads nginx after renewal, is documented in the SecureBytes Platform project.