Step-CA: Running Your Own Certificate Authority
An ACME server of your own, and the root you now have to look after forever

Contents
Running your own certificate authority used to be a punishment. The tooling was openssl invoked through a config file with a syntax apparently designed by someone settling a grudge, a directory of serial numbers you were expected to maintain by hand, and a folk tradition of copy-pasted commands nobody in the chain fully understood. The output was a certificate valid for ten years, because renewing it was so unpleasant that everyone silently agreed to do it once.
step-ca is what happens when someone looks at that and asks why a homelab cannot have the same thing Let’s Encrypt has. It is a small Go daemon that runs an ACME server on your own network. Point Caddy, Traefik, certbot, lego or cert-manager at it exactly as you would point them at Let’s Encrypt, change one URL, and they renew certificates against your CA automatically, forever, for names that never appear in a public log.
I ran a private CA for years before this existed and I ran it badly, which qualifies me to say: the tooling is now good, and it has changed which parts of the problem are hard. The hard part is no longer issuance. It is the root key and the trust distribution, and no software fixes those for you.
Why bother, when DNS-01 exists
I have argued at length that DNS-01 against Let’s Encrypt is the right first move for most homelabs, and I stand by it. Publicly trusted certificates for private names, no root to distribute, an hour of work. If that solves your problem, stop reading and go do that instead.
There are four situations where it does not solve the problem:
You do not want your names in Certificate Transparency. Every public certificate is logged permanently and publicly. A wildcard hides the specific names, and if even the domain is something you would rather not advertise, a private CA is the only way to have TLS without telling anyone.
You do not own a domain. DNS-01 requires DNS you control. A private CA is indifferent to all of that and will sign for mylab.local, an IP address, or a name you invented over lunch.
You want certificate lifetimes measured in hours. step-ca’s default is 24 hours and its maximum default is 24 hours, which is a statement of philosophy. Short lifetimes mean a stolen key is worthless by tomorrow and revocation — the part of PKI that has never worked properly — becomes an academic concern rather than an operational one. Let’s Encrypt will not issue you a 24-hour certificate.
You want to issue for things that are not web servers. Client certificates for mTLS, SSH host and user certificates, certificates bound to a device rather than a name. Public CAs do not do this for you.
If none of those are true for you, the honest answer is that a private CA is a hobby rather than a fix. That is a fine reason to run one — most of my rack is a hobby — but it should be a conscious one, because the root key is a permanent obligation.
The setup
Initialisation asks a handful of questions and produces a working CA. The important choice is made here and cannot be revisited later without reissuing everything, so read the flags before you type:
| |
What you get is a two-tier PKI: a root CA that signs exactly one thing (the intermediate), and an intermediate that signs everything else. This structure exists so the root key can go offline. It is the entire reason the design is shaped this way, and skipping it makes the rest of this article pointless.
Then enable the ACME provisioner, which is the feature that makes the whole thing worth using:
| |
And run it. It wants very little:
| |
Now the payoff. Caddy, pointed at your CA instead of Let’s Encrypt, is a two-line change:
| |
That is it. Caddy now renews an 8-hour certificate from your CA every few hours, without configuration, without a cron job, without ever thinking about it again. The first time I saw a certificate rotate three times in a day with nobody touching anything, I understood why the short-lifetime people are so smug about it.
The root key is the whole problem
Here is the part every tutorial skims and every real deployment lives or dies on. Your root CA key can mint a certificate for any name in existence — example.com, your bank, anything — and every device that trusts your root will believe it. On a network where you have installed that root on family laptops and phones, a stolen root key means someone can transparently intercept those people’s traffic to anywhere.
That is a bigger, sharper risk than anything else in a normal homelab, and it is created by this project rather than mitigated by it.
The two-tier structure is the answer, and it only works if you actually do the second half:
| |
The root key goes on encrypted offline media, in two places, with the passphrase somewhere you will find it in five years and nobody else will find at all. The running daemon keeps only the intermediate key, which has a limited lifetime and can be replaced by bringing the root out of the drawer for ten minutes. This is the entire operational discipline, and it takes fifteen minutes once.
A better version, if you have the hardware: keep the root on a YubiKey. step-ca supports PKCS#11 and YubiKey PIV, so the root key is generated on the token and physically cannot be extracted. Signing a new intermediate means plugging it in and touching it. That is a genuinely strong arrangement for something sitting in a drawer, and it removes the “is my encrypted USB stick still readable” question entirely.
Whatever you choose, do it on day one. The root key stays on the server “for now” in every deployment that ends badly.
Distributing trust
Every device that talks to your services needs your root certificate installed. This is the tax, and it is the reason DNS-01 wins for most people.
| |
Containers are their own small hell, because each image ships its own trust store and half of them do not use the system one. Go binaries read the system store; Python’s requests reads certifi’s bundle and ignores the system store entirely unless you set REQUESTS_CA_BUNDLE; Node reads its own compiled-in list unless you set NODE_EXTRA_CA_CERTS; Java has keytool and a temper. Budget an afternoon of finding out which of your services belongs to which church.
Android has made adding a user root progressively harder and applications can pin against user-added roots regardless. iOS needs the profile installed and the certificate manually enabled in a settings screen three levels deep that nobody finds by accident. Appliances — TVs, printers, cameras — mostly cannot be told anything at all, and those devices will simply never trust your CA. If something on that list needs to reach a service, that service needs a publicly trusted certificate, and you are running both systems side by side. That is a normal, sane outcome, and worth planning for rather than discovering.
The other half: SSH certificates
The feature that pushed me from “interesting” to “installed” has nothing to do with HTTPS. step-ca is also an SSH certificate authority, and it is the most approachable one I have used.
If you have never met SSH certificates: they replace authorized_keys with signed, expiring credentials. Instead of copying your public key onto every host and then never removing it, the CA signs a short-lived certificate saying “this key belongs to smarc, valid for 16 hours, allowed to log in as these principals”. Hosts trust the CA and check the signature. Adding a machine means one line in sshd_config. Removing a person means nothing at all, because their certificate expires this evening.
| |
The certificate lands in your agent and expires by itself. Host certificates work the same way in reverse and kill the “authenticity of host cannot be established” prompt that everyone has been blindly accepting since 1999 — a prompt which, when you think about it, is the only thing standing between you and a machine-in-the-middle on first connect.
I have written about SSH certificate authorities as their own topic, because the idea stands alone. It is worth saying here that if you are going to run step-ca at all, this half of it costs you an extra ten minutes and delivers more day-to-day value than the TLS half.
Backups, and the disaster you should rehearse
The CA holds three things worth losing sleep over: the intermediate key, the CA configuration and provisioner list, and the badger database of issued certificates. The root, by now, is in a drawer.
| |
Back that up somewhere restic can reach, and then answer the question that actually matters: what happens when the CA disk dies? Every service certificate expires within 24 hours, so you have a day to restore before your entire network stops trusting itself. That is a short window and it will arrive at an inconvenient moment.
Rehearse it. Restore the archive to a fresh container, point DNS at it, watch a Caddy instance renew successfully. It takes twenty minutes and it converts a genuinely frightening dependency into a known procedure. If the restore does not work, you would rather find out now — snapshots are not backups, and a CA is precisely the kind of thing people back up without ever testing.
If the disaster is worse than a disk — the intermediate key is compromised, say — the recovery is: fetch the root from the drawer, sign a fresh intermediate, distribute nothing, because the root that every device trusts has not changed. That is the payoff for the two-tier structure, and it is why the offline root is worth the fifteen minutes.
Troubleshooting
Caddy: acme: error: 400 :: urn:ietf:params:acme:error:serverInternal. Usually the CA’s own certificate is not trusted by Caddy yet. acme_ca_root must point at the root certificate file, readable by the Caddy process, inside the container if Caddy is containerised.
Certificates issued, browser still complains. Check whether you installed the root or the intermediate. Only the root goes in the trust store; the intermediate must be sent by the server as part of the chain. If your proxy serves a leaf with no chain, Firefox may cope by caching and Chrome will not, which produces the maddening “works for me” bug.
step ca health fine, ACME clients time out. The ACME directory URL must be reachable and the hostname must match a SAN on the CA’s own certificate. A CA reachable at 192.168.1.50 but initialised only with ca.mylab.local will fail the TLS handshake before ACME starts.
Everything expires simultaneously after eight months. The intermediate has a default lifetime of ten years, but a provisioner cannot issue a certificate outliving the intermediate — so as the intermediate nears its end, leaf lifetimes silently shrink and then fail. Put the intermediate’s expiry in your calendar the day you create it.
Renewals stop after a power cut. step-ca must be up for anything to renew, and with 24-hour certificates, “down for a day” is a full outage of every service. Give the CA a boot order ahead of everything else, monitor it harder than you monitor the things it serves, and understand that you have created a hard dependency at the centre of your network.
The verdict
step-ca is excellent software and I enjoy running it. It turns private PKI from a manual chore into the same automatic renewal loop the public web uses, and short-lived certificates are a genuine security improvement that public CAs will not sell you at any price.
It is the right choice if you need mTLS between services, if you want certificates for names you refuse to publish, if you have no domain, or if you want lifetimes measured in hours. If your requirement is “HTTPS on my internal services without warnings”, DNS-01 does that with none of the root-key liability and none of the trust-distribution work, and choosing the harder tool for the easier problem is how homelabs become jobs.
And if you do run it: get the root key offline on day one, before you have a chance to get used to it being there.




