HOWTOIntermediate

Building an Internal Certificate Authority with step-ca and ACME Automation

Stand up a private PKI with Smallstep step-ca, issue short-lived certificates over ACME, and automate renewals for internal services.

Dylan H.

Tutorials

October 26, 2026
7 min read
Building an Internal Certificate Authority with step-ca and ACME Automation

Introduction

Internal dashboards, databases, and admin panels still need TLS. The usual shortcuts are self-signed certificates that everyone clicks through, or a long-lived wildcard certificate copied between servers. Both teach users to ignore browser warnings, and both turn into an outage when a forgotten certificate expires.

A private certificate authority fixes this. You issue certificates from a CA that your devices trust, make them short-lived, and let ACME clients renew them automatically. Smallstep step-ca is a lightweight, open-source CA that speaks ACME, the same protocol Let's Encrypt uses, so standard tools like certbot, acme.sh, Caddy, and Traefik work against it unchanged.

In this guide you will:

  • Initialize a root and intermediate CA with step-ca
  • Run it as a hardened systemd service
  • Enable the ACME provisioner
  • Distribute the root certificate to clients
  • Automate issuance and renewal with certbot
  • Verify, monitor, and troubleshoot the setup

Prerequisites

  • A dedicated host or VM for the CA. Treat it as sensitive infrastructure and keep it patched and firewalled.
  • DNS that resolves the CA name (ca.internal.example in this guide) from every client.
  • TCP 443 open from clients to the CA host.
  • Clients must be able to reach the services they validate, because ACME uses HTTP-01 or DNS-01 challenges.

Security note: The CA's private keys are the most valuable secret in this design. Anyone holding them can mint certificates your devices will trust. Keep the root key offline where practical, restrict access to the host, and back it up encrypted.

Step 1: Install step and step-ca

Download the step CLI and the step-ca server from the Smallstep releases page. Check the current version number first and verify the published checksums before installing.

# Replace VERSION with the current release, for example 0.28.0
VERSION=0.28.0
cd /tmp
wget https://dl.smallstep.com/gh-release/cli/gh-release-header/v${VERSION}/step-cli_${VERSION}-1_amd64.deb
wget https://dl.smallstep.com/gh-release/certificates/gh-release-header/v${VERSION}/step-ca_${VERSION}-1_amd64.deb
 
sudo dpkg -i step-cli_${VERSION}-1_amd64.deb step-ca_${VERSION}-1_amd64.deb
 
step version
step-ca version

Create a dedicated unprivileged user that owns the CA data:

sudo useradd --system --home /etc/step-ca --shell /usr/sbin/nologin step
sudo mkdir -p /etc/step-ca
sudo chown step:step /etc/step-ca
sudo chmod 700 /etc/step-ca

Step 2: Initialize the CA

Run the initialization as the step user with STEPPATH pointing at the data directory. This generates the root key, an intermediate key, and a default configuration.

sudo -u step STEPPATH=/etc/step-ca step ca init \
  --deployment-type standalone \
  --name "Example Internal CA" \
  --dns ca.internal.example \
  --address ":443" \
  --provisioner admin@example.com

You will be prompted for a password that encrypts the CA keys. Choose a long random password and store it in your password manager.

Write the password to a file the service can read, and lock it down:

echo 'YOUR-STRONG-PASSWORD' | sudo tee /etc/step-ca/password.txt > /dev/null
sudo chown step:step /etc/step-ca/password.txt
sudo chmod 400 /etc/step-ca/password.txt

Note the root certificate fingerprint printed at the end of init. Clients use it to bootstrap trust. You can print it again at any time:

sudo -u step step certificate fingerprint /etc/step-ca/certs/root_ca.crt

Step 3: Enable the ACME Provisioner

A provisioner is the policy that decides who can obtain certificates. Add an ACME provisioner so standard clients can request them without a token.

sudo -u step STEPPATH=/etc/step-ca step ca provisioner add acme --type ACME

For internal use, shorten the default certificate lifetime. Short-lived certificates limit the damage of a leaked key and make renewal automation a requirement instead of an afterthought. Edit /etc/step-ca/config/ca.json and set claims on the ACME provisioner:

{
  "type": "ACME",
  "name": "acme",
  "claims": {
    "minTLSCertDuration": "5m",
    "maxTLSCertDuration": "168h",
    "defaultTLSCertDuration": "72h"
  }
}

This issues 72-hour certificates by default and caps them at seven days.

Step 4: Run step-ca as a systemd Service

Create /etc/systemd/system/step-ca.service:

[Unit]
Description=Smallstep step-ca
After=network-online.target
Wants=network-online.target
 
[Service]
User=step
Group=step
Environment=STEPPATH=/etc/step-ca
ExecStart=/usr/bin/step-ca /etc/step-ca/config/ca.json --password-file /etc/step-ca/password.txt
Restart=on-failure
RestartSec=5
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/etc/step-ca/db
 
