Skip to content

docs: correct the publishing reference and document the existingSecret cert mode#619

Open
Aleksei Sviridkin (lexfrei) wants to merge 1 commit into
mainfrom
docs/gateway-cert-modes-and-publishing-reference
Open

docs: correct the publishing reference and document the existingSecret cert mode#619
Aleksei Sviridkin (lexfrei) wants to merge 1 commit into
mainfrom
docs/gateway-cert-modes-and-publishing-reference

Conversation

@lexfrei

Copy link
Copy Markdown
Contributor

Split out of #615 so the corrections below get reviewed as what they are — behavioural claims about the TenantGateway controller and the publishing path — rather than riding along in a typo fix. Both the v1.5 and the next snapshots are updated.

publishing.exposure is gone

The reference documented publishing.exposure. That key was added and then reverted (revert(ingress)!: remove broken publishing.exposure=loadBalancer mode) before v1.5 shipped: it exists in release-1.4 and in no branch since. An operator on v1.5 following the table set a key that is silently ignored. The row is removed from both snapshots, along with a stale cross-reference to it on the Gateway API pages, and a note tells v1.4 upgraders to drop it. The v1.4 docs are deliberately left alone — there the key is real.

That cross-reference also asserted externalTrafficPolicy: Local for the legacy ingress path. It is only Local when publishing.externalIPs is empty; with external IPs set — the usual bare-metal install — the host ingress Service is a ClusterIP with externalTrafficPolicy: Cluster and NATs client source IPs exactly like the Gateway path. Corrected.

publishing.exposureClass is documented

Removing the dead key left next with no documented way to publish the host ingress at all. Its successor, publishing.exposureClass, is now in the reference: the four backends and what each renders, the guard that fails the render when a pool or cloud backend meets a non-empty publishing.externalIPs, and the KEP-5707 deprecation phasing (the later release numbers are approximate upstream, and the text says so rather than inventing precision).

The third cert mode

The Gateway API pages described a binary HTTP-01 / DNS-01 choice throughout — intro, controller responsibilities, listener layout, migration runbook, rollback, threat model and known limitations all assumed a per-tenant Issuer and Certificate always exist. A third mode ships and is selected by publishing.certificates.wildcardSecretName: the controller points listeners at an operator-supplied wildcard Secret, mints neither Issuer nor Certificate, and garbage-collects what it previously owned. It now has its own section, and the surrounding prose no longer contradicts it. Two consequences that were undocumented:

  • The ACME account private key outlives a mode switch. Only the Issuer referencing it is collected. On next a post-delete hook removes it when the gateway release goes away — not when the mode changes; in v1.5 nothing removes it at all.
  • The mode removes a safety net. On the ACME paths a hostname the tenant does not control simply fails to get a certificate. Here nothing is issued, nothing proves domain control, and the Gateway serves the operator Secret with whatever SANs it carries.

What the option does to a tenant tree

The Secret name — never the key material — rides the cluster values channel that every tenant inherits, so this is not root-scoped in practice even though root is the only supported configuration. Three cases, with different remedies:

  1. An inheriting child gets a *.<child-apex> listener on the owner Gateway against the same Secret, so the SAN list must cover each child apex.
  2. A child that owns a Gateway resolves the Secret in its own namespace — the controller renders certificateRefs without a namespace and issues no ReferenceGrant, so the reference never crosses namespaces — and the Secret must be replicated there.
  3. On the default ingress path, a child running its own ingress controller gets neither: its apps drop their ACME annotation cluster-wide while default-ssl-certificate reaches only the publishing controller, so ingress-nginx serves its built-in self-signed certificate. Replicating the Secret does not help, because the flag is gated on the namespace. That is an open bug — A non-empty wildcard Secret name disables ACME for every tenant but only certifies one ingress controller — child tenants silently serve a fake certificate cozystack#3296 — and the docs say so rather than presenting it as a constraint to configure around.

Verification

Built with the Hugo version CI uses, in both environments — production (1491 pages) and development (1694 pages, the only one that renders next/, which production excludes). Every claim above was checked against release-1.5 and main rather than inferred.

@netlify

netlify Bot commented Jul 14, 2026

Copy link
Copy Markdown

Deploy Preview for cozystack ready!

