Configure popular ACME clients to use a private CA with the ACME protocol

The ACME protocol radically simplifies TLS and HTTPS's deployment by letting you obtain certificates automatically, without human interaction. step-ca works with any ACMEv2 (RFC8555) compliant client that supports the http-01, dns-01, or tls-alpn-01 challenge. This page contains tutorial-style example client configuration. Even if you don't use one of the listed clients, the concepts are generally portable to arbitrary clients.

If you run into any issues please let us know in GitHub Discussions.

About this tutorial

  • Learn how to automatically issue certificates to popular ACME clients using the ACME protocol.
  • Examples include copy/paste code blocks and specific commands for nginx, certbot, and more.
  • When complete, you will have a fully functioning ACME configuration using a private certificate authority.
  • Estimated effort: Reading time ~7 mins, Lab time ~20 to 60 mins.



ACME and step-ca Recap

Most ACME clients connect to Let’s Encrypt’s CA by default. To connect to step-ca, you need to point the client to the right ACME Directory URL.

A single instance of step-ca can have multiple ACME provisioners, each with their own ACME Directory URL that looks like:


For example, an ACME provisioner named acme has the directory URL:


Communication between an ACME client and server always uses HTTPS. Clients will validate the server’s HTTPS certificate using the public root certificates in your system’s default trust store.


certbot is the granddaddy of ACME clients. Built and supported by the EFF, it's the standard-bearer for production-grade command-line ACME.

To get a certificate from step-ca using certbot you need to:

  1. Point certbot at your ACME directory URL using the --server flag
  2. Tell certbot to trust your root certificate using the REQUESTS_CA_BUNDLE environment variable

For example:

sudo REQUESTS_CA_BUNDLE=$(step path)/certs/root_ca.crt \ certbot certonly -n --standalone -d foo.internal \ --server https://ca.internal/acme/acme/directory

sudo is required in certbot's standalone mode so it can listen on port 80 to complete the http-01 challenge. If you already have a webserver running you can use webroot mode instead. With the appropriate plugin certbot also supports the dns-01 challenge for most popular DNS providers. Deeper integrations with nginx and apache can even configure your server to use HTTPS automatically (we'll set this up ourselves later). All of this works with step-ca.

You can renew all of the certificates you've installed using cerbot by running:

sudo REQUESTS_CA_BUNDLE=$(step path)/certs/root_ca.crt certbot renew

You can automate renewal with a simple cron entry:

*/15 * * * * root REQUESTS_CA_BUNDLE=$(step path)/certs/root_ca.crt certbot -q renew

The certbot packages for some Linux distributions will create a cron entry or systemd timer like this for you. This entry won't work with step-ca because it doesn't set the REQUESTS_CA_BUNDLE environment variable. You'll need to manually tweak it to do so.

More subtly, certbot's default renewal job is tuned for Let's Encrypt's 90 day certificate lifetimes: it's run every 12 hours, with actual renewals occurring for certificates within 30 days of expiry. By default, step-ca issues certificates with much shorter 24 hour lifetimes. The cron entry above accounts for this by running certbot renew every 15 minutes. You'll also want to configure your domain to only renew certificates when they're within a few hours of expiry by adding a line like:

renew_before_expiry = 8 hours

to the top of your renewal configuration (e.g., in /etc/letsencrypt/renewal/foo.internal.conf). is another popular command-line ACME client. It's written completely in shell (bash, dash, and sh compatible) with very few dependencies.

To get a certificate from step-ca using you need to:

  1. Point at your ACME directory URL using the --server flag
  2. Tell to trust your root certificate using the --ca-bundle flag

For example:

sudo --issue --standalone -d foo.internal \
    --server https://ca.internal/acme/acme/directory \
    --ca-bundle $(step path)/certs/root_ca.crt \
    --fullchain-file foo.crt \
    --key-file foo.key

Like certbot, can solve the http-01 challenge in standalone mode and webroot mode. It can also solve the dns-01 challenge for many DNS providers.

Renewals are slightly easier since remembers to use the right root certificate. It can also remember how long you'd like to wait before renewing a certificate. Unfortunately, the duration is specified in days (via the --days flag) which is too coarse for step-ca's default 24 hour certificate lifetimes. So the easiest way to schedule renewals with is to force them at a reasonable frequency, like every 8 hours, via cron:

