Deploy Step-CA on Linux: Build a Private CA with Automatic TLS Certificate Issuance via ACME

Security tutorial - IT technology blog
Security tutorial - IT technology blog

The Problem with Internal TLS: Self-Signed Chaos and Expired Certificates

After my server got hit by SSH brute-force attacks at midnight, I became obsessive about locking down every attack surface I’d been glossing over. That incident drilled something home: firewalls and fail2ban protect the perimeter, but internal traffic was flowing in plaintext — credentials, session tokens, all of it.

Most teams land on one of two approaches for internal TLS: skip encryption entirely and trust that the internal network stays clean, or scatter self-signed certificates everywhere. Both look like solutions. Both accumulate problems.

Skip TLS internally and you’re one compromised host away from capturing plaintext credentials across your entire service mesh. Use self-signed certs and you’re managing perpetual browser warnings, chasing down expired certificates before services break, and slowly training your team to click through security prompts without reading them.

The underlying gap: there’s no trusted certificate authority for your internal infrastructure. Public CAs like Let’s Encrypt only issue certificates for publicly resolvable domain names. Your internal hostnames — api.internal, db.prod.local, vault.homelab — will never qualify.

Step-CA fills that gap. It’s an open-source private Certificate Authority that speaks ACME — the same protocol Let’s Encrypt uses. Your existing ACME clients (certbot, acme.sh, Caddy) point at your private CA instead, with no code changes. Certificates get issued automatically and renewed before expiry, for any hostname you control.

Core Concepts Before You Install Anything

What Step-CA Is

Step-CA (from Smallstep) is a CA server that runs on your own infrastructure. It manages a root CA certificate and issues leaf certificates to clients that authenticate correctly. Unlike traditional enterprise CAs like Microsoft ADCS, Step-CA is built for small teams and plays well with modern DevOps tooling. The entire server ships as a single binary — no database, no external dependencies.

ACME Protocol — The Engine Behind Let’s Encrypt

ACME (Automated Certificate Management Environment, RFC 8555) is the protocol that made free, automatic TLS mainstream. A client proves domain control through a challenge — HTTP-01, DNS-01, or TLS-ALPN-01 — and the CA issues a certificate. With a private CA, you control both sides. That means validation rules for internal hostnames are entirely yours to configure.

The Trust Chain

To make Step-CA certificates trusted without browser warnings, install the Step-CA root certificate into the OS trust store once per machine. That’s it. Every certificate your CA issues going forward is automatically trusted on that machine — no per-cert configuration needed.

Hands-On: Deploying Step-CA with ACME

Step 1: Install Step-CA and the step CLI

Step-CA ships as a single binary. On Debian/Ubuntu:

# Install the step CLI (certificate management client)
wget https://dl.smallstep.com/cli/docs-cli-install/latest/step-cli_amd64.deb
sudo dpkg -i step-cli_amd64.deb

# Install the step-ca server
wget https://dl.smallstep.com/certificates/docs-ca-install/latest/step-ca_amd64.deb
sudo dpkg -i step-ca_amd64.deb

# Verify
step version
step-ca version

On RHEL/Rocky/AlmaLinux, grab the RPM packages from the Smallstep GitHub releases page.

Step 2: Initialize Your Private CA

Create a dedicated system user and initialize the CA under its home directory:

sudo useradd --system --home /etc/step-ca --shell /bin/false step
sudo mkdir -p /etc/step-ca
sudo chown step:step /etc/step-ca

# Initialize CA as the step user
sudo -u step env STEPPATH=/etc/step-ca step ca init \
  --name="My Internal CA" \
  --dns="ca.internal,ca.yourdomain.local" \
  --address=":9000" \
  --provisioner="[email protected]"

This generates root and intermediate CA certificates under /etc/step-ca/certs/ and private keys under /etc/step-ca/secrets/. You’ll be prompted to set a password for the CA’s private key. Store it somewhere secure — HashiCorp Vault, AWS Secrets Manager, or at minimum a 0400-permissioned file on the CA host that only the step user can read.

Step 3: Add an ACME Provisioner

Step-CA uses JWK provisioners by default. Add an ACME provisioner so standard ACME clients can request certificates:

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

Check /etc/step-ca/config/ca.json after running this — you should see an entry with "type": "ACME" in the provisioners array. The ACME directory URL will be https://ca.internal:9000/acme/acme/directory.

Step 4: Run Step-CA as a systemd Service

cat <<EOF | sudo tee /etc/systemd/system/step-ca.service
[Unit]
Description=step-ca Private Certificate Authority
After=network.target

