← Back to Wiki
PKI / Networking

Run an Internal CA with step-ca for Real HTTPS on Homelab Services

Run more than a couple of self-hosted admin tools on your LAN and you have hit the self-signed-certificate warning wall. Here is how to run your own internal certificate authority, so every internal hostname gets a real browser-trusted cert. No more click-through warnings.

Share on X

Why bother

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

The tool: step-ca

step-ca, from Smallstep, is a small self-contained certificate authority that speaks ACME. The same protocol Let's Encrypt uses. So any tool that already knows how to request a Let's Encrypt certificate points 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
BE WARNED: the container image runs as UID 1000. The host-mounted data directory needs chown -R 1000:1000 before first boot, or the container cannot write its own password and config files.
BE WARNED: the 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 points at your CA's ACME directory URL instead of Let's Encrypt. Proxmox, backup software, plenty of others:

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

Whatever timer the product already runs for its own certs handles auto-renewal. Nothing extra to set up.

The reverse proxy gotcha

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

Fix it by minting certificates directly with the CA's own CLI, then loading them as static files. That bypasses ACME's challenge-response flow entirely, which is fine on your own private CA:

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 is that static certs do not auto-renew the way ACME-managed ones do. Fix that with a daily cron job. Check each cert's remaining validity, re-issue once it is within about 10 days of expiry, and copy 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 after importing, not just close the tab, or the change never takes effect.

Lesson: before you trust that a reachable-looking reverse-proxy config is in use, check what DNS resolves the hostname to. A site block that validates syntactically is not on any live request path by default.