Why Run Your Own CA

Look at what is on a home network now. A NAS, Proxmox, Home Assistant, a printer, a router’s admin page. Every one of them serves HTTPS with a self-signed certificate. Every browser that visits throws a warning. People click through it. Then they click through the next one, and the one after that, until clicking through a certificate warning is just what you do. That is the habit an attacker needs.

A public CA does not fix it. Let’s Encrypt will not issue for localhost “because nobody uniquely owns it”1, and the same goes for any name that only exists inside the house. Even for a real domain, every certificate it issues goes into the public Certificate Transparency logs2. Get one for nas.yourname.uk and the name of your NAS is now public record.

Your own CA fixes both. It signs whatever names you use inside. Nothing is published anywhere. Once each device trusts its root, the warnings stop, and a warning becomes something that means something again.

step-ca is Smallstep’s open source CA, and it runs in one container. Everything here was run on step-ca 0.30.2 in Podman, with Caddy 2.11.7 and nginx 1.30.5 as its clients.

Use caseWhat step-ca gives itSection
Every device trusting your certificatesOne root to install, onceTrusting The Root
Internal HTTPS for anything that speaks ACMEThe same protocol as Let’s Encrypt, for internal namesInternal HTTPS With ACME
Services with no ACME clientCertificates that renew themselves on a timerServices That Cannot Do ACME
Phones and other client certificatesKeys a device holds to get in, and a way to revoke themIts own post: the defaults fight it
SSH loginsCertificates instead of authorized_keysIts own post: a different setup

Two of those have their own posts, because each is a setup of its own. Client Certificates From step-ca covers phones and mTLS, where five defaults have to change. SSH Logins Signed By step-ca covers SSH, which uses different keys and different files on every machine.

Starting It

The image initialises itself on first run from a few environment variables3. Two of them decide things that cannot be changed later without starting again, so set them on day one.

VariableDoes
DOCKER_STEPCA_INIT_NAMEThe issuer name on every certificate
DOCKER_STEPCA_INIT_DNS_NAMESThe names the CA answers on
DOCKER_STEPCA_INIT_ACME=trueAdds an ACME provisioner for internal HTTPS
DOCKER_STEPCA_INIT_SSH=trueMakes SSH host and user CA keys as well

The ACME and SSH switches are read by the image’s entrypoint4. Turn them on now even if you only want one of them today. Adding SSH later means building a new CA.

podman run -d --name step-ca \
  -v step:/home/step \
  -p 9000:9000 \
  -e DOCKER_STEPCA_INIT_NAME="Home CA" \
  -e DOCKER_STEPCA_INIT_DNS_NAMES="localhost,ca.home.example" \
  -e DOCKER_STEPCA_INIT_ACME=true \
  -e DOCKER_STEPCA_INIT_SSH=true \
  docker.io/smallstep/step-ca:0.30.2

It builds a root, an intermediate signed by that root, the SSH keys, and three provisioners, then serves on port 9000:

X.509 Root Fingerprint: cd2f8d4c60af32a0a534781cd63b4e2a9ce362e2c1ae5983b56a370bfa2c2489
SSH Host CA Key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy...
SSH User CA Key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy...
Serving HTTPS on :9000 ...
ProvisionerTypeSigns
adminJWKAnything you ask for with its password
acmeACMECertificates for clients that prove they own the name
sshpopSSHPOPSSH host certificate renewals

The provisioner is the CA’s idea of “who is asking”. Each one has its own rules, and most of what follows is setting those rules.

One CA, five usesstep-ca containerRoot, intermediateone root to trustacmeadminsshpopCRLprovisionersEvery devicetrusts the root, onceCaddy, ACME24 h, renews itselfNo ACMEstep ca renew --daemonPhones1 year, revoked by CRLSSHits own post
One root, trusted once, and a provisioner for each way in. ACME clients and renewed services live with the 24-hour default; phones need it changed; SSH is its own post.

Pin the version. latest was 0.30.2 on the day this was written, and a CA is the last thing you want changing under you on a pull.

Two Passwords, Not One

Smallstep’s Docker guide says one generated password covers both “the encrypted CA keys and the default CA provisioner”, kept in secrets/password3. On this image that is not what happens. Try to issue a certificate with that password and the provisioner refuses it:

failed to decrypt JWE: invalid password

The image’s own entrypoint explains it. It generates two random passwords, one for the CA keys and one for the provisioner. The provisioner’s is printed to the log once, then shredded4:

echo "👉 Your CA administrative password is: $(< $STEPPATH/provisioner_password )"
...
shred -u $STEPPATH/provisioner_password
PasswordUnlocksWhere it ends up
secrets/passwordThe root and intermediate keysIn the volume, used every time the CA starts
Provisioner passwordThe admin provisioner, which signs every certificate you ask forThe first run’s log, and nowhere else

So read the log on the first start and put that second password somewhere safe. Lose it and you cannot issue another certificate without editing the provisioner out by hand. Set DOCKER_STEPCA_INIT_PASSWORD before the first start if you would rather choose one yourself; the entrypoint then uses it for both4.

