Repository: nginx/nginx-gateway-fabric

Issue:
Allow user to configure hostPorts in helm chart
The containerPorts for the nginx container do not specify `hostPorts`.  The reason we don't specify hostPort by default is because it goes against Kubernetes best practices. From this [doc](https://kubernetes.io/docs/concepts/configuration/overview/#services):

> Don't specify a hostPort for a Pod unless it is absolutely necessary. When you bind a Pod to a hostPort, it limits the number of places the Pod can be scheduled, because each <hostIP, hostPort, protocol> combination must be unique. If you don't specify the hostIP and protocol explicitly, Kubernetes will use 0.0.0.0 as the default hostIP and TCP as the default protocol.

> If you only need access to the port for debugging purposes, you can use the [apiserver proxy](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster/#manually-constructing-apiserver-proxy-urls) or [kubectl port-forward](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/).

> If you explicitly need to expose a Pod's port on the node, consider using a [NodePort](https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport) Service before resorting to hostPort.

However, we can make hostPorts configurable in the helm chart and allow users to enable them if they need them. 

This configuration is supported by our ingress controller: https://github.com/nginxinc/kubernetes-ingress/blob/2e0daa1ad6476a5a4801162bcdf6a932534feba0/charts/nginx-ingress/values.yaml#L132


Discussion:

Solution patch:
diff --git a/apis/v1alpha2/nginxproxy_types.go b/apis/v1alpha2/nginxproxy_types.go
index 43b509d0..98f9d24a 100644
--- a/apis/v1alpha2/nginxproxy_types.go
+++ b/apis/v1alpha2/nginxproxy_types.go
@@ -479,6 +479,11 @@ type ContainerSpec struct {
 	// +optional
 	Lifecycle *corev1.Lifecycle `json:"lifecycle,omitempty"`
 
+	// HostPorts are the list of ports to expose on the host.
+	//
+	// +optional
+	HostPorts []HostPort `json:"hostPorts,omitempty"`
+
 	// VolumeMounts describe the mounting of Volumes within a container.
 	//
 	// +optional
@@ -599,12 +604,25 @@ const (
 // automatically if required. The default NodePort range enforced by Kubernetes is 30000-32767.
 type NodePort struct {
 	// Port is the NodePort to expose.
-	// kubebuilder:validation:Minimum=1
-	// kubebuilder:validation:Maximum=65535
+	// +kubebuilder:validation:Minimum=1
+	// +kubebuilder:validation:Maximum=65535
 	Port int32 `json:"port"`
 
 	// ListenerPort is the Gateway listener port that this NodePort maps to.
-	// kubebuilder:validation:Minimum=1
-	// kubebuilder:validation:Maximum=65535
+	// +kubebuilder:validation:Minimum=1
+	// +kubebuilder:validation:Maximum=65535
 	ListenerPort int32 `json:"listenerPort"`
 }
+
+// HostPort exposes an nginx container port on the host.
+type HostPort struct {
+	// Port to expose on the host.
+	// +kubebuilder:validation:Minimum=1
+	// +kubebuilder:validation:Maximum=65535
+	Port int32 `json:"port"`
+
+	// ContainerPort is the port on the nginx container to map to the HostPort.
+	// +kubebuilder:validation:Minimum=1
+	// +kubebuilder:validation:Maximum=65535
+	ContainerPort int32 `json:"containerPort"`
+}
diff --git a/apis/v1alpha2/zz_generated.deepcopy.go b/apis/v1alpha2/zz_generated.deepcopy.go
index 4b0f8bf9..d17e59b0 100644
--- a/apis/v1alpha2/zz_generated.deepcopy.go
+++ b/apis/v1alpha2/zz_generated.deepcopy.go
@@ -34,6 +34,11 @@ func (in *ContainerSpec) DeepCopyInto(out *ContainerSpec) {
 		*out = new(v1.Lifecycle)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.HostPorts != nil {
+		in, out := &in.HostPorts, &out.HostPorts
+		*out = make([]HostPort, len(*in))
+		copy(*out, *in)
+	}
 	if in.VolumeMounts != nil {
 		in, out := &in.VolumeMounts, &out.VolumeMounts
 		*out = make([]v1.VolumeMount, len(*in))
@@ -92,6 +97,21 @@ func (in *DeploymentSpec) DeepCopy() *DeploymentSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HostPort) DeepCopyInto(out *HostPort) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostPort.
+func (in *HostPort) DeepCopy() *HostPort {
+	if in == nil {
+		return nil
+	}
+	out := new(HostPort)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Image) DeepCopyInto(out *Image) {
 	*out = *in
diff --git a/charts/nginx-gateway-fabric/README.md b/charts/nginx-gateway-fabric/README.md
index 656ef9a8..26e932f8 100644
--- a/charts/nginx-gateway-fabric/README.md
+++ b/charts/nginx-gateway-fabric/README.md
@@ -264,9 +264,13 @@ The following table lists the configurable parameters of the NGINX Gateway Fabri
 | `certGenerator.ttlSecondsAfterFinished` | How long to wait after the cert generator job has finished before it is removed by the job controller. | int | `30` |
 | `clusterDomain` | The DNS cluster domain of your Kubernetes cluster. | string | `"cluster.local"` |
 | `gateways` | A list of Gateway objects. View https://gateway-api.sigs.k8s.io/reference/spec/#gateway for full Gateway reference. | list | `[]` |
-| `nginx` | The nginx section contains the configuration for all NGINX data plane deployments installed by the NGINX Gateway Fabric control plane. | object | `{"config":{},"container":{},"debug":false,"image":{"pullPolicy":"Always","repository":"ghcr.io/nginx/nginx-gateway-fabric/nginx","tag":"edge"},"imagePullSecret":"","imagePullSecrets":[],"kind":"deployment","plus":false,"pod":{},"replicas":1,"service":{"externalTrafficPolicy":"Local","loadBalancerClass":"","loadBalancerIP":"","loadBalancerSourceRanges":[],"nodePorts":[],"type":"LoadBalancer"},"usage":{"caSecretName":"","clientSSLSecretName":"","endpoint":"","resolver":"","secretName":"nplus-license","skipVerify":false}}` |
+| `nginx` | The nginx section contains the configuration for all NGINX data plane deployments installed by the NGINX Gateway Fabric control plane. | object | `{"config":{},"container":{"hostPorts":[],"lifecycle":{},"resources":{},"volumeMounts":[]},"debug":false,"image":{"pullPolicy":"Always","repository":"ghcr.io/nginx/nginx-gateway-fabric/nginx","tag":"edge"},"imagePullSecret":"","imagePullSecrets":[],"kind":"deployment","plus":false,"pod":{},"replicas":1,"service":{"externalTrafficPolicy":"Local","loadBalancerClass":"","loadBalancerIP":"","loadBalancerSourceRanges":[],"nodePorts":[],"type":"LoadBalancer"},"usage":{"caSecretName":"","clientSSLSecretName":"","endpoint":"","resolver":"","secretName":"nplus-license","skipVerify":false}}` |
 | `nginx.config` | The configuration for the data plane that is contained in the NginxProxy resource. This is applied globally to all Gateways managed by this instance of NGINX Gateway Fabric. | object | `{}` |
-| `nginx.container` | The container configuration for the NGINX container. This is applied globally to all Gateways managed by this instance of NGINX Gateway Fabric. | object | `{}` |
+| `nginx.container` | The container configuration for the NGINX container. This is applied globally to all Gateways managed by this instance of NGINX Gateway Fabric. | object | `{"hostPorts":[],"lifecycle":{},"resources":{},"volumeMounts":[]}` |
+| `nginx.container.hostPorts` | A list of HostPorts to expose on the host. This configuration allows containers to bind to a specific port on the host node, enabling external network traffic to reach the container directly through the host's IP address and port. Use this option when you need to expose container ports on the host for direct access, such as for debugging, legacy integrations, or when NodePort/LoadBalancer services are not suitable. Note: Using hostPort may have security and scheduling implications, as it ties pods to specific nodes and ports. | list | `[]` |
+| `nginx.container.lifecycle` | The lifecycle of the NGINX container. | object | `{}` |
+| `nginx.container.resources` | The resource requirements of the NGINX container. | object | `{}` |
+| `nginx.container.volumeMounts` | volumeMounts are the additional volume mounts for the NGINX container. | list | `[]` |
 | `nginx.debug` | Enable debugging for NGINX. Uses the nginx-debug binary. The NGINX error log level should be set to debug in the NginxProxy resource. | bool | `false` |
 | `nginx.image.repository` | The NGINX image to use. | string | `"ghcr.io/nginx/nginx-gateway-fabric/nginx"` |
 | `nginx.imagePullSecret` | The name of the secret containing docker registry credentials. Secret must exist in the same namespace as the helm release. The control plane will copy this secret into any namespace where NGINX is deployed. | string | `""` |
diff --git a/charts/nginx-gateway-fabric/templates/_helpers.tpl b/charts/nginx-gateway-fabric/templates/_helpers.tpl
index d0c7c50d..a1d5b686 100644
--- a/charts/nginx-gateway-fabric/templates/_helpers.tpl
+++ b/charts/nginx-gateway-fabric/templates/_helpers.tpl
@@ -106,5 +106,7 @@ Filters out empty fields from a struct.
     {{- $result = merge $result (dict $key $value) }}
   {{- end }}
 {{- end }}
-{{- $result | toYaml }}
+{{- if $result -}}
+{{- $result | toYaml -}}
+{{- end -}}
 {{- end }}
diff --git a/charts/nginx-gateway-fabric/templates/nginxproxy.yaml b/charts/nginx-gateway-fabric/templates/nginxproxy.yaml
index b5e33292..0c4640c5 100644
--- a/charts/nginx-gateway-fabric/templates/nginxproxy.yaml
+++ b/charts/nginx-gateway-fabric/templates/nginxproxy.yaml
@@ -18,8 +18,11 @@ spec:
         {{- toYaml .Values.nginx.pod | nindent 8 }}
       {{- end }}
       container:
-        {{- if .Values.nginx.container }}
-          {{- toYaml .Values.nginx.container | nindent 8 }}
+        {{- with .Values.nginx.container }}
+        {{- $filtered := include "filterEmptyFields" . }}
+        {{- if $filtered }}
+        {{- $filtered | nindent 8 }}
+        {{- end }}
         {{- end }}
         image:
           {{- toYaml .Values.nginx.image | nindent 10 }}
@@ -34,8 +37,11 @@ spec:
         {{- toYaml .Values.nginx.pod | nindent 8 }}
       {{- end }}
       container:
-        {{- if .Values.nginx.container }}
-          {{- toYaml .Values.nginx.container | nindent 8 }}
+        {{- with .Values.nginx.container }}
+        {{- $filtered := include "filterEmptyFields" . }}
+        {{- if $filtered }}
+        {{- $filtered | nindent 8 }}
+        {{- end }}
         {{- end }}
         image:
           {{- toYaml .Values.nginx.image | nindent 10 }}
@@ -46,6 +52,9 @@ spec:
     {{- if .Values.nginx.service }}
     service:
       {{- with .Values.nginx.service }}
-      {{- include "filterEmptyFields" . | nindent 6 }}
+      {{- $filtered := include "filterEmptyFields" . }}
+      {{- if $filtered }}
+      {{- $filtered | nindent 6 }}
+      {{- end }}
       {{- end }}
     {{- end }}
diff --git a/charts/nginx-gateway-fabric/values.schema.json b/charts/nginx-gateway-fabric/values.schema.json
index 15582052..7b2034f7 100644
--- a/charts/nginx-gateway-fabric/values.schema.json
+++ b/charts/nginx-gateway-fabric/values.schema.json
@@ -313,6 +313,53 @@
         },
         "container": {
           "description": "The container configuration for the NGINX container. This is applied globally to all Gateways managed by this\ninstance of NGINX Gateway Fabric.",
+          "properties": {
+            "hostPorts": {
+              "description": "A list of HostPorts to expose on the host.\nThis configuration allows containers to bind to a specific port on the host node,\nenabling external network traffic to reach the container directly through the host's IP address and port.\nUse this option when you need to expose container ports on the host for direct access,\nsuch as for debugging, legacy integrations, or when NodePort/LoadBalancer services are not suitable.\nNote: Using hostPort may have security and scheduling implications, as it ties pods to specific nodes and ports.",
+              "items": {
+                "properties": {
+                  "containerPort": {
+                    "maximum": 65535,
+                    "minimum": 1,
+                    "required": [],
+                    "type": "integer"
+                  },
+                  "port": {
+                    "maximum": 65535,
+                    "minimum": 1,
+                    "required": [],
+                    "type": "integer"
+                  }
+                },
+                "required": [],
+                "type": "object"
+              },
+              "required": [],
+              "title": "hostPorts",
+              "type": "array"
+            },
+            "lifecycle": {
+              "description": "The lifecycle of the NGINX container.",
+              "required": [],
+              "title": "lifecycle",
+              "type": "object"
+            },
+            "resources": {
+              "description": "The resource requirements of the NGINX container.",
+              "required": [],
+              "title": "resources",
+              "type": "object"
+            },
+            "volumeMounts": {
+              "description": "volumeMounts are the additional volume mounts for the NGINX container.",
+              "items": {
+                "required": []
+              },
+              "required": [],
+              "title": "volumeMounts",
+              "type": "array"
+            }
+          },
           "required": [],
           "title": "container",
           "type": "object"
diff --git a/charts/nginx-gateway-fabric/values.yaml b/charts/nginx-gateway-fabric/values.yaml
index e49bcc20..8354e2b9 100644
--- a/charts/nginx-gateway-fabric/values.yaml
+++ b/charts/nginx-gateway-fabric/values.yaml
@@ -400,15 +400,41 @@ nginx:
 
   # -- The container configuration for the NGINX container. This is applied globally to all Gateways managed by this
   # instance of NGINX Gateway Fabric.
-  container: {}
+  container:
+    # @schema
+    # type: array
+    # items:
+    #   type: object
+    #   properties:
+    #     port:
+    #       type: integer
+    #       required: true
+    #       minimum: 1
+    #       maximum: 65535
+    #     containerPort:
+    #       type: integer
+    #       required: true
+    #       minimum: 1
+    #       maximum: 65535
+    # @schema
+    # -- A list of HostPorts to expose on the host.
+    # This configuration allows containers to bind to a specific port on the host node,
+    # enabling external network traffic to reach the container directly through the host's IP address and port.
+    # Use this option when you need to expose container ports on the host for direct access,
+    # such as for debugging, legacy integrations, or when NodePort/LoadBalancer services are not suitable.
+    # Note: Using hostPort may have security and scheduling implications, as it ties pods to specific nodes and ports.
+    hostPorts: []
+    # - port: 80
+    #   containerPort: 80
+
     # -- The resource requirements of the NGINX container.
-    # resources: {}
+    resources: {}
 
     # -- The lifecycle of the NGINX container.
-    # lifecycle: {}
+    lifecycle: {}
 
     # -- volumeMounts are the additional volume mounts for the NGINX container.
-    # volumeMounts: []
+    volumeMounts: []
 
   # -- The service configuration for the NGINX data plane. This is applied globally to all Gateways managed by this
   # instance of NGINX Gateway Fabric.
diff --git a/config/crd/bases/gateway.nginx.org_nginxproxies.yaml b/config/crd/bases/gateway.nginx.org_nginxproxies.yaml
index 9947e34e..ac2ab0e6 100644
--- a/config/crd/bases/gateway.nginx.org_nginxproxies.yaml
+++ b/config/crd/bases/gateway.nginx.org_nginxproxies.yaml
@@ -81,6 +81,31 @@ spec:
                             description: Debug enables debugging for NGINX by using
                               the nginx-debug binary.
                             type: boolean
+                          hostPorts:
+                            description: HostPorts are the list of ports to expose
+                              on the host.
+                            items:
+                              description: HostPort exposes an nginx container port
+                                on the host.
+                              properties:
+                                containerPort:
+                                  description: ContainerPort is the port on the nginx
+                                    container to map to the HostPort.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                                port:
+                                  description: Port to expose on the host.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                              required:
+                              - containerPort
+                              - port
+                              type: object
+                            type: array
                           image:
                             description: Image is the NGINX image to use.
                             properties:
@@ -3467,6 +3492,31 @@ spec:
                             description: Debug enables debugging for NGINX by using
                               the nginx-debug binary.
                             type: boolean
+                          hostPorts:
+                            description: HostPorts are the list of ports to expose
+                              on the host.
+                            items:
+                              description: HostPort exposes an nginx container port
+                                on the host.
+                              properties:
+                                containerPort:
+                                  description: ContainerPort is the port on the nginx
+                                    container to map to the HostPort.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                                port:
+                                  description: Port to expose on the host.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                              required:
+                              - containerPort
+                              - port
+                              type: object
+                            type: array
                           image:
                             description: Image is the NGINX image to use.
                             properties:
@@ -6885,18 +6935,17 @@ spec:
                             automatically if required. The default NodePort range enforced by Kubernetes is 30000-32767.
                           properties:
                             listenerPort:
-                              description: |-
-                                ListenerPort is the Gateway listener port that this NodePort maps to.
-                                kubebuilder:validation:Minimum=1
-                                kubebuilder:validation:Maximum=65535
+                              description: ListenerPort is the Gateway listener port
+                                that this NodePort maps to.
                               format: int32
+                              maximum: 65535
+                              minimum: 1
                               type: integer
                             port:
-                              description: |-
-                                Port is the NodePort to expose.
-                                kubebuilder:validation:Minimum=1
-                                kubebuilder:validation:Maximum=65535
+                              description: Port is the NodePort to expose.
                               format: int32
+                              maximum: 65535
+                              minimum: 1
                               type: integer
                           required:
                           - listenerPort
diff --git a/deploy/crds.yaml b/deploy/crds.yaml
index 7517ce1c..b0322888 100644
--- a/deploy/crds.yaml
+++ b/deploy/crds.yaml
@@ -666,6 +666,31 @@ spec:
                             description: Debug enables debugging for NGINX by using
                               the nginx-debug binary.
                             type: boolean
+                          hostPorts:
+                            description: HostPorts are the list of ports to expose
+                              on the host.
+                            items:
+                              description: HostPort exposes an nginx container port
+                                on the host.
+                              properties:
+                                containerPort:
+                                  description: ContainerPort is the port on the nginx
+                                    container to map to the HostPort.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                                port:
+                                  description: Port to expose on the host.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                              required:
+                              - containerPort
+                              - port
+                              type: object
+                            type: array
                           image:
                             description: Image is the NGINX image to use.
                             properties:
@@ -4052,6 +4077,31 @@ spec:
                             description: Debug enables debugging for NGINX by using
                               the nginx-debug binary.
                             type: boolean
+                          hostPorts:
+                            description: HostPorts are the list of ports to expose
+                              on the host.
+                            items:
+                              description: HostPort exposes an nginx container port
+                                on the host.
+                              properties:
+                                containerPort:
+                                  description: ContainerPort is the port on the nginx
+                                    container to map to the HostPort.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                                port:
+                                  description: Port to expose on the host.
+                                  format: int32
+                                  maximum: 65535
+                                  minimum: 1
+                                  type: integer
+                              required:
+                              - containerPort
+                              - port
+                              type: object
+                            type: array
                           image:
                             description: Image is the NGINX image to use.
                             properties:
@@ -7470,18 +7520,17 @@ spec:
                             automatically if required. The default NodePort range enforced by Kubernetes is 30000-32767.
                           properties:
                             listenerPort:
-                              description: |-
-                                ListenerPort is the Gateway listener port that this NodePort maps to.
-                                kubebuilder:validation:Minimum=1
-                                kubebuilder:validation:Maximum=65535
+                              description: ListenerPort is the Gateway listener port
+                                that this NodePort maps to.
                               format: int32
+                              maximum: 65535
+                              minimum: 1
                               type: integer
                             port:
-                              description: |-
-                                Port is the NodePort to expose.
-                                kubebuilder:validation:Minimum=1
-                                kubebuilder:validation:Maximum=65535
+                              description: Port is the NodePort to expose.
                               format: int32
+                              maximum: 65535
+                              minimum: 1
                               type: integer
                           required:
                           - listenerPort
diff --git a/internal/controller/provisioner/objects.go b/internal/controller/provisioner/objects.go
index 1fc2b43b..5c3b8501 100644
--- a/internal/controller/provisioner/objects.go
+++ b/internal/controller/provisioner/objects.go
@@ -788,6 +788,15 @@ func (p *NginxProvisioner) buildNginxPodTemplateSpec(
 				container.Command = append(container.Command, "/agent/entrypoint.sh")
 				container.Args = append(container.Args, "debug")
 			}
+
+			for _, hostPort := range containerSpec.HostPorts {
+				for i, port := range container.Ports {
+					if hostPort.ContainerPort == port.ContainerPort {
+						container.Ports[i].HostPort = hostPort.Port
+					}
+				}
+			}
+
 			spec.Spec.Containers[0] = container
 		}
 	}



Test patch:
diff --git a/internal/controller/provisioner/objects_test.go b/internal/controller/provisioner/objects_test.go
index 96710f89..632b5c43 100644
--- a/internal/controller/provisioner/objects_test.go
+++ b/internal/controller/provisioner/objects_test.go
@@ -257,6 +257,11 @@ func TestBuildNginxResourceObjects_NginxProxyConfig(t *testing.T) {
 			Name:      "gw",
 			Namespace: "default",
 		},
+		Spec: gatewayv1.GatewaySpec{
+			Listeners: []gatewayv1.Listener{
+				{Name: "port-8443", Port: 8443, Protocol: "tcp"},
+			},
+		},
 	}
 
 	resourceName := "gw-nginx"
@@ -293,6 +298,7 @@ func TestBuildNginxResourceObjects_NginxProxyConfig(t *testing.T) {
 							corev1.ResourceCPU: resource.Quantity{Format: "100m"},
 						},
 					},
+					HostPorts: []ngfAPIv1alpha2.HostPort{{ContainerPort: int32(8443), Port: int32(8443)}},
 				},
 			},
 		},
@@ -344,6 +350,12 @@ func TestBuildNginxResourceObjects_NginxProxyConfig(t *testing.T) {
 	g.Expect(container.ImagePullPolicy).To(Equal(corev1.PullAlways))
 	g.Expect(container.Resources.Limits).To(HaveKey(corev1.ResourceCPU))
 	g.Expect(container.Resources.Limits[corev1.ResourceCPU].Format).To(Equal(resource.Format("100m")))
+
+	g.Expect(container.Ports).To(ContainElement(corev1.ContainerPort{
+		ContainerPort: 8443,
+		Name:          "port-8443",
+		HostPort:      8443,
+	}))
 }
 
 func TestBuildNginxResourceObjects_Plus(t *testing.T) {