Name Link
🔨 Latest commit 461e842
🔍 Latest deploy log https://app.netlify.com/projects/cozystack/deploys/6a5644f80fdbf800082508f9
😎 Deploy Preview https://deploy-preview-619--cozystack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@lexfrei, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 73f5edc7-1fe8-4595-af81-8b155b928ace

📥 Commits

Reviewing files that changed from the base of the PR and between 74d49a1 and 461e842.

📒 Files selected for processing (4)
  • content/en/docs/next/networking/gateway-api.md
  • content/en/docs/next/operations/configuration/platform-package.md
  • content/en/docs/v1.5/networking/gateway-api.md
  • content/en/docs/v1.5/operations/configuration/platform-package.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/gateway-cert-modes-and-publishing-reference

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the Cozystack documentation for both the 'next' and 'v1.5' versions to detail the new 'existingSecret' certificate mode, which allows using an operator-provided wildcard Secret instead of ACME. It also documents the 'publishing.exposureClass' configuration as the successor to the deprecated 'publishing.exposure' setting. The review feedback suggests improving clarity and consistency by using full spec paths (such as 'spec.ingress: true' and 'spec.gateway: true') instead of shorthand terms in the documentation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.


- **Inheriting children** (the default, no `spec.gateway`) have no Gateway of their own. Their `*.<child-apex>` listener is rendered on the owner's Gateway and bound to the Secret in the *owner's* namespace, so coverage depends entirely on that Secret's SAN list — a bare `*.<apex>` does not match `*.<child-apex>`, and clients of the child subdomain are served the owner's certificate and see a hostname mismatch. Replicating the Secret into the child's namespace fixes nothing here, because no listener reads it; the SANs must cover each child apex.
- **Children that own a Gateway** (`spec.gateway: true`) render their own `TenantGateway`, inherit the Secret *name* through the cluster values channel, and resolve it in **their own** namespace. Gateway API does allow a listener to reference a Secret in another namespace, via a `ReferenceGrant`, but the controller does not use that route: it renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference is always local. There the Secret does have to be replicated, or the tenant is left without a certificate.
- **Children on the ingress-nginx path** (`gateway.enabled=false`, the default) are hit hardest. The Secret name reaches every tenant, and the app and system ingress templates drop their per-host ACME annotation as soon as it is non-empty — but the ingress chart passes `default-ssl-certificate` only to the *publishing* controller. A child running its own ingress controller (`ingress: true`) therefore ends up with neither: its apps have no certificate of their own, and its controller has no default one, so ingress-nginx serves its built-in self-signed certificate for every host in that tenant. Replicating the Secret does not help — the flag is gated on the namespace, not on the Secret. This is tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set `wildcardSecretName` on a cluster whose child tenants run their own ingress controllers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with the previous bullet point (which uses spec.gateway: true), it is clearer to refer to the tenant-level ingress setting as spec.ingress: true instead of ingress: true.

Suggested change
- **Children on the ingress-nginx path** (`gateway.enabled=false`, the default) are hit hardest. The Secret name reaches every tenant, and the app and system ingress templates drop their per-host ACME annotation as soon as it is non-empty — but the ingress chart passes `default-ssl-certificate` only to the *publishing* controller. A child running its own ingress controller (`ingress: true`) therefore ends up with neither: its apps have no certificate of their own, and its controller has no default one, so ingress-nginx serves its built-in self-signed certificate for every host in that tenant. Replicating the Secret does not help — the flag is gated on the namespace, not on the Secret. This is tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set `wildcardSecretName` on a cluster whose child tenants run their own ingress controllers.
- **Children on the ingress-nginx path** (`gateway.enabled=false`, the default) are hit hardest. The Secret name reaches every tenant, and the app and system ingress templates drop their per-host ACME annotation as soon as it is non-empty — but the ingress chart passes `default-ssl-certificate` only to the *publishing* controller. A child running its own ingress controller (`spec.ingress: true`) therefore ends up with neither: its apps have no certificate of their own, and its controller has no default one, so ingress-nginx serves its built-in self-signed certificate for every host in that tenant. Replicating the Secret does not help — the flag is gated on the namespace, not on the Secret. This is tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set `wildcardSecretName` on a cluster whose child tenants run their own ingress controllers.


