Renew TLS certs on Kubernetes with a CronJob and acme.sh

Created at 2026-10-21 21:30
Last edited at 2026-10-21 20:09

Set up automatic cert renewal for your self-hosted services using acme.sh. In the examples below I use the porkbun api and acme.sh to issue and renew letsencrypt certificates.

Prerequisites

RBAC settings for our CronJob, so the k8s Secret containing the certs can be created and updated automatically. A persistentVolumeClaim is also created so the files generated by acme.sh in the CronJob can be persisted.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: acme-sh-pvc
  namespace: mynamespace
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: acme-sh-sa
  namespace: mynamespace
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: acme-sh-role
  namespace: mynamespace
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get", "list", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: acme-sh-rolebinding
  namespace: mynamespace
subjects:
  - kind: ServiceAccount
    name: acme-sh-sa
    namespace: mynamespace
roleRef:
  kind: Role
  name: acme-sh-role
  apiGroup: rbac.authorization.k8s.io

CronJob

Uses the above configured ServiceAccount. The script will check for existing certificates and creates them if there aren’t any yet. Existing certificates will be renewed if necessary. The certificate chain and the private key will be stored in a k8s Secret.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: acme-sh-renew
  namespace: mynamespace
spec:
  schedule: "0 2 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: acme-sh-sa
          restartPolicy: OnFailure
          securityContext:
            runAsUser: 1000
            runAsGroup: 1000
            fsGroup: 1000
            runAsNonRoot: true
          containers:
            - name: acme-sh
              image: ghcr.io/acmesh-official/acme.sh@sha256:34d0c9a75e0f5222b9bab885cbb3c0c01ae4414f4bebbc16bdbc2298140970ef
              securityContext:
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: true
                capabilities:
                  drop:
                    - ALL
              envFrom:
                - secretRef:
                    name: porkbun-api-keys
              env:
                - name: DOMAIN
                  value: "home.mydomain.com"
              volumeMounts:
                - name: acme-data
                  mountPath: /acme.sh
              command: ["/bin/sh", "-c"]
              args:
                - |
                  if [ ! -f /acme.sh/kubectl ]; then
                    echo "Downloading kubectl"
                    curl -sSL -o /acme.sh/kubectl "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
                    chmod +x /acme.sh/kubectl
                  else
                    echo "kubectl already installed"
                  fi

                  /acme.sh/kubectl version

                  create_secret() {
                    /acme.sh/kubectl create secret tls tls-secret \
                      --namespace=mynamespace \
                      --cert=/acme.sh/${DOMAIN}_ecc/fullchain.cer \
                      --key=/acme.sh/${DOMAIN}_ecc/${DOMAIN}.key \
                      --dry-run=client \
                      -o yaml \
                    | /acme.sh/kubectl apply -f -
                  }

                  echo "Make sure acme.sh auto-upgrade is disabled"
                  sed -i "s/AUTO_UPGRADE='1'/AUTO_UPGRADE='0'/g" /acme.sh/account.conf

                  if [ ! -f /acme.sh/${DOMAIN}_ecc/fullchain.cer ]; then
                    echo "Issuing new cert"
                    acme.sh --issue --server letsencrypt --dns dns_porkbun -d "$DOMAIN" -d "*.${DOMAIN}"
                    create_secret
                  else
                    echo "Execute renewal"
                    acme.sh --cron --renew-hook "create_secret"
                  fi                  
          volumes:
            - name: acme-data
              persistentVolumeClaim:
                claimName: acme-sh-pvc

Gateway and HTTPRoutes

The Gateway uses the Secret generated by the CronJob. HTTPRoutes will use the Gateway to terminate TLS. With the config below, HTTP traffic will be redirected to HTTPS.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: gateway
  namespace: mynamespace
spec:
  gatewayClassName: traefik
  listeners:
    - name: web
      protocol: HTTP
      port: 8000
      hostname: "*.home.mydomain.com"
      allowedRoutes:
        namespaces:
          from: All
    - name: websecure
      protocol: HTTPS
      port: 8443
      hostname: "*.home.mydomain.com"
      tls:
        mode: Terminate
        certificateRefs:
          - name: tls-secret
      allowedRoutes:
        namespaces:
          from: All
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: myservice-redirect-route
  namespace: mynamespace
spec:
  hostnames:
    - "myservice.home.mydomain.com"
  parentRefs:
    - name: gateway
      sectionName: web
      namespace: mynamespace
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https

---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: myservice-https-route
  namespace: mynamespace
spec:
  parentRefs:
    - name: gateway
      sectionName: websecure
      namespace: mynamespace
  hostnames:
    - "myservice.home.mydomain.com"
  rules:
    - backendRefs:
        - name: myservice
          port: 8080