Skip to content
Go back

step-ca: Let's Encrypt for Your LAN

By KingPin 13 min read
step-ca: Let's Encrypt for Your LAN
Contents

Your Browser Says “Not Secure” and You Keep Clicking Anyway

You have Grafana, Home Assistant, a Gitea box, and a printer that insists on its own web UI. Every one of them greets you with a certificate warning. You click “Advanced”, then “Proceed”, a few dozen times a week, and you have trained yourself to ignore the one signal TLS exists to give you.

The usual fixes are bad. Self-signed certs mean the warnings never end. Buying a real domain and getting Let’s Encrypt certs for grafana.yourdomain.com works, but every cert lands in public Certificate Transparency logs, so anyone can search for the internal hostnames you thought were private. Plus you now need DNS API tokens sitting on your LAN boxes.

The better answer: run your own ACME server. step-ca from Smallstep speaks the same protocol Let’s Encrypt does. Point Caddy or Traefik at it, trust one root certificate on your devices, and every internal service gets a cert that issues and renews itself. Below you get the Compose file, the Traefik equivalent, the root-trust step that trips everyone up, and the lifetime defaults.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/step-ca-home-lab-acme

The basic step-ca Docker setup and mTLS with nginx are covered in mTLS Explained: When Regular TLS Isn’t Paranoid Enough. Here we use the same CA for ACME.

Why a Private ACME Server Beats the Alternatives

A public CA proves you control a public name. Your LAN names (grafana.home.arpa) are not public, so no public CA will sign them. That leaves four options:

  1. Self-signed per service. Zero setup, permanent warnings, no renewal.
  2. Public domain plus DNS-01. Real certs, but your hostnames are in CT logs and your DNS provider token lives on the LAN.
  3. Your own CA with ACME. One root to trust, automated issuance, nothing leaves the network.
  4. Public domain plus delegated DNS-01. A CNAME points _acme-challenge at a self-hosted acme-dns server, so the only credential on the LAN can update one TXT record. A wildcard for *.lan.yourdomain.tld keeps individual hostnames out of CT logs. The cost: a real domain, and acme-dns needs port 53 open to the internet. Pick this one if installing a root on every device is the dealbreaker. Thanks to reader Tom for suggesting this one in the comments.

home.arpa is the name RFC 8375 reserves for home networks. It never resolves on the public internet, which is the point. Use it instead of .local, which belongs to mDNS and causes odd resolver behavior on some systems.

Option 3 sounds like over-engineering until you count the cost: one container and about ten lines of config. Hiring a forklift to move a couch is over-engineering. This is a dolly.

step-ca and Caddy: The Compose File

Pin the versions. At the time of writing, step-ca is at v0.30.2 and Caddy at v2.11.7.

compose.yaml
name: step-ca-demo
services:
ca:
image: smallstep/step-ca:0.30.2
restart: unless-stopped
environment:
DOCKER_STEPCA_INIT_NAME: "Home Lab CA"
DOCKER_STEPCA_INIT_DNS_NAMES: "ca.home.arpa,localhost"
DOCKER_STEPCA_INIT_ACME: "true"
DOCKER_STEPCA_INIT_PASSWORD_FILE: /run/secrets/ca_password
secrets:
- ca_password
volumes:
- step-data:/home/step
networks:
lab:
aliases:
- ca.home.arpa
healthcheck:
test: ["CMD", "step", "ca", "health"]
interval: 5s
timeout: 3s
retries: 20
caddy:
image: caddy:2.11.7-alpine
restart: unless-stopped
depends_on:
ca:
condition: service_healthy
ports:
- "8080:80"
- "8443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./site:/srv:ro
- ./root_ca.crt:/etc/caddy/root_ca.crt:ro
- caddy-data:/data
networks:
lab:
aliases:
- demo.home.arpa
networks:
lab:
volumes:
step-data:
caddy-data:
secrets:
ca_password:
file: ./ca_password.txt

