cert-manager is one of the standard ways to manage TLS certificates in Kubernetes. It can request, issue, renew, and rotate certificates automatically, and it integrates well with Ingress controllers, gateways, and applications running inside the cluster.

In public environments, the most common choice is Let’s Encrypt. In a home lab, though, a private Certificate Authority (CA) is often more practical: it works without exposing services to the Internet, it does not depend on ACME challenges, and it gives you fast, repeatable certificate issuance for internal hostnames.

This is an updated version of my original post. The idea is the same, but the manifests, API versions, and recommendations have been refreshed for current Kubernetes and cert-manager releases.

In this article, I will use mkcert to create a local CA, import that CA into cert-manager, and issue a test certificate from a ClusterIssuer.

What this setup is good for

This approach is a good fit when:

  • you run a home lab or internal Kubernetes cluster
  • you want trusted HTTPS for internal services
  • you control the client machines that will trust the CA
  • you do not want to depend on public DNS or public ACME challenges

It is not the right approach for public Internet-facing services. For that, use an ACME issuer such as Let’s Encrypt.

Install cert-manager

Start by installing cert-manager in your cluster. The official release manifest is the simplest option for a lab environment because it includes the CRDs and the controller components.

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

Then verify that the pods are running:

kubectl -n cert-manager get pods
NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-webhook-578954cdd-wwzgg       1/1     Running   0          14d
cert-manager-86548b886-cflh5               1/1     Running   1          14d
cert-manager-cainjector-6d59c8d4f7-lgtv4   1/1     Running   1          14d

Once cert-manager is ready, the next step is to give it a CA that it can use to sign certificates.

Create a local CA with mkcert

For this example, I will use mkcert, a very convenient tool for generating locally trusted development certificates.

Download the binary for your platform from the mkcert releases page, create a working directory, and initialize your CA there.

The example below uses Linux on amd64. If you are on macOS, installing mkcert with Homebrew is usually easier, but the overall workflow is the same.

mkdir internalCA && cd internalCA
wget https://github.com/FiloSottile/mkcert/releases/download/v1.4.3/mkcert-v1.4.3-linux-amd64
chmod +x mkcert-v1.4.3-linux-amd64
CAROOT=. ./mkcert-v1.4.3-linux-amd64 -install

This creates the CA certificate and private key in the local directory and also installs the root CA in your machine’s trust store.

On Linux, mkcert usually copies the root CA into /usr/local/share/ca-certificates/. On macOS and Windows, it updates the native system trust store instead.

root@zbox:/tmp/internalCA# ls -l
total 4700
-rwxr-xr-x 1 root root 4803796 Nov 25 14:17 mkcert-v1.4.3-linux-amd64
-r-------- 1 root root    2484 Dec 26 00:54 rootCA-key.pem
-rw-r--r-- 1 root root    1594 Dec 26 00:54 rootCA.pem
root@zbox:/tmp/internalCA# ls -l /usr/local/share/ca-certificates/
total 4
-rw-r--r-- 1 root root 1594 Dec 26 00:48 mkcert_development_CA_75165785500873602433925991678959123844.crt

From here, cert-manager needs access to that CA certificate and private key.

Store the CA key pair in Kubernetes

Create a TLS secret in the cert-manager namespace:

kubectl create secret tls ca-key-pair \
   --cert=rootCA.pem \
   --key=rootCA-key.pem \
   --namespace=cert-manager

Verify that the secret was created correctly:

root@zbox:/tmp/internalCA# kubectl -n cert-manager describe secret ca-key-pair
Name:         ca-key-pair
Namespace:    cert-manager
Labels:       <none>
Annotations:  <none>

Type:  kubernetes.io/tls

Data
====
tls.crt:  1688 bytes
tls.key:  2488 bytes

Create a ClusterIssuer

cert-manager supports two issuer scopes:

  • Issuer, which is namespaced
  • ClusterIssuer, which can be used from any namespace

For a home lab, ClusterIssuer is usually more convenient, especially if several services in different namespaces will request certificates.

Create localCAissuer.yaml and point it to the secret you just created:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: ca-issuer
spec:
  ca:
    secretName: ca-key-pair

Apply it and inspect its status:

kubectl apply -f localCAissuer.yaml
kubectl describe clusterissuer ca-issuer

If everything is correct, the issuer should become Ready.

Example output:

Name:         ca-issuer
Namespace:    
Labels:       <none>
Annotations:  <none>
API Version:  cert-manager.io/v1
Kind:         ClusterIssuer
Metadata:
  Creation Timestamp:  2020-11-29T19:01:59Z
  Generation:          1
  Managed Fields:
    API Version:  cert-manager.io/v1
    Fields Type:  FieldsV1
    fieldsV1:
      f:status:
        f:conditions:
    Manager:      controller
    Operation:    Update
    Time:         2020-11-29T19:01:59Z
    API Version:  cert-manager.io/v1
    Fields Type:  FieldsV1
    fieldsV1:
      f:metadata:
        f:annotations:
          .:
          f:kubectl.kubernetes.io/last-applied-configuration:
      f:spec:
        .:
        f:ca:
          .:
          f:secretName:
    Manager:         kubectl-client-side-apply
    Operation:       Update
    Time:            2020-11-29T19:01:59Z
  Resource Version:  1072
  Self Link:         /apis/cert-manager.io/v1/clusterissuers/ca-issuer
  UID:               4c8396be-a038-4170-b754-3107cb39425c
Spec:
  Ca:
    Secret Name:  ca-key-pair
Status:
  Conditions:
    Last Transition Time:  2020-11-29T19:01:59Z
    Message:               Signing CA verified
    Reason:                KeyPairVerified
    Status:                True
    Type:                  Ready
Events:                    <none>

Request a certificate

Now that the CA is available through cert-manager, create your first Certificate resource.

For this example I will use nip.io in the hostname, because it is convenient in small labs and does not require dedicated DNS management for every test service.

Create testcert.yaml with these values:

  • kind: Certificate
  • namespace: default
  • issuer: ClusterIssuer ca-issuer
  • dnsNames: test.192.168.1.222.nip.io

Replace 192.168.1.222 with the IP address that makes sense in your own environment. If you use another wildcard DNS helper, just adapt the hostname.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: test-cert
  namespace: default
spec:
  secretName: test-cert-tls
  issuerRef:
    name: ca-issuer
    kind: ClusterIssuer
  commonName: test.192.168.1.222.nip.io
  organizations:
  - ZBOX CA
  dnsNames:
  - test.192.168.1.222.nip.io

Apply the manifest:

kubectl apply -f testcert.yaml

If the request succeeds, the certificate should become ready:

root@zbox:/tmp/internalCA# kubectl get certificate test-cert 
NAME               READY   SECRET                 AGE
test-cert          True    test-cert-tls          6d

You can then inspect the generated secret:

root@zbox:/tmp/internalCA# kubectl describe secret test-cert-tls
Name:         test-cert-tls
Namespace:    default
Labels:       <none>
Annotations:  cert-manager.io/alt-names: test.192.168.1.222.nip.io
              cert-manager.io/certificate-name: test-cert
              cert-manager.io/common-name: test.192.168.1.222.nip.io
              cert-manager.io/ip-sans: 
              cert-manager.io/issuer-group: 
              cert-manager.io/issuer-kind: ClusterIssuer
              cert-manager.io/issuer-name: ca-issuer
              cert-manager.io/uri-sans: 

Type:  kubernetes.io/tls

Data
====
ca.crt:   1688 bytes
tls.crt:  1489 bytes
tls.key:  1679 bytes

At this point, cert-manager has signed the certificate using the CA generated by mkcert, and stored the result in a Kubernetes TLS secret containing ca.crt, tls.crt, and tls.key.

You can now reference that secret from an Ingress controller, a Gateway, or any application that expects a standard Kubernetes TLS secret. In my case, I originally used it with Traefik for local HTTPS endpoints.

Practical notes

  • Use cert-manager.io/v1 resources. Older API versions such as v1alpha2 are long deprecated.
  • A private CA created with mkcert is excellent for development and home labs, but it is not a public trust solution.
  • Every client that should trust certificates issued by this CA must also trust the mkcert root CA.
  • Keep the CA private key safe. Anyone with access to it can issue certificates trusted by your lab clients.
  • If you want certificates that browsers trust by default on the public Internet, use an ACME issuer such as Let’s Encrypt instead.

Why this still works well in 2026

Even with all the changes in Kubernetes over the last few years, this pattern is still extremely useful in internal environments:

  • cert-manager handles issuance and renewal automatically
  • workloads consume certificates through normal Kubernetes secrets
  • you avoid manual certificate generation for every service
  • you get a setup that is simple, predictable, and easy to reproduce

For a home lab, that is usually exactly what you want.

If you later decide to expose services publicly, the migration path is straightforward: keep cert-manager, replace the private CA issuer with an ACME issuer, and request publicly trusted certificates with almost the same workflow.