0 */8 * * * root "/home/<user>/"/ --cron --home "/home/<user>/" --force > /dev/null

Caddy v2

Caddy is an HTTP/2 web server with automatic HTTPS powered by an integrated ACME client. In addition to serving static websites, Caddy is commonly used as a TLS-terminating API gateway proxy.

Caddy comes with its own ACME server and by default it will generate an internal CA and issue certificates to itself. But, you can configure Caddy to use a local step-ca instance to obtain certificates.

Here's a Caddyfile global config block. Add this to the top of your Caddyfile to get certificates from ca.internal for all configured domains:

  acme_ca https://ca.internal/acme/acme/directory
  acme_ca_root <step path>/root_ca.crt

Here's a Caddyfile that will use ca.internal only to get a certificate for foo.internal:


root * /var/www
tls {
  ca https://ca.internal/acme/acme/directory
  ca_root <step path>/certs/root_ca.crt

Replace <step path> with the output of the step path command.

Now run caddy to start serving HTTPS!

$ sudo caddy start

Check your work with curl:

$ curl https://foo.internal --cacert $(step path)/certs/root_ca.crt Hello, TLS!

Caddy will automatically renew its certificates after of the validity period elapses.


Nginx doesn’t support ACME natively, but you can use a command-line ACME client to get certificates for Nginx to use.

Here’s an example nginx.conf that runs Nginx in a common configuration where it terminates TLS and proxies to a back-end server listening on local loopback:

server { listen 443 ssl; server_name foo.internal; ssl_certificate /path/to/foo.crt; ssl_certificate_key /path/to/foo.key; location / { proxy_pass } }

With this code, you are telling Nginx to listen on port 443 using TLS, with a certificate and private key stored on disk. Other resources provide a more thorough explanation of NGINX's various TLS configuration options.

We can start an HTTP server using python and check our work with curl:

$ echo "Hello TLS!" > index.html $ python -m SimpleHTTPServer 8000 & $ curl https://foo.internal --cacert $(step path)/certs/root_ca.crt Hello TLS!

Nginx only reads certificates once, only at startup. When you renew the certificate on disk, Nginx won’t notice. After each renewal you’ll need to run the following command:

nginx -s reload

You can use the --exec flag on the step ca renew command to do this automatically:

step ca renew --daemon --exec "nginx -s reload" \ /path/to/foo.crt \ /path/to/foo.key

If you’re using certbot, check out the --post-hook flag to do the same thing. If you’re using, check out the --reloadcmd flag.


Apache httpd has integrated ACME support via mod_md. You can deploy certificates to Apache in a way similar to what we did for Nginx.

Here’s an example Apache configuration, using certificates issued by step-ca through certbot:

<VirtualHost *:443> ServerName foo.internal DocumentRoot /home/mmalone/www SSLEngine on SSLCertificateFile /etc/letsencrypt/live/foo.internal/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/foo.internal/privkey.pem </VirtualHost>

Start Apache and check your work with curl:

$ curl --cacert $(step path)/certs/root_ca.crt https://foo.internal Hello TLS

Like Nginx, Apache needs to be signaled after certificates are renewed by running the following command:

apachectl graceful


Publish Lab’s acme-client is an excellent ACMEv2 client written in Node.js. Take a look at an example of how easy it is to obtain a certificate and serve HTTPS in JavaScript:

Most importantly, to make things work:

  • Point the ACME client at your ACME directory URL
  • Tell the ACME client to trust your CA by configuring the HTTP client to verify certificates using your root certificate

To install dependencies and start the server run:

npm install node-acme-client
node acme.js

Then check your work with curl:

$ curl https://foo.internal:11443 \ --cacert $(step path)/certs/root_ca.crt Hello, TLS

This server supports optional client authentication using certificates and checks if the client authenticated in the handler:

$ curl https://foo.internal:11443 \ --cacert $(step path)/certs/root_ca.crt \ --cert mike.crt \ --key mike.key Hello,


lego is an ACME client library written in Go. You can use it to obtain a certificate from step-ca programmatically. You can find an example of this code here:

Essentially, the steps involved are:

  • Point lego at your ACME directory URL by setting lego.Config.CADirUrl
  • Tell lego to trust your CA by configuring an http.Client that trusts your root certificate and telling lego to use it

Fetch the required dependencies and start the server:

$ go get $ go get $ go run acme.go

Then test with curl:

$ curl https://foo.internal:5443 \ --cacert $(step path)/certs/root_ca.crt Hello, TLS!

The server is configured to verify client certificates if they are sent. That means the server is configured to support mutual TLS. The handler checks whether a client certificate was provided, and responds with a personalized greeting if one was.

You can get a client certificate from step-ca using an OAuth/OIDC provisioner:

$ step ca certificate mike.crt mike.key ✔ Provisioner: Google (OIDC) [client: <redacted>] ✔ CA: https://ca.internal ✔ Certificate: mike.crt ✔ Private Key: mike.key

And test mutual TLS out with curl:

$ curl https://foo.internal:5443 \ --cacert $(step path)/certs/root_ca.crt \ --cert mike.crt \ --key mike.key Hello,!

With a few tweaks to this code you can implement robust access control.

There are other good options for programmatic ACME in Go. The certmagic package builds on lego and offers higher level, easier to use abstractions. The x/crypto/acme package is lower level and offers more control, but it currently implements a pre-standardization draft version of ACME that doesn’t work with step-ca.


certbot is written in Python and exposes its acme module as a standalone package. You can find an example of obtaining a certificate and serving HTTPS in Python here:

Make sure that you:

  • Point the ACME client at your ACME Directory URL
  • Tell the ACME client to trust your CA by configuring the injected HTTP client to verify certificates using your root certificate

To install dependencies and start the server, run:

pip install acme pip install pem python

Then check your work with curl:

$ curl https://foo.internal:10443 \ --cacert $(step path)/certs/root_ca.crt Hello, TLS!

Like the Go example above, this server also supports mutual TLS and checks if the client authenticated in the handler:

$ curl https://foo.internal:10443 \ --cacert $(step path)/certs/root_ca.crt \ --cert mike.crt \ --key mike.key Hello,!


Traefik is a modern reverse-proxy with integrated support for ACME. It's designed primarily to handle ingress for a compute cluster, dynamically routing traffic to microservices and web applications.

Traefik v2

It's easy to get a certificate from step-ca in Traefik v2, using the tls-alpn-01 ACME challenge type.

Most importantly, Traefik will need to trust your root CA certificate. Either use the LEGO_CA_CERTIFICATES environment variable to provide the full path to your root_ca.crt when running traefik, or install your root certificate in your system's trust store by running step certificate install root_ca.crt.

In your Traefik static configuration, you'll need to add a certificatesResolvers block:

[certificatesResolvers] [certificatesResolvers.myresolver] [certificatesResolvers.myresolver.acme] caServer = "https://step-ca.internal/acme/acme/directory" email = "" storage = "acme.json" tlsChallenge = true

Then, when you add routers to your dynamic configuration for HTTPS traffic, you need to set tls and tls.certresolver:

[http] [http.routers] [http.routers.router1] ... [http.routers.router1.tls] certResolver = "myresolver"

If you're running Traefik inside a Docker container, you can get your root CA certificate and add it to the container's trust store by running the following:

$ step ca bootstrap --ca-url "${CA_URL}" --fingerprint "${CA_FINGERPRINT}" --install --force $ update-ca-certificates
Traefik v1

To get a certificate from step-ca to Traefik v1 you need to:

  • Point Traefik at your ACME directory URL using the caServer directive in your configuration file
  • Tell Traefik to trust your root certificate using the LEGO_CA_CERTIFICATES environment variable

Here’s an example traefik.toml file that configures Traefik to terminate TLS and proxy to a service listening on localhost:

defaultEntryPoints = ["http", "https"] [entryPoints] [entryPoints.http] address = ":80" [entryPoints.https] address = ":443" [entryPoints.https.tls] [acme] storage = "acme.json" caServer = "https://ca.internal/acme/acme/directory" entryPoint = "https" [acme.httpChallenge] entryPoint = "http" [[]] main = "foo.internal" [file] [frontends] [] backend = "foo" [backends] [] [] url = ""

Start Traefik by running:

LEGO_CA_CERTIFICATES=$(step path)/certs/root_ca.crt traefik

Start an HTTP server for Traefik to proxy to, and test with curl:

$ echo "Hello TLS!" > index.html $ python -m SimpleHTTPServer 8000 & $ curl https://foo.internal --cacert $(step path)/certs/root_ca.crt Hello TLS!

Unsubscribe anytime. See our privacy policy.