# Kubernetes — the certificate in a TLS secret renews by itself

> A CronJob fetches the fresh certificate once a day and updates the TLS secret your ingress uses; the key stays in that same secret.

URL: https://zrazu.com/en/docs/ssl-auto-kubernetes/

Use this if your site runs in Kubernetes behind an ingress (ingress-nginx, Traefik, HAProxy Ingress and so on) and the certificate is stored in a secret of type `kubernetes.io/tls`. Ingress controllers watch secrets themselves: once you update the secret, the site serves the new certificate within a minute or two.

How it works: a CronJob fetches the certificate from the link once a day, compares it with the one in the secret, checks that it matches the key from that same secret, and replaces only `tls.crt`. The key is never copied anywhere. The job's permissions are to read and change one secret in one namespace.

## What you need

- The certificate link from your account: `https://api.zrazu.com/ssl-pull/zsp_YOUR_TOKEN`.
- `kubectl` with access to your site's namespace.
- Outbound internet access from the cluster (to `api.zrazu.com` and to the Alpine Linux mirrors — the image installs `curl`, `openssl` and `jq` on every run).

## Step 1. The TLS secret (if it does not exist yet)

Download `https://api.zrazu.com/ssl-pull/zsp_YOUR_TOKEN/fullchain.pem` into a file named `fullchain.pem` and create the secret with your key:

```sh
curl -fsS -o fullchain.pem "https://api.zrazu.com/ssl-pull/zsp_YOUR_TOKEN/fullchain.pem"
kubectl -n NAMESPACE_NAME create secret tls zrazu-tls --cert=fullchain.pem --key=key.pem
```

In your site's Ingress, specify this secret: `spec.tls[].secretName: zrazu-tls`.

## Step 2. The CronJob

Save the text below to a file named `zrazu-ssl.yaml`. If your secret has a different name, replace `zrazu-tls` (it appears twice).

```yaml file=zrazu-ssl-k8s.yaml
# ZRAZU: updates the certificate in a TLS secret using the personal link. Runs once a day.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: zrazu-ssl
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: zrazu-ssl
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["zrazu-tls"]
    verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: zrazu-ssl
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: zrazu-ssl
subjects:
  - kind: ServiceAccount
    name: zrazu-ssl
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: zrazu-ssl
spec:
  schedule: "17 4 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          serviceAccountName: zrazu-ssl
          restartPolicy: Never
          containers:
            - name: update
              image: alpine:3.20
              env:
                - name: PULL
                  value: "https://api.zrazu.com/ssl-pull/zsp_YOUR_TOKEN"
                - name: SECRET
                  value: "zrazu-tls"
              command: ["/bin/sh", "-c"]
              args:
                - |
                  set -eu
                  apk add --no-cache curl openssl jq >/dev/null
                  SA=/var/run/secrets/kubernetes.io/serviceaccount
                  API="https://kubernetes.default.svc/api/v1/namespaces/$(cat $SA/namespace)/secrets/$SECRET"
                  k() { curl -fsS --cacert "$SA/ca.crt" -H "Authorization: Bearer $(cat $SA/token)" "$@"; }
                  cd /tmp
                  curl -fsS --retry 3 --max-time 60 -o new.pem "$PULL/fullchain.pem"
                  grep -q "BEGIN CERTIFICATE" new.pem || { echo "ZRAZU: not a certificate"; exit 1; }
                  k "$API" > secret.json
                  jq -r '.data["tls.crt"]' secret.json | base64 -d > current.pem
                  if cmp -s new.pem current.pem; then echo "ZRAZU: unchanged"; exit 0; fi
                  jq -r '.data["tls.key"]' secret.json | base64 -d > key.pem
                  cert_pub=$(openssl x509 -in new.pem -noout -pubkey | openssl sha256)
                  key_pub=$(openssl pkey -in key.pem -pubout | openssl sha256)
                  rm -f key.pem
                  [ "$cert_pub" = "$key_pub" ] || { echo "ZRAZU: certificate is for another key"; exit 1; }
                  openssl x509 -in new.pem -noout -checkend 0 >/dev/null || { echo "ZRAZU: expired"; exit 1; }
                  crt=$(base64 < new.pem | tr -d '\n')
                  k -X PATCH -H "Content-Type: application/merge-patch+json" --data "{\"data\":{\"tls.crt\":\"$crt\"}}" "$API" >/dev/null
                  echo "ZRAZU: updated, valid until $(openssl x509 -in new.pem -noout -enddate | cut -d= -f2)"
```

Apply it in your site's namespace:

```sh
kubectl -n NAMESPACE_NAME apply -f zrazu-ssl.yaml
```

## Check

1. Run the job once by hand: `kubectl -n NAMESPACE_NAME create job --from=cronjob/zrazu-ssl zrazu-ssl-test`.
2. Log: `kubectl -n NAMESPACE_NAME logs job/zrazu-ssl-test` — "updated" or "unchanged".
3. Site: [https://zrazu.com/tools/ssl/example.com/](https://zrazu.com/tools/ssl/example.com/).
4. Remove the test job: `kubectl -n NAMESPACE_NAME delete job zrazu-ssl-test`.

## If it did not work

- `403` in the log — the Role is for the wrong secret or the wrong namespace: the name in `resourceNames` must match the secret's name.
- `apk: … network error` — the cluster has no internet access to the Alpine mirrors. Build your own image with `curl`, `openssl` and `jq` and remove the `apk add` line.
- "certificate is for another key" — the certificate was reissued with a new key: recreate the secret (step 1) with the new key.
- The secret is managed by cert-manager — then you do not need this guide: cert-manager renews the certificate by itself through the ZRAZU ACME server (in your account, "Installation" → ACME).
