Skip to content

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

Merged
myasnikovdaniil merged 1 commit into
mainfrom
docs/gateway-cert-modes-and-publishing-reference
Jul 15, 2026
Merged

docs: correct the publishing reference and document the existingSecret cert mode#619
myasnikovdaniil merged 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 9b692ad
🔍 Latest deploy log https://app.netlify.com/projects/cozystack/deploys/6a5740077ab73700088e1c49
😎 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

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

Next review available in: 59 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: 80f38658-30ce-4227-91aa-d331f34030ac

📥 Commits

Reviewing files that changed from the base of the PR and between 74d49a1 and 9b692ad.

📒 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 6 times, most recently 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

@myasnikovdaniil myasnikovdaniil 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.

LGTM.

High-signal documentation correction — removes the dead publishing.exposure key (reverted before v1.5 shipped), fixes the stale externalTrafficPolicy cross-reference, and threads the third cert mode (existingSecret / wildcardSecretName) consistently through Overview, the mermaid diagrams, the listener table, the security section, Certificates, the migration runbook, Known limitations, and Troubleshooting. Verified against source: wildcardSecretName selects existingSecret and mints no Issuer/Certificate, the mode-switch GC and the next-only post-delete ACME-account cleanup both exist, the ClusterIssuers still render from solver, and the child-tenant wildcard hazard (#3296) is real. The next / v1.5 split is correct, v1.4 is untouched, #certificates anchors resolve, and there's no cross-version link leakage. Two small notes inline; the deep controller/Cilium runtime specifics rest on your stated verification (not checkable from the docs repo).

| `publishing.ingressName` | `"tenant-root"` | Ingress controller to use for exposing services. |
| `publishing.externalIPs` | `[]` | List of external IPs used for the specified ingress controller. If not specified, a LoadBalancer service is used by default. |
| `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.ingressNameAdmin` | `""` | Name of a **separate** admin ingress that system components attach their admin routes to (currently only Keycloak) — for example one bound to a private IP and unreachable from the internet. On the Gateway path the value is a namespace (of a `Gateway` named `cozystack`); on the ingress-nginx path it is an `ingressClassName`. The two coincide because Cozystack names each ingressClass after its tenant namespace. Empty (the default) keeps admin endpoints on `publishing.ingressName`. Two caveats: Cozystack does **not** create the admin Gateway — provision it out of band first, or the admin routes attach to nothing; and the admin routes render only when Keycloak's `ingress.adminHost` is set, so setting this key alone is a no-op. |

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.

Undisclosed scope: the PR body enumerates its changes precisely but doesn't mention this new publishing.ingressNameAdmin row — worth a line in the description (or splitting it out), given this PR was itself split from #615 to keep the corrections reviewable. The content reads well and the two caveats (admin Gateway not auto-created; no-op unless Keycloak ingress.adminHost is set) are the right ones to surface.

- **Envoy** runs as a Cilium DaemonSet (`cilium.envoy.enabled=true`) and handles both TLS termination (HTTPS listeners) and TLS passthrough (dedicated per-service listeners for the kubeapiserver and the KubeVirt VM export / CDI upload proxies). `envoy.enabled=true` is the default for fresh Cozystack installations; operators upgrading an existing cluster where the Cilium values were set explicitly should verify the flag is on before flipping `gateway.enabled`.
- **LoadBalancer IP** is allocated by whichever LB mechanism the cluster admin has configured at the platform layer — same shape as ingress-nginx today. Cozystack ships MetalLB installed but does not render any `IPAddressPool` / `L2Advertisement` / `BGPAdvertisement` / `CiliumLoadBalancerIPPool` from the tenant chart. Admins wire up the allocator that fits their environment (MetalLB pool with L2 / BGP, Cilium LB-IPAM with announcer, [robotlb](https://github.com/aenix-io/robotlb) against a Hetzner Robot fleet, or `Service.spec.externalIPs` as a manual pinning mechanism). The tenant API stays mechanism-agnostic — there is no `gatewayIP` field on the Tenant CR. To pin a specific address, the operator pre-creates the LoadBalancer Service with `loadBalancerIP` set, or hands the tenant a reference to a named admin-managed pool.
- **`externalTrafficPolicy`**: the LoadBalancer Service that backs the Gateway is created by Cilium and uses the Kubernetes default (`Cluster`). Source IPs of external clients are therefore NAT'd to the receiving node. This differs from the legacy ingress-nginx `loadBalancer` exposure (`publishing.exposure: loadBalancer`), which sets `externalTrafficPolicy: Local` and constrains the LB IP to nodes hosting ingress pods. Operators who need source IP preservation for Gateway-API traffic must patch the Service themselves or front it with a PROXY-protocol-capable upstream LB.
- **`externalTrafficPolicy`**: the LoadBalancer Service that backs the Gateway is created by Cilium and uses the Kubernetes default (`Cluster`). Source IPs of external clients are therefore NAT'd to the receiving node. The legacy ingress-nginx path behaves the same way whenever `publishing.externalIPs` is set — the usual bare-metal install — because the host ingress Service is then a `ClusterIP` carrying `spec.externalIPs` with `externalTrafficPolicy: Cluster`. It preserves source IPs only when `publishing.externalIPs` is left empty: the Service is a `LoadBalancer` with `externalTrafficPolicy: Local`, which constrains the LB IP to nodes hosting ingress pods. Operators who need source IP preservation for Gateway-API traffic must patch the Service themselves or front it with a PROXY-protocol-capable upstream LB.

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.

Optional completeness: since this PR also introduces exposureClass (whose metallb/cilium/robotlb backends also render the host ingress as type: LoadBalancer), one clause on whether those pool-backed paths preserve source IPs (i.e. set externalTrafficPolicy: Local) would round this out. Purely additive — the paragraph is accurate as written.

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>
@myasnikovdaniil myasnikovdaniil force-pushed the docs/gateway-cert-modes-and-publishing-reference branch from 461e842 to 9b692ad Compare July 15, 2026 08:08
@myasnikovdaniil myasnikovdaniil merged commit 9606de9 into main Jul 15, 2026
2 checks passed
@myasnikovdaniil myasnikovdaniil deleted the docs/gateway-cert-modes-and-publishing-reference branch July 15, 2026 08:09
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.

2 participants