---
title: "Kubernetes & cert-manager — Certificates for Laravel | Roundly"
description: "Drive cert-manager from Laravel — the kubernetes driver adds TLS hosts to an Ingress and reads Certificate status over the Kubernetes API."
url: https://roundly-consulting.com/open-source/docs/certificates-for-laravel/kubernetes
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Certificates for Laravel](https://roundly-consulting.com/open-source/docs/certificates-for-laravel.md)

# Kubernetes & cert-manager

The kubernetes driver — the default — talks to the Kubernetes API directly over Laravel’s HTTP client, with no SDK. It provisions by adding a TLS entry and host rules to an Ingress, lets cert-manager issue the certificate into a secret, and reads cert-manager Certificate resources for status and listing:

```bash
CERTIFICATES_DRIVER=kubernetes
CERTIFICATES_K8S_NAMESPACE=tenants
CERTIFICATES_K8S_ISSUER=letsencrypt
CERTIFICATES_K8S_ISSUER_KIND=ClusterIssuer
CERTIFICATES_K8S_INGRESS_NAME=tenant-domains
CERTIFICATES_K8S_INGRESS_CLASS=nginx
CERTIFICATES_K8S_SERVICE_NAME=web
CERTIFICATES_K8S_SERVICE_PORT=80
```

```php
use RoundlyConsulting\Certificates\Facades\Certificates;

$certificate = Certificates::for('shop.example.com')->using('kubernetes')->issue();

Certificates::statusReport('shop.example.com', driver: 'kubernetes'); // Ready condition + notAfter
```

cert-manager issues asynchronously, so a first issue() patches the Ingress and returns the row still requested — cert-manager has not created or finished the Certificate yet. Run certificates:sync (or Certificates::sync('kubernetes')) to record the outcome once it is Ready: it sets issued\_at and fires CertificateIssued — or marks the row failed and fires CertificateFailed. Schedule it every few minutes on this driver (see Artisan commands).

## Driver keys

| Key | Env | Default | Purpose |
| --- | --- | --- | --- |
| `base_url` | `CERTIFICATES_K8S_BASE_URL` | https://kubernetes.default.svc | Kubernetes API server URL. |
| `token` | `CERTIFICATES_K8S_TOKEN` | null | Bearer token; wins over token\_path. |
| `token_path` | `CERTIFICATES_K8S_TOKEN_PATH` | in-cluster SA token | File the token is read from when token is empty. |
| `ca_path` | `CERTIFICATES_K8S_CA_PATH` | in-cluster SA CA | CA bundle for the API server’s TLS; null or empty uses the system bundle, only false (0, off, no) disables verification. |
| `namespace` | `CERTIFICATES_K8S_NAMESPACE` | default | Namespace of the Ingress and the Certificate resources — for every certificate of this driver. |
| `issuer` | `CERTIFICATES_K8S_ISSUER` | letsencrypt | cert-manager issuer name — for every certificate of this driver. |
| `issuer_kind` | `CERTIFICATES_K8S_ISSUER_KIND` | ClusterIssuer | ClusterIssuer or Issuer — picks the annotation. |
| `ingress.name` | `CERTIFICATES_K8S_INGRESS_NAME` | null | The Ingress to manage — set this. |
| `ingress.class` | `CERTIFICATES_K8S_INGRESS_CLASS` | nginx | Ingress class annotation on a newly created Ingress. |
| `service.name` | `CERTIFICATES_K8S_SERVICE_NAME` | null | Backend service routed for new hosts. |
| `service.port` | `CERTIFICATES_K8S_SERVICE_PORT` | 80 | Backend service port. |

The issuer and namespace apply to every certificate of the driver. For a second issuer or namespace, register another driver with Certificates::extend() and pick it with using().

## Authentication

By default the driver authenticates with the in-cluster service-account token and verifies the API server against the in-cluster CA bundle. From outside the cluster, set base\_url and pass the token directly — an inline token wins over token\_path — along with a CA bundle path:

```bash
CERTIFICATES_K8S_BASE_URL=https://k8s.example.com:6443
CERTIFICATES_K8S_TOKEN=service-account-token
CERTIFICATES_K8S_CA_PATH=/etc/ssl/k8s/ca.crt
```

ca\_path is the CA bundle the API server’s certificate is verified against. null (or empty) verifies against the system CA bundle instead, and only a false value (CERTIFICATES\_K8S\_CA\_PATH=false, or 0, off, no) disables TLS verification — not recommended. API errors and misconfiguration throw KubernetesApiException with the HTTP status and response body.

## What the Ingress looks like

When the Ingress named by ingress.name does not exist yet, it is created with the ingress class and cert-manager issuer annotations — cert-manager.io/cluster-issuer for a ClusterIssuer, cert-manager.io/issuer otherwise. Each issuance appends a TLS entry whose secretName is the derived certificate name, plus a rule routing / to the configured service for each host the Ingress does not route yet — a host that already has a rule keeps it as it is:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tenant-domains                          # ingress.name
  namespace: tenants                            # namespace
  annotations:
    kubernetes.io/ingress.class: nginx          # ingress.class
    cert-manager.io/cluster-issuer: letsencrypt # issuer + issuer_kind
spec:
  tls:
    - hosts:
        - shop.example.com
      secretName: generated-tls-shop-example-com
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web                       # service.name
                port:
                  number: 80                    # service.port
```

An existing Ingress is merge-patched and keeps its own annotations, so make sure it already carries the cert-manager issuer annotation. If the Ingress already has a TLS entry for the secret covering the same hosts, and every host is routed, nothing changes — cert-manager keeps renewing the secret itself. A re-issue with a changed set of domains (alsoFor()) rewrites that entry’s hosts and adds a rule for each new host; rules are never removed.

Every certificate shares this Ingress, and the provisioning lock is per certificate, so a write that loses a race to another domain’s issuance (409 Conflict, including two processes creating the Ingress at once) is re-read and retried up to five times, keeping both TLS entries. After the fifth conflict it throws KubernetesApiException.

## Status and listing

Status comes from the Certificate resource of the same name, mapped onto the registry lifecycle:

- Ready=True — issued, or expired once status.notAfter has passed.
- Issuing=True — pending: still being issued.
- Issuing=False with reason Failed, or a recorded lastFailureTime — failed.
- Anything else — expired when status.notAfter has passed, otherwise pending: Ready=False alone (cert-manager reports it while still issuing), no conditions yet, or no Certificate resource at all (ingress-shim creates it moments after the Ingress is patched). Pending is not an error.

expiresAt is status.notAfter and issuer is spec.issuerRef.name. get() lists every Certificate in the namespace by its first dnsName (or commonName) — run certificates:sync --driver=kubernetes to pull them into the registry. A pending report never demotes a row that is already requested or renewing.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/certificates-for-laravel.md)
