Certificates expire. In a Kubernetes homelab with Linkerd service mesh, this means the identity issuer certificate needs renewal annually. Without automation, this becomes a manual task that’s easy to forget until mTLS breaks across the cluster.

This post covers installing cert-manager on a bare-metal kubeadm cluster and configuring it to automatically rotate Linkerd’s identity issuer certificate.

The Problem

Linkerd uses a two-tier PKI:

CertificatePurposeDefault Lifetime
Trust AnchorRoot CA for the mesh10 years
Identity IssuerSigns proxy certificates1 year

The identity issuer expires annually. When it does, new proxy sidecars cannot obtain valid certificates, breaking mTLS. The trust anchor rarely needs rotation, but the identity issuer requires attention.

Solution Overview

┌─────────────────────────────────────────────────────────────────┐
│                     cert-manager                                │
│  ┌──────────────────────┐    ┌────────────────────────────────┐ │
│  │ linkerd-trust-anchor │───▶│ linkerd-identity-issuer (Cert) │ │
│  │      (Issuer)        │    │  secretName: ...-certmanager   │ │
│  └──────────────────────┘    └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
                                          │
                                          ▼
┌─────────────────────────────────────────────────────────────────┐
│                   linkerd-cert-sync CronJob                     │
│  Runs hourly, converts tls.crt/tls.key → crt.pem/key.pem       │
│  Restarts linkerd-identity deployment if certificate changed   │
└─────────────────────────────────────────────────────────────────┘
                                          │
                                          ▼
┌─────────────────────────────────────────────────────────────────┐
│                     linkerd namespace                           │
│  ┌────────────────────────┐    ┌──────────────────────────────┐ │
│  │ linkerd-identity-issuer│    │   linkerd-identity           │ │
│  │  (Opaque Secret)       │◀───│   (Deployment)               │ │
│  │  crt.pem, key.pem      │    │   Uses certificate for mTLS  │ │
│  └────────────────────────┘    └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

cert-manager issues and renews the certificate. A CronJob syncs it to Linkerd’s expected secret format and restarts the identity service when changes occur.

Directory Structure

infrastructure/cert-manager/
├── cert-manager-values.yaml      # Helm configuration
├── cluster-issuers.yaml          # Self-signed and CA issuers
├── linkerd-certificates.yaml     # Linkerd cert + sync CronJob + RBAC
├── prometheus-alerts.yaml        # Expiry alerting rules
├── setup-cert-manager.sh         # Main installation script
├── setup-linkerd-certs.sh        # Linkerd integration script
└── README.md

Installation

Step 1: Install cert-manager

Helm values for cert-manager with Prometheus metrics enabled:

# cert-manager-values.yaml
crds:
  enabled: true
  keep: true

prometheus:
  enabled: true
  servicemonitor:
    enabled: true
    namespace: cert-manager
    labels:
      release: prometheus

resources:
  requests:
    cpu: 10m
    memory: 32Mi
  limits:
    memory: 64Mi

webhook:
  resources:
    requests:
      cpu: 10m
      memory: 32Mi
    limits:
      memory: 64Mi

cainjector:
  resources:
    requests:
      cpu: 10m
      memory: 32Mi
    limits:
      memory: 128Mi

featureGates: "ExperimentalCertificateSigningRequestControllers=true"

global:
  logLevel: 2

Installation script:

#!/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NAMESPACE="cert-manager"

# Create namespace with Linkerd injection
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
kubectl annotate namespace "$NAMESPACE" linkerd.io/inject=enabled --overwrite

# Add Helm repo
helm repo add jetstack https://charts.jetstack.io 2>/dev/null || true
helm repo update jetstack

# Install cert-manager
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace "$NAMESPACE" \
  --values "$SCRIPT_DIR/cert-manager-values.yaml" \
  --wait

# Wait for webhook
kubectl wait --for=condition=Available deployment/cert-manager-webhook \
  -n "$NAMESPACE" --timeout=120s

# Create ClusterIssuers
kubectl apply -f "$SCRIPT_DIR/cluster-issuers.yaml"

