TL;DR — Quick Summary
step-ca turns any server into a private CA for internal TLS, mTLS, and SSH certs. Complete guide to installation, provisioners, ACME, and production setup.
step-ca is an open-source private certificate authority from Smallstep that lets you run your own PKI for internal TLS, mutual TLS between microservices, and SSH certificates — all without sending traffic to a public CA. If you have ever struggled with Let’s Encrypt failing on private domains, with self-signed certificate warnings, or with distributing trust across dozens of services, step-ca solves every one of these problems with a modern, automation-first design. This guide covers the full lifecycle: why you need a private CA, architecture, installation, provisioners, ACME for automated renewal, SSH certificates, and a production HA deployment.
Why a Private CA?
Public CAs like Let’s Encrypt require domain validation over the public internet. This immediately excludes:
- Internal hostnames —
postgres.internal,api.corp, or any.local/.corpdomain - mTLS for microservices — mutual TLS requires each service to present a client certificate; issuing thousands of short-lived service certs from a public CA is impractical and expensive
- Zero-trust networking — every connection authenticated by certificate, not network position
- Air-gapped environments — no public internet access at all
- Service mesh without sidecars — native TLS between services using step-ca provisioned certs avoids the overhead of a full Istio/Linkerd deployment
step-ca fills this gap with an ACME-compatible server, short certificate lifetimes (24 hours by default), automated renewal daemons, and first-class Kubernetes integration via the autocert webhook and cert-manager step-issuer.
Architecture
┌─────────────────────────────────────────────┐
│ step-ca server │
│ ┌──────────┐ ┌─────────────┐ │
│ │ Root CA │→ │Intermediate │ │
│ │ (offline)│ │ CA │ │
│ └──────────┘ └──────┬──────┘ │
│ │ signs │
│ ┌────────────────────▼──────────────────┐ │
│ │ Provisioners │ │
│ │ JWK │ ACME │ OIDC │ AWS │ K8sSA │… │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↑ HTTPS API (port 9000)
┌────┴────┐
│step CLI │ certbot Caddy Traefik cert-manager
└─────────┘
The root CA signs only the intermediate CA certificate. In production you keep the root CA key offline (air-gapped or in a hardware security module). The intermediate CA is what step-ca runs with day-to-day — its key is on disk, optionally encrypted with a password or KMS. Provisioners are the authentication mechanisms that gate certificate issuance: clients must prove identity through the provisioner before step-ca will sign a CSR.
Installation
Binary (Linux/macOS)
# step CLI
wget https://github.com/smallstep/cli/releases/latest/download/step_linux_amd64.tar.gz
tar xzf step_linux_amd64.tar.gz && sudo mv step /usr/local/bin/
# step-ca server
wget https://github.com/smallstep/certificates/releases/latest/download/step-ca_linux_amd64.tar.gz
tar xzf step-ca_linux_amd64.tar.gz && sudo mv step-ca /usr/local/bin/
macOS (Homebrew)
brew install step
brew install step-ca
Docker
docker run -v step:/home/step \
-p 9000:9000 \
-e "DOCKER_STEPCA_INIT_NAME=Internal CA" \
-e "DOCKER_STEPCA_INIT_DNS_NAMES=ca.internal,localhost" \
-e "DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT=true" \
smallstep/step-ca
Kubernetes (Helm + autocert)
helm repo add smallstep https://smallstep.github.io/helm-charts
helm install step-certificates smallstep/step-certificates \
--set ca.name="Internal CA" \
--set ca.dns="step-certificates.default.svc.cluster.local"
# autocert webhook — auto-injects TLS into annotated pods
helm install autocert smallstep/autocert \
--set autocert.caURL=https://step-certificates.default.svc.cluster.local
Initial Setup: step ca init
step ca init \
--name "Internal CA" \
--dns ca.internal \
--address :9000 \
--provisioner admin@corp.internal
This creates under $(step path)/:
certs/root_ca.crt— root CA certificate (distribute to all clients)certs/intermediate_ca.crt— intermediate certificatesecrets/root_ca_key— root CA private key (keep offline after setup)secrets/intermediate_ca_key— intermediate key (used by running server)config/ca.json— server configurationconfig/defaults.json— client defaults (CA URL, fingerprint)
Start the server:
step-ca $(step path)/config/ca.json
For production, create a systemd unit:
[Unit]
Description=Smallstep CA
After=network.target
[Service]
User=step
ExecStart=/usr/local/bin/step-ca /etc/step-ca/config/ca.json
Restart=always
RestartSec=5
Environment=STEPPATH=/etc/step-ca
[Install]
WantedBy=multi-user.target
Provisioners
Provisioners are authentication plugins. The initial step ca init creates a JWK provisioner. Add more with:
step ca provisioner add <name> --type <TYPE>
JWK — Interactive / Script-based
The default provisioner. Clients authenticate with a JSON Web Token signed by the provisioner key. Good for interactive use and scripts:
step ca certificate api.internal api.crt api.key
# prompts for provisioner password
ACME — Automated (Caddy, Nginx, certbot)
Creates an internal ACME server. Any ACME client can request certificates from your private CA:
step ca provisioner add acme --type ACME
certbot example pointing at your internal CA:
certbot certonly \
--server https://ca.internal:9000/acme/acme/directory \
--standalone \
-d api.internal \
--agree-tos -m admin@corp.internal
Caddy — just set the ACME directory in Caddyfile:
api.internal {
tls {
ca https://ca.internal:9000/acme/acme/directory
}
reverse_proxy localhost:8080
}
OIDC — Single Sign-On Based
Ties certificate issuance to your identity provider (Google, Azure AD, Okta). Users authenticate with their SSO credentials to get a certificate:
step ca provisioner add google --type OIDC \
--client-id <GOOGLE_CLIENT_ID> \
--client-secret <SECRET> \
--configuration-endpoint https://accounts.google.com/.well-known/openid-configuration
Cloud Instance Identity (AWS / GCP / Azure)
VMs prove their identity using the cloud provider’s instance metadata service. No shared secrets:
step ca provisioner add aws --type AWS --aws-account 123456789012
step ca provisioner add gcp --type GCP --gcp-project my-project
step ca provisioner add azure --type Azure --azure-tenant <TENANT_ID>
An EC2 instance can then call:
step ca certificate $(hostname) /etc/tls/host.crt /etc/tls/host.key \
--provisioner aws
Kubernetes Service Accounts (K8sSA)
Pods authenticate using their Kubernetes service account JWT. Ideal for workload identity inside the cluster:
step ca provisioner add k8s --type K8sSA \
--public-key /path/to/k8s/sa-public-key.pub
X5C and SSHPOP
X5C allows existing X.509 certificates to vouch for new certificate requests. SSHPOP allows existing SSH certificates to bootstrap new TLS certificates — useful for migrating from SSH-first infrastructure.
Issuing Certificates
Manual Issuance with step CLI
# ECDSA P-256 (default) with SANs
step ca certificate api.internal api.crt api.key \
--san api.corp \
--san 10.0.1.50
# Ed25519 key
step ca certificate api.internal api.crt api.key --kty OKP --crv Ed25519
# RSA 4096 (for legacy compatibility)
step ca certificate api.internal api.crt api.key --kty RSA --size 4096
# Short-lived cert (1 hour)
step ca certificate api.internal api.crt api.key --not-after 1h
Certificate Templates
step-ca supports Go templates for custom certificate extensions, policies, and SAN manipulation:
{
"subject": {{ toJson .Subject }},
"sans": {{ toJson .SANs }},
"keyUsage": ["keyEncipherment", "digitalSignature"],
"extKeyUsage": ["serverAuth", "clientAuth"],
"extensions": [
{
"id": "1.3.6.1.4.1.99999.1",
"value": {{ toJson .Token.environment }}
}
]
}
SSH Certificates
step-ca issues SSH host and user certificates, enabling bastion-less access with SSO:
# SSH user certificate (authenticates a human)
step ssh certificate jcarlos@corp.internal id_ecdsa \
--provisioner google
# SSH host certificate (authenticates a server to clients)
step ssh certificate --host api.internal ssh_host_ecdsa_key \
--provisioner aws
Configure sshd to trust your CA for host and user certs:
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/step_user_ca.pub
HostCertificate /etc/ssh/ssh_host_ecdsa_key-cert.pub
HostKey /etc/ssh/ssh_host_ecdsa_key
Users configure their ~/.ssh/known_hosts:
@cert-authority * ecdsa-sha2-nistp256 AAAA...
Now SSH to any server in the fleet without adding individual host keys — the CA certificate vouches for every host.
Renewal and Revocation
Automatic Renewal (—daemon)
step-ca issues short-lived certificates (24 hours by default). The --daemon flag keeps them renewed:
step ca renew --daemon api.crt api.key
# renews at ~2/3 of lifetime, replaces files in place, sends SIGHUP to process
In a systemd service:
ExecStartPost=/usr/local/bin/step ca renew --daemon \
/etc/tls/service.crt /etc/tls/service.key \
--exec "systemctl reload my-service"
Revocation
Passive revocation — step-ca’s default. Short certificate lifetimes (hours, not years) mean revocation is implicit: just stop renewing. A compromised cert is valid for at most a few hours.
Active revocation — Enable CRL or OCSP in ca.json for immediate revocation:
step ca revoke --serial 1234567890abcdef
Check revocation status:
step certificate inspect api.crt | grep -A3 "CRL\|OCSP"
openssl ocsp -issuer intermediate_ca.crt -cert api.crt \
-url http://ca.internal:9000/ocsp
Database Backends and High Availability
| Backend | Default | HA | Use case |
|---|---|---|---|
| Badger | Yes | No | Single instance, dev/small teams |
| PostgreSQL | No | Yes | Production multi-replica |
| MySQL | No | Yes | Production multi-replica |
| NoSQL (BadgerDB v3) | No | No | Slightly higher performance single node |
Switch to PostgreSQL in ca.json:
"db": {
"type": "postgresql",
"dataSource": "postgresql://stepca:password@pg.internal:5432/stepca?sslmode=require"
}
For HA: run two or more step-ca instances pointing at the same PostgreSQL database, behind a load balancer (nginx, HAProxy, or a cloud LB). step-ca is stateless beyond the database, so horizontal scaling is straightforward.
Trust Distribution
System trust store
# Install CA root into the OS trust store
step certificate install $(step path)/certs/root_ca.crt
# Windows
Import-Certificate -FilePath root_ca.crt -CertStoreLocation Cert:\LocalMachine\Root
# Java
keytool -importcert -alias step-root -file root_ca.crt \
-keystore $JAVA_HOME/lib/security/cacerts -storepass changeit
Kubernetes — cert-manager step-issuer
helm install cert-manager jetstack/cert-manager --set installCRDs=true
kubectl apply -f - <<EOF
apiVersion: certmanager.step.sm/v1beta1
kind: StepClusterIssuer
metadata:
name: step-issuer
spec:
url: https://step-certificates.default.svc.cluster.local
caBundle: <base64-root-ca-crt>
provisioner:
name: k8s-sa
kid: <provisioner-key-id>
passwordRef:
name: step-provisioner-password
key: password
EOF
Annotate any Certificate resource with issuerRef: name: step-issuer and cert-manager will request the certificate from your internal CA automatically.
Comparison: Private CA Options
| Tool | ACME server | SSH certs | Provisioners | HA | Ease of setup | License |
|---|---|---|---|---|---|---|
| step-ca | Yes (built-in) | Yes | 8 types | Yes (ext DB) | High | Apache 2 |
| CFSSL | No | No | None | Yes | Medium | BSD |
| Vault PKI | Via plugin | Via Vault SSH | Via Vault auth | Yes (Raft) | Low | BSL |
| EJBCA | Yes | No | Many | Yes | Very low | LGPL / EE |
| Let’s Encrypt | Yes (public) | No | ACME only | N/A | Very high | N/A |
| mkcert | No | No | None | No | Very high | MIT |
step-ca wins on the combination of ACME compatibility, SSH certificates, multiple provisioner types, and operational simplicity. Vault PKI offers more flexibility if you are already running Vault. EJBCA is the choice for regulated industries requiring a full-featured enterprise CA with audit trails and HSM integration out of the box.
Production Deployment: ACME for Internal Services
This scenario: 20 microservices on Kubernetes, all using mTLS, with Caddy as ingress, PostgreSQL for step-ca HA, and cert-manager for in-cluster certificate issuance.
# 1. Initialize CA (run once, store root key offline after)
step ca init --name "Corp Internal CA" --dns ca.corp.internal \
--address :9000 --provisioner admin@corp.internal \
--deployment-type standalone
# 2. Add ACME provisioner
step ca provisioner add acme --type ACME
# 3. Add K8sSA provisioner
step ca provisioner add k8s --type K8sSA \
--public-key /var/run/secrets/kubernetes.io/serviceaccount/public-key.pub
# 4. Deploy step-ca with PostgreSQL backend
# Edit ca.json db block to point at PostgreSQL
# Deploy as Deployment with 2 replicas behind a Service
# 5. Bootstrap every node and pod
step ca bootstrap \
--ca-url https://ca.corp.internal:9000 \
--fingerprint $(step certificate fingerprint root_ca.crt) \
--install # installs into system trust store
# 6. Caddy ingress (automatic cert from internal ACME)
# api.corp.internal {
# tls { ca https://ca.corp.internal:9000/acme/acme/directory }
# reverse_proxy service-a:8080
# }
# 7. cert-manager issues certs to pods via step-issuer
# Pods mount TLS certs from Secrets created by cert-manager
Gotchas and Edge Cases
Clock skew — step-ca rejects CSRs if the client clock is more than a few minutes off. Ensure NTP is running on all nodes. Kubernetes pods share the node clock but VMs and containers in non-k8s environments can drift.
Root CA key protection — After step ca init, move root_ca_key offline. The intermediate key is all step-ca needs to operate. If the intermediate key is compromised, rotate it by issuing a new intermediate from the offline root — you do not need to redistribute the root CA to all clients.
ACME and IP SANs — ACME HTTP-01 and DNS-01 challenges work for domain names. For IP address SANs, use the step CLI directly or configure a provisioner that does not require domain validation.
Certificate transparency — Unlike public CAs, step-ca does not submit certificates to CT logs. This is usually desirable for internal infrastructure — your internal hostnames remain private.
Wildcard certificates — step-ca can issue wildcard SANs (*.internal) but they are discouraged. Prefer per-service certificates with short lifetimes for better security isolation. With automated renewal via --daemon, per-service certs have no operational overhead advantage over wildcards.
Provisioner password management — Store provisioner passwords in a secrets manager (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault). Never hardcode them in scripts. step-ca supports reading the password from a file, which you can populate from a secrets manager at startup.
Summary
- step-ca runs a full private PKI: root CA, intermediate CA, and a REST API for certificate issuance
- Use short-lived certificates (default 24h) and
--daemonrenewal instead of CRL/OCSP for most use cases - ACME provisioner makes step-ca a drop-in internal CA for Caddy, Nginx, Traefik, and certbot
- Cloud provisioners (AWS, GCP, Azure) enable zero-secret workload identity bootstrapping
- K8sSA and cert-manager integration bring automated per-pod TLS to Kubernetes without sidecars
- SSH certificates from step-ca eliminate the need for per-host known_hosts management
- For HA, switch the database backend to PostgreSQL and run multiple replicas behind a load balancer
- Keep the root CA key offline; rotate the intermediate CA key independently if compromised