[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now step-ca
sudo systemctl status step-ca --no-pager

Open the firewall for HTTPS only from your internal networks:

sudo ufw allow from 10.0.0.0/8 to any port 443 proto tcp

Step 5: Distribute the Root Certificate

Clients must trust your root before they will accept any certificate it signs. Fetch the root using the fingerprint from Step 2 so you are not trusting an unverified download:

step ca bootstrap \
  --ca-url https://ca.internal.example \
  --fingerprint YOUR_ROOT_FINGERPRINT \
  --install

On Debian and Ubuntu clients without the step CLI, install the root into the system store manually:

sudo cp root_ca.crt /usr/local/share/ca-certificates/example-internal-root.crt
sudo update-ca-certificates

For a Windows fleet, publish the root through Group Policy under Computer Configuration > Windows Settings > Security Settings > Public Key Policies > Trusted Root Certification Authorities. For managed browsers on macOS and mobile devices, push it through your MDM as a certificate payload.

Step 6: Issue and Renew Certificates with certbot

With the root trusted on the service host, point certbot at your CA's ACME directory:

export REQUESTS_CA_BUNDLE=/etc/step-ca/certs/root_ca.crt
 
sudo -E certbot certonly \
  --standalone \
  --server https://ca.internal.example/acme/acme/directory \
  --domain app.internal.example \
  --agree-tos \
  --email admin@example.com \
  --no-eff-email

The default HTTP-01 challenge needs port 80 reachable on the service host and the CA able to resolve app.internal.example. For hosts that cannot expose port 80, use a DNS-01 plugin instead.

Because certificates last 72 hours, renewal must run frequently. Certbot installs a systemd timer, but make sure it uses the CA's root and reloads your service:

sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Then set the environment variable for the renewal service with a drop-in:

sudo systemctl edit certbot.service
[Service]
Environment=REQUESTS_CA_BUNDLE=/etc/step-ca/certs/root_ca.crt

If your reverse proxy supports ACME natively, such as Caddy or Traefik, skip certbot and set the ACME server URL to the same directory address. They handle renewal themselves.

Verification

Confirm the CA is healthy from any client that trusts the root:

step ca health --ca-url https://ca.internal.example --root /etc/step-ca/certs/root_ca.crt
# Expected output: ok

Inspect the certificate that was issued and check its lifetime and issuer:

sudo step certificate inspect /etc/letsencrypt/live/app.internal.example/fullchain.pem --short

Test the live service the way a browser would, without skipping validation:

curl -v https://app.internal.example/ 2>&1 | grep -E "subject:|issuer:|expire date|SSL certificate verify"

You should see SSL certificate verify ok and your internal intermediate as the issuer. Finally, test the renewal path without waiting three days:

sudo -E certbot renew --dry-run

Monitoring Expiry

Short-lived certificates make silent renewal failure the main risk. Add an independent check that alerts when a certificate has less than 24 hours left:

step certificate needs-renewal --expires-in 24h \
  /etc/letsencrypt/live/app.internal.example/fullchain.pem \
  && echo "RENEWAL NEEDED"

Run it from cron, a monitoring agent, or a Prometheus blackbox exporter probe on the HTTPS endpoint.

Troubleshooting

x509: certificate signed by unknown authority The client does not trust your root. Re-run the bootstrap step, or confirm update-ca-certificates reported one added certificate. Tools like Python and Java use their own trust stores, so set REQUESTS_CA_BUNDLE or import the root into the Java keystore.

ACME challenge fails with a connection or DNS error The CA must resolve and reach the requesting host. Test with curl http://app.internal.example/.well-known/acme-challenge/test from the CA host, and check that no firewall blocks port 80 between them.

step-ca fails to start with a password or permission error Verify that /etc/step-ca/password.txt is readable by the step user and that the password matches the one used at init. Check journalctl -u step-ca -n 50.

bind: permission denied on port 443 The service needs CAP_NET_BIND_SERVICE, or run on a high port such as 9000 and update the --address setting and client URLs.

Certificates issue but browsers still warn Firefox uses its own store unless enterprise roots are enabled. Set security.enterprise_roots.enabled to true through policy, or import the root directly.

Clock skew errors Certificates are time-sensitive. Ensure the CA and clients sync time through NTP or chrony.

Summary

You now have a private certificate authority that issues short-lived, automatically renewed certificates through standard ACME tooling. The key practices to keep: protect the CA keys and consider keeping the root offline, keep lifetimes short so revocation is rarely needed, distribute the root through your existing device management, and monitor expiry independently of the renewal job. From here, consider adding an offline root with an intermediate-only online CA, SSH certificate provisioning with step-ca, or mutual TLS between internal services.