Sitemap

Running a Private Certificate Authority with Step-CA in Docker

10 min readOct 11, 2026

--

This is part 1 of my 3 part series.

  1. Set up a Private Certificate Authority
  2. Set up SSH certificates
  3. Set up Authentik so SSH certificates are provisioned when logged into.

Smallstep have created several simple ways to create your own private Certificate Authority.

We will also enable it to support SSH certificates.

We’ll be using their steps for Docker.

GENERAL PROCESS

  1. Install step-cli.
  2. Run the step-ca container (creates the root and intermediate certificates).
  3. Get the root certificate’s fingerprint.
  4. Bootstrap each end device to trust the root (plus Firefox separately).
  5. On the service’s host, request a certificate from the CA for the hostname clients use to reach the service.
  6. Point the service at the certificate.
  7. Set up auto-renewal.

INSTALL STEP-CLI

Depending on your operating system the installation may vary. Please consult the installation documentation.

I am using Ubuntu:

sudo apt-get update && sudo apt-get install -y --no-install-recommends curl gpg ca-certificates
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/keyrings/smallstep.asc
sudo tee /etc/apt/sources.list.d/smallstep.sources > /dev/null << EOF
Types: deb
URIs: https://packages.smallstep.com/stable/debian
Suites: debs
Components: main
Signed-By: /etc/apt/keyrings/smallstep.asc
EOF
sudo apt-get update && sudo apt-get -y install step-cli

CREATE THE CA CONTAINER

Firstly we’ll create a container using the image provided by Smallstep.

It will listen on port 9000.

Run:

docker run -d --name step-ca --restart unless-stopped \
-v step:/home/step \
-p 9000:9000 \
-e "DOCKER_STEPCA_INIT_NAME=Smallstep" \
-e "DOCKER_STEPCA_INIT_DNS_NAMES=localhost,$(hostname -f),<hostname-clients-use-to-reach-the-ca>,<ipaddr-clients-will-use-to-reach-the-ca>" \
-e "DOCKER_STEPCA_INIT_REMOTE_MANAGEMENT=true" \
-e "DOCKER_STEPCA_INIT_SSH=true" \
smallstep/step-ca:0.30.2

Ensure that you use a container tag that is not ‘latest’ as using the latest tag is bad practice since the container could introduce breaking changes if it’s ever recreated.

I’ll use smallstep/step-ca:0.30.2 instead.

What does this container do?

This container creates a root key (meant to be kept private), a root certificate (can be shared publicly) as well as an intermediate certificate.

The root certificate is what all the devices trust.

However the root private key (currently stored in /var/lib/docker/volumes/step/_data/secrets/) must be kept secure. How you keep it secure is up to you. I’ve seen it recommended that people store it offline onto a Yubikey, and they use the intermediate key to sign everything.

The intermediate certificate is signed by the root, and its private key is what actually signs your service certificates.

If the intermediate key is ever compromised, you can use the root key to sign a new intermediate certificate, and your devices continue trusting the same root.

What does DOCKER_STEPCA_INIT_DNS_NAMES do?

This is the list of hostnames and IP addresses that clients will use to reach the CA.

The CA puts these into its own HTTPS certificate. When a client connects, it checks that the hostname or IP address that it used is actually in the certificate. If it isn’t then the connection is refused by the client.

For example, earlier I tried to connect to the CA using its IP address (rather than the DNS name that I specified) and therefore my connection was rejected.

What does DOCKER_STEPCA_INIT_SSH=true do?

This sets up the CA to also issue SSH certificates as well as HTTPS certificates.

Without this setting, the CA can only issue HTTPS certificates.

FIND THE USERNAME AND PASSWORD FOR THE CA

The logs for this container will display the CA admin username and password:

docker logs step-ca
Press enter or click to view image in full size

HAVE YOUR MACHINE TRUST THE CA

We can connect to the Certificate Authority now that it’s up and running. To do this, clients need to know the URL, and the SHA256 fingerprint of the Certificate Authority.

{
CA_FINGERPRINT=$(docker run --rm -v step:/home/step smallstep/step-ca:0.30.2 step certificate fingerprint certs/root_ca.crt)
step ca bootstrap --ca-url https://localhost:9000 --fingerprint $CA_FINGERPRINT --install
}

What does this do?

This spins up a temporary container that reads the root certificate’s fingerprint from the step volume (the volume is on the host at /var/lib/docker/volumes/step/_data) then places it into a variable called $CA_FINGERPRINT.

Then it sets up the client so that it can talk to the CA securely by downloading the root certificate (the root cert that needs to be installed on every device in order to trust the CA) and then cross-referencing it with the SHA256 hash. This is called ‘bootstrapping’.

The install flag adds the root certificate to the client’s system trust store, so your programs like curl and browsers will trust any certificate that the CA issues.

Run a health check:

curl https://localhost:9000/health
Press enter or click to view image in full size

TRUSTING THE CA ON LINUX CLIENTS

Every client needs the root certificate installed before it will trust certificates issued by the CA.

The process is the same as above, except the client reaches the CA over the network instead of through localhost.

  1. On the client install step-cli. Follow the documentation for your OS.

For Debian/Ubuntu:

sudo apt-get update && sudo apt-get install -y --no-install-recommends curl gpg ca-certificates
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://packages.smallstep.com/keys/apt/repo-signing-key.gpg -o /etc/apt/keyrings/smallstep.asc
sudo tee /etc/apt/sources.list.d/smallstep.sources > /dev/null << EOF
Types: deb
URIs: https://packages.smallstep.com/stable/debian
Suites: debs
Components: main
Signed-By: /etc/apt/keyrings/smallstep.asc
EOF
sudo apt-get update && sudo apt-get -y install step-cli

2. Obtain the root certificate’s fingerprint. This can be shared publicly as the hash doesn’t allow anyone to do anything with your CA.

On the CA HOST:

docker exec step-ca step certificate fingerprint certs/root_ca.crt
Press enter or click to view image in full size

3. On the CLIENT, run these commands to download the root certificate, cross-reference with SHA256 fingerprint, and if it passes, install it to the trust store.

CA_FINGERPRINT=<ROOT_CERT_FINGERPRINT>

Ensure that you change the IP address to the correct IP address of the CA:

step ca bootstrap --ca-url https://<CA_IP_ADDRESS>:9000 --fingerprint $CA_FINGERPRINT --install
Press enter or click to view image in full size

4. Confirm that it actually works:

curl https://<CA_IP_ADDRESS_OR_DNS_NAME>:9000/health
Press enter or click to view image in full size

If it fails it would look like this:

Press enter or click to view image in full size

If you ever want to uninstall the root certificate:

step certificate uninstall ~/.step/certs/root_ca.crt
rm -rf ~/.step

TRUSTING THE CA ON WINDOWS CLIENTS

  1. On the client install step-cli. Follow the documentation for your OS.
winget install Smallstep.step

2. Run Powershell as an admin and bootstrap the root:

step ca bootstrap --ca-url https://<CA_HOSTNAME_OR_IP_FROM_DNS_NAMES>:9000 --fingerprint <ROOT_CERT_FINGERPRINT> --install
Press enter or click to view image in full size

CHANGES FOR FIREFOX

If you use a Chromium-based browser then your connection should say secure. However if you use Firefox then it may still say that it’s not secure:

Press enter or click to view image in full size

This is because Firefox ignores the Windows/Linux trusted store and prefers its own.

Get Lumenyx’s stories in your inbox

Join Medium for free to get updates from this writer.

To fix it we should import the root certificate into Firefox directly:

  1. Firefox settings
  2. Privacy & Security
  3. Advanced Settings
  4. Certificates
  5. Manage Certificates
  6. Authorities tab > Import
  7. Choose root_ca.crt ( %USERPROFILE%\.step\certs\root_ca.crt)
  8. Tick ‘Trust this CA to identify websites”

ISSUE A CERTIFICATE FOR YOUR SERVICE

This machine will need step-cli installed and bootstrapped to the CA (follow the above steps).

On the machine running the service that needs the certificate, request one from the CA.

Create the certs directory:

mkdir -p ~/certs

Use the hostname that you will type into the browser to reach the service:

step ca certificate <service-hostname> \
~/certs/<container-name>.crt \
~/certs/<container-name>.key \
--san <main-service-hostname> \
--san <any-additional-hostnames/ip-addresses>
Press enter or click to view image in full size

This command creates a Certificate Signing Request (CSR) and sends it to the CA, which signs it and returns the certificate.

IMPORTANT — Name the certificate the same name as the service’s container. The auto-renew step uses this name to restart the container after each renewal. For example my grafana server’s container name is called “grafana”.

If the names don’t match, the service will keep using the old certificate until it expires.

When it asks ‘What provisioner key do you want to use?’ select ‘admin’ and then enter your admin password.

Press enter or click to view image in full size

GIVE THE SERVICE CONTAINER ACCESS TO THE CERTIFICATE

step creates the certificate files so that only your user can read them, however the container needs to be able to read them too.

  1. On the machine hosting the container, find out which user the container runs as:
docker exec <container-name> id

Look at the uid at the start of the output. If it’s uid=0(root), the container runs as root and can already read the files, so skip to step 3.

If the uid is any other number, continue to step 2, even if the gid is 0.

For example, Grafana shows uid=472(grafana) gid=0(root). It runs as an ordinary user, so it still needs step 2:

Press enter or click to view image in full size

2. Note the gid number. Give that group read-only access to the key and make the certificate readable:

sudo chgrp <gid> ~/certs/<container-name>.key
chmod 640 ~/certs/<container-name>.key
chmod 644 ~/certs/<container-name>.crt

The certificate is public (644) and the key is restricted to those who own it and the container’s group (640).

3. Finally, point your service at the certificate.

We need to update the docker-compose file for our services container (eg Vaultwarden, Grafana, Frigate, etc), not the step-ca container.

Add this line to the docker-compose file for your container to mount the certs/key files into the container read-only:

    volumes:
- ${HOME}/certs/<.crt_file>.crt:/certs/<.crt_file>.crt:ro
- ${HOME}/certs/<.key_file>.key:/certs/<.key_file>.key:ro

Then tell the service to use the certificate. Every service has its own HTTPS (or TLS) settings, so check your service’s documentation , and point them at:

  • Certificate: /certs/<container-name>.crt
  • Key: /certs/<container-name>.key

If you go to the official documentation page, there should be a section for SSL/TLS/HTTPS (example https://docs.frigate.video/configuration/tls)

If you are struggling to find the appropriate environment variables as I was, ask an LLM:

“I’m running <service name> in Docker using the image image:tag, set up with docker compose. I have a TLS certificate and private key mounted into the container at /certs/<container-name>.crt and /certs/<container-name>.key. What do I add to my compose file to make the service serve HTTPS using these files? Please give the exact environment variables (or config file settings, if it doesn’t support environment variables), and link to the official documentation where they’re listed.”

For example, Grafana uses these environment variables, added under the service’s environment:

  GF_SERVER_PROTOCOL: https
GF_SERVER_CERT_FILE: /certs/grafana.crt
GF_SERVER_CERT_KEY: /certs/grafana.key
Press enter or click to view image in full size

Check if there’s any errors with the config:

docker compose config

Then recreate the container:

docker compose up -d

ENABLE AUTO-RENEW FOR CERTIFICATES

Certificates issued by the CA are intentionally short-lived. This is because if a key is ever stolen then it stops being useful after the certificate expires (24hr by default).

The CA doesn’t renew certificates itself. Each machine holding a certificate is responsible for renewing its own.

Here is the official documentation for more detail.

Create the service on the machine that hosts the service whose certificate you want to auto-renew.

Run this as your normal user (not root):

sudo tee /etc/systemd/system/cert-renewer@.service > /dev/null << EOF
[Unit]
Description=Renew certificate %i
After=network-online.target

[Service]
Type=oneshot
User=$USER
Environment=STEPPATH=$HOME/.step
Environment=CERT=$HOME/certs/%i.crt
Environment=KEY=$HOME/certs/%i.key
ExecCondition=/usr/bin/step certificate needs-renewal \${CERT}
ExecStart=/usr/bin/step ca renew --force \${CERT} \${KEY}
ExecStartPost=-/bin/chmod 644 \${CERT}
ExecStartPost=-/bin/sh -c 'docker inspect -f "{{.State.Running}}" %i 2>/dev/null | grep -qx true && docker restart %i'
EOF
  • The first ExecStartPost line keeps the certificate readable by the container after each renewal, since renewing rewrites the certificate file.
  • The second ExecStartPost line checks if the container is running, and only restarts it if it’s already up (this is because most services read their certs when they start).

Confirm it looks good:

cat /etc/systemd/system/cert-renewer@.service

Create the timer on the machine that hosts the service whose certificate you want to auto-renew:

sudo tee /etc/systemd/system/cert-renewer@.timer > /dev/null << EOF
[Unit]
Description=Check certificate %i for renewal

[Timer]
OnCalendar=*:0/15
RandomizedDelaySec=60
Persistent=true

[Install]
WantedBy=timers.target
EOF

Confirm it looks good:

cat /etc/systemd/system/cert-renewer@.timer

Now reload systemd:

sudo systemctl daemon-reload

Find the certificate (first you have to copy the certificate name):

basename -s .crt ~/certs/*.crt

Note the certificate name:

Press enter or click to view image in full size

Enable the timer:

IMPORTANT — YOU HAVE TO RUN THIS PART EACH TIME YOU MAKE A NEW CERTIFICATE

sudo systemctl enable --now cert-renewer@<cert-name>.timer

In this example this becomes:

sudo systemctl enable --now cert-renewer@grafana.timer

List the active timers that start with cert-renewer:

systemctl list-timers 'cert-renewer*'

You can close out of it by pressing ‘q’.

Confirm that the service itself works:

sudo systemctl start cert-renewer@grafana.service
journalctl -u cert-renewer@grafana.service -n 20

The logs may say that it was skipped, this is fine since it’s not time to renew the certificate.

However if you see a ‘file not found’ error instead, then that may be due to a misconfiguration.

CONCLUSION

You now have a private Certificate Authority running in Docker, with its root certificate trusted by your clients, ready to issue HTTPS and SSH certificates to your home lab.

From here, the next step is setting up SSH certificates so that you don’t have to copy keys into authorized_keys any more.

--

--