Three details matter here.

DOCKER_STEPCA_INIT_ACME=true makes the image’s entrypoint pass --acme to step ca init, which creates an ACME provisioner named acme. No manual step ca provisioner add needed. The provisioner name is part of the directory URL: https://ca.home.arpa:9000/acme/acme/directory. First acme is the path segment, second is the provisioner name.

DOCKER_STEPCA_INIT_DNS_NAMES must include the hostname clients use to reach the CA. The CA’s own TLS cert is issued for those names, so a mismatch gives you a hostname error before ACME even starts.

The network aliases do double duty. Caddy resolves ca.home.arpa to the CA container. The CA, in turn, resolves demo.home.arpa back to Caddy so it can validate the HTTP-01 challenge. In a real LAN, your DNS server (see split-horizon DNS) does this job instead.

Caddy needs the root certificate before it starts, so the example uses a small bootstrap script:

bootstrap.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
[ -f ca_password.txt ] || { head -c 32 /dev/urandom | base64 > ca_password.txt; chmod 600 ca_password.txt; }
docker compose up -d --wait ca
docker compose cp ca:/home/step/certs/root_ca.crt ./root_ca.crt
docker compose up -d caddy

Now the Caddyfile, and a gotcha that cost me a debugging session.

Caddyfile
demo.home.arpa {
tls {
ca https://ca.home.arpa:9000/acme/acme/directory
ca_root /etc/caddy/root_ca.crt
}
root * /srv
file_server
}

The obvious move is a global acme_ca and acme_ca_root in the options block. I tried it first. Caddy logged certificate obtained successfully with "issuer":"local". Caddy’s docs say it signs names ending in .localhost, .local, .internal, and .home.arpa with its own built-in local CA, not an ACME server. The global option never applied. The per-site tls { ca ... ca_root ... } block names the issuer explicitly, which overrides that default. Use per-site, and check the issuer field in the log.

Run it and check:

Terminal window
./bootstrap.sh
curl --cacert root_ca.crt --resolve demo.home.arpa:8443:127.0.0.1 https://demo.home.arpa:8443/
echo | openssl s_client -connect 127.0.0.1:8443 -servername demo.home.arpa -CAfile root_ca.crt 2>/dev/null \
| openssl x509 -noout -issuer -dates

On my run, curl returned HTTP 200 and the certificate showed:

issuer=O=Home Lab CA, CN=Home Lab CA Intermediate CA
notBefore=Oct 25 19:02:15 2026 GMT
notAfter=Oct 26 19:03:15 2026 GMT

Twenty-four hours plus a one-minute backdate. That is the default, and we will get to why it is fine.

The Traefik Version

Traefik talks to ACME through the lego library, so the setup mirrors any other ACME resolver. Point caServer at the step-ca directory and tell Traefik which root to trust:

traefik.yml
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
certificatesResolvers:
stepca:
acme:
storage: /acme/acme.json
caServer: https://ca.home.arpa:9000/acme/acme/directory
caCertificates:
- /certs/root_ca.crt
certificatesDuration: 24
httpChallenge:
entryPoint: web

Then opt a router in with a label:

compose.yaml (service labels)
labels:
- traefik.http.routers.grafana.rule=Host(`grafana.home.arpa`)
- traefik.http.routers.grafana.entrypoints=websecure
- traefik.http.routers.grafana.tls.certresolver=stepca

The caCertificates option is in the Traefik v3 ACME reference: it takes PEM file paths for an ACME server whose cert is not in the system trust store. Traefik’s ACME provider also reads the LEGO_CA_CERTIFICATES environment variable (plus LEGO_CA_SERVER_NAME and LEGO_CA_SYSTEM_CERT_POOL), which is handy when you do not want to edit the static config. Pick one. I prefer the config key because it shows up in version control.