- **Inheriting children** (the default, no `spec.gateway`) have no Gateway of their own. Their `*.<child-apex>` listener is rendered on the owner's Gateway and bound to the Secret in the *owner's* namespace, so coverage depends entirely on that Secret's SAN list — a bare `*.<apex>` does not match `*.<child-apex>`, and clients of the child subdomain are served the owner's certificate and see a hostname mismatch. Replicating the Secret into the child's namespace fixes nothing here, because no listener reads it; the SANs must cover each child apex.
- **Children that own a Gateway** (`spec.gateway: true`) render their own `TenantGateway`, inherit the Secret *name* through the cluster values channel, and resolve it in **their own** namespace. Gateway API does allow a listener to reference a Secret in another namespace, via a `ReferenceGrant`, but the controller does not use that route: it renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference is always local. There the Secret does have to be replicated, or the tenant is left without a certificate.
- **Children on the ingress-nginx path** (`gateway.enabled=false`, the default) are hit hardest. The Secret name reaches every tenant, and the app and system ingress templates drop their per-host ACME annotation as soon as it is non-empty — but the ingress chart passes `default-ssl-certificate` only to the *publishing* controller. A child running its own ingress controller (`ingress: true`) therefore ends up with neither: its apps have no certificate of their own, and its controller has no default one, so ingress-nginx serves its built-in self-signed certificate for every host in that tenant. Replicating the Secret does not help — the flag is gated on the namespace, not on the Secret. This is tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set `wildcardSecretName` on a cluster whose child tenants run their own ingress controllers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with the previous bullet point (which uses spec.gateway: true), it is clearer to refer to the tenant-level ingress setting as spec.ingress: true instead of ingress: true.

Suggested change
- **Children on the ingress-nginx path** (`gateway.enabled=false`, the default) are hit hardest. The Secret name reaches every tenant, and the app and system ingress templates drop their per-host ACME annotation as soon as it is non-empty — but the ingress chart passes `default-ssl-certificate` only to the *publishing* controller. A child running its own ingress controller (`ingress: true`) therefore ends up with neither: its apps have no certificate of their own, and its controller has no default one, so ingress-nginx serves its built-in self-signed certificate for every host in that tenant. Replicating the Secret does not help — the flag is gated on the namespace, not on the Secret. This is tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set `wildcardSecretName` on a cluster whose child tenants run their own ingress controllers.
- **Children on the ingress-nginx path** (`gateway.enabled=false`, the default) are hit hardest. The Secret name reaches every tenant, and the app and system ingress templates drop their per-host ACME annotation as soon as it is non-empty — but the ingress chart passes `default-ssl-certificate` only to the *publishing* controller. A child running its own ingress controller (`spec.ingress: true`) therefore ends up with neither: its apps have no certificate of their own, and its controller has no default one, so ingress-nginx serves its built-in self-signed certificate for every host in that tenant. Replicating the Secret does not help — the flag is gated on the namespace, not on the Secret. This is tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set `wildcardSecretName` on a cluster whose child tenants run their own ingress controllers.

