Real HTTPS subdomains for your home services or VPS, without the yak-shaving.
You point a Cloudflare-hosted domain at this tool, list your services in a YAML file, and a few minutes later you have:
- A real Let's Encrypt wildcard certificate (
*.yourdomain.com) - A pretty
https://*.yourdomain.comsubdomain for every service - A
https://yourdomain.com/dashboard listing them all - Automatic certificate renewal
- (Home mode) Local DNS so it all works inside your LAN without exposing anything to the internet
No clicking through Nginx Proxy Manager, no Traefik labels on every container,
no pi5.local:8442 URLs.
- What you get (the picture)
- Is this for you?
- Home setup (Raspberry Pi / mini server in your LAN)
- Internet setup (public VPS)
- External access via Tailscale (optional)
- How it works
services.yamlreference- Security considerations — read this
- When NOT to use this
- Troubleshooting
- Contributing
- License
Cloudflare DNS API
│
│ DNS-01 challenge
▼
┌────────────────────┐ ┌──────────┐ ┌──────────────────┐
│ Your laptop/phone │◄──►│ certbot │◄──►│ Let's Encrypt │
│ (LAN or WAN) │ │ (Docker) │ │ wildcard cert │
└─────────┬──────────┘ └────┬─────┘ └──────────────────┘
│ HTTPS │ writes cert
│ *.yourdomain.com ▼
▼ ┌──────────────────────────────┐
┌──────────────────┐ │ /etc/letsencrypt/live/ │
│ dnsmasq (home) │ │ yourdomain.com/ │
│ *.yourdomain │ └──────────────┬───────────────┘
│ → 192.168.x.x │ │
└─────────┬────────┘ ▼
│ ┌──────────────────────────────┐
▼ │ nginx │
│ ┌────────────────────────┐ │
┌──────────────────┐ │ │ apex → dashboard.html │ │
│ services.yaml ├────►│ │ jupyter → :8888 │ │
│ (you edit this) │ │ │ chat → :3000 │ │
└──────────────────┘ │ │ vibe → :3029 │ │
│ │ … │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
Two modes, almost identical config:
| Mode | When | DNS | Public ports |
|---|---|---|---|
home |
Pi / mini-server in your LAN | local dnsmasq | none |
internet |
VPS, public website | Cloudflare A-records | 80 + 443 |
- You self-host a handful of services and you're tired of
:8443in URLs - You want a single Let's Encrypt cert that covers everything
- You're comfortable editing a YAML file and running shell scripts
- You're OK with nginx and Docker on the host
- You want a click-everything UI → use Nginx Proxy Manager
- You want service discovery from container labels → use Traefik
- You don't have a Cloudflare-hosted domain (see the workaround)
- Your services need to be reachable on the public internet with auth — certgen doesn't add authentication; see Security considerations
certgen is opinionated: it assumes nginx on the host, Cloudflare DNS, Docker for certbot, and one wildcard cert per domain. If any of those don't fit, you'll fight the tool. It's also a starter kit, not a turnkey production setup — see the security section before exposing anything publicly.
This is the most common use case. You have a Raspberry Pi (or a Mac mini /
NUC / old laptop) running a few Docker services, and you want clean URLs
like https://jupyter.home.example.com/ instead of http://192.168.1.10:8888.
On the server that will run certgen:
- Linux (Debian/Ubuntu/Raspberry Pi OS tested; should work on anything with apt and systemd)
- Docker (for the certbot container)
- nginx installed and running on the host (
sudo apt install nginx) - dnsmasq installed and running on the host
(
sudo apt install dnsmasq) — used for in-LAN name resolution - python3-yaml (
sudo apt install python3-yaml) - A Cloudflare-hosted domain (see step 1 if you don't have one yet)
Any domain you own is fine, as long as Cloudflare is its DNS provider.
You don't need a "professional" TLD. .cc, .xyz, .click etc. are cheap
(~$5–15/year) and work just as well for a homelab. Two easy paths:
- Buy the domain at Cloudflare directly
(
Account → Domain Registration → Register Domains). Cloudflare sells at cost; DNS is automatically managed. - Buy elsewhere (Namecheap, Porkbun, Hetzner, INWX, IONOS, …) — then add the zone to Cloudflare and point your registrar's nameservers at Cloudflare's. This takes ~24h to propagate. Instructions: https://developers.cloudflare.com/dns/zone-setups/full-setup/setup/
Skip to step 2.
certgen never needs your Cloudflare password. It uses a scoped API token with just enough permission to edit DNS records on your domain.
- Log in at https://dash.cloudflare.com/
- Click your profile icon (top right) → My Profile
- Left sidebar → API Tokens
- Click Create Token
- Find the template "Edit zone DNS" and click Use template
- Permissions are pre-filled (
Zone → DNS → Edit) — leave them - Zone Resources — change
All zonesto:Include→Specific zone→ pickyourdomain.com
- (Optional) Set a TTL so the token auto-expires (e.g. 1 year)
- Click Continue to summary → Create Token
- Copy the token immediately — Cloudflare only shows it once.
It looks like
cfut_xxxxxxxxxxxxxxxxxxxxxxxxx. Save it somewhere safe (password manager).
sudo apt update
sudo apt install -y git nginx dnsmasq python3-yaml docker.io docker-compose-v2
sudo usermod -aG docker $USER
# Log out and back in for the docker group to take effect.
git clone https://github.com/stefan-kp/certgen.git ~/certgen
cd ~/certgenDon't have Docker yet?
curl -fsSL https://get.docker.com | sudo sh
cp .env.example .env
nano .envFill in:
CLOUDFLARE_TOKEN=cfut_...the-token-from-step-2...
DOMAIN=yourdomain.com
LETSENCRYPT_EMAIL=you@yourdomain.com
MODE=home
PI_IP=192.168.1.10 # this server's IP in your LAN — find it with: ip -4 addr | grep eth0
LE_STAGING=1 # ← keep at 1 for the first run!
EXT_DOMAIN= # optional — see "External access via Tailscale" below. Leave empty for now.💡 Why
LE_STAGING=1first? If your DNS is misconfigured or the token permissions are wrong, you'll burn through Let's Encrypt's production rate limit (5 failures per hour). Staging has no such limit — verify it works there first, then switch to production.
./scripts/cert-issue.shYou should see:
[certgen] MODE=home → DNS-01 challenge via Cloudflare (wildcard *.yourdomain.com)
…
Successfully received certificate.
[certgen] ✅ Certificate stored at: …/letsencrypt/live/yourdomain.com/fullchain.pem
The cert lives under letsencrypt/live/yourdomain.com/ (owned by root,
because certbot insists). It's a real cert from the staging CA — your
browser will warn that the issuer is not trusted, that's expected.
Now switch to production:
# 1. Flip the flag
sed -i 's/LE_STAGING=1/LE_STAGING=0/' .env
# 2. Throw away the staging cert + account state for this domain
sudo rm -rf letsencrypt/live/yourdomain.com \
letsencrypt/archive/yourdomain.com \
letsencrypt/renewal/yourdomain.com.conf
# 3. Re-issue
./scripts/cert-issue.shVerify it's a production cert:
echo | openssl s_client -connect 127.0.0.1:443 -servername anything.yourdomain.com 2>/dev/null \
| openssl x509 -noout -issuer -datesThe issuer should be Let's Encrypt, not (STAGING) Let's Encrypt.
cp services.example.yaml services.yaml
nano services.yamlEach entry maps a subdomain to a backend. Minimal example:
apex_dashboard: true
services:
- name: jupyter
port: 8888 # → https://jupyter.yourdomain.com → http://127.0.0.1:8888
- name: chat
port: 3000
title: Open WebUI
icon: "🤖"
tag: AI
desc: Local LLM frontend
- name: plex
target: http://192.168.1.40:32400 # service on another machine in the LAN
title: PlexOptional helper — scan your sibling docker_apps/* for compose files and
suggest entries:
./scripts/discover-services.sh # print suggestions
./scripts/discover-services.sh --append # interactive y/N per serviceFull field reference: see services.yaml reference
below.
./scripts/apply-config.shThis:
- Renders an nginx site at
/etc/nginx/sites-available/yourdomain.com.confwith a server block per service - Renders the dashboard at
/var/www/html/yourdomain.com/index.html - Writes
/etc/dnsmasq.d/yourdomain.com.confwith a wildcard rule pointing*.yourdomain.com → PI_IP - Runs
nginx -tand reloads nginx - Restarts dnsmasq
- Prints all the URLs you can now visit
The certificate works everywhere. The wildcard DNS only works for devices that ask this server as their DNS resolver. You have three options.
This is the cleanest path: every device on your Wi-Fi automatically uses your Pi as DNS without any per-device setup.
Fritz!Box:
- Visit http://fritz.box and log in
- Home Network → Network → Network Settings tab
- Scroll down → click the "IPv4 Settings" button (bottom right)
- Under "Local DNS Server" enter your Pi's IP (e.g.
192.168.1.10) - Click OK → Apply
Devices already on Wi-Fi might need to reconnect (or renew their DHCP lease) to pick up the new DNS.
Other routers: look for "DHCP", "DNS server", or "LAN DNS". The setting is typically named "DNS server given out to clients" or similar. Set it to the Pi's IP.
A simpler alternative: in Cloudflare, add A-records pointing
yourdomain.com and *.yourdomain.com directly at your LAN IP (e.g.
192.168.1.10). Public DNS will return the private IP for anyone who looks
it up — that's fine, knowing your LAN IP without being on the LAN is
useless.
Caveat: most consumer routers (including Fritz!Box) have a "DNS Rebind Protection" that drops public DNS answers containing private IPs. You'll need to whitelist your domain:
Fritz!Box rebind whitelist: Home Network → Network → Network Settings → DNS Rebind Protection → Hostnames, add yourdomain.com.
Once that's done, no DNS setup on any device is needed — public DNS just works. Trade-off: your Pi is now a dependency only as a webserver (not as a DNS server), and your LAN IP is visible in public DNS.
If you can't touch the router, set DNS server 192.168.1.10 on each
device individually (macOS: Network → Wi-Fi → Details → DNS; iOS: Wi-Fi
ⓘ → Configure DNS; Android: Wi-Fi → IP settings → Static).
None of the above helps when you're on cellular or someone else's Wi-Fi. For that you need a VPN back to your home network (Tailscale, WireGuard, Cloudflare Tunnel). certgen doesn't set that up — it's outside scope. If you want it, Tailscale is the easiest entry point.
Let's Encrypt certs are valid for 90 days. certgen ships a systemd timer that checks weekly and renews when there's less than 30 days left.
Edit systemd/certgen-renew.service and adjust the paths/user to match
your install (default expects /opt/certgen and user stefan).
If sudo systemctl reload nginx requires a password for your user, either
set User=root in the unit (simplest) or add a passwordless sudoers entry:
yourusername ALL=(root) NOPASSWD: /bin/systemctl reload nginx
Then install:
sudo ./systemd/install.sh
# Verify
systemctl list-timers certgen-renew.timer
journalctl -u certgen-renew.service -n 50You can also run a renewal check on demand:
./scripts/cert-renew.shIt's idempotent — if the cert isn't due, it does nothing.
The same tool, with MODE=internet. Slightly different DNS setup (Cloudflare
A-records pointing at your public IP, no dnsmasq), and significantly more
work before certgen — you have to harden the VPS, set up SSH/firewall,
install Docker.
A full walkthrough from "I just bought a VPS" to "HTTPS is live" is in INTERNET-SETUP.md.
MODE=home keeps everything on your LAN. But what if you want to reach the
same services from your phone or laptop when you're not home — without
exposing anything to the public internet?
Tailscale gives every device a stable IP in the
100.64.0.0/10 range, reachable only inside your private tailnet. certgen can
serve all your services under a second domain that points at that
Tailscale IP — alongside the LAN setup, on the same cert.
Set one variable in .env:
EXT_DOMAIN=ext.yourdomain.comWith this set:
- The wildcard cert is also issued for
*.${EXT_DOMAIN}(a single cert, same renewal lineage). - Every service gets a second
server_name, so it answers at both${name}.${DOMAIN}(LAN) and${name}.${EXT_DOMAIN}(Tailscale). - The dashboard rewrites its links to
*.${EXT_DOMAIN}automatically when opened via the external domain, so the links stay reachable from outside.
-
Install Tailscale on this host and note its Tailscale IP:
tailscale ip -4 # e.g. 100.81.242.111 -
Point the external wildcard at it. In Cloudflare DNS, add an
Arecord:*.ext.yourdomain.com A 100.81.242.111It's a private IP, so it only resolves usefully from devices on your tailnet — that's the whole point.
Wildcard certs only cover one label, so
*.yourdomain.comdoes not covercode.ext.yourdomain.com. That's whyEXT_DOMAINadds its own*.${EXT_DOMAIN}to the cert. -
Set
EXT_DOMAINin.env, then re-issue the cert and re-apply:./scripts/cert-issue.sh # extends the cert with *.${EXT_DOMAIN} ./scripts/apply-config.sh # adds the second server_name to every block
In MODE=home the local dnsmasq wildcard (*.${DOMAIN} → PI_IP) already
covers *.${EXT_DOMAIN} too, so nothing extra is needed for LAN resolution.
Note: this only proxies HTTP(S). Non-HTTP protocols (SMB, SSH, etc.) don't go through nginx — reach those directly over Tailscale on the host's
100.xIP. A pretty${EXT_DOMAIN}hostname is optional convenience there.
There are three moving parts and one config file:
1. services.yaml — the source of truth. You edit this; everything else
is generated.
2. scripts/render.py — reads services.yaml and emits two things:
- An nginx site config (one
serverblock per service + a catch-all + an apex dashboard vhost) - The dashboard HTML (cards for every enabled service, linking to its subdomain)
3. scripts/apply-config.sh — calls render.py, writes the outputs
into the right places, runs nginx -t, reloads nginx, and updates dnsmasq
(home mode only).
4. Certbot in Docker — issues and renews the wildcard cert using
Cloudflare's DNS API. Triggered by cert-issue.sh (once) and
cert-renew.sh (weekly via systemd timer).
That's it. No daemons of certgen's own, no controller, no admin UI. The running pieces are nginx, dnsmasq, and a 30-second weekly certbot container.
# If true (default), https://${DOMAIN}/ and https://www.${DOMAIN}/ render a
# dashboard listing every enabled service. Set false to return 404 on the apex.
apex_dashboard: true
services:
- name: jupyter # REQUIRED. Becomes ${name}.${DOMAIN}.
port: 8888 # Shorthand: target = http://127.0.0.1:8888
# OR
target: http://192.168.1.40:8096
# Full URL, when the service runs on another host
# or on a non-standard scheme.
enabled: true # Default true. Set false to disable without deleting.
websocket: true # Default true. Harmless for plain HTTP services;
# required for Jupyter, code-server, ttyd, etc.
# ----- Dashboard fields (all optional) -----
title: Jupyter Notebook
icon: "📓"
tag: Data Science
desc: Interactive notebooks for data analysis.
dashboard: true # Default true. Set false to proxy but not show
# the service on the dashboard.To make changes take effect: ./scripts/apply-config.sh.
This is a starter kit, not a security audit. What's covered and what isn't:
What certgen does:
- Browser-trusted HTTPS via Let's Encrypt with automatic renewal
- TLS 1.2 + 1.3 only
- HTTP→HTTPS redirect on every subdomain
- A 404 catch-all so typo'd subdomains don't accidentally land on another service
What certgen does NOT do:
- No authentication. Whatever you proxy is reachable by whoever can reach your nginx — that's the whole LAN (home mode) or the whole internet (internet mode). If a service has no built-in auth, anyone can use it. Bolt it on with Authelia, Authentik, oauth2-proxy, or by putting the service behind a VPN/Tailscale.
- No rate-limiting. Easy to add via nginx
limit_req_zoneif you need it. - No service isolation. Containers on a shared Docker network can reach each other. Use separate networks, user namespacing, or rootless Docker for stronger isolation.
- No traffic encryption between nginx and your containers. Usually fine
on
127.0.0.1. If you proxy across machines on an untrusted network, add TLS on that hop too. - No monitoring or alerting. A service can be 502 for weeks without certgen noticing. Use Uptime Kuma or similar.
- No backups. Snapshot
letsencrypt/andservices.yamlsomewhere if you don't want a renewal-day surprise.
- "I have a chat UI" sounds harmless until someone finds your Ollama instance, ramps the GPU, and you eat the bill. Even local-only models can leak data if any service has an upload endpoint without auth.
- Read your VPS provider's terms of service for prohibited workloads.
- Have an "off switch" plan. The quick one:
sudo systemctl stop nginx. - Rotate your Cloudflare API token if it ever lands in a screenshot, paste, or backup that you wouldn't trust someone else with.
If your services handle money, personal data, or anything you'd hate to leak, treat this as the baseline on top of which you add your own measures — not as the finished product.
- You want zero-config service discovery from Docker labels → Traefik is purpose-built for that.
- You want a clicky web UI for everything → Nginx Proxy Manager.
- You don't run nginx on the host and don't want to add it → this isn't the tool. The whole design assumes one nginx on the host.
- You need clustered HA or load-balancing → certgen is single-host.
- Your domain isn't on Cloudflare and you can't move it → see INTERNET-SETUP.md → "If you don't have a Cloudflare-hosted domain".
- You want a "best practices" production setup → certgen is "good enough for a homelab." Production needs auth, monitoring, backups, capacity planning, incident playbooks — none of which are here.
The Cloudflare token doesn't have permission, or you set DOMAIN to
something Cloudflare doesn't manage.
# Verify the token can reach the API:
curl -sH "Authorization: Bearer $CLOUDFLARE_TOKEN" \
https://api.cloudflare.com/client/v4/user/tokens/verify
# Should return "status": "active"Another site in /etc/nginx/sites-enabled/ uses the same subdomain.
List all server_names:
sudo grep -rE '^\s*server_name' /etc/nginx/sites-enabled/Disable the conflicting site:
sudo rm /etc/nginx/sites-enabled/conflicting-site.conf
sudo systemctl reload nginxPort 53 is held by systemd-resolved. Either disable it
(sudo systemctl disable --now systemd-resolved) or configure dnsmasq to
listen on a different interface only.
Browser DNS cache. Open chrome://net-internals/#dns and clear it, or
use an incognito window.
/etc/resolv.conf likely points at your router. Make the Pi use its own
dnsmasq first:
# NetworkManager:
sudo nmcli connection modify "Wired connection 1" \
ipv4.dns "127.0.0.1 192.168.1.1" ipv4.ignore-auto-dns yes
sudo nmcli connection up "Wired connection 1"The service is ignoring X-Forwarded-Proto. Fix in the service itself
(for uvicorn: add --proxy-headers --forwarded-allow-ips='*' to the
command). For services you don't control, nginx sub_filter can rewrite
the body, but app-level fix is cleaner.
The systemd unit can't reload nginx without sudo. Either run the unit as
root (set User=root in certgen-renew.service) or add a passwordless
sudoers entry for systemctl reload nginx.
Issues and PRs welcome. This started as a "scratch my own itch" project, so the YAML schema and the rendered nginx config reflect one person's preferences. If you'd like to extend it (other DNS providers, other reverse proxies, an auth layer, …), open an issue first to talk through the design.
MIT.