Hi!

It’s been a little long coming, but well, it’s summer and I needed a break 😎

That being said, vacations are over so here we are. We’re still talking about the Gateway API, and at this point, still using Cilium’s which is perfect for our use case. This time I wanted to look at scenario where the Gateway is shared, and managed by someone else.

So the Agenda:

  1. Scenario for a shared Gateway
  2. Configuring HTTPRoutes and Gateways accross different namespaces
  3. Managing Secrets, also in different namespaces
  4. Conclusion

1. Scenario for a shared Gatewaay

1.1. thoughs on the need to share a gateway

Up till now, we managed the gateway in a distributed approach. We had a namespace, containing manifests for apps and, living in the same namespace, a Gateway and an Httproute to expose the application.

And that works fine. But what about the segregation of duty?

Remember the schema for role based organization with the gateway API:

illustration1

Considering that the namespace is often a security boundary, with rbac applied, letting all the objects related to the exposure in the same place may be not appropriate for our security requirements.

Regarding the gateway classes, those are not namespaced resources anyway, so the control of access, while still to be taken into accound, is not addressed through namespace separation.


df@df-2404lts:~$ k api-resources | grep gatewayclass
ciliumgatewayclassconfigs           cgcc                                cilium.io/v2alpha1                   true         CiliumGatewayClassConfig
gatewayclasses                      gc                                  gateway.networking.k8s.io/v1         false        GatewayClass

The gateway is a namespaced resource however, and it makes sense to think about a way to put it in another namespace, managed by another team.


df@df-2404lts:~$ k api-resources | grep gateways
gateways                            gtw                                 gateway.networking.k8s.io/v1         true         Gateway

We could imagine an organization as below:

  • namespace managed by cluster operators containing the shared gateway, exposed on the external network.
  • namespaces managed by apps owners, containing the Httproutes and all the other apps related resources.

Also, as a reminder, we can add annotations to the underlying service of a gateway, to make the said gateway an internal gateway:


spec:
  gatewayClassName: cilium
  infrastructure:
    annotations:
      "service.beta.kubernetes.io/azure-load-balancer-internal": "true"

Taking that into account, it should be possible to apply a policy that force a gateway created in an app namespace to add the spec.infrastructure.annotations parameters that we want. But that’s a story for another time 😝

Last, regarding TLS configuration, which is absolutely mandatory in a real world scenario, we saw that the certificate is referenced at the gateway level.


spec:
  gatewayClassName: cilium
  listeners:
  - protocol: HTTPS
    port: 443
    name: gundam-tls-gw
    tls:
      mode: Terminate
      certificateRefs:
      - name: apptekewscloud
        kind: Secret
        namespace: gundam
        group: ""

We will not dig in secrets engines or sexy operators in this article. For now, we will just consider a scenario where, as for the gateway, the secrets associated to the TLS configurations are managed in another namespace.

1.2. A word on the lab environment

Before getting into the heart of the topic, a little bit about the lab environment.

For this article, the lab used relies ont on an AKS server but on a local kubeadm single node cluster. I used Vagrant with a vagrant file as below:


K8SSERVER_COUNT = 1
IMAGE = "bento/ubuntu-24.04"

Vagrant.configure("2") do |config|

  (1..K8SSERVER_COUNT).each do |i|
    config.vm.define "k8sserver#{i}" do |k8sservers|
      k8sservers.vm.box = IMAGE
      k8sservers.vm.hostname = "k8scilium#{i}"
      k8sservers.vm.network  :private_network, ip: "192.168.56.#{i+16}"
      k8sservers.vm.provision "shell", privileged: true,  path: "scripts/k8s_install.sh"
      config.vm.provision "file", source: "scripts/k8s_postconfig.sh", destination: "/home/vagrant/k8s_postconfig.sh"
      config.vm.provision "file", source: "yamlconfig/ciliumgw.yaml", destination: "/home/vagrant/yamlconfig/ciliumgw.yaml"
      config.vm.provision "file", source: "yamlconfig/demoapp.yaml", destination: "/home/vagrant/yamlconfig/demoapp.yaml"
      config.vm.provision "file", source: "seccompprofiles/audit.json", destination: "/var/lib/kubelet/seccomp/profiles"
      config.vm.provision "file", source: "seccompprofiles/violation.json", destination: "/var/lib/kubelet/seccomp/profiles"
      config.vm.provision "file", source: "seccompprofiles/fine-grained.json", destination: "/var/lib/kubelet/seccomp/profiles"
    end
  end