| `publishing.certificates.issuerName` | `"letsencrypt-prod"` | `ClusterIssuer` name for TLS certificates used in system Helm releases. |
| `publishing.certificates.wildcard` | `false` | Opt-in shared wildcard certificate on the default ingress-nginx path (`gateway.enabled=false`). When `true` with `solver=dns01` and no `publishing.certificates.wildcardSecretName`, the platform issues one `*.<root-host>` + `<root-host>` `Certificate` via the DNS-01 `ClusterIssuer` and serves it as the ingress controller's default SSL certificate, so system services stop minting a per-host ACME certificate each — avoiding Let's Encrypt rate limits at scale, at parity with the Gateway API path. Ignored on `http01` (cannot issue wildcards) and when `gateway.enabled=true` (the `TenantGateway` controller issues the wildcard there). Coverage caveat: a single-label wildcard does not cover a custom service host outside `*.<root-host>` (e.g. a keycloak `ingress.host` on another domain) or a child tenant's nested host (`<service>.<tenant>.<root-host>`); those services fall back to the default certificate, which would not cover them. Enabling it propagates the issued wildcard Secret name through the cluster values channel that child tenants inherit, so this is not root-tenant scoped — only enable it when every exposed host is covered, or supply a covering `publishing.certificates.wildcardSecretName` instead. Off by default so a `dns01` cluster is never switched silently on upgrade. |
| `publishing.certificates.wildcardSecretName` | `""` | Operator-provided wildcard TLS Secret. When set, platform services and the root tenant's ingress/Gateway serve under this pre-existing Secret instead of minting per-host ACME certificates; only the NAME travels through the platform values channel, never the certificate or private key. The Secret must already exist in the publishing namespace (`tenant-root` by default — the namespace running the root ingress controller selected by `publishing.ingressName`), be of type `kubernetes.io/tls`, and cover the served hosts (typically `*.<root-host>` + `<root-host>`). Takes precedence over `publishing.certificates.wildcard` — when set, no ACME issuance happens. Leave empty to keep ACME issuance. |
| `publishing.certificates.wildcardSecretName` | `""` | Operator-provided wildcard TLS Secret. When set, platform services and the root tenant's ingress/Gateway serve under this pre-existing Secret instead of minting per-host ACME certificates; only the NAME travels through the platform values channel, never the certificate or private key. The Secret must already exist in the publishing namespace (`tenant-root` by default — the namespace running the root ingress controller selected by `publishing.ingressName`), be of type `kubernetes.io/tls`, and cover the served hosts (typically `*.<root-host>` + `<root-host>`). Takes precedence over `publishing.certificates.wildcard` — when set, no ACME `Certificate` is issued for these hosts and no per-tenant `Issuer` is created (`solver` / `issuerName` still govern the cluster-wide `ClusterIssuer`s, which are rendered either way). Supported for the root tenant only — but not enforced: the Secret NAME is distributed through the cluster values channel that every tenant inherits, so enabling it also affects children, which splits into three cases. An inheriting child (no `gateway: true`) has no Gateway of its own — its `*.<child-apex>` listener is rendered on the owner's Gateway against this same Secret, so the SAN list must cover every child apex, or those clients are served the owner's certificate and see a hostname mismatch (replicating the Secret into the child's namespace does nothing there). A child with `gateway: true` owns a Gateway and resolves the Secret in **its own** namespace — the controller renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference never crosses namespaces — and the Secret must be replicated there or that tenant gets no certificate at all. On the default ingress-nginx path (`gateway.enabled=false`) a child running its own ingress controller (`ingress: true`) gets neither a per-host ACME certificate nor the operator Secret: its apps drop the ACME annotation cluster-wide, while `default-ssl-certificate` is passed only to the publishing controller, so ingress-nginx serves its built-in self-signed certificate for that tenant's hosts — and replicating the Secret does not help, because the flag is gated on the namespace. That is an open bug, tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set this on a cluster whose child tenants run their own ingress controllers. See [Gateway API → Certificates]({{% ref "/docs/next/networking/gateway-api#certificates" %}}). Leave empty to keep ACME issuance. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid confusion with platform-level settings (such as gateway.enabled), it is clearer to refer to tenant-level settings using their full spec paths (e.g., spec.gateway: true and spec.ingress: true).

