# A private CA with cert-manager and trust-manager

> Run your own certificate authority inside the cluster: a self-signed root, a CA ClusterIssuer, leaf certificates for internal services, and trust-manager copying the root to every namespace so clients trust it.

Interactive graph: https://k8wiz.com/graphs/cert-manager-private-ca-trust-manager

Services that talk to each other inside the cluster need TLS too, and a public CA won't sign `payments-api.payments.svc`. The usual answer is a private CA: cert-manager creates a root certificate with a self-signed issuer, then uses that root to sign certificates for your services.

Issuing is half the job. Every client also has to trust the root. trust-manager takes the root's public certificate and copies it into a ConfigMap in each namespace you pick, so Pods can mount it as their CA bundle.

## Resources

### ClusterIssuer selfsigned-bootstrap

you write it, cert-manager.io/v1

Signs a certificate with its own private key. It exists only to create the root CA, and you shouldn't use it for anything else.

### Certificate internal-root-ca

you write it, cert-manager.io/v1, namespace cert-manager

The root CA. `isCA: true` adds the CA basic constraint, and the long duration avoids frequent root rotations. It lives in the `cert-manager` namespace because a ClusterIssuer reads its Secret from there.

### Secret internal-root-ca

created by a controller, v1, namespace cert-manager

Holds the root's `tls.crt`, `tls.key` and `ca.crt`. The private key here can sign certificates for any name, so lock down who can read Secrets in this namespace.

### ClusterIssuer internal-ca

you write it, cert-manager.io/v1

Signs certificates with the root's key. Any namespace can ask it for a certificate, so pair it with a policy, such as cert-manager's approver-policy, if teams shouldn't get each other's names.

### Certificate payments-api-tls

you write it, cert-manager.io/v1, namespace payments

A server certificate for the payments API, valid for its in-cluster DNS names. It lasts 90 days and renews after 60, so a short lifetime costs nothing.

### CertificateRequest payments-api-tls-1

created by a controller, cert-manager.io/v1, namespace payments

Carries the CSR in `spec.request`. An approver must set the `Approved` condition before the CA issuer signs it. By default cert-manager approves every request itself. Disable that and install approver-policy to control which names each namespace can get.

### Secret payments-api-tls

created by a controller, v1, namespace payments

`tls.crt` and `tls.key` for the server, plus `ca.crt` with the root. The payments API mounts it and serves HTTPS with it.

### Deployment payments-api

you write it, apps/v1, namespace payments

Mounts `payments-api-tls` as a volume and serves HTTPS on 8443. Most servers don't reload certificates on their own, so either pick one that watches the files or restart it on renewal with a tool such as Reloader.

### Bundle internal-ca-bundle

you write it, trust.cert-manager.io/v1alpha1

A trust-manager object. It reads `ca.crt` from the root's Secret, adds the public CAs if you ask it to, and writes the result to a ConfigMap in every namespace with the label `trust: internal`. trust-manager only reads sources from its trust namespace, `cert-manager` by default.

### ConfigMap internal-ca-bundle

created by a controller, v1, namespace checkout

Written by trust-manager into each matching namespace, here `checkout`. It holds only public certificates, so it's fine as a ConfigMap. When the root rotates, trust-manager updates every copy.

### Deployment checkout

you write it, apps/v1, namespace checkout

Calls `https://payments-api.payments.svc:8443`. It mounts the bundle at `/etc/ssl/certs/ca-certificates.crt`, where most Linux TLS libraries look, so the payments certificate verifies.

## How the resources connect

- Certificate internal-root-ca → ClusterIssuer selfsigned-bootstrap: issuerRef (references)
- Certificate internal-root-ca → Secret internal-root-ca: writes (creates)
- ClusterIssuer internal-ca → Secret internal-root-ca: ca.secretName (references)
- Certificate payments-api-tls → ClusterIssuer internal-ca: issuerRef (references)
- Certificate payments-api-tls → CertificateRequest payments-api-tls-1: creates (CSR) (creates)
- ClusterIssuer internal-ca → CertificateRequest payments-api-tls-1: signs once approved (references)
- Certificate payments-api-tls → Secret payments-api-tls: writes (creates)
- Deployment payments-api → Secret payments-api-tls: mounts (references)
- Bundle internal-ca-bundle → Secret internal-root-ca: source ca.crt (references)
- Bundle internal-ca-bundle → ConfigMap internal-ca-bundle: writes per namespace (creates)
- Deployment checkout → ConfigMap internal-ca-bundle: mounts (references)
- Deployment checkout → Deployment payments-api: HTTPS, verifies chain (traffic)