end

For those who would like to have a look on the differents scripts, everyting is availble on a dedicated github repository.

Additionaly, I created a dedicated gateway class.


apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: custom-cilium-gateway-class
spec:
  controllerName: io.cilium/gateway-controller
  description: A GatewayClass with NodePort services.
  parametersRef:
    group: cilium.io
    kind: CiliumGatewayClassConfig
    name: gateway-class-config
    namespace: ciliumgateway

With a Cilium CRD to force the Gateway underlying service to be of the NodePort type.


apiVersion: cilium.io/v2alpha1
kind: CiliumGatewayClassConfig
metadata:
  name: gateway-class-config
  namespace: ciliumgateway
spec:
  service:
    type: NodePort


Now we need an app. We can use the same basis as in our previous article on Httproute, which gives us some pod managed by a deployment, and the associated service, plus a confgmap associated to the pod configuration.


df@df-2404lts:~$ k get all -n gundam 
NAME                            READY   STATUS    RESTARTS   AGE
pod/barbatos-5798674fd7-kcsck   1/1     Running   0          3m16s
pod/barbatos-5798674fd7-nwvpt   1/1     Running   0          3m16s
pod/barbatos-5798674fd7-s6twk   1/1     Running   0          3m16s

NAME                  TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/barbatossvc   ClusterIP   100.65.120.78   <none>        8090/TCP   3m16s

NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/barbatos   3/3     3            3           3m16s

NAME                                  DESIRED   CURRENT   READY   AGE
replicaset.apps/barbatos-5798674fd7   3         3         3       3m16s

df@df-2404lts:~$ k describe configmaps -n gundam index-html-barbatos 
Name:         index-html-barbatos
Namespace:    gundam
Labels:       <none>
Annotations:  <none>

Data
====
index.html:
----
<html>
<h1>Welcome to Barbatos App 2</h1>
</br>
<h2>This is a demo to illustrate Gateway API </h2>
<img src="https://imgs.search.brave.com/AfLpq5XX4tK6TtxoWLDbd_665qDaxYgPAJKBCxVl5aE/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly9tLm1l/ZGlhLWFtYXpvbi5j/b20vaW1hZ2VzL0kv/NjFyYkhlLTdCbEwu/anBn" />
</html



BinaryData
====

Events:  <none>

If we want to expose this app, we define a gateway as below:


apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: gundam-gw
  namespace: gundam
spec:
  gatewayClassName: custom-cilium-gateway-class
  listeners:
  - protocol: HTTP
    port: 80
    name: gundam-gw

And an httproute like this:


apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: gundam-httproute
  namespace: gundam
spec:
  parentRefs:
  - name: gundam-gw
  rules:
  - backendRefs:
    - name: barbatossvc
      port: 8090
      kind: Service
    matches:
    - path:
        type: PathPrefix
        value: /barbatos
    filters:
    - type: URLRewrite
      urlRewrite:
        path:
          type: ReplacePrefixMatch
          replacePrefixMatch: /

Because we are on a single kubeadm cluster, we used, as said earlier, a gateway-class-config, specific to Cilium Gateway API to change the underlying service from a LoadBalancer to a NodePort


df@df-2404lts:~$ k get service -n ciliumgateway 
NAME                            TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)         AGE
cilium-gateway-cilium-gateway   NodePort   100.65.91.189   <none>        443:30246/TCP   27h

If the gateway is accessible externaly, we should get something like this with a curl command:


df@df-2404lts:~$ curl -i -X GET http://192.168.56.17:30977/barbatos
HTTP/1.1 200 OK
server: envoy
date: Wed, 20 Aug 2025 11:56:59 GMT
content-type: text/html
content-length: 289
last-modified: Wed, 20 Aug 2025 10:02:42 GMT
etag: "68a59d42-121"
accept-ranges: bytes
x-envoy-upstream-service-time: 0

<html>
<h1>Welcome to Barbatos App 2</h1>
</br>
<h2>This is a demo to illustrate Gateway API </h2>
<img src="https://imgs.search.brave.com/AfLpq5XX4tK6TtxoWLDbd_665qDaxYgPAJKBCxVl5aE/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly9tLm1l/ZGlhLWFtYXpvbi5j/b20vaW1hZ2VzL0kv/NjFyYkhlLTdCbEwu/anBn" />
</html