Do not skip certificatesDuration. Traefik assumes 90-day certs (2160 hours) unless told otherwise, and with that default it checks for renewal once every 24 hours. Against a 24-hour cert, that leaves renewal riding on one daily tick landing before expiry. Set to 24, Traefik renews 6 hours before expiry and checks every 10 minutes. If you raise step-ca’s lifetimes later, raise this number to match.

I ran this config with Traefik v3.7.16 against the same step-ca and a whoami container at whoami.home.arpa. Traefik got its cert from step-ca and curl returned 200.

Root Trust: Where Everyone Gets Stuck

Issuing certs is the easy half. A cert signed by your CA is only valid on devices that trust your root. Miss one client type and you are back to clicking “Proceed”. Only the public root (root_ca.crt) ever leaves the CA. Never copy the key.

Linux and macOS hosts. The step CLI installs it for you:

Terminal window
step certificate install root_ca.crt

This adds the root to the system trust store, and it needs root privileges on most systems. On macOS it uses the system keychain.

Debian and Ubuntu, by hand. The file must end in .crt:

Terminal window
sudo cp root_ca.crt /usr/local/share/ca-certificates/home-lab-ca.crt
sudo update-ca-certificates

Firefox. Firefox keeps its own store and ignores the system one by default. You have two options. In about:config, set security.enterprise_roots.enabled to true and Firefox reads the OS store. For fleets, drop a policy file at /etc/firefox/policies/policies.json on Linux:

policies.json
{
"policies": {
"Certificates": {
"ImportEnterpriseRoots": true
}
}
}

Restart Firefox after either change.

Docker containers. Containers do not inherit the host trust store. Any container that calls an internal HTTPS service (a Grafana webhook, a Gitea runner, an app hitting your internal Vault) needs the root. On Debian-based images:

compose.yaml (client container)
services:
app:
image: your-app:latest
volumes:
- ./root_ca.crt:/usr/local/share/ca-certificates/home-lab-ca.crt:ro
command: sh -c "update-ca-certificates && exec your-app"

That only works if the image has update-ca-certificates and runs as root at start. The lighter option for tools built on OpenSSL or Go is to mount the file and set SSL_CERT_FILE=/certs/root_ca.crt. Beware: that variable replaces the bundle instead of adding to it, so the container can no longer verify public sites. If it also talks to the real internet, concatenate your root onto the system bundle first. Python’s requests ignores both unless you set REQUESTS_CA_BUNDLE. Node wants NODE_EXTRA_CA_CERTS, which does add to the defaults.

Phones. On iOS, install the root as a profile, then enable it under Settings, General, About, Certificate Trust Settings. Installing alone is not enough. On Android, install it as a CA certificate from Settings (security or encryption section, the exact path varies by vendor). Chrome honors user-installed CAs. Many apps do not, because apps targeting Android 7 and later ignore user CAs unless the developer opted in. Your browser works. Your random companion app might not.

Short Certs Are the Feature

step-ca’s defaults, from the source in authority/config: minimum TLS cert duration 5 minutes, maximum 24 hours, default 24 hours. A fresh ca.json from step ca init contains no claims block at all, so those built-in values apply. The observed 24-hour cert above matches.

This looks aggressive if you remember buying one-year certs. With ACME it does not matter. Caddy renews with a third of the lifetime left, and Traefik does the same once certificatesDuration matches, unattended. A leaked cert key is useful to an attacker for a day. Your 2 AM self does not care, because nothing needs renewing by hand.

The trap is dumb clients: a NAS, a router, a script that fetched a cert once and never checks again. RFC 8555 does let a client ask for notBefore and notAfter in an order, and step-ca honors them within the claims limits. Caddy, Traefik, and certbot do not send them, so in practice the provisioner’s claims decide. Raise them on the ACME provisioner in ca.json:

ca.json (excerpt)
{
"type": "ACME",
"name": "acme",
"claims": {
"maxTLSCertDuration": "2160h",
"defaultTLSCertDuration": "720h"
}
}