Trusting The Root

Nothing trusts your CA until you tell it to. A Debian box asking Caddy for a page signed by step-ca:

$ curl https://nas.home.example/
curl: (60) SSL certificate problem: unable to get local issuer certificate

Copy the root in, and the same request goes through:

cp root_ca.crt /usr/local/share/ca-certificates/home-ca.crt
update-ca-certificates
$ curl https://nas.home.example/
internal service, certificate from step-ca

That is the whole job. Once per device. step certificate install does the same for whatever system it runs on5. Only the root goes into a trust store. The intermediate travels with each certificate, and a phone that will present a client certificate needs it too, which is covered in the client certificates post.

Treat the root like the front door key it now is. Anything holding the root’s private key can mint a certificate for any name, and every device that trusts it will believe it.

Internal HTTPS With ACME

ACME is the protocol Let’s Encrypt uses, and step-ca speaks it for internal names6. Anything with an ACME client gets internal certificates the same way it gets public ones, renewals included. For Caddy it is two global lines:

{
	acme_ca https://ca.home.example:9000/acme/acme/directory
	acme_ca_root /etc/caddy/root_ca.crt
}

nas.home.example {
	reverse_proxy 192.0.2.30:5000
}

What Caddy logged on first start:

"msg":"trying to solve challenge","identifier":"nas.home.example","challenge_type":"tls-alpn-01"
"msg":"certificate obtained successfully","identifier":"nas.home.example"
CheckResult
Challengetls-alpn-01, answered by Caddy on port 443
IssuerHome CA Intermediate CA
Lifetime24 hours, from 17:31 to 17:32 the next day
RenewalCaddy’s own job, nothing to set up

A 24-hour certificate sounds alarming. It is not. An ACME client renews well before expiry, every day, with no one involved. A stolen key is worth a day. That is the default step-ca was built around, and here it is exactly right.

The challenge does need the CA to reach the name it is signing for. step-ca has to resolve nas.home.example and connect to it on port 443 for tls-alpn-01, or port 80 for http-01. Internal DNS that both sides use is the thing to get right first.

Services That Cannot Do ACME

Plenty of kit has no ACME client: a hypervisor’s web UI, a printer, an old appliance. For those, issue the certificate with the admin provisioner and renew it with step ca renew:

step ca certificate proxmox.home.example pve.crt pve.key
step ca renew --force pve.crt pve.key
SerialValid until
Issued241943140971…2026-10-10 17:33:03
After step ca renew206409156462…2026-10-10 17:33:06

New serial, fresh 24 hours, and no password asked for. The current certificate proves who is asking, which is the point: renewal needs no secret stored on the box beyond the key it already has. It does need the certificate to still be valid, so a renewal that misses its window means issuing again by hand.

Left running, it renews by itself. --daemon renews at two-thirds of the lifetime by default, and --exec runs a command after each renewal, which is where the reload goes7:

step ca renew --daemon --exec "systemctl reload nginx" pve.crt pve.key

Run that from a systemd unit and the box looks after itself. Forget it, and in 24 hours the service throws the same warning you set out to get rid of.

The CA Is The Easy Part

Getting step-ca running took one command. Getting a Debian box to trust it took two. Caddy needed two lines and asked for nothing else again. That is the part people put off for years, living with a browser warning on every internal page, and it is the cheapest job on this list.

What costs is everything a certificate touches after it is issued: the root on every device, the renewals nobody watches, the one service that cannot do ACME and has to be remembered. None of it is hard. All of it is the sort of job that only gets done properly when somebody owns it.

A home network is a small estate. It deserves the same thing a proper one gets: one root, held carefully, trusted deliberately, and a warning that means something when it appears. As such the CA is not the clever bit. The clever bit is never clicking through again.


  1. Let’s Encrypt — Certificates for localhost — cannot issue for “localhost” “because nobody uniquely owns it, and it’s not rooted in a top level domain”. ↩︎

  2. Let’s Encrypt — Certificate Transparency logs — “Let’s Encrypt submits all certificates we issue to CT logs.” ↩︎

  3. Smallstep — Run a private CA in Docker — the DOCKER_STEPCA_INIT_* variables; says the generated password is for “the encrypted CA keys and the default CA provisioner”. ↩︎ ↩︎

  4. smallstep/certificates — docker/entrypoint.sh — generates separate password and provisioner_password files, prints the provisioner password once and shreds it; one supplied password is written to both. ↩︎ ↩︎ ↩︎

  5. Smallstep — step certificate install — installs a root certificate into the system trust store. ↩︎

  6. Smallstep — ACME basics — step-ca’s ACME provisioner and the challenge types it supports. ↩︎

  7. Smallstep — step ca renew — with --daemon, “By default, it will renew the certificate before 2/3 of the validity period of the certificate has elapsed”; --exec provides “certificate reloads on your services”. ↩︎