Overview
Every homelab eventually hits the same wall: self-signed certificate warnings in every browser, curl commands needing --insecure, and services that refuse to talk to each other without valid TLS. The usual workarounds — accepting warnings, disabling verification — are both annoying and genuinely bad security habits.
Smallstep's step-ca is an open-source certificate authority server that solves this properly. It implements a full PKI stack with a root CA, intermediate CA, and support for the ACME protocol (the same one Let's Encrypt uses). Your homelab services can request and auto-renew TLS certificates from your internal CA, browsers stop complaining once you install the root cert, and you get real certificate lifecycle management instead of one-off hacks.
This project walks through standing up step-ca in Docker, configuring an ACME provisioner, wiring it to Traefik so all your reverse-proxied services get valid certs automatically, and distributing the root certificate to clients. By the end your internal domains will show a proper padlock — no warnings, no exceptions.
Architecture
The PKI hierarchy follows standard two-tier CA design:
Root CA (offline-capable)
└── Intermediate CA ← step-ca serves this
├── ACME Provisioner ← used by Traefik + any ACME client
├── JWK Provisioner ← used by step CLI for manual issuance
└── Issued Certs ← *.homelab.local, service.internal, etc.
Components:
| Component | Role |
|---|---|
step-ca container | CA server — issues and renews certificates |
step CLI | Management tool — initialize CA, issue certs manually |
| Traefik | Reverse proxy — requests certs via ACME from step-ca |
| Client systems | Trust the root CA cert added to their trust store |
Certificate flow for Traefik:
- Traefik starts and sees a domain needs a cert
- It contacts step-ca's ACME endpoint (
https://step-ca:9000/acme/acme/directory) - step-ca issues an HTTP-01 or TLS-ALPN-01 challenge
- Traefik completes the challenge and receives a signed cert
- Traefik stores the cert and renews ~2/3 through the validity period automatically
Ports:
9000— step-ca HTTPS API + ACME endpoint (internal network only)
Prerequisites
- Docker + Docker Compose
- Traefik already running (or follow the Traefik project in this series)
- A local domain you control (e.g.
homelab.local,home.arpa, or a real domain you own) - Access to add certificates to your client machines' trust stores
Build Instructions
Step 1: Install the step CLI
The step CLI is used to initialize the CA, inspect certificates, and issue certs manually. Install it on the Docker host:
# Linux (amd64)
wget https://dl.smallstep.com/gh-release/cli/gh-release-header/v0.27.4/step_linux_0.27.4_amd64.tar.gz
tar xzf step_linux_0.27.4_amd64.tar.gz
sudo mv step_0.27.4/bin/step /usr/local/bin/
step versionOr via Docker (if you prefer not to install locally):
docker run -it --rm -v /opt/step-ca:/home/step smallstep/step-ca step versionStep 2: Initialize the CA
Create a directory to hold CA data, then run step ca init to generate the root CA, intermediate CA, and initial config:
mkdir -p /opt/step-ca
docker run -it --rm \
-v /opt/step-ca:/home/step \
-e DOCKER_STEPCA_INIT_NAME="Homelab CA" \
-e DOCKER_STEPCA_INIT_DNS_NAMES="step-ca,localhost" \
-e DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT="true" \
-e DOCKER_STEPCA_INIT_ACME="true" \
smallstep/step-caThis generates:
/opt/step-ca/certs/root_ca.crt— root certificate (distribute to clients)/opt/step-ca/certs/intermediate_ca.crt— intermediate (step-ca serves this)/opt/step-ca/secrets/— encrypted private keys/opt/step-ca/config/ca.json— CA configuration
Save the fingerprint printed at the end — you need it to bootstrap clients:
Root fingerprint: abc123def456...
Step 3: Configure the CA
Edit /opt/step-ca/config/ca.json to tune certificate lifetimes and ensure the ACME provisioner is present. The init script adds ACME automatically when DOCKER_STEPCA_INIT_ACME=true, but verify:
cat /opt/step-ca/config/ca.jsonLook for the ACME provisioner block:
{
"type": "ACME",
"name": "acme",
"forceCN": false,
"claims": {
"maxTLSCertDuration": "2160h",
"defaultTLSCertDuration": "2160h"
}
}Adjust defaultTLSCertDuration to control cert lifetime. 2160h = 90 days (matching Let's Encrypt). For tighter security on internal certs, consider 720h (30 days) — Traefik auto-renews anyway.
Also add your internal domains to the DNS names in the CA config if needed. The default config allows any domain in ACME challenges.
Step 4: Create the Password File
step-ca encrypts its private keys and needs the password at startup:
# You were prompted for a password during init — store it
echo "your-ca-password-here" > /opt/step-ca/secrets/password.txt
chmod 600 /opt/step-ca/secrets/password.txtIn production, use Docker secrets or an environment-variable manager instead of a plain text file.
Step 5: Deploy step-ca with Docker Compose
Create /opt/step-ca/docker-compose.yml:
version: "3.8"
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
restart: unless-stopped
ports:
- "9000:9000"
volumes:
- /opt/step-ca:/home/step
environment:
STEPCA_INIT_PASSWORD_FILE: /home/step/secrets/password.txt
networks:
- proxy
labels:
- "traefik.enable=false" # Don't proxy the CA itself through Traefik
networks:
proxy:
external: trueStart it:
cd /opt/step-ca
docker compose up -d
docker compose logs -f step-caYou should see:
2026/07/29 00:00:00 Serving HTTPS on :9000 ...
Step 6: Verify the CA is Running
# Check health endpoint
curl --insecure https://localhost:9000/health
# Get the CA root certificate
step ca root root_ca.crt --ca-url https://localhost:9000 \
--fingerprint <your-fingerprint-here>
# Inspect it
step certificate inspect root_ca.crtStep 7: Configure Traefik to Use step-ca for ACME
In Traefik's static config (traefik.yml or equivalent), add a new certificate resolver pointing at your step-ca ACME endpoint:
# traefik.yml (static config)
certificatesResolvers:
internal:
acme:
email: admin@homelab.local
storage: /certs/internal-acme.json
caServer: https://step-ca:9000/acme/acme/directory
# Use TLS-ALPN-01 challenge (no port 80 needed)
tlsChallenge: {}Because step-ca uses your internal root CA (not a public trusted root), Traefik needs to trust it. Add your root certificate to Traefik's container trust store:
# docker-compose.yml for Traefik
services:
traefik:
image: traefik:v3.1
environment:
# Tell Go's crypto to include your root CA
LEGO_CA_CERTIFICATES: /certs/root_ca.crt
volumes:
- /opt/step-ca/certs/root_ca.crt:/certs/root_ca.crt:ro
- /etc/traefik/certs:/certsThen point any Docker service at the internal resolver:
# Labels on any service container
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`myapp.homelab.local`)"
- "traefik.http.routers.myapp.tls=true"
- "traefik.http.routers.myapp.tls.certresolver=internal"Restart Traefik and watch the logs — it will contact step-ca and obtain a certificate within seconds.
Step 8: Distribute the Root Certificate to Clients
Browsers and operating systems only trust certificates signed by a CA in their trust store. Add your root CA to all client machines.
Linux (Debian/Ubuntu):
sudo cp root_ca.crt /usr/local/share/ca-certificates/homelab-root.crt
sudo update-ca-certificatesLinux (RHEL/Fedora/Arch):
sudo cp root_ca.crt /etc/pki/ca-trust/source/anchors/homelab-root.crt
sudo update-ca-trust extractmacOS:
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain root_ca.crtWindows (PowerShell as Administrator):
Import-Certificate -FilePath "root_ca.crt" `
-CertStoreLocation "Cert:\LocalMachine\Root"Firefox (uses its own trust store — add manually):
Go to Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import.
Docker containers / CI pipelines:
COPY root_ca.crt /usr/local/share/ca-certificates/homelab-root.crt
RUN update-ca-certificatesStep 9: Issue Certificates Manually (Optional)
For services that don't support ACME, issue certificates directly with the step CLI:
# Bootstrap the step CLI to trust your CA
step ca bootstrap \
--ca-url https://localhost:9000 \
--fingerprint <your-fingerprint>
# Issue a cert (prompts for JWK provisioner password)
step ca certificate myservice.homelab.local myservice.crt myservice.key
# Issue a wildcard cert
step ca certificate "*.homelab.local" wildcard.crt wildcard.key \
--san "*.homelab.local" --san "homelab.local"
# Inspect the issued cert
step certificate inspect myservice.crt
# Renew a cert
step ca renew myservice.crt myservice.keyStep 10: Enable SSH Certificate Authority (Bonus)
step-ca can also act as an SSH certificate authority — a major upgrade over authorized_keys files:
# During init, use --ssh flag (or add an SSH provisioner manually)
step ca provisioner add sshpop --type SSHPOP
# Issue an SSH user certificate
step ssh certificate your-username id_ed25519.pub
# The issued cert is valid for a limited time and inherits SSH principals
# On each server, configure sshd to trust the CA:
step ssh config --host --set IdentityFile=/etc/ssh/ssh_host_ecdsa_key >> /etc/ssh/sshd_configTesting
Verify Traefik Certs
# Confirm step-ca issued the cert (not a self-signed fallback)
echo | openssl s_client -connect myapp.homelab.local:443 2>/dev/null \
| openssl x509 -noout -issuer -dates
# Should show:
# issuer=O=Homelab CA, CN=Homelab CA Intermediate CA
# notBefore=...
# notAfter=... (90 days out)Verify Browser Trust
Navigate to https://myapp.homelab.local — the padlock should be green with no warnings. Click the padlock and inspect the certificate chain:
myapp.homelab.local
└── Homelab CA Intermediate CA
└── Homelab CA Root CA ← your root, trusted by OS
Check Certificate Renewal
Traefik renews certificates automatically when they pass 2/3 of their lifetime. To force a test:
# Temporarily issue a very short cert (5 minute lifetime)
step ca certificate test.homelab.local test.crt test.key \
--not-after 5m
# Watch Traefik logs for renewal attempt
docker logs traefik -f | grep -i acmeCheck CA Health
# Built-in health endpoint
curl https://localhost:9000/health
# List active certificates (requires admin credentials)
step ca admin list --ca-url https://localhost:9000Deployment Notes
Backup your CA data. Everything in /opt/step-ca is irreplaceable — if you lose the private keys, you must reissue every certificate and redistribute a new root to every client.
# Minimal backup — run before any CA changes
tar czf step-ca-backup-$(date +%Y%m%d).tar.gz /opt/step-caFirewall the CA port. Port 9000 should be accessible only from your internal network — never exposed to the internet. Your internal CA should not be reachable from outside your homelab.
# UFW example — allow only LAN
sudo ufw allow from 192.168.1.0/24 to any port 9000Monitor certificate expiry. Even with auto-renewal, set up an external expiry check:
# Add to crontab or a monitoring script
openssl s_client -connect myapp.homelab.local:443 \
-servername myapp.homelab.local 2>/dev/null \
| openssl x509 -noout -enddateExtensions and Next Steps
mTLS between services: Issue client certificates to individual services and configure them to present certs to each other. Any service that can't present a valid cert from your CA is rejected — strong zero-trust segmentation inside your homelab.
ACME clients beyond Traefik: Any ACME-compatible tool (certbot, acme.sh, cert-manager in Kubernetes) can use your internal CA by pointing at https://step-ca:9000/acme/acme/directory.
Kubernetes cert-manager integration: Install cert-manager in your k3s/k8s cluster and configure an Issuer pointing at step-ca. Every pod can then request its own certificate automatically.
Step-CA RA mode: Run step-ca in Registration Authority mode backed by a Hardware Security Module (HSM) like a YubiKey — the private key never lives on disk.
OIDC provisioner: Connect step-ca to an identity provider (Authentik, Keycloak) so users authenticate via SSO before receiving a certificate. Pairs perfectly with the Keycloak SSO project in this series.
Automated client provisioning: Write an Ansible playbook that bootstraps new VMs into your PKI on first boot — install the root cert, configure the step CLI, and request service certificates automatically.
Running your own CA is a significant upgrade to homelab security maturity. Once it's in place, every TLS handshake inside your network is verified against a CA you control — not a browser warning you've trained yourself to click through.