Public TLS is easy because Let’s Encrypt exists.
Internal TLS is usually pain:
- self-signed certs everywhere
- browser warnings
- manual renewals
- random “works on my machine” trust stores
This guide shows how to run step-ca as your homelab Certificate Authority and expose it via ACME, so your services can get automatic, renewable TLS certs like they would with Let’s Encrypt.
Goal: “green lock” in the homelab, with copy-paste steps.
- Docker + Docker Compose
- A stable internal DNS name for your CA, example:
ca.hse.local - A stable internal DNS pattern for services, example:
app1.hse.local - One ACME client or reverse proxy that can talk ACME (Traefik, Caddy, certbot, etc.)
- Root CA: offline trust anchor (you install this cert in your devices)
- Intermediate CA: online signer that issues your service certificates
This is the standard layout, and it is what step-ca bootstraps by default.
We enable an ACME provisioner so anything ACME-capable can request and renew certs automatically.
Do not publish your CA to the internet. It should be reachable only from your LAN or your VPN.
Create docker-compose.yml:
services:
step-ca:
image: smallstep/step-ca:latest
container_name: step-ca
ports:
- "9000:9000"
environment:
- DOCKER_STEPCA_INIT_NAME=HomeLab CA
- DOCKER_STEPCA_INIT_DNS_NAMES=ca.hse.local,localhost
volumes:
- step:/home/step
restart: unless-stopped
volumes:
step:Start it:
docker compose up -dDOCKER_STEPCA_INIT_NAME= Issuer nameDOCKER_STEPCA_INIT_DNS_NAMES= hostnames/IPs your CA will accept requests on
Get the generated CA password (save it).
docker compose exec step-ca cat secrets/passwordThis is just to get started. For “real” setups, treat that password like a secret.
This is the part that removes browser warnings.
You will:
- export the Root CA certificate from the step-ca volume
- copy it to every client
- install it into the OS trust store
- in some browsers you have to install the Root CA separately
You do not install the private key anywhere. Only the public Root CA cert (
root_ca.crt).
-
Export
root_ca.crtfrom your step-ca host-
On the host where step-ca runs:
-
mkdir -p ~/pki docker compose exec step-ca cat certs/root_ca.crt > ~/pki/root_ca.crt
-
-
Verify you got a certificate:
-
openssl x509 -in ~/pki/root_ca.crt -noout -subject -issuer -dates
-
-
-
Copy the Root CA cert to a client
-
Install Root CA cert on Client/Browser
-
e.g. Debian/Ubuntu
-
sudo install -D -m 0644 /tmp/root_ca.crt /usr/local/share/ca-certificates/homelab-root-ca.crt sudo update-ca-certificates
-
Quick test:
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /usr/local/share/ca-certificates/homelab-root-ca.crt
-
-
Test from the client (reach the CA over HTTPS):
curl -v https://ca.hse.local:9000/healthReplace the hostname if needed
You want:
- no TLS trust error
- an HTTP 200 from
/health
If you get a TLS error, your trust store install did not take effect (or you installed the wrong file).
Add an ACME provisioner named acme:
docker compose exec step-ca step ca provisioner add acme --type ACMEThen restart the step-ca container so it loads the updated config:
docker compose restart step-caQuick check (optional): list provisioners and confirm acme exists:
docker compose exec step-ca step ca provisioner listACME clients need the “directory URL” in this format:
https://{ca-host-fqdn}:{port}/acme/{provisioner-name}/directory
If your CA is ca.hse.local:9000 and the provisioner is acme, your directory URL is:
https://ca.hse.local:9000/acme/acme/directory
Traefik can use a custom ACME server via caServer, and it can trust your CA via caCertificates.
-
Traefik static config (YAML example):
-
command: # ... # ACME with step-ca - "--certificatesresolvers.homelab.acme.caserver=https://ca.hse.local:9000/acme/acme/directory" - "--certificatesresolvers.homelab.acme.cacertificates=/etc/ssl/homelab/root_ca.crt" - "--certificatesresolvers.homelab.acme.storage=/acme/acme.json" - "--certificatesresolvers.homelab.acme.tlschallenge=true"
-
tlsChallenge(TLS-ALPN-01) is the easiest when Traefik is the thing terminating TLS.
-
-
Docker Compose tip
-
Mount your root cert into Traefik:
-
services: traefik: image: traefik:v3.3 volumes: # Root CA cert from Step 2 - ~/pki/root_ca.crt:/etc/ssl/homelab/root_ca.crt:ro # Persistent ACME storage - ./acme:/acme # plus your normal Traefik config...
-
-
-
In your app Compose:
-
labels: - "traefik.enable=true" - "traefik.http.routers.whoami.rule=Host(`app1.hse.local`)" - "traefik.http.routers.whoami.entrypoints=websecure" - "traefik.http.routers.whoami.tls=true" - "traefik.http.routers.whoami.tls.certresolver=homelab"
If you want no browser warnings on laptops, phones, and desktops:
- export your
root_ca.crt - install it into each device’s trust store and browser (or your MDM, or your homelab bootstrap script)
If you do not distribute trust, everything still “works”, but every client will complain forever.
Be intentional. Installing a root CA is a global trust action.
- firewall it
- expose it only on LAN or VPN
Your step-ca Docker volume contains critical state (keys, config, DB). Back it up like you would a password manager.
Short-lived certs reduce blast radius, but only if your renewals actually work.
- you did not trust the root CA for the HTTPS connection
- fix by: providing a CA bundle to the client (Traefik
caCertificates, Caddyacme_ca_root, certbotREQUESTS_CA_BUNDLE)
Remember the pattern:
https://{ca-host-fqdn}:{port}/acme/{provisioner-name}/directory
Your CA must be initialized with DNS names it will accept requests on (DOCKER_STEPCA_INIT_DNS_NAMES). If you forgot your real CA hostname, re-init in a clean volume and do it right.