← back to /blog← powrót do /blog
A private CA at home: step-ca on a QNAP and no more "proceed anyway"
Anyone running anything over HTTPS at home (Vaultwarden, Proxmox, a NAS, Home Assistant) knows the moment: the browser goes red and, for the hundredth time, you click “Advanced → Proceed (unsafe)”. After a month you stop reading those warnings entirely, and that is exactly the habit you do not want.
Let’s Encrypt cannot help here: it will not issue for *.cygal.lan, because that
domain does not exist in public DNS and cannot be validated. That leaves two
options: self-signed certificates forever, or your own CA. I went with my
own, and the house has had a green padlock everywhere it should ever since.
Here is exactly what I run, in copy-paste form.
What I run
- A QNAP “Selene” (
192.168.100.210) running Container Station (plain Docker with a nicer UI). - A
smallstep/step-cacontainer on port9000, which is the entire CA, namedCygal-Home-CA. - A
caddy:2container wearing two hats: HTTPS front end for Vaultwarden (port8443) and a static file server hosting the root-CA self-service page (port8088). - Internal domain:
*.cygal.lan.
Two containers and one config file. You genuinely do not need a cluster or a dedicated machine for this.
Step 1: step-ca in a container
In Container Station (or plain docker compose, same thing):
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
restart: unless-stopped
ports:
- "9000:9000"
volumes:
- /share/CACHEDEV1_DATA/step-ca:/home/step
environment:
DOCKER_STEPCA_INIT_NAME: "Cygal-Home-CA"
DOCKER_STEPCA_INIT_DNS_NAMES: "selene.cygal.lan,192.168.100.210,localhost"
DOCKER_STEPCA_INIT_PROVISIONER_NAME: "admin"
Two things worth getting right immediately:
List every name in DOCKER_STEPCA_INIT_DNS_NAMES. These are the names the CA
issues its own certificate for. If you use the IP today and add DNS tomorrow, a
missing name means a TLS error on every step call and a CA you have to
re-initialise. Mine has three: FQDN, IP, and localhost.
The password. On first start the container generates the CA key password and prints it to the logs. Pull it out and file it in your password manager (mine lives in the very Vaultwarden this CA protects; yes, I know):
docker logs step-ca 2>&1 | grep -i password
Check that it is alive:
docker exec step-ca step ca health --ca-url https://localhost:9000 \
--root /home/step/certs/root_ca.crt
Now the single most important value in this whole exercise: the root fingerprint. It replaces trust in the transport, so you can hand the root certificate out over plain HTTP as long as the recipient verifies the fingerprint.
docker exec step-ca step certificate fingerprint /home/step/certs/root_ca.crt
Save it; every step below needs it.
Step 2: the first service certificate
By default step-ca sets up a JWK provisioner (mine is called admin), which
means “to get a certificate, supply the password”. Issuing one for Vaultwarden:
docker exec -it step-ca step ca certificate \
"vault.cygal.lan" \
/home/step/certs/vw.crt /home/step/secrets/vw.key \
--provisioner admin \
--not-after 8760h
8760h is one year. I will come back to that number, because it is exactly where
my setup currently carries debt.
Copy the certificate and key wherever the service expects them. In my case into
the directory mounted into Caddy as /certs.
Step 3: Caddy as the HTTPS front end
Vaultwarden serves plain HTTP inside its container; Caddy handles TLS. My
complete Caddyfile:
{
admin off
auto_https off
}
# Self-service page for installing the root CA (plain HTTP, because new devices
# do not trust the CA yet; integrity comes from fingerprint verification in the
# install scripts). Published on the host as :8088.
:80 {
root * /srv
file_server
}
# HTTPS front end for Vaultwarden.
# Static certificate issued by step-ca (Cygal-Home-CA), valid for 1 year.
# Published on the host as :8443.
:443 {
tls /certs/vw.crt /certs/vw.key
encode gzip
reverse_proxy 192.168.100.210:32787 {
header_up X-Real-IP {remote_host}
}
}
One line deserves a comment: auto_https off. Caddy is clever by default and
will try to obtain certificates from Let’s Encrypt on its own. On a .lan
network that cannot succeed and ends in a loop of errors in the log. Since I
supply the certificate myself, I turn the automation off.
Step 4: distributing the root CA across the house
This is the part people skip, and without it the whole CA is pointless: every device needs your root in its trust store. Laptop, partner’s phone, work Mac, Raspberry Pi, console.
So I built a small self-service page: static HTML served by Caddy on :8088,
with the root certificate, install scripts, and the fingerprint front and centre.
You open it on a new device, pick your platform, paste one command. The /srv
layout:
/srv
├── index.html
├── root_ca.crt
├── install-linux.sh
├── install-macos.sh
└── install-windows.ps1
The Linux script. The fingerprint check is the whole point, and it is what makes plain HTTP delivery acceptable:
#!/usr/bin/env bash
set -euo pipefail
CERT_NAME="cygal-home-ca-root"
EXPECTED_FP="<PASTE_YOUR_FINGERPRINT_HERE>"
CERT_URL="${CERT_URL:-http://192.168.100.210:8088/root_ca.crt}"
[ "$(id -u)" -eq 0 ] || { echo "Run with sudo" >&2; exit 1; }
TMP="$(mktemp)"; trap 'rm -f "$TMP"' EXIT
curl -fsSL "$CERT_URL" -o "$TMP"
FP="$(openssl x509 -in "$TMP" -noout -fingerprint -sha256 \
| sed 's/.*=//; s/://g' | tr 'A-F' 'a-f')"
if [ "$FP" != "$EXPECTED_FP" ]; then
echo "ERROR: fingerprint mismatch!" >&2
exit 1
fi
echo "Fingerprint OK: $FP"
if [ -d /usr/local/share/ca-certificates ] && command -v update-ca-certificates >/dev/null; then
install -m 0644 "$TMP" "/usr/local/share/ca-certificates/${CERT_NAME}.crt"
update-ca-certificates
elif [ -d /etc/pki/ca-trust/source/anchors ] && command -v update-ca-trust >/dev/null; then
install -m 0644 "$TMP" "/etc/pki/ca-trust/source/anchors/${CERT_NAME}.crt"
update-ca-trust extract
else
echo "ERROR: unknown distribution." >&2
exit 1
fi
echo "OK: root CA installed."
Usage from any machine in the house:
curl -fsSL http://192.168.100.210:8088/install-linux.sh | sudo bash
macOS (System Keychain, prompts for an admin password):
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain root_ca.crt
Windows, PowerShell as Administrator:
Import-Certificate -FilePath .\root_ca.crt `
-CertStoreLocation Cert:\LocalMachine\Root
If the machine already has the step CLI, one command does everything. It fetches
the root, verifies the fingerprint, installs it system-wide:
step ca bootstrap \
--ca-url https://192.168.100.210:9000 \
--fingerprint <PASTE_YOUR_FINGERPRINT_HERE> \
--install
Three traps you will hit anyway
- Firefox keeps its own trust store and ignores the system one. Either set
security.enterprise_roots.enabledtotrueinabout:config, or import the root manually in Settings. Chrome, Edge, and Safari use the system store. - iOS takes two steps. Installing the profile is only half of it: then go to Settings → General → About → Certificate Trust Settings and flip the switch manually. Without that the certificate is installed and quietly ignored.
- Android “user certificates” are ignored by apps (only the browser honours them) unless an app opts in. On phones, plan for the browser only.
Step 5: automating renewal (my next step)
To be straight with you: I do not have this yet. My certificates are issued by hand and last a year, a classic ticking clock. Eleven months from now I will have forgotten this exists, and Vaultwarden will break at the least convenient moment. Here is how I intend to fix it, and how I would do it from day one.
The key piece is ACME. step-ca can be your own Let’s Encrypt. Add the provisioner:
docker exec -it step-ca step ca provisioner add acme --type ACME
docker restart step-ca
(When starting from scratch, DOCKER_STEPCA_INIT_ACME: "true" in the compose
file is enough.)
Suddenly Caddy handles everything by itself, with no manual .crt files:
{
acme_ca https://192.168.100.210:9000/acme/acme/directory
acme_ca_root /certs/root_ca.crt
}
vault.cygal.lan {
reverse_proxy 192.168.100.210:32787
}
Caddy issues the certificate, renews it, and reloads itself. Step 2 disappears entirely, and so does half of what I currently have to remember.
Step 6: certificates for the machines themselves (and how they renew)
Certificates for services behind Caddy are one thing, but sooner or later you want a certificate on the machine itself: for mTLS between services, for Prometheus, for the Proxmox UI, for syslog over TLS. Caddy cannot help here; the host has to issue and renew its own certificate.
First, the step CLI from the official repository (Debian/Ubuntu):
sudo apt-get update && sudo apt-get install -y --no-install-recommends curl gpg ca-certificates
sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg \
-o /etc/apt/keyrings/smallstep.asc
cat <<'EOF' | sudo tee /etc/apt/sources.list.d/smallstep.sources
Types: deb
URIs: https://packages.smallstep.com/stable/debian
Suites: debs
Components: main
Signed-By: /etc/apt/keyrings/smallstep.asc
EOF
sudo apt-get update && sudo apt-get install -y step-cli
Then bootstrap against the CA, the same command as in step 4: it fetches the root, verifies the fingerprint, and installs it system-wide:
sudo step ca bootstrap \
--ca-url https://192.168.100.210:9000 \
--fingerprint <PASTE_YOUR_FINGERPRINT_HERE> \
--install
And issue a host certificate:
sudo mkdir -p /etc/step/certs
sudo step ca certificate "$(hostname -f)" \
"/etc/step/certs/$(hostname -s).crt" \
"/etc/step/certs/$(hostname -s).key" \
--provisioner admin
Now the detail that makes this automatable at all: renewal does not need the
provisioner password. step ca renew authenticates with the existing
certificate (mTLS), so no CA secret has to sit on the machine. That makes an
automated renewer safe even on a laptop that leaves the house.
Smallstep publishes a ready-made unit template pattern for this.
/etc/systemd/system/cert-renewer@.service:
[Unit]
Description=Certificate renewer for %I
After=network-online.target
Documentation=https://smallstep.com/docs/step-ca/renewal
StartLimitIntervalSec=0
[Service]
Type=oneshot
User=root
Environment=STEPPATH=/etc/step-ca \
CERT_LOCATION=/etc/step/certs/%i.crt \
KEY_LOCATION=/etc/step/certs/%i.key
; Only renew when renewal is actually due.
ExecCondition=/usr/bin/step certificate needs-renewal ${CERT_LOCATION}
ExecStart=/usr/bin/step ca renew --force ${CERT_LOCATION} ${KEY_LOCATION}
; Reload the service that consumes the certificate, if it exists.
ExecStartPost=/usr/bin/env sh -c "! systemctl --quiet is-active %i.service || systemctl try-reload-or-restart %i"
[Install]
WantedBy=multi-user.target
And /etc/systemd/system/cert-renewer@.timer, which checks every 15 minutes
whether renewal is due; the ExecCondition above is what keeps it from renewing
too early:
[Unit]
Description=Timer for certificate renewal of %I
[Timer]
Persistent=true
OnCalendar=*:1/15
AccuracySec=1us
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
Enable it per service that consumes the certificate. The name after @ is that
service’s name, because the unit reloads it afterwards:
sudo systemctl enable --now cert-renewer@nginx.timer
systemctl list-timers | grep cert-renewer
Checking that it is actually alive:
step certificate inspect --short /etc/step/certs/$(hostname -s).crt
sudo systemctl status cert-renewer@nginx.service
One trap, specifically for laptops. step ca renew works as long as the old
certificate is still valid: that certificate is the credential. If the machine
was powered off for longer than the certificate’s lifetime, renewal fails and you
have to issue a fresh one (step ca certificate with the provisioner again).
With one-year certificates this never matters; with 24-hour ones a laptop comes
back from holiday with a dead certificate. Match --not-after to how the
hardware is actually used: short for servers, generous for laptops.
And finally: shorten the certificate lifetime. A year is a relic of manual certificate management. Once renewal runs itself, drop to a week or a day. A short certificate means a smaller window if a key leaks, and immediate feedback when the automation breaks, instead of a silent time bomb going off a year later.
How I know any of this actually works
I maintain a tool for exactly this, certops,
because checking things by hand with openssl s_client stops scaling around the
third service. It checks CA providers (including a Smallstep provider), endpoint
certificates, chains, expiry dates, and trust stores, both locally and across a
fleet over SSH:
certops check vault.cygal.lan:8443
certops fleet trust verify -f certops.yaml
fleet trust verify answers precisely the question “does every machine in the
house have my root, and is it the same one everywhere”, which is the thing
hand-distributed certificates make easiest to miss.
Wrapping up
One evening’s work: two containers, one Caddyfile, one self-service page. A green padlock on everything at home, no more “proceed anyway”, and, most valuable to me, the instinct that a red screen means a real problem, rather than a daily inconvenience to click through.
If you are starting from scratch, do one thing differently than I did: turn ACME on immediately. The rest is copy-paste from above.
- Paweł