## How it works

1. **Bootstrap a root with the self-signed issuer.** The `selfsigned-bootstrap` ClusterIssuer signs `internal-root-ca` with its own key. Because the Certificate has `isCA: true`, the result can sign other certificates. cert-manager stores it in a Secret in `cert-manager`.
2. **Turn the root into an issuer.** The `internal-ca` ClusterIssuer points at the root's Secret. From now on, any Certificate that names `internal-ca` is signed by the root.
3. **A service asks for a certificate.** The payments team writes a Certificate for its Service's DNS names. cert-manager generates a key, builds a CSR and stores it in a CertificateRequest. Once the request is approved, the CA issuer signs it and cert-manager writes `payments-api-tls`.
4. **The server uses it.** The payments API mounts the Secret and serves HTTPS with it. Thirty days before expiry, cert-manager issues a new certificate and updates the Secret. The kubelet refreshes the mounted files within a minute or so.
5. **trust-manager distributes the root.** Clients need the root's public certificate to verify the server. The Bundle copies `ca.crt` from the root's Secret, together with the public CAs, into a ConfigMap in every namespace labeled `trust: internal`.
6. **The client verifies the chain.** `checkout` mounts the bundle as its system CA file and calls the payments API over HTTPS. The server's certificate chains to the internal root, the root is in the bundle, and the connection verifies without `insecureSkipVerify`.

## Common mistakes

- A ClusterIssuer of type `ca` reads its Secret from the cluster resource namespace, `cert-manager` by default. Put the root's Secret anywhere else and the issuer reports `secret not found`.
- Mounting a Secret or ConfigMap with `subPath`, as the client does here, stops the kubelet from updating that file. That's fine for a root that changes once in years. Mount the whole volume where files must follow renewals.
- When the root expires, every certificate it signed stops verifying at once. Rotate it well before then: add the new root to the Bundle first, wait for clients to pick it up, then switch the issuer.
- With the default approver, anyone who can create a Certificate in any namespace can get a certificate for any name from `internal-ca`. Use a namespaced Issuer per team, or approver-policy, to limit that.

## The manifests you write

```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-bootstrap
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-root-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: Example Internal Root CA
  secretName: internal-root-ca
  duration: 87600h # 10 years
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    kind: ClusterIssuer
    name: selfsigned-bootstrap
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-root-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: payments-api-tls
  namespace: payments
spec:
  secretName: payments-api-tls
  duration: 2160h # 90 days
  renewBefore: 720h # 30 days
  dnsNames:
    - payments-api.payments.svc
    - payments-api.payments.svc.cluster.local
  usages: [server auth]
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    kind: ClusterIssuer
    name: internal-ca
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
  namespace: payments
spec:
  selector:
    matchLabels:
      app: payments-api
  template:
    metadata:
      labels:
        app: payments-api
    spec:
      containers:
        - name: api
          image: ghcr.io/acme/payments-api:2.3.0
          args: ["--tls-cert=/tls/tls.crt", "--tls-key=/tls/tls.key"]
          ports:
            - containerPort: 8443
          volumeMounts:
            - name: tls
              mountPath: /tls
              readOnly: true
      volumes:
        - name: tls
          secret:
            secretName: payments-api-tls
---
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: internal-ca-bundle
spec:
  sources:
    - useDefaultCAs: true
    - secret:
        name: internal-root-ca
        key: ca.crt
  target:
    configMap:
      key: ca-certificates.crt
    namespaceSelector:
      matchLabels:
        trust: internal
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout
  namespace: checkout
spec:
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      containers:
        - name: checkout
          image: ghcr.io/acme/checkout:5.1.0
          volumeMounts:
            - name: ca-bundle
              mountPath: /etc/ssl/certs/ca-certificates.crt
              subPath: ca-certificates.crt
              readOnly: true
      volumes:
        - name: ca-bundle
          configMap:
            name: internal-ca-bundle
```
