Raymii.org
Quis custodiet ipsos custodes?Home | About | All pages | Cluster Status | RSS Feed
Self-signed Root CA in Kubernetes with k3s, cert-manager and traefik. Bonus howto on regular certificates
Published: 17-07-2024 04:22 | Author: Remy van Elst | Text only version of this article
Table of Contents
Now that I'm learning Kubernetes for a few weeks, I'm finally at the point where I was 20 years ago with regular boring old tech, being able to host multiple domains, password protection and high available clusters. It seems we have to re-invent the wheel every time but in the end, it's just resume-driven development, the underlying stack costs more, is way more complex but for the user, nothing changes, they see the same website as always. Not all change is progress. Enough of being a curmudgeon, time to continue with Kubernetes. In this episode of 'Remy discovers Kubernetes', I'm setting up cert-manager
, not with Lets Encrypt, but with a self-signed certificate authority. I'll also show you how to set up a regular certificate, one you've for example bought somewhere. I'll also cover nameConstraints
to make the risk of compromise of your trusted root ca lower.
Recently I removed all Google Ads from this site due to their invasive tracking, as well as Google Analytics. Please, if you found this content useful, consider a small donation using any of the options below:
I'm developing an open source monitoring app called Leaf Node Monitoring, for windows, linux & android. Go check it out!
Consider sponsoring me on Github. It means the world to me if you show your appreciation and you'll help pay the server costs.
You can also sponsor me by getting a Digital Ocean VPS. With this referral link you'll get $200 credit for 60 days. Spend $25 after your credit expires and I'll get $25!
Here's what we'll end up with, a trusted local Root CA, Intermediate CA and Leaf Certificate for a web service:
I'm using Kubernetes / k3s version v1.30.2+k3s1
.
But why not Let's Encrypt?
Not that I'm not a huge fan of Let's Encrypt, 8 years ago I wrote a guide on using it with DirectAdmin, but my Kubernetes cluster is local only, not reachable from the internet.
That means I cannot use the HTTP-01
challenge
and my domain provider has no plugin for the DNS-01
challenge. So no
automated certificates for me, since I'm not exposing this setup to the
internet.
Kubernetes cert-manager is a native application that automates the management and issuance of TLS certificates within Kubernetes clusters. It provides a set of custom resources to issue certificates, attach them to services, and simplifies the process of obtaining, renewing, and using those certificates.
In my case, it is possible to use your own CA and get the benefit of automated issuance, secret management and renewal.
I can simply trust the self signed root certificate and all certificates issued by that CA will be trusted in my browser.
You can also buy a certificate, for example, an extended validation (EV) cert and set that up. I'll cover that later on in this guide as well.
Installing cert-manager
I'm using Helm
to install cert-manager
. In my first article
I covered the admin workstation setup so I assume you have kubectl
set up.
I'll also assume you have a domain name for your cluster
to use in the certificate. I'm continuing with the echoapp
fromthat guide.
Use the following commands to add the Helm repo:
helm repo add jetstack https://charts.jetstack.io
helm repo update
Install cert-manager
with Helm:
helm install \
cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.15.1 \
--set crds.enabled=true \
--set webhook.timeoutSeconds=4 \
--set replicaCount=2 \
--set podDisruptionBudget.enabled=true \
--set podDisruptionBudget.minAvailable=1
Output:
cert-manager v1.15.1 has been deployed successfully!
In order to begin issuing certificates, you will need to set up a ClusterIssuer
or Issuer resource (for example, by creating a 'letsencrypt-staging' issuer).
More information on the different types of issuers and how to configure them
can be found in our documentation:
https://cert-manager.io/docs/configuration/
For information on how to configure cert-manager to automatically provision
Certificates for Ingress resources, take a look at the `ingress-shim`
documentation:
https://cert-manager.io/docs/usage/ingress/
Create a folder for your yaml files:
mkdir certmanager
cd certmanager
A warning on security and nameConstraints
You are at risk if the Root CA key is compromised. If the key is stolen, it can be
used to create trusted certificates for everything. Luckily there is something
we can do, using nameConstraints
to limit the scope of the Root Certificate to,
in our case, a single domain (k3s.homelab.mydomain.org
). This means that if
your key would be compromised, it would only be able to issue certificates for
anything under that domain, not your bank for example.
RFC 5280 provides for something called
Name Constraints
, which allow an X.509 CA to have a scope limited to
certain names, including the parent domains of the certificates issued by the
CA. For example, a host constraint of .example.com
allows the CA to issue
certificates for anything under .example.com
, but not any other host. For
other hosts, clients will fail to validate the chain. More info here.
See my guide on nameConstraints to set that up along with this guide.
Create the self signed root CA
The topmost certificate in our certificate chain will be a self signed
certificate authority, the so called Root CA
. The Root CA signs one or
more intermediate CA's, which in turn sign leaf certificates. For example,
for raymii.org
, the Root CA is USERTrust RSA Certification Authority
. The
intermediate CA is Sectigo RSA Domain Validation Secure Server CA
and the
leaf certificate is for this site, raymii.org
:
For an actual trusted root CA, the root certificate would be offline in a HSM (hardware security module) and is only used to sign intermediate CA's once in a while.
For our setup, this Root CA
certificate is the only certificate you have to
import in your OS / browser to make all issued certificates trusted.
Create a file to describe this resource:
vim spnw-root-ca.yaml
Contents:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: spnw-root-ca-issuer-selfsigned
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: spnw-root-ca
namespace: cert-manager
spec:
isCA: true
commonName: spnw-root-ca
secretName: spnw-root-ca-secret
duration: 87600h # 10y
renewBefore: 78840h # 9y
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: spnw-root-ca-issuer-selfsigned
kind: ClusterIssuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: spnw-root-ca-issuer
spec:
ca:
secretName: spnw-root-ca-secret
Things to note are the isCA
attribute, the duration
and the renewBefore
.
For a Root CA you want those to be long.
Apply it:
kubectl -n cert-manager apply -f spnw-root-ca.yaml
Output:
clusterissuer.cert-manager.io/spnw-root-ca-issuer-selfsigned created
certificate.cert-manager.io/spnw-root-ca created
clusterissuer.cert-manager.io/spnw-root-ca-issuer created
Check if creation worked and view info about the root certificate:
kubectl describe ClusterIssuer -n cert-manager
Output:
Name: spnw-root-ca-issuer
Namespace:
Labels: <none>
Annotations: <none>
API Version: cert-manager.io/v1
Kind: ClusterIssuer
Metadata:
Creation Timestamp: 2024-07-16T03:56:25Z
Generation: 1
Resource Version: 2329384
UID: 70[...]59
Spec:
Ca:
Secret Name: spnw-root-ca-secret
Status:
Conditions:
Last Transition Time: 2024-07-16T03:56:25Z
Message: Signing CA verified
Observed Generation: 1
Reason: KeyPairVerified
Status: True
Type: Ready
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal KeyPairVerified 37s (x2 over 37s) cert-manager-clusterissuers Signing CA verified
Name: spnw-root-ca-issuer-selfsigned
Namespace:
Labels: <none>
Annotations: <none>
API Version: cert-manager.io/v1
Kind: ClusterIssuer
Metadata:
Creation Timestamp: 2024-07-16T03:56:25Z
Generation: 1
Resource Version: 2329379
UID: 9e[...]e
Spec:
Self Signed:
Status:
Conditions:
Last Transition Time: 2024-07-16T03:56:25Z
Observed Generation: 1
Reason: IsReady
Status: True
Type: Ready
Events: <none>
You can query the Secret
to fetch the Certificate
which in turn can be
fed into openssl
to see the attributes:
kubectl get secret spnw-root-ca-secret -n cert-manager -o jsonpath='
{.data.tls\.crt}' | base64 --decode | openssl x509 -noout -text
The secret has multiple filenames, tls.crt
contains the certificate,
tls.key
contains the private key.
Output:
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
86:9[...]d:e8
Signature Algorithm: ecdsa-with-SHA256
Issuer: CN = spnw-root-ca
Validity
Not Before: Jul 16 04:04:23 2024 GMT
Not After : Jul 14 04:04:23 2034 GMT
Subject: CN = spnw-root-ca
Subject Public Key Info:
Public Key Algorithm: id-ecPublicKey
Public-Key: (256 bit)
pub:
04:8[...]f5
ASN1 OID: prime256v1
NIST CURVE: P-256
X509v3 extensions:
X509v3 Key Usage: critical
Digital Signature, Key Encipherment, Certificate Sign
X509v3 Basic Constraints: critical
CA:TRUE
X509v3 Subject Key Identifier:
70:[...]:45
Signature Algorithm: ecdsa-with-SHA256
Note the Not After
date being 10 years later than the Not Before
date. For
a root certificate you want long validity.
Create the intermediate CA
This intermediate CA will sign the certificates for our services. The YAML
looks a lot like the root ca, but it's missing the SelfSigned
issuer and
the actual Issuer
is our freshly created root ca.
vim spnw-intermediate-ca1.yaml
Contents:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: spnw-intermediate-ca1
namespace: cert-manager
spec:
isCA: true
commonName: spnw-intermediate-ca1
secretName: spnw-intermediate-ca1-secret
duration: 43800h # 5y
renewBefore: 35040h # 4y
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: spnw-root-ca-issuer
kind: ClusterIssuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: spnw-intermediate-ca1-issuer
spec:
ca:
secretName: spnw-intermediate-ca1-secret
This intermediate CA has a shorter validity. You can get information on the CA the same way as before:
kubectl describe ClusterIssuer -n cert-manager
Output:
Name: spnw-intermediate-ca1-issuer
Namespace:
Labels: <none>
Annotations: <none>
API Version: cert-manager.io/v1
Kind: ClusterIssuer
Metadata:
Creation Timestamp: 2024-07-16T04:15:03Z
Generation: 1
Resource Version: 2334652
UID: b[...]9e04
Spec:
Ca:
Secret Name: spnw-intermediate-ca1-secret
Status:
Conditions:
Last Transition Time: 2024-07-16T04:15:28Z
Message: Signing CA verified
Observed Generation: 1
Reason: KeyPairVerified
Status: True
Type: Ready
And as before, you can query the Secret
to get the certificate data in OpenSSL:
kubectl get secret spnw-intermediate-ca1-secret -n cert-manager -o
jsonpath='{.data.tls\.crt}' | base64 --decode | openssl
x509 -noout -text
Output:
Certificate:
Data:
Version: 3 (0x2)
Serial Number:
bc:09:f4:5e:75:92:11:c3:af:68:81:45:30:22:06:76
Signature Algorithm: ecdsa-with-SHA256
Issuer: CN = spnw-root-ca
Validity
Not Before: Jul 16 04:15:08 2024 GMT
Not After : Jul 15 04:15:08 2029 GMT
Subject: CN = spnw-intermediate-ca1
You can also use openssl
to test that the intermediate CA was actually
signed by the Root CA:
openssl verify -CAfile <(kubectl -n cert-manager get secret
spnw-root-ca-secret -o jsonpath='{.data.tls\.crt}' | base64 --decode) <
(kubectl -n cert-manager get secret spnw-intermediate-ca1-secret -o
jsonpath='{.data.tls\.crt}' | base64 --decode)
This commands queries the two secrets for the public keys, then passes the
output of that as a file to openssl
, using the <()
process substitution syntax.
Output:
/dev/fd/62: OK
If you get an error like below:
error 20 at 0 depth lookup: unable to get local issuer certificate
error /dev/fd/62: verification failed
Then something went wrong. openssl x509
is your friend when debugging.
If you want to remove and re-issue all the above, you must also delete the
secrets associated. This will not happen automatically if you kubectl
delete -f .
the resources:
kubectl -n cert-manager delete secret spnw-root-ca-secret spnw-intermediate-ca1-secret
Otherwise you might notice that your certificates have not changed, even though you thought you re-issued them.
Testing the certificates
This step is optional, but might help you troubleshoot any issues. We're going
to issue a test certificate, which we'll test with the openssl
command line
tooling to validate our certificate and chain.
Create a yaml file:
vim test-cert.yaml
Contents:
apiVersion: v1
kind: Namespace
metadata:
name: cert-test
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: test-server
namespace: cert-test
spec:
secretName: test-server-secret
isCA: false
usages:
- server auth
- client auth
dnsNames:
- "test-server.cert-test.svc.cluster.local"
- "test-server"
issuerRef:
name: spnw-intermediate-ca1-issuer
kind: ClusterIssuer
Apply:
kubectl apply -f test-cert.yaml
Output:
namespace/cert-test created
certificate.cert-manager.io/test-server created
Use the openssl verify
command to check the chain. The first parameter is
-CAFile
, with the <()
shell construct to get the output of the kubectl
command which gets the Secret
for the Root CA. The second parameter,
-untrusted
, contains our intermediate CA in the same way and the last,
unnamed, parameter contains our leaf certificate:
openssl verify -CAfile <(kubectl -n cert-manager get secret
spnw-root-ca-secret -o jsonpath='{.data.tls\.crt}' |
base64 --decode) -untrusted <(kubectl -n cert-manager get secret
spnw-intermediate-ca1-secret -o jsonpath='{.data.tls\.crt}' |
base64 --decode) <(kubectl -n cert-test get secret test-server-secret -o
jsonpath='{.data.tls\.crt}' | base64 --decode)
Output:
/dev/fd/61: OK
See this link
for more info on why we must provide the intermediate CA via the -untrusted
parameter.
You can also use this shell command to feed all certificates in the Secret
to openssl
. The sed
magic is there because openssl
only parses the
first certificate in the output, and there might be multiple.
OLDIFS=$IFS; IFS=':' certificates=$(kubectl get secret
test-server-secret -n cert-test -o json | jq -r '.data["tls.crt"]' |
base64 --decode | sed -n '/-----BEGIN/,/-----END/{/-----BEGIN/ s/^/:/;
p}'); for certificate in ${certificates#:}; do echo $certificate |
openssl x509 -noout -ext subjectAltName -subject -issuer; echo;
done; IFS=$OLDIFS
Output:
X509v3 Subject Alternative Name: critical
DNS:test-server.cert-test.svc.cluster.local, DNS:test-server
subject=
issuer=CN = spnw-intermediate-ca1
No extensions in certificate
subject=CN = spnw-intermediate-ca1
issuer=CN = spnw-root-ca
Same for the CA as known by our issued test certificate. This file has only 1 certificate in my case, but it's good practice to make sure the command goes well if in the future there might be multiple certificates in such output:
OLDIFS=$IFS; IFS=':' certificates=$(kubectl get secret
test-server-secret -n cert-test -o json | jq -r '.data["ca.crt"]' |
base64 --decode | sed -n '/-----BEGIN/,/-----END/{/-----BEGIN/ s/^/:/;
p}'); for certificate in ${certificates#:}; do echo $certificate |
openssl x509 -noout -ext subjectAltName -subject -issuer; echo "---";
done; IFS=$OLDIFS
Output:
No extensions in certificate
subject=CN = spnw-root-ca
issuer=CN = spnw-root-ca
After testing you can delete the test certificates and namespace:
kubectl delete -f test-certificate.yaml
Output:
namespace "cert-test" deleted
certificate.cert-manager.io "test-server" deleted
Ingress (Service) Certificate
After all that hard setup and testing we can finally use our self signed CA to
automatically issue a certificate for our echo
app.
In my other article on how to host multiple domains,
I set up an echo
app service as a simple test and coupled a hostname to
that app (echo.k3s.homelab.mydomain.org
). I assume you have that setup as
well.
Edit (or create) the Ingress
:
vim echoapp-ingress.yaml
Contents:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: echo-ingress
namespace: echoapp
annotations:
cert-manager.io/cluster-issuer: spnw-intermediate-ca1-issuer
cert-manager.io/common-name: "echo.k3s.homelab.mydomain.org"
spec:
ingressClassName: traefik
rules:
- host: echo.k3s.homelab.mydomain.org
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: echo-service
port:
number: 80
tls:
- hosts:
- echo.k3s.homelab.mydomain.org
secretName: echo-cert-secret
Things to note in this file are the annotations
and the tls
part.
annotations:
cert-manager.io/cluster-issuer: spnw-intermediate-ca1-issuer
cert-manager.io/common-name: "echo.k3s.homelab.mydomain.org"
The annotation cluster-issuer
tells ingress-shim
which ClusterIssuer
to
use and for backwards compatibility I've included the annotation
common-name
. If you omit the latter annotation, you will be issued a
Certificate
with an empty Subject
. This is not a bad thing
but not all software understands Subject Alternative Names
and not all
software can handle an empty Subject
.
The sub-component ingress-shim
watches Ingress
resources across your
cluster. If it observes an Ingress
with annotations described in
the Supported Annotations
section, it will ensure a Certificate
resource with the name provided in
the tls.secretName
field and configured as described on the Ingress
exists in the Ingress's
namespace.
As you can see, the tls
section contains the hostname and a Secret
to use:
tls:
- hosts:
- echo.k3s.homelab.mydomain.org
secretName: echo-cert-secret
If this secret already exists, it will use tls.crt
, ca.crt
and tls.key
respectively. If the secret does not exist, ingress-shim
will make sure a
new Certificate
is issued.
Apply the config:
kubectl -n echoapp apply -f echoapp-ingress.yaml
Output:
ingress.networking.k8s.io/echo-ingress created
You can check if the Certificate
was issued correctly:
kubectl describe Certificate -n echoapp
Output:
Name: echo-cert-secret
Namespace: echoapp
API Version: cert-manager.io/v1
Kind: Certificate
Metadata:
Kind: Ingress
Name: echo-ingress
[...]
Spec:
Common Name: echo.k3s.homelab.mydomain.org
Dns Names:
echo.k3s.homelab.mydomain.org
Issuer Ref:
Group: cert-manager.io
Kind: ClusterIssuer
Name: spnw-intermediate-ca1-issuer
Secret Name: echo-cert-secret
Usages:
digital signature
key encipherment
Status:
Conditions:
Last Transition Time: 2024-07-16T04:57:57Z
Message: Certificate is up to date and has not expired
Observed Generation: 1
Reason: Ready
Status: True
Type: Ready
Not After: 2024-10-14T04:57:56Z
Not Before: 2024-07-16T04:57:56Z
Renewal Time: 2024-09-14T04:57:56Z
Events: <none>
As we did above, you can get the contents of the Certificate
and pipe that
into openssl
to see more info:
kubectl get secret echo-cert -n echoapp -o jsonpath='{.data.tls\.crt}' |
base64 --decode | openssl x509 -noout -ext
subjectAltName -subject -issuer
Output:
X509v3 Subject Alternative Name:
DNS:echo.k3s.homelab.mydomain.org
subject=CN = echo.k3s.homelab.mydomain.org
issuer=CN = spnw-intermediate-ca1
After importing the root CA (as a .crt
file containing the PEM contents) in
Windows via certmgr.msc
as a Trusted Root Certificate Authority
:
All browsers trust sites with a certificate issued by our Root CA:
Using an existing regular certificate (not self signed)
If you have bought a certificate somewhere (like Sectigo
), or a certificate
issues by another internal CA (not in Kubernetes) and want to use that on
your cluster, you can create a secret manually and use it in your Ingress
.
You must omit the cert-manager.io
annotations
in your Ingress
and
reference the existing Secret
in your tls
section.
Place the PEM encoded certificate you received from your CA in a file named
tls.crt
. Append the (entire) intermediate chain to that file, so at the top
you have your certificate and below, in order, the certificate chain.
Place your PEM encoded private key in a file named tls.key
. Execute the
following command to create a Secret
. Note that it is a generic
secret,
not a tls
type secret. tls
type secrets are handled by cert-manager
and
we don't want that in this case.
kubectl create secret generic
echo-cert-official --from-file=tls.crt=tls.crt --from-file=tls.key=tls.key
-n echoapp
Output:
secret/echo-cert-official created
In your Ingress
yaml file, you must refer to the secret by name:
tls:
[...]
secretName: echo-cert-official
Apply the Ingress
and after a few seconds, your site will serve
the "official" certificate.