# Create Prometheus alerts
kubectl apply -f "$SCRIPT_DIR/prometheus-alerts.yaml"

# Wait for CA
kubectl wait --for=condition=Ready certificate/selfsigned-ca \
  -n "$NAMESPACE" --timeout=60s

Step 2: Create ClusterIssuers

Two ClusterIssuers provide flexibility for issuing certificates:

---
# Self-signed ClusterIssuer
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-issuer
spec:
  selfSigned: {}
---
# Self-signed CA certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: selfsigned-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: selfsigned-ca
  secretName: selfsigned-ca-secret
  duration: 87600h # 10 years
  renewBefore: 8760h # 1 year
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsigned-issuer
    kind: ClusterIssuer
    group: cert-manager.io
---
# CA ClusterIssuer using the self-signed CA
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: ca-issuer
spec:
  ca:
    secretName: selfsigned-ca-secret

Step 3: Configure Linkerd Certificate Rotation

The Linkerd integration requires the trust anchor CA key to be available. This should exist from the initial Linkerd installation:

infrastructure/linkerd/
├── ca.crt    # Trust anchor certificate
├── ca.key    # Trust anchor private key
└── ...

The Linkerd certificate configuration:

---
# Issuer using Linkerd trust anchor
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: linkerd-trust-anchor
  namespace: linkerd
spec:
  ca:
    secretName: linkerd-trust-anchor
---
# Certificate for identity issuer
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: linkerd-identity-issuer
  namespace: linkerd
spec:
  secretName: linkerd-identity-issuer-certmanager
  duration: 8760h     # 1 year
  renewBefore: 720h   # 30 days before expiry
  issuerRef:
    name: linkerd-trust-anchor
    kind: Issuer
  commonName: identity.linkerd.cluster.local
  isCA: true
  privateKey:
    algorithm: ECDSA
    size: 256
  usages:
    - cert sign
    - crl sign
    - server auth
    - client auth

The Secret Format Problem

cert-manager creates TLS secrets with keys tls.crt and tls.key. Linkerd expects crt.pem and key.pem. A CronJob handles this translation:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: linkerd-cert-sync
  namespace: linkerd
spec:
  schedule: "0 * * * *"  # Every hour
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: linkerd-cert-sync
          restartPolicy: OnFailure
          containers:
          - name: sync
            image: bitnami/kubectl:latest
            command:
            - /bin/bash
            - -c
            - |
              set -e

              TLS_CRT=$(kubectl get secret linkerd-identity-issuer-certmanager -n linkerd -o jsonpath='{.data.tls\.crt}')
              TLS_KEY=$(kubectl get secret linkerd-identity-issuer-certmanager -n linkerd -o jsonpath='{.data.tls\.key}')
              CURRENT_CRT=$(kubectl get secret linkerd-identity-issuer -n linkerd -o jsonpath='{.data.crt\.pem}' 2>/dev/null || echo "")

              if [ "$TLS_CRT" != "$CURRENT_CRT" ]; then
                echo "Certificate changed, updating linkerd-identity-issuer secret..."

                kubectl create secret generic linkerd-identity-issuer \
                  --from-literal=crt.pem="$(echo $TLS_CRT | base64 -d)" \
                  --from-literal=key.pem="$(echo $TLS_KEY | base64 -d)" \
                  --namespace=linkerd \
                  --dry-run=client -o yaml | kubectl apply -f -

                kubectl label secret linkerd-identity-issuer -n linkerd \
                  linkerd.io/control-plane-component=identity \
                  linkerd.io/control-plane-ns=linkerd \
                  --overwrite

                kubectl rollout restart deployment linkerd-identity -n linkerd
                echo "Linkerd identity issuer updated and identity service restarted"
              else
                echo "Certificate unchanged, no action needed"
              fi

The CronJob requires RBAC to read secrets and restart deployments:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: linkerd-cert-sync
  namespace: linkerd
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: linkerd-cert-sync
  namespace: linkerd
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "create", "update", "patch"]
- apiGroups: ["apps"]
  resources: ["deployments"]
  verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: linkerd-cert-sync
  namespace: linkerd
subjects:
- kind: ServiceAccount
  name: linkerd-cert-sync
  namespace: linkerd
roleRef:
  kind: Role
  name: linkerd-cert-sync
  apiGroup: rbac.authorization.k8s.io

Step 4: Add Prometheus Alerts

Alert before certificates expire:

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: cert-manager-alerts
  namespace: cert-manager
  labels:
    release: prometheus
    app: cert-manager
spec:
  groups:
  - name: cert-manager
    rules:
    - alert: CertificateExpiringSoon
      expr: |
        certmanager_certificate_expiration_timestamp_seconds - time() < 30 * 24 * 3600
        and
        certmanager_certificate_expiration_timestamp_seconds - time() > 14 * 24 * 3600
      for: 1h
      labels:
        severity: warning
      annotations:
        summary: "Certificate {{ $labels.name }} expires in less than 30 days"

    - alert: CertificateExpiringVerySoon
      expr: |
        certmanager_certificate_expiration_timestamp_seconds - time() < 14 * 24 * 3600
        and
        certmanager_certificate_expiration_timestamp_seconds - time() > 7 * 24 * 3600
      for: 1h
      labels:
        severity: warning
      annotations:
        summary: "Certificate {{ $labels.name }} expires in less than 14 days"

    - alert: CertificateExpiringCritical
      expr: |
        certmanager_certificate_expiration_timestamp_seconds - time() < 7 * 24 * 3600
      for: 10m
      labels:
        severity: critical
      annotations:
        summary: "Certificate {{ $labels.name }} expires in less than 7 days"

    - alert: CertificateNotReady
      expr: |
        certmanager_certificate_ready_status{condition="False"} == 1
      for: 15m
      labels:
        severity: warning
      annotations:
        summary: "Certificate {{ $labels.name }} is not ready"

    - alert: CertManagerAbsent
      expr: |
        absent(up{job="cert-manager"})
      for: 10m
      labels:
        severity: critical
      annotations:
        summary: "cert-manager is not running"

Running the Installation

# Install cert-manager
cd infrastructure/cert-manager/
./setup-cert-manager.sh

# Configure Linkerd certificate rotation
./setup-linkerd-certs.sh

Verification

Check certificate status:

kubectl get certificates -A

NAMESPACE      NAME                      READY   SECRET                                AGE
cert-manager   selfsigned-ca             True    selfsigned-ca-secret                  10m
linkerd        linkerd-identity-issuer   True    linkerd-identity-issuer-certmanager   5m

Check expiry date:

kubectl get secret linkerd-identity-issuer -n linkerd -o jsonpath='{.data.crt\.pem}' | \
  base64 -d | openssl x509 -noout -dates

notBefore=Jan  1 11:48:02 2026 GMT
notAfter=Jan  1 11:48:02 2027 GMT

Verify Linkerd is healthy:

linkerd check

Manually trigger a sync:

kubectl create job --from=cronjob/linkerd-cert-sync manual-sync -n linkerd
kubectl logs job/manual-sync -n linkerd

What This Does Not Cover

cert-manager handles X.509 certificates. The following require separate tracking:

Token TypeExampleExpiry
GitLab deploy tokensRegistry pull secretsConfigurable in GitLab
AWS access keysS3 credentialsOptional rotation
API keysSplunk HEC tokensTypically no expiry
Database passwordsPostgreSQL credentialsNo expiry

These tokens need a different solution such as a ConfigMap-based registry or external secrets management.

Summary

ComponentStatus
cert-managerManages certificate lifecycle
Self-signed CA10-year internal CA
Linkerd identity issuerAuto-renews 30 days before expiry
Sync CronJobConverts secret format hourly
Prometheus alerts30/14/7 day warnings

The Linkerd identity issuer certificate now renews automatically. Prometheus alerts provide early warning if renewal fails.

Configuration available at k8s-configs/infrastructure/cert-manager.