How-to guide · Labs and advanced
How to run a small private CA with step-ca (lab)
In one sentence
You will have a lab CA running smallstep's step-ca that issues and renews 24-hour certificates, speaks ACME, and refuses names outside your lab domain.
- Platforms
- Linux
- macOS
- Level
- Practitioner · 8 steps
On this page
Before you start
- A Linux or macOS machine (or WSL) for the CA, and a lab domain that is not used on the internet, such as
lab.internal. - About 30 minutes.
opensslfor checking results.
Why this matters: a private CA issues certificates for names and uses that public CAs cannot cover, such as internal hosts and mutual TLS clients. step-ca makes the safe defaults easy: two tiers, short lifetimes and automatic renewal.
Steps
1. Install step and step-ca
step is the command-line client and step-ca the CA server. On Linux, download both from their GitHub releases and check them against the published checksums:
curl -sLO https://github.com/smallstep/certificates/releases/download/v0.30.2/step-ca_linux_amd64.tar.gz
curl -sL -o ca-checksums.txt https://github.com/smallstep/certificates/releases/download/v0.30.2/checksums.txt
curl -sLO https://github.com/smallstep/cli/releases/download/v0.31.0/step_linux_amd64.tar.gz
curl -sL -o cli-checksums.txt https://github.com/smallstep/cli/releases/download/v0.31.0/checksums.txt
grep ' step-ca_linux_amd64.tar.gz$' ca-checksums.txt | sha256sum -c
grep ' step_linux_amd64.tar.gz$' cli-checksums.txt | sha256sum -c
step-ca_linux_amd64.tar.gz: OK
step_linux_amd64.tar.gz: OK
Unpack them and put step and step-ca on your PATH. Each release also carries a Sigstore bundle (.sigstore.json) that proves who built it; How to sign code with signtool and cosign shows how to check one. For package managers on other systems, follow the installation docs.
2. Initialise the CA
Put a long random password in a file that only you can read, then create the PKI. --acme also adds an ACME provisioner:
umask 077
head -c 24 /dev/urandom | base64 > pw.txt
step ca init --deployment-type standalone --name "Example Lab CA" \
--dns localhost --address 127.0.0.1:9000 \
--provisioner admin@example.com \
--password-file pw.txt --provisioner-password-file pw.txt --acme
Generating root certificate... done!
Generating intermediate certificate... done!
✔ Root certificate: /home/lab/.step/certs/root_ca.crt
✔ Root private key: /home/lab/.step/secrets/root_ca_key
✔ Root fingerprint: dd6a159a8239a9b498aaa2a83a344881224a0aee41e17d5a4cdcf72713286447
✔ Intermediate certificate: /home/lab/.step/certs/intermediate_ca.crt
✔ Intermediate private key: /home/lab/.step/secrets/intermediate_ca_key
...
Your PKI is ready to go.
These settings make the CA reachable only from this machine, as localhost. For other lab machines, initialise it with a name and address they can reach instead, for example --dns ca.lab.internal --address :9000; the certbot command in step 6 and the bootstrap in step 8 use that name.
You now have a two-tier PKI: a root valid for ten years and an intermediate that signs everyday certificates. Note the root key on the same disk: this is exactly what makes it a lab. smallstep’s own production advice is that “The root key is not needed for day-to-day CA operation and should be stored offline.”
3. Start the CA
step-ca $(step path)/config/ca.json --password-file pw.txt
2026/10/20 04:20:38 The primary server URL is https://localhost:9000
2026/10/20 04:20:38 Root certificates are available at https://localhost:9000/roots.pem
2026/10/20 04:20:38 X.509 Root Fingerprint: dd6a159a8239a9b498aaa2a83a344881224a0aee41e17d5a4cdcf72713286447
2026/10/20 04:20:38 Serving HTTPS on 127.0.0.1:9000 ...
In another terminal, step ca health should answer ok. For anything that runs longer than a test, run it as a dedicated non-root user under systemd, as the production guide describes.
4. Issue a certificate
The admin@example.com provisioner authorises a request when you give its password:
step ca certificate app.lab.internal app.crt app.key \
--provisioner admin@example.com --provisioner-password-file pw.txt
step certificate inspect app.crt --short
X.509v3 TLS Certificate (ECDSA P-256) [Serial: 2137...8708]
Subject: app.lab.internal
Issuer: Example Lab CA Intermediate CA
Provisioner: admin@example.com [ID: Wotk...PC-8]
Valid from: 2026-10-20T02:19:51Z
to: 2026-10-21T02:20:51Z
The certificate lives 24 hours, step-ca’s default. The key is generated on your machine and app.crt already holds the leaf followed by the intermediate, which is the full chain a server such as nginx needs.
5. Renew automatically
Short lifetimes only work with automatic renewal. Renew by hand to see it work:
step ca renew --force app.crt app.key
Your certificate has been saved in app.crt.
By default step authenticates the renewal with the current certificate and key over mutual TLS, so no password is needed. To keep a service running, let step watch the certificate. By default it renews when about two-thirds of the lifetime has passed, and --exec reloads the service afterwards:
step ca renew --daemon --exec "systemctl reload nginx" app.crt app.key
smallstep’s renewal docs prefer a systemd timer (cert-renewer@.timer) for production-like setups.
6. Use ACME, like a public CA
The --acme flag created a provisioner named acme. Its directory follows the pattern https://<ca>/acme/<provisioner>/directory:
curl -s --cacert $(step path)/certs/root_ca.crt https://localhost:9000/acme/acme/directory
{"newNonce":"https://localhost:9000/acme/acme/new-nonce","newAccount":"https://localhost:9000/acme/acme/new-account","newOrder":"https://localhost:9000/acme/acme/new-order","revokeCert":"https://localhost:9000/acme/acme/revoke-cert","keyChange":"https://localhost:9000/acme/acme/key-change"}
Any ACME client can now use it. smallstep’s ACME client tutorial shows certbot; certbot must trust your root through REQUESTS_CA_BUNDLE:
sudo REQUESTS_CA_BUNDLE=$(step path)/certs/root_ca.crt \
certbot certonly -n --standalone -d app.lab.internal \
--server https://ca.lab.internal:9000/acme/acme/directory
The CA must be able to reach the name to validate it, just as a public CA would. With 24-hour certificates, run certbot renew often; the tutorial suggests every 15 minutes.
7. Restrict the names your CA may sign
A lab CA should not be able to sign www.example.com. Add a policy to the authority section of $(step path)/config/ca.json and restart step-ca:
"policy": {
"x509": {
"allow": { "dns": ["*.lab.internal"] }
}
}
Requests outside the list are now refused:
$ step ca certificate www.example.com www.crt www.key --provisioner admin@example.com --provisioner-password-file pw.txt
The request was forbidden by the certificate authority: dns name "www.example.com" not allowed
This policy is enforced by the CA itself. Name constraints inside the CA certificate, which clients enforce, are covered in the private CA chapter.
8. Distribute the root
Every client must trust root_ca.crt. Compare its fingerprint with the one printed at init before you install it anywhere:
step certificate fingerprint $(step path)/certs/root_ca.crt
dd6a159a8239a9b498aaa2a83a344881224a0aee41e17d5a4cdcf72713286447
On machines with step, step ca bootstrap --ca-url https://ca.lab.internal:9000 --fingerprint <fingerprint> --install fetches the root, checks the fingerprint and adds it to the system trust store. Elsewhere, follow Linux, Windows, macOS or Java.
Check it worked
Verify an issued certificate against the root, as any client would:
openssl verify -CAfile $(step path)/certs/root_ca.crt -untrusted app.crt app.crt
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted app.crt app.crt
app.crt: OK
O = Example Lab CA, CN = Example Lab CA Intermediate CA
error 20 at 1 depth lookup: unable to get local issuer certificate
error app.crt: verification failed
Against your root, the chain is valid. Against the public trust store it fails, as it should: nobody outside your lab trusts this CA. Paste the chain into the chain checker to see each link:
If something goes wrong
requested duration of 720h1m0s is more than the authorized maximum certificate duration of 24h1m0s: the provisioner’sclaims(maxTLSCertDuration,defaultTLSCertDuration) set the limits. Raise them inca.jsononly as far as your renewal really reaches.dns name "…" not allowed: the policy from step 7 is working. Add the domain toallowif it belongs to the lab.- Clients report x509: certificate signed by unknown authority or self-signed certificate in certificate chain: the root is not in that client’s trust store. Install it; never turn verification off.
- Services fail every morning: the 24-hour certificates expired because renewal is not running. Check the timer or daemon, and monitor expiry.
- ACME validation fails: the CA cannot resolve or reach the name. It validates from its own network, so its DNS must know your lab names.
Why this works
The root signs only the intermediate; the intermediate signs everything else, so the root key can be kept away from the network in a real deployment (private CA). Clients trust the root because you installed it, then validate each chain up to it. The same ACME protocol that public CAs use automates issuance, and short lifetimes keep the damage of a lost key small without relying on revocation (inventory, expiry and renewal).
Go deeper
- smallstep: Getting started with step-ca: the full first-run walkthrough.
- smallstep: Configuration:
ca.json, provisioners, claims and policies. - smallstep: Production considerations: root key handling, service accounts and revocation.
- RFC 8555: the ACME protocol.
- RFC 3647: the framework for the policy documents a production CA needs.
Early edition. Checked against the standards; an independent expert review is in progress. Found a mistake? Tell us.