Suggested change
| `publishing.certificates.wildcardSecretName` | `""` | Operator-provided wildcard TLS Secret. When set, platform services and the root tenant's ingress/Gateway serve under this pre-existing Secret instead of minting per-host ACME certificates; only the NAME travels through the platform values channel, never the certificate or private key. The Secret must already exist in the publishing namespace (`tenant-root` by default — the namespace running the root ingress controller selected by `publishing.ingressName`), be of type `kubernetes.io/tls`, and cover the served hosts (typically `*.<root-host>` + `<root-host>`). Takes precedence over `publishing.certificates.wildcard` — when set, no ACME `Certificate` is issued for these hosts and no per-tenant `Issuer` is created (`solver` / `issuerName` still govern the cluster-wide `ClusterIssuer`s, which are rendered either way). Supported for the root tenant only — but not enforced: the Secret NAME is distributed through the cluster values channel that every tenant inherits, so enabling it also affects children, which splits into three cases. An inheriting child (no `gateway: true`) has no Gateway of its own — its `*.<child-apex>` listener is rendered on the owner's Gateway against this same Secret, so the SAN list must cover every child apex, or those clients are served the owner's certificate and see a hostname mismatch (replicating the Secret into the child's namespace does nothing there). A child with `gateway: true` owns a Gateway and resolves the Secret in **its own** namespace — the controller renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference never crosses namespaces — and the Secret must be replicated there or that tenant gets no certificate at all. On the default ingress-nginx path (`gateway.enabled=false`) a child running its own ingress controller (`ingress: true`) gets neither a per-host ACME certificate nor the operator Secret: its apps drop the ACME annotation cluster-wide, while `default-ssl-certificate` is passed only to the publishing controller, so ingress-nginx serves its built-in self-signed certificate for that tenant's hosts — and replicating the Secret does not help, because the flag is gated on the namespace. That is an open bug, tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set this on a cluster whose child tenants run their own ingress controllers. See [Gateway API → Certificates]({{% ref "/docs/next/networking/gateway-api#certificates" %}}). Leave empty to keep ACME issuance. |
| `publishing.certificates.wildcardSecretName` | `""` | Operator-provided wildcard TLS Secret. When set, platform services and the root tenant's ingress/Gateway serve under this pre-existing Secret instead of minting per-host ACME certificates; only the NAME travels through the platform values channel, never the certificate or private key. The Secret must already exist in the publishing namespace (`tenant-root` by default — the namespace running the root ingress controller selected by `publishing.ingressName`), be of type `kubernetes.io/tls`, and cover the served hosts (typically `*.<root-host>` + `<root-host>`). Takes precedence over `publishing.certificates.wildcard` — when set, no ACME `Certificate` is issued for these hosts and no per-tenant `Issuer` is created (`solver` / `issuerName` still govern the cluster-wide `ClusterIssuer`s, which are rendered either way). Supported for the root tenant only — but not enforced: the Secret NAME is distributed through the cluster values channel that every tenant inherits, so enabling it also affects children, which splits into three cases. An inheriting child (no `spec.gateway: true`) has no Gateway of its own — its `*.<child-apex>` listener is rendered on the owner's Gateway against this same Secret, so the SAN list must cover every child apex, or those clients are served the owner's certificate and see a hostname mismatch (replicating the Secret into the child's namespace does nothing there). A child with `spec.gateway: true` owns a Gateway and resolves the Secret in **its own** namespace — the controller renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference never crosses namespaces — and the Secret must be replicated there or that tenant gets no certificate at all. On the default ingress-nginx path (`gateway.enabled=false`) a child running its own ingress controller (`spec.ingress: true`) gets neither a per-host ACME certificate nor the operator Secret: its apps drop the ACME annotation cluster-wide, while `default-ssl-certificate` is passed only to the publishing controller, so ingress-nginx serves its built-in self-signed certificate for that tenant's hosts — and replicating the Secret does not help, because the flag is gated on the namespace. That is an open bug, tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set this on a cluster whose child tenants run their own ingress controllers. See [Gateway API → Certificates]({{% ref "/docs/next/networking/gateway-api#certificates" %}}). Leave empty to keep ACME issuance. |

