Homelab Rebuild Part 8: Running My Own Certificate Authority

The annoyance Link to heading

Every internal service with a web interface greets you with a certificate warning. Firewall, NAS, backup server, access point โ€” same picture everywhere, one click to dismiss each time.

It isn’t just tedious. It trains you to click warnings away, and eventually you’ll dismiss one that mattered. Exactly the reflex you don’t want to build.

The fix is your own certificate authority. One root certificate installed on your devices, and from then on the browser trusts everything issued by it.

Why step-ca Link to heading

Self-signed certificates would have been the obvious route. Two OpenSSL commands and you’re done.

Except it doesn’t scale: every certificate has to be generated, distributed and renewed individually. With a handful of services that’s a calendar reminder you’ll miss.

step-ca speaks ACME โ€” the same protocol Let’s Encrypt uses on the public internet, just pointed inward. The service fetches its own certificate and renews it on its own. You set it up once and don’t touch it again.

The structure is the usual one: a root certificate with a long lifetime that could stay offline, and an intermediate doing the actual work. Only the intermediate signs โ€” lose it and you replace it without every device needing a new root.

How it fits together Link to heading

Flow diagram: the firewall requests a certificate from step-ca, step-ca verifies control of the name by connecting back over HTTP, issues the certificate, and afterwards the web server restarts.

The return path is the interesting part โ€” all four faults were there.

The flow has four steps, and the order explains why debugging was so tedious:

  1. The firewall asks the CA for a certificate for its name
  2. The CA checks whether the requester really controls that name โ€” by opening a connection to it itself
  3. Only if that works does it issue the certificate
  4. And then the service actually has to load it

Step two is the crux. The CA connects back, and anything standing in the way of that return path makes issuance fail โ€” with errors that look at first glance like a problem at the requesting end.

What went wrong Link to heading

Four faults. Three on the return path, one after it.

The name that only resolved over IPv6 Link to heading

Issuance failed with a timeout. A curl test from another machine showed the port was reachable. So why couldn’t the CA get through?

Because it took a different route. Internal DNS answered the firewall’s name with IPv6 addresses only โ€” two of them, for LAN and WAN. There was no IPv4 address at all.

And the container running the CA has no IPv6 route. The attempt failed before a packet left the box:

Immediate connect fail: Network is unreachable

No timeout, no firewall โ€” the address family simply wasn’t reachable.

The lesson: when a check runs over a name, test what the checking machine resolves. Not what your laptop resolves. A getent hosts name on the right box would have shown it in ten seconds.

Fixed with a host entry that returns IPv4 only.

The IP address nobody would issue for Link to heading

The certificate request contained two identifiers: a hostname plus the firewall’s IP address. Sensible on paper โ€” then both routes work without a warning.

The CA disagreed:

"status":"invalid"
"type":"urn:ietf:params:acme:error:rejectedIdentifier"
"detail":"The server will not issue certificates for the identifier"

Certificates for IP addresses are possible but not universally allowed, and the default configuration refuses them. As long as the address was in the request, the whole order could never complete โ€” one rejected identifier fails the entire request, even when everything else is fine.

Fixed by dropping the IP. The price: the interface has to be reached by name; over the address the warning stays.

The web server blocking its own return path Link to heading

Now the check arrived โ€” and got the wrong thing.

For verification the client places a file that the CA fetches over port 80. Except the firewall’s own web server answered there and issued a redirect to HTTPS:

< HTTP/1.1 301 Moved Permanently
< Location: https://.../.well-known/acme-challenge/...

A chicken-and-egg problem in its purest form: the service the certificate is meant for occupies exactly the port the verification runs over.

The firewall’s ACME plugin has a mode that extends its own web server with a snippet of config for the duration of the check. That mode was selected โ€” but didn’t take effect, because a field for listening addresses held the IP of the CA instead of the firewall’s own. A value that doesn’t exist on that machine at all.

You don’t find that by thinking. Only by looking.

The certificate nobody loaded Link to heading

And then the fault that would have gone unnoticed longest.

Issuance completed. The plugin showed a fresh certificate with a new expiry date. All good?

No. A request from outside still returned the old one:

echo | openssl s_client -connect name:443 2>/dev/null \
  | openssl x509 -noout -dates -serial

The serial number was unchanged. The web server was holding the old file in memory. Renewal and delivery are two separate things, and one doesn’t follow automatically from the other.

Fixed with an automation in the plugin that restarts the web server after every renewal. Verified with a forced renewal: the serial changed without me doing anything by hand.

And the broader lesson: don’t check whether the software says something happened. Check what actually arrives.

The lifetime trap Link to heading

One thing you only notice the next morning: step-ca issues certificates with a 24-hour lifetime by default.

As a default that’s well considered โ€” short lifetimes are safer, and with working automation you never notice. Except that automation then has to run flawlessly every single day. Miss once and you’re facing an expired interface in the morning.

For a home network I consider that too tight. I set the lifetime to 30 days. A single failed run no longer hurts, and I have most of a month to notice.

The setting lives in the CA’s claims. Important detail: claims on the provisioner override those at authority level. Set them at the top while the bottom has its own, and nothing changes.

Plus a cron entry restarting the web server once a night โ€” a fallback in case the automation silently stops working after an update. Costs nothing and catches the case you’d otherwise notice four weeks later.

Learned along the way Link to heading

Two small things that cost time.

Two renewals running in parallel don’t get along. I’d clicked “renew” twice in the plugin because nothing seemed to happen the first time. The result was an error about an invalid one-time value โ€” both runs shared the same working directory and got in each other’s way. Click once, then wait.

The firewall’s root shell is csh, not bash. Constructs like 2>/dev/null before a pipe behave differently there and return “Ambiguous output redirect”. Either use csh syntax or put a bash -c '...' in front. It gets you precisely when you’re trying to debug something under time pressure.

Distributing the root certificate Link to heading

For the browser to trust any of this, the root certificate has to reach your devices. And there’s a trap here:

Firefox uses its own certificate store. Installing the root in the operating system isn’t enough โ€” on Linux, Firefox won’t pick it up. The import goes through its settings, and you have to tick “Trust this CA to identify websites”. Without that the certificate is imported but does nothing.

For several machines you can do it centrally via a policy file. The operating system still needs the certificate separately so that command-line tools and other browsers follow.

Check the fingerprint before importing. A root certificate is a trust decision โ€” install the wrong one and you have a problem you can no longer see.

Where things stand Link to heading

The firewall interface shows a green padlock. The certificate renews itself, gets imported by itself, and the web server reloads it by itself.

What’s still outstanding: proof that the fully automatic run works without my involvement. I’ve tested it with a forced renewal; the scheduled one is due mid-September. I’ll write a follow-up on how it went.

And the chain is built for one service so far. NAS, backup server and Proxmox still show their warnings โ€” each of them speaks ACME or can at least accept a certificate. That’s busywork for a quiet evening.

Next up Link to heading

Next come the rules. The VLANs are in place, devices land in the right network automatically โ€” but traffic is still allowed everywhere. Only once that’s constrained does the separation become a boundary.


Part of a series about rebuilding my home network. As of August 2026. Questions, corrections and your own experiences are welcome in the comments.