Restart step-ca after editing. That gives 30-day default certs with a 90-day ceiling; the next ACME cert in my test came out at 720 hours. Traefik users set certificatesDuration: 720 to match. Keep the default as short as the laziest client allows.

The root and intermediate from step ca init are different. The source sets both to 10 years, and the root in my run showed notAfter=Oct 22 2036. Plan the root rotation for a date you will still care about, or at least write it down.

Protect the Root Key

The CA is now the most valuable file set on your network. Anyone holding the intermediate key and password can mint a cert for anything.home.arpa, or for yourbank.com, which every device with your root will accept.

What the Docker image does: it generates keys and writes them under /home/step/secrets in the volume. In my run that directory held root_ca_key, intermediate_ca_key, and a plaintext password file, since the image stores the init password for unattended starts. So:

  1. Back up the whole step-data volume to encrypted storage. Losing it means reissuing your root to every device.
  2. Keep your own copy of the password file somewhere other than that volume’s backup.
  3. Do not publish the CA port (9000) beyond the LAN.
  4. Restrict what the CA will sign. step-ca supports a policy under authority in ca.json:
ca.json (excerpt)
"authority": {
"policy": {
"x509": {
"allow": { "dns": ["*.home.arpa"] },
"allowWildcardNames": false
}
}
}

The Smallstep docs describe policies that self-hosted step-ca applies at the authority level, checked before signing. The policy limits what this CA signs. It does not embed name constraints in the root certificate, so a stolen key can still sign anything.

I tested this block on v0.30.2. demo.home.arpa issued. other.example.com was rejected with dns name "other.example.com" not allowed, and so were *.home.arpa (wildcards off) and a.b.home.arpa, because *.home.arpa matches exactly one label. Add "*.lab.home.arpa" and friends if you nest names. The policy applies to every provisioner, including the admin JWK one.

step-ca vs mkcert

mkcert is a good tool for one job. It makes a local root, installs it into your trust stores, and mints a cert for localhost in one command. Its latest release is v1.4.4 from April 2022, and it has no ACME server and no renewal.

mkcertstep-ca
ACME serverNoYes
Auto-renewalNoYes, via your ACME client
Best forOne dev laptopMultiple hosts and services
SetupOne commandA container and a root distribution plan
Revocation, policyNoYes, passive revocation, optional CRL, and policy

Pick mkcert if the only consumer is your own laptop. Pick step-ca the moment a second machine or a service you do not want to babysit needs a cert. A hand-minted cert sitting on a NAS will expire on a day you did not plan for.

If you want the hosted version with a management UI, Smallstep sells one. The open source CA does everything in this post.

The SumGuy Take

Run step-ca for any home lab with more than three internal services. It is one container. The only real work is distributing the root, and you do that once per device. Use home.arpa, enable the ACME provisioner at init, set the issuer per site in Caddy, and back up the volume.

Common Questions

Can step-ca issue wildcard certificates?

Yes, but only with the DNS-01 challenge. ACME servers require DNS-01 for wildcards, so step-ca needs your client to publish a TXT record. On a LAN with no DNS API, skip wildcards and let Caddy or Traefik request one cert per hostname.

Can I use .local instead of home.arpa with step-ca?

Yes, step-ca will sign .local names, but avoid them. The .local domain is reserved for multicast DNS, so some resolvers handle it differently. The home.arpa name was reserved by RFC 8375 for home networks and has no such baggage.

What happens if step-ca is down when a certificate expires?

The service keeps serving its old certificate until it expires, then clients see a TLS error. Caddy and Traefik retry renewal repeatedly, so a short outage is harmless. With 24-hour certs, a CA outage longer than a day starts breaking services.

Do I need to trust the root on every device?

Yes. A browser or app only accepts certificates from roots in its trust store. Every device that opens an internal HTTPS URL needs root_ca.crt installed, including phones, containers, and Firefox, which keeps a separate store.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Garrul: 24 More Releases, Mostly Cheaper

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts