Self-hosted ACME Certificates with OpenBao and Traefik
ACME is an excellent protocol that allows us to obtain free certificates from Let’s Encrypt CA.
But what if you want to do the same thing internally within your company using your own CA?
There are many ACME-compatible software options available. Today, we’re going to specifically look into using OpenBao to generate and maintain your own Root CA, including Intermediates, and use that engine as an ACME Server.
Traefik will act as the client, requesting certificates via ACME for the Kubernetes ingresses it manages.
Let’s start with setting up OpenBao.
OpenBao Setup
Initially forked from HashiCorp Vault, OpenBao is a OSS secrets management solution that you can self-host. It includes several secrets engines, one of which is the PKI Engine, with which you can create your own certificates.
The PKI Engine has integrated ACME directories, and we’ll utilize that.
Assuming you already have an OpenBao instance running, if not, here’s a sample values.yaml file to use with the official openbao/openbao-helm Chart:
global:
# TLS termination at the OpenBao level is recommended
tlsDisable: false
csi:
enabled: false
injector:
enabled: false
server:
# DISCLAIMER: Do not use static keys in production!
extraSecretEnvironmentVars:
- envName: STATIC_SEAL_KEY
secretName: bao-static-seal-key
secretKey: key
resources:
requests:
memory: 1Gi
cpu: 500m
limits:
memory: 2Gi
cpu: 1000m
ha:
enabled: true
replicas: 3
raft:
enabled: true
config: |
ui = true
listener "tcp" {
address = "[::]:8200"
cluster_address = "[::]:8201"
tls_cert_file = "/openbao/tls/tls.crt"
tls_key_file = "/openbao/tls/tls.key"
}
storage "raft" {
path = "/openbao/data"
retry_join {
leader_tls_servername = "bao.example.com"
leader_api_addr = "https://openbao-active:8200"
leader_client_cert_file = "/openbao/tls/tls.crt"
leader_client_key_file = "/openbao/tls/tls.key"
leader_ca_cert_file = "/openbao/tls/ca.crt"
}
}
# DISCLAIMER: Do not use static keys in production!
seal "static" {
current_key_id = "ID"
current_key = "env://STATIC_SEAL_KEY"
}
service_registration "kubernetes" {}
updateStrategyType: RollingUpdate
livenessProbe:
enabled: true
ingress:
tls:
- hosts:
- bao.example.com
secretName: bao-tls
enabled: true
ingressClassName: myingress
hosts:
- host: bao.example.com
paths: []
volumes:
- name: tls
secret:
secretName: bao-tls
volumeMounts:
- name: tls
mountPath: /openbao/tls
readOnly: true
After setup, you’ll need to initialize your OpenBao instance:
bao operator init --recovery-threshold=NUMBER --recovery-shares=NUMBER
Choose a number of recovery-shares and threshold that suits your needs. For more information about recovery keys, refer to the documentation.
Once initialized, you’ll have a root token that you can use to configure your OpenBao instance. The root token is meant for short-term use to set up another authentication method. Revoke your root token as soon as you’ve done that, as keeping it around is considered bad practice. Ideally, set up a working authentication method directly upon initialization using the self-init feature of OpenBao.
PKI Engine
Now, let’s create our PKI Engines to host our Root and Intermediate CAs (also see the official documentation):
# Enable one PKI Engine for our Root CA
bao secrets enable pki_root
# Tune it to have a maximum TTL of 10 years
bao secrets tune -max-lease-ttl=3650d pki_root
# Generate our Root CA
bao write pki_root/root/generate/internal common_name="Company CA" ttl=3650d
# Set URL Configuration
bao write pki_root/config/urls issuing_certificates="https://bao.example.com/v1/pki_root/ca" crl_distribution_points="https://bao.example.com/v1/pki_root/crl"
# Enable PKI Engine for our Intermediate CA
bao secrets enable pki_int
# Tune it to have a maximum TTL of 5 years
bao secrets tune -max-lease-ttl=43800h pki_int
# Create our Intermediate CA
bao write pki_int/intermediate/generate/internal common_name="Company INT 2025" ttl=43800h -format=json | jq .data.csr -r > int.csr
# Use the created CSR to get it signed by the Root PKI
bao write pki_root/root/sign-intermediate csr=@int.csr format=pem_bundle ttl=43800h -format=json | jq .data.certificate -r > int.crt
# Now set the Intermediate CA to be signed with the signed CRT
bao write pki_int/intermediate/set-signed certificate=@int.crt
# Finally, set URL Configuration for the Intermediate CA
bao write pki_int/config/urls issuing_certificates="https://bao.example.com/v1/pki_int/ca" crl_distribution_points="https://bao.example.com/v1/pki_int/crl"
Next, activate the ACME feature on the Intermediate PKI Engine and create a role for Traefik’s use:
bao write pki_int/config/cluster aia_path=https://bao.example.com/v1/pki_int path=https://bao.example.com/v1/pki_int
bao write pki_int/config/acme enabled=true
# This will set the max_ttl of the created certificates to 1 year
bao write pki_int/roles/traefik allowed_domains=example.com allow_subdomains=true max_ttl=365d
We are now ready to use OpenBao with Traefik.
Traefik
Configuring Traefik can be somewhat challenging to understand, and it took me some time to get this working. If you think my configuration could be improved, please let me know.
# Values.yaml for Traefik Helm Chart
volumes:
providers:
kubernetesIngress:
ingressClass: traefik
deployment:
replicas: 1
ingressClass:
name: traefik
isDefaultClass: true
certificatesResolvers:
openbao:
acme:
email: admin@openbao.example.com
storage: /data/acme.json
caServer: https://bao.example.com/v1/pki_int/roles/traefik/acme/directory
httpChallenge:
entrypoint: web
ports:
web:
redirectTo:
port: websecure
websecure:
tls:
enabled: true
resolver: openbao
There might be other configurations you would want, such as how to expose Traefik, but that is beyond the scope of this guide.
Upon starting Traefik, you should see it initiating the acme.Provider:
2025-12-23T06:21:18Z INF Starting provider *acme.Provider
2025-12-23T06:21:24Z INF Register... providerName=openbao.acme
Ingress
Now, let’s create an Ingress for an application:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: openbao
name: monitoring-grafana
namespace: monitoring
spec:
ingressClassName: traefik
rules:
- host: grafana.example.com
http:
paths:
- backend:
service:
name: monitoring-grafana
port:
number: 80
path: /
pathType: ImplementationSpecific
tls:
- hosts:
- grafana.example.com
The critical annotations here are:
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: openbao
These tell Traefik to create a certificate for this Ingress using our certresolver, OpenBao.
If everything is configured correctly, you shouldn’t see many logs from Traefik. The Ingress should work, and in OpenBao, you’ll find the generated certificate as well:
bao list pki_int/certs
Keys
----
xz:xy:x0:tt
bao read pki_int/cert/xz:xy:x0:tt -format=json | jq .data.certificate -r | openssl x509 -noout -text
Certificate:
Data:
Version: 3 (0x2)
Serial Number: xyz
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=Company INT
Validity
Not Before: Dec 23 06:28:44 2025 GMT
Not After : Jan 24 06:29:14 2026 GMT
Subject: CN=grafana.example.com
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
Public-Key: (too many bits)
Modulus:
xyz
Exponent: 65537 (0x10001)
X509v3 extensions: [xyz]
Signature Algorithm: sha256WithRSAEncryption
Signature Value: xyz
Congratulations, you have created certificates with your own PKI by using the ACME protocol on OpenBao!