<!-- https://pkizerotohero.com/guides/private-ca-step-ca/ | Markdown mirror of the HTML page | PKI Zero to Hero, by Duyutech BV -->

> Run a lab private CA with smallstep step-ca: install, initialise, issue and renew short-lived certificates, add ACME, restrict names and plan production.

Source: https://pkizerotohero.com/guides/private-ca-step-ca/ · Last reviewed: 2026-10-20

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](https://pkizerotohero.com/guides/#task-advanced)

Platforms

- Linux
- macOS

Who it is for

- [IT ops](https://pkizerotohero.com/start/#itops)
- [Developer](https://pkizerotohero.com/start/#developer)
- [Expert](https://pkizerotohero.com/start/#expert)

Level

Practitioner · 8 steps

Warning

For labs and tests only

This guide builds a CA for a lab, a test environment or a home network. The root and intermediate keys end up as files on one machine, protected by one password. A production private PKI needs an offline root, protected keys, written policies and people who own it: start with [A CA of your own](https://pkizerotohero.com/handbook/private-ca/) before you build one.

## 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](https://pkizerotohero.com/handbook/glossary/#private-ca) issues certificates for names and uses that public CAs cannot cover, such as internal hosts and [mutual TLS](https://pkizerotohero.com/handbook/glossary/#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:

```bash
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
```

```text
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](https://pkizerotohero.com/guides/code-signing-signtool-cosign/) shows how to check one. For package managers on other systems, follow the [installation docs](https://smallstep.com/docs/step-ca/installation/).

### 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:

```bash
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
```

```text
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

```bash
step-ca $(step path)/config/ca.json --password-file pw.txt
```

```text
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](https://smallstep.com/docs/step-ca/certificate-authority-server-production/) describes.

### 4. Issue a certificate

The `admin@example.com` provisioner authorises a request when you give its password:

```bash
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
```

```text
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:

```bash
step ca renew --force app.crt app.key
```

```text
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:

```bash
step ca renew --daemon --exec "systemctl reload nginx" app.crt app.key
```

smallstep’s [renewal docs](https://smallstep.com/docs/step-ca/renewal/) 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`:

```bash
curl -s --cacert $(step path)/certs/root_ca.crt https://localhost:9000/acme/acme/directory
```

```text
{"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](https://smallstep.com/docs/tutorials/acme-protocol-acme-clients/) shows certbot; certbot must trust your root through `REQUESTS_CA_BUNDLE`:

```bash
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`:

```json
"policy": {
  "x509": {
    "allow": { "dns": ["*.lab.internal"] }
  }
}
```

Requests outside the list are now refused:

```text
$ 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](https://pkizerotohero.com/handbook/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:

```bash
step certificate fingerprint $(step path)/certs/root_ca.crt
```

```text
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](https://pkizerotohero.com/guides/add-root-ca-linux/), [Windows](https://pkizerotohero.com/guides/add-root-ca-windows/), [macOS](https://pkizerotohero.com/guides/add-root-ca-macos/) or [Java](https://pkizerotohero.com/guides/add-root-ca-java/).

## Check it worked

Verify an issued certificate against the root, as any client would:

```bash
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
```

```text
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:

*Interactive tool: Chain checker (runs in the browser at [/tools/chain/](https://pkizerotohero.com/tools/chain/))*

## 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](https://pkizerotohero.com/errors/go-x509-unknown-authority/)** or [self-signed certificate in certificate chain](https://pkizerotohero.com/errors/curl-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](https://pkizerotohero.com/handbook/private-ca/)). Clients trust the root because you installed it, then validate each chain up to it. The same [ACME](https://pkizerotohero.com/handbook/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](https://pkizerotohero.com/handbook/certificate-lifecycle/)).

## Go deeper

- [smallstep: Getting started with step-ca](https://smallstep.com/docs/step-ca/getting-started/): the full first-run walkthrough.
- [smallstep: Configuration](https://smallstep.com/docs/step-ca/configuration/): `ca.json`, provisioners, claims and policies.
- [smallstep: Production considerations](https://smallstep.com/docs/step-ca/certificate-authority-server-production/): root key handling, service accounts and revocation.
- [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555): the ACME protocol.
- [RFC 3647](https://www.rfc-editor.org/rfc/rfc3647): 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](https://pkizerotohero.com/contact/?topic=content-error&page=/guides/private-ca-step-ca/).

Last reviewed 20 October 2026Edition 0.9

## Keep going

### Learn why it works

- [Chapter 22: A CA of your own: private two-tier PKI](https://pkizerotohero.com/handbook/private-ca/)
- [Chapter 20: ACME and the 47-day schedule](https://pkizerotohero.com/handbook/acme/)
- [Chapter 19: Inventory, expiry and renewal](https://pkizerotohero.com/handbook/certificate-lifecycle/)

### Error pages

- [x509: certificate signed by unknown authority](https://pkizerotohero.com/errors/go-x509-unknown-authority/)
- [SSL certificate problem: self-signed certificate in certificate chain](https://pkizerotohero.com/errors/curl-self-signed-certificate-in-certificate-chain/)

### Tools

- [Chain checker](https://pkizerotohero.com/tools/chain/): Paste a chain and see which link is missing, expired or out of order.

### Topics

- [Private PKI](https://pkizerotohero.com/topics/private-pki/)

## More guides: Labs and advanced

- [How to build a test CA with OpenSSL (lab only)](https://pkizerotohero.com/guides/openssl-test-ca/)
- [How to sign code with signtool and cosign](https://pkizerotohero.com/guides/code-signing-signtool-cosign/)
- [How to use SSH certificates instead of known\_hosts](https://pkizerotohero.com/guides/ssh-certificates/)
- [How to test post-quantum hybrid TLS](https://pkizerotohero.com/guides/post-quantum-tls-test/)