| `publishing.exposure` | `"externalIPs"` | Exposure mode for the ingress-nginx Service. Possible values: `externalIPs`, `loadBalancer`. The default writes `Service.spec.externalIPs` from `publishing.externalIPs`; `loadBalancer` switches to `Service.type: LoadBalancer` and a `CiliumLoadBalancerIPPool` over the same IPs (with `externalTrafficPolicy: Local` to preserve client source IP). `Service.spec.externalIPs` is deprecated upstream in v1.36 (KEP-5707); plan to switch to `loadBalancer` before upgrading past Kubernetes v1.40 when the `AllowServiceExternalIPs` feature gate flips off. The `loadBalancer` mode requires Cilium L2/BGP announcements to reach the IP from outside the cluster (off by default in cozystack), and at least one address in `publishing.externalIPs` (otherwise render fails). |
| `publishing.certificates.solver` | `"http01"` | ACME challenge solver type for default letsencrypt issuer. Possible values: `http01`, `dns01`. |
| `publishing.certificates.issuerName` | `"letsencrypt-prod"` | `ClusterIssuer` name for TLS certificates used in system Helm releases. |
| `publishing.certificates.wildcardSecretName` | `""` | Operator-provided wildcard TLS Secret. When set, platform services and the root tenant's ingress/Gateway serve under this pre-existing Secret instead of minting per-host ACME certificates, and no per-tenant `Issuer` or `Certificate` is created for them (`solver` / `issuerName` still govern the cluster-wide `ClusterIssuer`s, which are rendered either way); only the NAME travels through the platform values channel, never the certificate or private key. The Secret must already exist in the publishing namespace (`tenant-root` by default — the namespace running the root ingress controller selected by `publishing.ingressName`), be of type `kubernetes.io/tls`, and cover the served hosts (typically `*.<root-host>` + `<root-host>`). Supported for the root tenant only — but not enforced: the Secret NAME is distributed through the cluster values channel that every tenant inherits, so enabling it also affects children, which splits into three cases. An inheriting child (no `gateway: true`) has no Gateway of its own — its `*.<child-apex>` listener is rendered on the owner's Gateway against this same Secret, so the SAN list must cover every child apex, or those clients are served the owner's certificate and see a hostname mismatch (replicating the Secret into the child's namespace does nothing there). A child with `gateway: true` owns a Gateway and resolves the Secret in **its own** namespace — the controller renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference never crosses namespaces — and the Secret must be replicated there or that tenant gets no certificate at all. On the default ingress-nginx path (`gateway.enabled=false`) a child running its own ingress controller (`ingress: true`) gets neither a per-host ACME certificate nor the operator Secret: its apps drop the ACME annotation cluster-wide, while `default-ssl-certificate` is passed only to the publishing controller, so ingress-nginx serves its built-in self-signed certificate for that tenant's hosts — and replicating the Secret does not help, because the flag is gated on the namespace. That is an open bug, tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set this on a cluster whose child tenants run their own ingress controllers. See [Gateway API → Certificates]({{% ref "/docs/v1.5/networking/gateway-api#certificates" %}}). Leave empty to keep ACME issuance. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid confusion with platform-level settings (such as gateway.enabled), it is clearer to refer to tenant-level settings using their full spec paths (e.g., spec.gateway: true and spec.ingress: true).

Suggested change
| `publishing.certificates.wildcardSecretName` | `""` | Operator-provided wildcard TLS Secret. When set, platform services and the root tenant's ingress/Gateway serve under this pre-existing Secret instead of minting per-host ACME certificates, and no per-tenant `Issuer` or `Certificate` is created for them (`solver` / `issuerName` still govern the cluster-wide `ClusterIssuer`s, which are rendered either way); only the NAME travels through the platform values channel, never the certificate or private key. The Secret must already exist in the publishing namespace (`tenant-root` by default — the namespace running the root ingress controller selected by `publishing.ingressName`), be of type `kubernetes.io/tls`, and cover the served hosts (typically `*.<root-host>` + `<root-host>`). Supported for the root tenant only — but not enforced: the Secret NAME is distributed through the cluster values channel that every tenant inherits, so enabling it also affects children, which splits into three cases. An inheriting child (no `gateway: true`) has no Gateway of its own — its `*.<child-apex>` listener is rendered on the owner's Gateway against this same Secret, so the SAN list must cover every child apex, or those clients are served the owner's certificate and see a hostname mismatch (replicating the Secret into the child's namespace does nothing there). A child with `gateway: true` owns a Gateway and resolves the Secret in **its own** namespace — the controller renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference never crosses namespaces — and the Secret must be replicated there or that tenant gets no certificate at all. On the default ingress-nginx path (`gateway.enabled=false`) a child running its own ingress controller (`ingress: true`) gets neither a per-host ACME certificate nor the operator Secret: its apps drop the ACME annotation cluster-wide, while `default-ssl-certificate` is passed only to the publishing controller, so ingress-nginx serves its built-in self-signed certificate for that tenant's hosts — and replicating the Secret does not help, because the flag is gated on the namespace. That is an open bug, tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set this on a cluster whose child tenants run their own ingress controllers. See [Gateway API → Certificates]({{% ref "/docs/v1.5/networking/gateway-api#certificates" %}}). Leave empty to keep ACME issuance. |
| `publishing.certificates.wildcardSecretName` | `""` | Operator-provided wildcard TLS Secret. When set, platform services and the root tenant's ingress/Gateway serve under this pre-existing Secret instead of minting per-host ACME certificates, and no per-tenant `Issuer` or `Certificate` is created for them (`solver` / `issuerName` still govern the cluster-wide `ClusterIssuer`s, which are rendered either way); only the NAME travels through the platform values channel, never the certificate or private key. The Secret must already exist in the publishing namespace (`tenant-root` by default — the namespace running the root ingress controller selected by `publishing.ingressName`), be of type `kubernetes.io/tls`, and cover the served hosts (typically `*.<root-host>` + `<root-host>`). Supported for the root tenant only — but not enforced: the Secret NAME is distributed through the cluster values channel that every tenant inherits, so enabling it also affects children, which splits into three cases. An inheriting child (no `spec.gateway: true`) has no Gateway of its own — its `*.<child-apex>` listener is rendered on the owner's Gateway against this same Secret, so the SAN list must cover every child apex, or those clients are served the owner's certificate and see a hostname mismatch (replicating the Secret into the child's namespace does nothing there). A child with `spec.gateway: true` owns a Gateway and resolves the Secret in **its own** namespace — the controller renders `certificateRefs` without a namespace and issues no `ReferenceGrant`, so the reference never crosses namespaces — and the Secret must be replicated there or that tenant gets no certificate at all. On the default ingress-nginx path (`gateway.enabled=false`) a child running its own ingress controller (`spec.ingress: true`) gets neither a per-host ACME certificate nor the operator Secret: its apps drop the ACME annotation cluster-wide, while `default-ssl-certificate` is passed only to the publishing controller, so ingress-nginx serves its built-in self-signed certificate for that tenant's hosts — and replicating the Secret does not help, because the flag is gated on the namespace. That is an open bug, tracked as [cozystack/cozystack#3296](https://github.com/cozystack/cozystack/issues/3296); until it is fixed, do not set this on a cluster whose child tenants run their own ingress controllers. See [Gateway API → Certificates]({{% ref "/docs/v1.5/networking/gateway-api#certificates" %}}). Leave empty to keep ACME issuance. |

