SSL: automatic renewal

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.

Renews by itself

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:

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).

# 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:

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/.
  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).

This page in Markdown