← Back to Wiki
PKI / Networking

Give Your Homelab Real HTTPS Certificates With Your Own Internal CA

If you run more than a couple of self-hosted admin tools on your LAN, you've hit the self-signed-certificate warning wall. Here's how to run your own internal certificate authority so every internal hostname gets a real, browser-trusted cert — no more click-through warnings.

Why bother

Self-signed certs (or plain HTTP) for internal-only admin tools — your hypervisor, your NAS, your monitoring dashboard — are annoying in a way that compounds. Every new browser profile has to click through a warning once, bookmarks look untrustworthy, and browser automation tools often flatly refuse to attach to a page showing a certificate warning at all. A private internal CA fixes all of this at once: mint your own root certificate, trust it once per device, and every internal hostname just works.

The tool: step-ca

step-ca (Smallstep) is a small, self-contained certificate authority that speaks ACME (the same protocol Let's Encrypt uses) — meaning tools that already know how to request a Let's Encrypt certificate can be pointed at your own CA instead with minimal changes. Run it in Docker with an env-driven first-boot bootstrap:

services:
  step-ca:
    image: smallstep/step-ca:latest
    container_name: step-ca
    restart: unless-stopped
    ports:
      - "9000:9000"
    volumes:
      - /opt/stepca/data:/home/step
    environment:
      DOCKER_STEPCA_INIT_NAME: "My Internal CA"
      DOCKER_STEPCA_INIT_DNS_NAMES: "stepca.internal.example.com,<ca-host-ip>,localhost"
      DOCKER_STEPCA_INIT_PROVISIONER_NAME: "acme"
      DOCKER_STEPCA_INIT_ACME: "true"
      DOCKER_STEPCA_INIT_PASSWORD_FILE: /home/step/secrets/password.txt
Gotcha — the container image runs as UID 1000. The host-mounted data directory needs chown -R 1000:1000 before first boot, or the container can't write its own password/config files.
Gotcha — default cert lifetime is 24 hours. Add an explicit claims block to both provisioners in ca.json, or every issued certificate expires in a day:
{
  "type": "ACME",
  "name": "acme-1",
  "claims": {
    "minTLSCertDuration": "5m",
    "maxTLSCertDuration": "2160h",
    "defaultTLSCertDuration": "720h"
  }
}

Native ACME clients — the easy path

Anything with a built-in ACME client (Proxmox, backup software, etc.) can just be pointed at your CA's ACME directory URL instead of Let's Encrypt:

pvenode acme account register default [email protected] \
  --directory https://stepca.internal.example.com:9000/acme/acme-1/directory
pvenode config set --acme domains=pve.internal.example.com
pvenode acme cert order --force

Auto-renewal is handled by whatever timer/scheduler the product already runs for its own certs — nothing extra to set up.

The reverse proxy gotcha

If your reverse proxy (Caddy, in this case) already has a wildcard certificate loaded for your public domain (e.g. a Cloudflare Origin certificate covering *.example.com), adding a per-site ACME directive pointing at your internal CA will not trigger a new certificate request — even with correct DNS and trust in place. The reverse proxy checks "is this domain already covered by any loaded certificate" server-wide before deciding whether to run ACME automation for a given site, and a wildcard cert satisfies that check regardless of which site block it was actually meant for.

The fix: mint certificates directly with the CA's own CLI (bypassing ACME's challenge-response flow entirely, which is fine since this is your own private CA), and load them as static files:

docker exec step-ca step ca certificate internal-app.example.com \
  /home/step/certs/internal-app.crt /home/step/certs/internal-app.key \
  --provisioner acme --password-file /home/step/secrets/password.txt -f
internal-app.example.com {
	tls /config/internal/internal-app.crt /config/internal/internal-app.key
	reverse_proxy <backend>
}

The tradeoff: static certs don't auto-renew like ACME-managed ones do. Fix it with a simple daily cron job that checks each cert's remaining validity and re-issues it once it's within ~10 days of expiry, copying the result to wherever your reverse proxy reads certs from.

Trusting the root certificate

Installing the root cert into your OS's system trust store is not enough on Linux — browsers keep their own separate certificate databases:

Both browsers cache their trust store in memory for the life of the process — fully quit and relaunch (not just close the tab) after importing for the change to take effect.

Lesson: before trusting that a reachable-looking reverse-proxy config is actually being used, check what DNS actually resolves the hostname to — a site block that validates syntactically doesn't mean it's on any live request path.