@lexfrei Aleksei Sviridkin (lexfrei) force-pushed the docs/gateway-cert-modes-and-publishing-reference branch 5 times, most recently from 2abf956 to fa89b7f Compare July 14, 2026 13:54
The platform-package tables documented publishing.exposure. The key was
added, then reverted before v1.5 shipped, and never returned. Drop the
stale row from the v1.5 and next references, note the removal for anyone
upgrading from v1.4 with the key still set, and drop the cross-reference on
the Gateway API pages, which additionally asserted externalTrafficPolicy:
Local for the legacy ingress path. That holds only when
publishing.externalIPs is empty; with external IPs set — the usual
bare-metal install — the host ingress Service is a ClusterIP with
externalTrafficPolicy: Cluster and NATs client source IPs exactly like the
Gateway path does.

Document publishing.exposureClass, the successor key on the next branch,
which the reference omitted entirely: the backends it maps to, the guard
that fails the render when a pool or cloud backend is combined with a
non-empty publishing.externalIPs, and the real deprecation timeline —
AllowServiceExternalIPs defaults to false from Kubernetes v1.40, so the
migration has to happen before that upgrade, not after it.

Document the cert mode that publishing.certificates.wildcardSecretName
selects. The Gateway API pages described a binary HTTP-01 / DNS-01 choice
throughout — intro, controller responsibilities, listener layout, migration
runbook, rollback and threat model all assumed a per-tenant Issuer and
Certificate always exist. A third mode ships: with an operator-provided
wildcard Secret the controller mints neither and garbage collects what it
previously owned, though the ACME account private key outlives the switch.
The cluster-wide ClusterIssuers are unaffected — they render from the
solver either way.

Spell out what the option does to a tenant tree, in all three cases rather
than only the two on the Gateway path. The Secret name — never the key
material — rides the cluster values channel, so an inheriting child gets a
*.<child-apex> listener on the owner's Gateway and needs SAN coverage,
while a child owning a Gateway resolves the Secret in its own namespace and
needs it replicated there. On the default ingress path a child running its
own ingress controller gets neither certificate and is served the built-in
self-signed one from ingress-nginx; that case is an open bug rather than a
constraint an operator can configure around, and the docs now say so and
point at the tracker. Root-tenant scope is the supported configuration and
is not enforced by any guard.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei Aleksei Sviridkin (lexfrei) force-pushed the docs/gateway-cert-modes-and-publishing-reference branch from fa89b7f to 461e842 Compare July 14, 2026 14:17
@lexfrei Aleksei Sviridkin (lexfrei) marked this pull request as ready for review July 14, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant