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.examplein 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 versionCreate 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-caStep 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.comYou 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.txtNote 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.crtStep 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 ACMEFor 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.targetEnable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now step-ca
sudo systemctl status step-ca --no-pagerOpen the firewall for HTTPS only from your internal networks:
sudo ufw allow from 10.0.0.0/8 to any port 443 proto tcpStep 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 \
--installOn 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-certificatesFor 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-emailThe 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.shThen 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.crtIf 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: okInspect the certificate that was issued and check its lifetime and issuer:
sudo step certificate inspect /etc/letsencrypt/live/app.internal.example/fullchain.pem --shortTest 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-runMonitoring 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.