However, at this point, we still host the gateway in the same namespace as the Httproute and the application, which is not our target.

Ok, let’s go into to topic and get started with the shared gateway part.

2. Configuring HTTPRoutes and Gateways accross different namespaces

2.1. Looking at some of the Gateway properties

To achieve our goal, we need to dig in the API specifications.

We can find in the spec.listeners description a reference to the allowedRoutes which contains a namespace field. Its default value, as displayed in the below table is Same which means that by default, it configures the gateway to accept Httproutes from the same namespace.

Field Description Default
namespaces Namespaces indicates namespaces from which Routes may be attached to this Listener. This is restricted to the namespace of this Gateway by default. { from:Same }

Following the links in the documentation, we find that the accepted values.

Field Description
All Routes/ListenerSets in all namespaces may be attached to this Gateway.
Selector Only Routes/ListenerSets in namespaces selected by the selector may be attached to this Gateway.
Same Only Routes/ListenerSets in the same namespace as the Gateway may be attached to this Gateway.
None No Routes/ListenerSets may be attached to this Gateway.

Ok, let’s create a new gateway API, this time, in another namespace.


apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: shared-gw
  namespace: ciliumgateway
spec:
  gatewayClassName: custom-cilium-gateway-class
  listeners:
  - protocol: HTTP
    port: 80
    name: shared-gw

We should have the following objects after applying the manifests.


df@df-2404lts:~$ k get service -n ciliumgateway cilium-gateway-
cilium-gateway-cilium-gateway  cilium-gateway-shared-gw       
df@df-2404lts:~$ k get service -n ciliumgateway cilium-gateway-shared-gw 
NAME                       TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
cilium-gateway-shared-gw   NodePort   100.65.216.238   <none>        80:32708/TCP   37s

If we update the httproute created earlier with the reference to this new gateway.


spec:
  parentRefs:
  - name: shared-gw
    namespace: ciliumgateway

We get the following status.


Status:
  Parents:
    Conditions:
      Last Transition Time:  2025-08-20T17:04:32Z
      Message:               HTTPRoute is not allowed to attach to this Gateway due to namespace restrictions
      Observed Generation:   1
      Reason:                NotAllowedByListeners
      Status:                False
      Type:                  Accepted
      Last Transition Time:  2025-08-20T17:04:32Z
      Message:               Service reference is valid
      Observed Generation:   1
      Reason:                ResolvedRefs
      Status:                True
      Type:                  ResolvedRefs
    Controller Name:         io.cilium/gateway-controller
    Parent Ref:
      Group:      gateway.networking.k8s.io
      Kind:       Gateway
      Name:       shared-gw
      Namespace:  ciliumgateway
Events:           <none>

Which makes sense because we did not specify yet the appropriate parameters to make our gateway a shared gateway. Let’s do this. As found earlier, we need to add the following in the listener configuration.


    allowedRoutes:
      namespaces:
        from: All

And this time, the status shows us that the gateway accepted the Httproute.


Status:
  Parents:
    Conditions:
      Last Transition Time:  2025-08-20T17:16:32Z
      Message:               Accepted HTTPRoute
      Observed Generation:   1
      Reason:                Accepted
      Status:                True
      Type:                  Accepted
      Last Transition Time:  2025-08-20T17:04:32Z
      Message:               Service reference is valid
      Observed Generation:   1
      Reason:                ResolvedRefs
      Status:                True
      Type:                  ResolvedRefs
    Controller Name:         io.cilium/gateway-controller
    Parent Ref:
      Group:      gateway.networking.k8s.io
      Kind:       Gateway
      Name:       shared-gw
      Namespace:  ciliumgateway
Events:           <none>

And it’s reflected by the result of a curl command on the node.


df@df-2404lts:~$ curl http://192.168.56.17:32708/barbatos
<html>
<h1>Welcome to Barbatos App 2</h1>
</br>
<h2>This is a demo to illustrate Gateway API </h2>
<img src="https://imgs.search.brave.com/AfLpq5XX4tK6TtxoWLDbd_665qDaxYgPAJKBCxVl5aE/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly9tLm1l/ZGlhLWFtYXpvbi5j/b20vaW1hZ2VzL0kv/NjFyYkhlLTdCbEwu/anBn" />
</html

However, allowing Httproutes from any namespace is a bit much. We should be able to use the Selector instead to reference the namespaces that we want to specifically allow.

Checking the gundam namespace, we can get the default label.


df@df-2404lts:~$ k get namespace --show-labels gundam 
NAME     STATUS   AGE     LABELS
gundam   Active   7h33m   kubernetes.io/metadata.name=gundam

We can change the gateway listener to this.


    allowedRoutes:
      namespaces:
        #from: All
        from: Selector
        selector:
          matchLabels:
            kubernetes.io/metadata.name: "gundam"

The status is not changed, and the curl command result is still the same. But you can test on your own if you don’t believe me ^^

Now we will add another app in a new namespace called demoapp.


df@df-2404lts:~$ k get all -n demoapp 
NAME                          READY   STATUS    RESTARTS        AGE
pod/demoapp-d67cd5b89-bjxst   1/1     Running   3 (7h41m ago)   30h
pod/demoapp-d67cd5b89-hjwp9   1/1     Running   3 (7h41m ago)   30h
pod/demoapp-d67cd5b89-qmdp2   1/1     Running   3 (7h41m ago)   30h

NAME                 TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
service/demoappsvc   ClusterIP   100.65.98.75   <none>        8080/TCP   30h

NAME                      READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/demoapp   3/3     3            3           30h

NAME                                DESIRED   CURRENT   READY   AGE
replicaset.apps/demoapp-d67cd5b89   3         3         3       30h

with its associated Httproute.


apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: demo-httproute
  namespace: demoapp
spec:
  parentRefs:
  - name: shared-gw
    namespace: ciliumgateway
  hostnames:
  - "k8scilium1"
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: demoappsvc
      port: 8080

The status shows that the gateway does not accept the route due to selector restrictions.


Conditions:
      Last Transition Time:  2025-08-20T17:28:44Z
      Message:               HTTPRoute is not allowed to attach to this Gateway due to namespace selector restrictions

It’s interesting to note that, while the selector field can be used to provide more than one label, it works as an AND base operator. So to add Httproutes from the demoapp namespace in the allowed list, setting the listener as below does not work.


        from: Selector
        selector:
          matchLabels:
            kubernetes.io/metadata.name: "gundam"
            kubernetes.io/metadata.name: "demoapp"
        

Here, it’s because it’s not possible to have the kubernetes.io/metadata.name with both the values gundam and demoapp. We could add another labels though, like this.

But since matchLabels acts as an AND, it would not work, neither for the gundam namespace, nor the demoapp namespace.

Instead, the equivalent of an OR expression in the selector section looks like this


        from: Selector
        selector:
          #matchLabels:
          #  kubernetes.io/metadata.name: "gundam"
          matchExpressions:
          - { key: kubernetes.io/metadata.name, operator: In, values: [gundam,demoapp] }

Ok, that’s it, the gateway is shared between either all namespaces, or between a set of namespaces, depending of the from section in the spec.listeners[].allowedRoutes.namespaces.

Let’s have a look at the TLS part now.

3. Managing Secrets, also in different namespaces

With the Gateway APi, TLS management is done on the Gateway level. The listeners[].protocol have to be set to HTTPS and the listeners[].port is usually to 443.

Also, the tls section contains the information for the certificate. A gateway configured with a listener with tls look like this, with its associated secret.


apiVersion: v1
data:
  tls.crt: LS0t===Truncated===tLQo=
  tls.key: LS0t===Truncated===tLQo=
kind: Secret
metadata:
  name: k8scilium1tls
  namespace: ciliumgateway
type: kubernetes.io/tls
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: shared-gw-tls
  namespace: ciliumgateway
spec:
  gatewayClassName: custom-cilium-gateway-class
  listeners:
  - protocol: HTTPS
    port: 443
    name: cilium-gateway
    from: Selector
    selector:
      matchExpressions:
      - { key: kubernetes.io/metadata.name, operator: In, values: [gundam,demoapp] }
    tls:
      certificateRefs:
      - kind: Secret
        group: ""
        name: k8scilium1tls
---

When the certificate is referenced like this, it implies that the secret is in the same namespace as the gateway, as stated in the documentation extract below.

Field Description Default
group Group is the group of the referent. For example, “gateway.networking.k8s.io”. When unspecified or empty string, core API group is inferred.  
kind Kind is kind of the referent. For example “Secret”. Secret
name Name is the name of the referent.  
namespace Namespace is the namespace of the referenced object. When unspecified, the local namespace is inferred. Note that when a namespace different than the local namespace is specified, a ReferenceGrant object is required in the referent namespace to allow that namespace’s owner to accept the reference. See the ReferenceGrant documentation for details.  

To perform further tests, we’ll create a new namespace and recreat the certificate in this namespace.


apiVersion: v1
kind: Namespace
metadata:
  name: certificates
spec: {}
status: {}
---
apiVersion: v1
data:
  tls.crt: LS0t===Truncated===tLQo=
  tls.key: LS0t===Truncated===tLQo=
kind: Secret
metadata:
  creationTimestamp: null
  name: k8scilium1tls
  namespace: certificates
type: kubernetes.io/tls

And add the namespace field to the gateway listener.


    tls:
      certificateRefs:
      - kind: Secret
        group: ""
        name: k8scilium1tls
        namespace: certificates

Checking the status, we can see that the reference to the certificate is not allowed.


Status:
  Conditions:
    Last Transition Time:  2025-08-21T12:26:43Z
    Message:               Gateway successfully scheduled
    Observed Generation:   1
    Reason:                Accepted
    Status:                True
    Type:                  Accepted
    Last Transition Time:  2025-08-21T12:26:43Z
    Message:               Gateway successfully reconciled
    Observed Generation:   1
    Reason:                Programmed
    Status:                True
    Type:                  Programmed
  Listeners:
    Attached Routes:  0
    Conditions:
      Last Transition Time:  2025-08-21T12:26:43Z
      Message:               Invalid CertificateRef
      Reason:                Invalid
      Status:                False
      Type:                  Programmed
      Last Transition Time:  2025-08-21T12:26:43Z
      Message:               Listener Accepted
      Observed Generation:   1
      Reason:                Accepted
      Status:                True
      Type:                  Accepted
      Last Transition Time:  2025-08-21T12:26:43Z
      Message:               CertificateRef is not permitted
      Reason:                RefNotPermitted
      Status:                False
      Type:                  ResolvedRefs
    Name:                    cilium-gateway
    Supported Kinds:
      Group:  gateway.networking.k8s.io
      Kind:   HTTPRoute

Which is as expected, because we did not use a ReferenceGrant as specified in the documentation. This object is used to specify which objects can refer to another object. In our case, we want to allow the gateway to reference secrets located in another namespace, namely, the certificates namespace, so we get this.


apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
  name: allow-ciliumgateway-to-ref-secrets
  namespace: certificates
spec:
  from:
  - group: gateway.networking.k8s.io
    kind: Gateway
    namespace: ciliumgateway
  to:
  - group: ""
    kind: Secret  

Let’s curl the cluster on its node port to validate that everything works.


df@df-2404lts:~$ curl -k -i -X GET https://192.168.56.17:31487/barbatos
HTTP/1.1 200 OK
server: envoy
date: Thu, 21 Aug 2025 12:43:08 GMT
content-type: text/html
content-length: 289
last-modified: Wed, 20 Aug 2025 10:02:42 GMT
etag: "68a59d42-121"
accept-ranges: bytes
x-envoy-upstream-service-time: 0

<html>
<h1>Welcome to Barbatos App 2</h1>
</br>
<h2>This is a demo to illustrate Gateway API </h2>
<img src="https://imgs.search.brave.com/AfLpq5XX4tK6TtxoWLDbd_665qDaxYgPAJKBCxVl5aE/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly9tLm1l/ZGlhLWFtYXpvbi5j/b20vaW1hZ2VzL0kv/NjFyYkhlLTdCbEwu/anBn" />
</html

df@df-2404lts:~$ curl -k -i -X GET https://k8scilium1:31487/
HTTP/1.1 200 OK
server: envoy
date: Thu, 21 Aug 2025 12:43:50 GMT
content-type: text/html
content-length: 615
last-modified: Wed, 13 Aug 2025 14:33:41 GMT
etag: "689ca245-267"
accept-ranges: bytes
x-envoy-upstream-service-time: 0

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

The attentive readers may have noticed that the curl is done once on the IP and the other time on the hostname. That’s because the Httproute in one case reference a value for the hostname, while it does not in the other case. Lastly, all of the hostname may work with the gateway because we did not specified any value in its configuration.

Ok that’s all for today ^^

4. Summary

Going further on the Gateway API usage, this time we explored a shared gateway scenario and what it implies. We saw that the gateway can be configured quite easily to be more selective on which namespace the Httproutes should origin from. And last we saw the ReferenceGrant that is used to list which objects, and from where, can reference another object in another namespace.

Hope it was useful. IT was for me anyway 😸