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.

Task
Labs and advanced
Platforms
  • Linux
  • macOS
Who it is for
Level
Practitioner · 8 steps
On this page
  1. Before you start
  2. Steps
  3. 1. Install step and step-ca
  4. 2. Initialise the CA
  5. 3. Start the CA
  6. 4. Issue a certificate
  7. 5. Renew automatically
  8. 6. Use ACME, like a public CA
  9. 7. Restrict the names your CA may sign
  10. 8. Distribute the root
  11. Check it worked
  12. If something goes wrong
  13. Why this works
  14. Go deeper

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.
  • openssl for 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:

Try it: Chain checkerOpen the full tool

Runs in your browser. Nothing you paste is sent anywhere.

Every certificate the server sends, leaf first. The output of openssl s_client -showcerts -connect example.com:443 works as it is: we ignore the text around the certificates.

If something goes wrong

  • requested duration of 720h1m0s is more than the authorized maximum certificate duration of 24h1m0s: the provisioner’s claims (maxTLSCertDuration, defaultTLSCertDuration) set the limits. Raise them in ca.json only as far as your renewal really reaches.
  • dns name "…" not allowed: the policy from step 7 is working. Add the domain to allow if 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

Early edition. Checked against the standards; an independent expert review is in progress. Found a mistake? Tell us.

Last reviewed Edition 0.9