[Service]
Type=simple
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

[Install]
WantedBy=multi-user.target
EOF

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

Store your CA password in /etc/step-ca/password.txt and lock down permissions:

sudo chmod 600 /etc/step-ca/password.txt
sudo chown step:step /etc/step-ca/password.txt

Step 5: Bootstrap Client Trust

Every machine that needs to trust your CA must install the root certificate. The step CLI handles this in a few seconds:

# Get the CA fingerprint (run once on the CA server)
step certificate fingerprint /etc/step-ca/certs/root_ca.crt

# On each client machine — bootstrap trust
step ca bootstrap \
  --ca-url https://ca.internal:9000 \
  --fingerprint YOUR_FINGERPRINT_HERE

# Install root cert into system trust store
sudo step certificate install $(step path)/certs/root_ca.crt

Push the fingerprint to client machines via Ansible, Salt, or Puppet as a one-time bootstrap task. Once that runs, the machine trusts every certificate your CA signs — including ones issued months later — without touching it again.

Step 6: Issue Certificates via ACME

Any ACME client can now point at your private CA endpoint. With certbot — make sure Step 5 is done first, since certbot verifies the ACME server’s TLS certificate against the system trust store:

certbot certonly \
  --standalone \
  --server https://ca.internal:9000/acme/acme/directory \
  --domain api.internal \
  --email [email protected] \
  --agree-tos

If certbot fails to reach the ACME directory, the most common cause is the root cert not being installed to the system store. Force-feed it explicitly with REQUESTS_CA_BUNDLE:

REQUESTS_CA_BUNDLE=/etc/step-ca/certs/root_ca.crt certbot certonly \
  --standalone \
  --server https://ca.internal:9000/acme/acme/directory \
  --domain api.internal \
  --email [email protected] \
  --agree-tos

With acme.sh (useful when certbot isn’t available or you’re scripting cert issuance):

acme.sh --issue \
  --server https://ca.internal:9000/acme/acme/directory \
  --domain api.internal \
  --standalone \
  --ca-bundle /etc/step-ca/certs/root_ca.crt

Caddy users get this automatically — set acme_ca in your Caddyfile to your Step-CA directory URL and Caddy handles everything else.

Step 7: Configure Certificate Lifetime and Renewal

Step-CA issues ACME certificates with a 24-hour lifetime by default. Short on purpose: it forces automation from day one and limits exposure if a private key is ever compromised — a leaked cert that expires in 24 hours does far less damage than one valid for a year. Adjust the ACME provisioner claims in ca.json to match your renewal cadence:

{
  "type": "ACME",
  "name": "acme",
  "claims": {
    "minTLSCertDuration": "24h",
    "maxTLSCertDuration": "720h",
    "defaultTLSCertDuration": "168h"
  }
}

Certbot’s systemd timer handles renewal automatically. If you’re using the step CLI directly, the renew daemon mode keeps certificates fresh without cron jobs:

# Run as a background daemon — renews proactively before expiry
step ca renew --daemon \
  /etc/ssl/private/api.internal.crt \
  /etc/ssl/private/api.internal.key

Verification

# Inspect the issued certificate
step certificate inspect /etc/letsencrypt/live/api.internal/cert.pem

# Test TLS from a client that has bootstrapped trust
curl https://api.internal

# Verify cert chain against your root CA explicitly
openssl verify -CAfile /etc/step-ca/certs/root_ca.crt \
  /etc/letsencrypt/live/api.internal/cert.pem

Where to Go from Here

You now have a private CA that your internal infrastructure trusts, issuing and renewing TLS certificates automatically via standard ACME. No more self-signed cert warnings. No more services going down because someone forgot to renew a cert. No more “it’s just internal traffic” justifications for plaintext communications.

A few things worth handling before you roll this out widely: back up your root CA private key offline and in multiple locations — lose it, and every certificate in your infrastructure needs to be reissued from scratch. For high availability, run a secondary Step-CA instance using a shared intermediate CA. And audit which machines have bootstrapped trust before decommissioning the CA host, or you’ll spend a lively afternoon chasing TLS errors across your fleet.

Beyond the basics, Step-CA supports OIDC provisioners (certificates tied to your Google or GitHub identity), SSH certificate authority mode (short-lived SSH certificates replacing static key management), and hardware key storage via PKCS#11. Worth exploring once the ACME foundation is running smoothly — each feature solves a real problem without adding operational overhead you don’t need yet.

Share: