Running a Private Certificate Authority with Step-CA in Docker
This is part 1 of my 3 part series.
- Set up a Private Certificate Authority
- Set up SSH certificates
- 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
- Install step-cli.
- Run the step-ca container (creates the root and intermediate certificates).
- Get the root certificate’s fingerprint.
- Bootstrap each end device to trust the root (plus Firefox separately).
- On the service’s host, request a certificate from the CA for the hostname clients use to reach the service.
- Point the service at the certificate.
- 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-cliCREATE 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.2Ensure 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-caHAVE 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
stepvolume (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/healthTRUSTING 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.
- 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-cli2. 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.crt3. 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 --install4. Confirm that it actually works:
curl https://<CA_IP_ADDRESS_OR_DNS_NAME>:9000/healthIf it fails it would look like this:
If you ever want to uninstall the root certificate:
step certificate uninstall ~/.step/certs/root_ca.crtrm -rf ~/.stepTRUSTING THE CA ON WINDOWS CLIENTS
- On the client install step-cli. Follow the documentation for your OS.
winget install Smallstep.step2. 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> --installCHANGES 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:
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:
- Firefox settings
- Privacy & Security
- Advanced Settings
- Certificates
- Manage Certificates
- Authorities tab > Import
- Choose
root_ca.crt( %USERPROFILE%\.step\certs\root_ca.crt) - 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 ~/certsUse 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>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.
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.
- On the machine hosting the container, find out which user the container runs as:
docker exec <container-name> idLook 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:
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>.crtThe 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:roThen 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>.crtand/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.keyCheck if there’s any errors with the config:
docker compose configThen recreate the container:
docker compose up -dENABLE 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@.serviceCreate 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
EOFConfirm it looks good:
cat /etc/systemd/system/cert-renewer@.timerNow reload systemd:
sudo systemctl daemon-reloadFind the certificate (first you have to copy the certificate name):
basename -s .crt ~/certs/*.crtNote the certificate name:
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>.timerIn this example this becomes:
sudo systemctl enable --now cert-renewer@grafana.timerList 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 20The 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.









