Skip to content

Repository files navigation

certgen — Solve the SSL & DNS hassle

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.com subdomain 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.


Table of contents


What you get (the picture)

                           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

Is this for you?

✅ Yes, if…

  • You self-host a handful of services and you're tired of :8443 in 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

❌ Probably not, if…

  • 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

🤔 Honest caveats

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.


Home setup (Raspberry Pi / mini server in your LAN)

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.

Prerequisites

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)

Step 1 — A domain at Cloudflare

Any domain you own is fine, as long as Cloudflare is its DNS provider.

If you don't have a domain yet

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/

If you already have a domain at Cloudflare

Skip to step 2.

Step 2 — Create a Cloudflare API token

certgen never needs your Cloudflare password. It uses a scoped API token with just enough permission to edit DNS records on your domain.

  1. Log in at https://dash.cloudflare.com/
  2. Click your profile icon (top right) → My Profile
  3. Left sidebar → API Tokens
  4. Click Create Token
  5. Find the template "Edit zone DNS" and click Use template
  6. Permissions are pre-filled (Zone → DNS → Edit) — leave them
  7. Zone Resources — change All zones to:
    • IncludeSpecific zone → pick yourdomain.com
  8. (Optional) Set a TTL so the token auto-expires (e.g. 1 year)
  9. Click Continue to summaryCreate Token
  10. Copy the token immediately — Cloudflare only shows it once. It looks like cfut_xxxxxxxxxxxxxxxxxxxxxxxxx. Save it somewhere safe (password manager).

Step 3 — Install certgen

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 ~/certgen

Don't have Docker yet? curl -fsSL https://get.docker.com | sudo sh

Step 4 — Configure .env

cp .env.example .env
nano .env

Fill 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=1 first? 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.

Step 5 — Get the wildcard certificate

./scripts/cert-issue.sh

You 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.sh

Verify 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 -dates

The issuer should be Let's Encrypt, not (STAGING) Let's Encrypt.

Step 6 — Define your services

cp services.example.yaml services.yaml
nano services.yaml

Each 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: Plex

Optional 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 service

Full field reference: see services.yaml reference below.

Step 7 — Apply

./scripts/apply-config.sh

This:

  1. Renders an nginx site at /etc/nginx/sites-available/yourdomain.com.conf with a server block per service
  2. Renders the dashboard at /var/www/html/yourdomain.com/index.html
  3. Writes /etc/dnsmasq.d/yourdomain.com.conf with a wildcard rule pointing *.yourdomain.com → PI_IP
  4. Runs nginx -t and reloads nginx
  5. Restarts dnsmasq
  6. Prints all the URLs you can now visit

Step 8 — Make every device in your LAN use it

The certificate works everywhere. The wildcard DNS only works for devices that ask this server as their DNS resolver. You have three options.

Option A — Tell your router to hand out this server as DNS (recommended)

This is the cleanest path: every device on your Wi-Fi automatically uses your Pi as DNS without any per-device setup.

Fritz!Box:

  1. Visit http://fritz.box and log in
  2. Home Network → Network → Network Settings tab
  3. Scroll down → click the "IPv4 Settings" button (bottom right)
  4. Under "Local DNS Server" enter your Pi's IP (e.g. 192.168.1.10)
  5. Click OKApply

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.

Option B — Just use Cloudflare DNS even for the LAN 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.

Option C — Configure each device manually

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).

What about my phone outside the home network?

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.

Step 9 — Auto-renewal

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 50

You can also run a renewal check on demand:

./scripts/cert-renew.sh

It's idempotent — if the cert isn't due, it does nothing.


Internet setup (public VPS)

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.


External access via Tailscale (optional)

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.com

With 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.

Setup

  1. Install Tailscale on this host and note its Tailscale IP:

    tailscale ip -4        # e.g. 100.81.242.111
  2. Point the external wildcard at it. In Cloudflare DNS, add an A record:

    *.ext.yourdomain.com   A   100.81.242.111
    

    It'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.com does not cover code.ext.yourdomain.com. That's why EXT_DOMAIN adds its own *.${EXT_DOMAIN} to the cert.

  3. Set EXT_DOMAIN in .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.x IP. A pretty ${EXT_DOMAIN} hostname is optional convenience there.


How it works

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 server block 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.


services.yaml reference

# 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.


Security considerations — read this

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_zone if 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/ and services.yaml somewhere if you don't want a renewal-day surprise.

Things to think about before exposing anything publicly

  • "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.


When NOT to use this

  • 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.

Troubleshooting

cert-issue.sh fails with "DNS problem" / "no TXT record"

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"

nginx -t complains about duplicate server_name

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 nginx

dnsmasq won't restart — "address already in use"

Port 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.

Subdomain works in curl but not in the browser

Browser DNS cache. Open chrome://net-internals/#dns and clear it, or use an incognito window.

The Pi itself can't resolve *.yourdomain.com

/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"

A service renders absolute http:// URLs in its HTML (mixed content)

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.

Cert renewal fires but nginx doesn't pick it up

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.


Contributing

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.


License

MIT.

About

Easy solve the SSL+DNS hustle for your local server (cloudflare optimized)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages