Setting Up an Internal Certificate Authority with step-ca (The Hard Way)
Every homelab eventually hits the same wall: you’ve got a dozen internal services, they’re all throwing “Not Secure” warnings, and you’re tired of clicking through certificate errors like some kind of animal.
Let’s Encrypt can actually issue certs for internal services if you own the domain — DNS-01 challenge doesn’t care if the server is public. But LE has a hard 90-day limit, requires renewal automation, and depends on an external service. An internal CA gives you longer lifetimes (up to a year+), zero external dependencies, certs for bare IPs and non-domain hostnames, and works even if your internet is down.
This guide walks through setting up Smallstep step-ca as an internal CA using rootless Podman. Fair warning: the setup is rougher than it should be. But once it’s running, it works well.
Why an Internal CA?
- Longer cert lifetimes — up to a year or more, vs LE’s hard 90-day cap
- No external dependency — works offline, no DNS challenge automation needed
- Certs for anything — bare IPs, internal hostnames, custom SANs that LE won’t touch
- No rate limits — issue as many certs as you want, no third-party outages
Architecture
- Host: RHEL 9 VM running rootless Podman
- Container:
smallstep/step-ca:latest - Port: 8443 (host) → 9000 (container)
- DNS:
ca.example.com(internal DNS) - Storage: Podman named volume (not a bind mount — more on that later)
- Reverse proxy: Nginx (terminates TLS and re-encrypts to step-ca)
The Setup
Create a Dedicated User
Following the principle of separate users per service group (same pattern as Keycloak, seedbox, etc.):
1 2 | useradd -m -s /bin/bash stepca loginctl enable-linger stepca |
enable-linger lets the user’s systemd services (and Podman containers) run without an active login session.
Docker Compose File
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | --- version: "2.1" services: step-ca: image: docker.io/smallstep/step-ca:latest container_name: step-ca environment: DOCKER_STEPCA_INIT_NAME: "example.com CA" DOCKER_STEPCA_INIT_DNS_NAMES: "ca.example.com,localhost" DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT: "true" DOCKER_STEPCA_INIT_PROVISIONER_NAME: "[email protected]" volumes: - step:/home/step ports: - "8443:9000" restart: unless-stopped volumes: step: |
Why Named Volumes, Not Bind Mounts
This is where I lost an hour. My other services all use bind mounts (./storage/service:/data), so naturally I tried:
1 2 | volumes: - ./storage/step-ca:/home/step |
It doesn’t work with rootless Podman. The step-ca container runs as UID 1000 internally, but rootless Podman’s user namespace mapping means that UID doesn’t map to anything with write permissions on your bind mount. You’ll get:
/entrypoint.sh: line 59: /home/step/password: Permission denied
Over and over. I tried podman unshare chown, chmod 777, user: "0:0", and userns_mode: keep-id (which conflicts with podman-compose’s pod networking). None of it worked cleanly.
Named volumes just work. Podman manages the permissions internally. Your data lives at ~/.local/share/containers/storage/volumes/step/ — find it with podman volume inspect step.
Pull and Start
1 2 3 | su - stepca podman-compose pull # Select docker.io/smallstep/step-ca:latest podman-compose up -d |
Get Your Credentials
1 | podman logs step-ca 2>&1 | grep password |
The auto-init prints your admin password once. Save it immediately. You’ll also want the root CA fingerprint:
1 | podman logs step-ca 2>&1 | grep fingerprint |
Or get it later:
1 | podman exec step-ca step certificate fingerprint /home/step/certs/root_ca.crt |
Manual Initialization (Recommended)
The auto-init via environment variables works, but if you want more control (like choosing your own password), initialize manually:
1 2 3 4 | podman-compose down podman volume rm step podman run -it -v step:/home/step docker.io/smallstep/step-ca:latest step ca init |
The wizard asks for:
- PKI Name: example.com (becomes the CA organization)
- DNS Names: ca.example.com (add the server IP too if you want direct access)
- Listen Address: :9000
- Provisioner Name: [email protected]
- Password: Set something you’ll remember
Then write the password file:
1 2 | podman run -it -v step:/home/step docker.io/smallstep/step-ca:latest \ sh -c 'echo "YOUR_PASSWORD" > /home/step/secrets/password' |
Start the CA:
1 | podman-compose up -d |
What the Init Wizard Doesn’t Ask
Organization, locality, state, country — none of it. The wizard only sets the Common Name. Your root cert will show O=example.com CA and nothing else. No L=Anytown, no ST=State, no C=US.
For a public CA this would be unacceptable. For an internal homelab CA, it’s cosmetic — the certs work identically either way. If it bothers you, you can generate the root and intermediate certs manually with step certificate create and pass --set flags for the org fields. But honestly, for internal use, it doesn’t matter.
Configure Cert Lifetimes
The default cert lifetime is 24 hours. Yes, really. Smallstep’s philosophy is short-lived certs with automatic renewal. Great in theory, annoying in practice for a homelab.
Edit the CA config:
1 | podman exec -it step-ca vi /home/step/config/ca.json |
Add claims under authority:
1 2 3 4 5 6 7 8 | "authority": { "enableAdmin": true, "claims": { "minTLSCertDuration": "5m", "maxTLSCertDuration": "8760h", "defaultTLSCertDuration": "2160h" } } |
That gives you: - Default: 90 days - Maximum: 1 year - Minimum: 5 minutes
Restart after editing:
1 | podman-compose restart
|
Nginx Reverse Proxy
If you want to access the CA through your existing nginx setup on port 443:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | server { listen 443 ssl http2; server_name ca.example.com; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; set $ca https://127.0.0.1:8443; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_pass $ca; proxy_ssl_verify off; } } |
Important: proxy_ssl_verify off is required because step-ca serves its own self-signed TLS. Without this, nginx rejects the upstream connection.
Gotcha: The step CLI may have issues bootstrapping through the nginx proxy (double-TLS). For cert operations, point directly at port 8443:
1 | step ca certificate ... --ca-url https://ca.example.com:8443 |
Issuing Certificates
Install the Step CLI
Mac:
1 | brew install step |
RHEL:
1 | sudo dnf install -y step-cli |
Bootstrap (One-Time Per Machine)
Get your CA’s root fingerprint:
1 | curl -sk https://ca.example.com:8443/roots.pem | step certificate fingerprint /dev/stdin |
Then bootstrap:
1 | step ca bootstrap --ca-url https://ca.example.com:8443 --fingerprint <YOUR_FINGERPRINT> |
Generate a Wildcard Certificate
1 2 3 4 | step ca certificate "*.example.com" wildcard.crt wildcard.key \ --san "*.example.com" --san "example.com" \ --ca-url https://ca.example.com:8443 \ --not-after=8760h |
Enter your provisioner password when prompted. You now have a 1-year wildcard cert for your entire domain.
Trust the Root CA
Mac:
1 2 3 | sudo security add-trusted-cert -d -r trustRoot \ -k /Library/Keychains/System.keychain \ ~/.step/certs/root_ca.crt |
RHEL/Fedora:
1 2 | sudo cp ~/.step/certs/root_ca.crt /etc/pki/ca-trust/source/anchors/ sudo update-ca-trust |
Windows:
Import root_ca.crt into Trusted Root Certification Authorities via certmgr.msc.
Container Management
Add step-ca to your container management script alongside your other services:
1 2 3 4 5 6 7 8 9 10 11 12 | # Update section echo "------------->Updating STEPCA containers..." su - stepca -c 'podman-compose down' su - stepca -c 'podman-compose pull' su - stepca -c 'yes | podman system prune' # Up section su - stepca -c 'podman-compose up --force-recreate --build -d' # Status section echo "----------------->STEPCA status" su - stepca -c 'podman ps' |
Backup
The named volume holds your root CA keys. If you lose them, every cert you’ve ever issued is orphaned. Back them up:
1 2 | podman exec step-ca cat /home/step/secrets/root_ca_key > ~/root_ca_key_backup.pem podman exec step-ca cat /home/step/secrets/intermediate_ca_key > ~/intermediate_ca_key_backup.pem |
Store these somewhere safe and offline.
Was It Worth It?
Honestly? The setup experience is rough. Smallstep prioritizes their paid product and the open source version shows it — no web GUI, no org fields in the wizard, 24-hour default lifetimes, and rootless Podman permission issues that ate an hour.
But once it’s running, it does what it’s supposed to. You get an API-driven CA that issues proper certs with a single command. No more self-signed cert warnings across your homelab.
If you just need a quick wildcard cert and don’t care about an ongoing CA, OpenSSL is simpler:
1 2 3 4 5 6 7 8 9 | openssl req -x509 -new -nodes -keyout rootCA.key -sha256 -days 3650 -out rootCA.crt \ -subj "/C=US/ST=State/L=Anytown/O=example.com/CN=example.com Root CA" openssl genrsa -out wildcard.key 2048 openssl req -new -key wildcard.key -out wildcard.csr \ -subj "/C=US/ST=State/L=Anytown/O=example.com/CN=*.example.com" openssl x509 -req -in wildcard.csr -CA rootCA.crt -CAkey rootCA.key \ -CAcreateserial -out wildcard.crt -days 365 -sha256 \ -extfile <(echo "subjectAltName=DNS:*.example.com,DNS:example.com") |
But if you want a proper CA that multiple machines can request certs from, step-ca earns its keep — just budget some patience for the setup.