Skip to content
Published on

IngressClass Deep Dive — Running Multiple Ingress Controllers in One Cluster

Authors

Introduction: When One Controller Is Not Enough

In a small cluster, a single Ingress Controller is enough. But as operational scale grows, situations naturally arise where one cluster needs more than one controller. For example, when you want to separate external traffic exposed to the internet from internal traffic exposed only on the corporate network; or when you want an independent entry point per team; or when you are gradually moving from ingress-nginx to a different controller.

The key mechanism here is IngressClass. IngressClass decides "which controller handles this Ingress resource." This article covers the structure of the IngressClass resource, default classes, multi-controller scenarios, controller scope settings, conflict prevention, and operational and cost considerations in depth from a practitioner's view.

The IngressClass Resource and the Default Class

IngressClass is a cluster-scoped resource that states, via spec.controller, which controller implementation owns this class.

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: nginx-external
spec:
  controller: k8s.io/ingress-nginx
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: nginx-internal
spec:
  controller: k8s.io/ingress-nginx

Note that both IngressClasses use the same controller string (k8s.io/ingress-nginx). The controller value only indicates "what kind of controller implementation this is"; the classes themselves are distinguished by name (nginx-external, nginx-internal). That is, you can run multiple instances of the same kind of controller, each owning a different class.

An Ingress resource declares which class it belongs to via spec.ingressClassName.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
spec:
  ingressClassName: nginx-external
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 80

The Default Class

Adding the following annotation to an IngressClass makes it the default class.

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: nginx-external
  annotations:
    ingressclass.kubernetes.io/is-default-class: "true"
spec:
  controller: k8s.io/ingress-nginx

An Ingress that does not specify ingressClassName is automatically assigned to the default class. The caveat is that there must be only one default class in the cluster. Marking more than one as default makes it ambiguous which applies, so in a multi-controller environment it is safest to explicitly set ingressClassName on every Ingress.

Annotation vs spec.ingressClassName

In the past, the class was specified with the following annotation.

metadata:
  annotations:
    kubernetes.io/ingress.class: nginx

But this approach is deprecated, and the current recommended approach is the spec.ingressClassName field. The differences are as follows.

Aspectkubernetes.io/ingress.class (annotation)spec.ingressClassName (field)
Statusdeprecatedrecommended
Validationfree string, no validationcan validate IngressClass existence
Default classnot supportedsupported via is-default-class
Multi-controllerworks but non-standardstandard mechanism

In new environments, do not use the annotation; use spec.ingressClassName. That said, some controllers still recognize the annotation for backward compatibility, so the two approaches may coexist during migration. In that case, which approach takes precedence depends on the controller implementation, so standardizing on one reduces confusion.

Controller Scope — Which Ingresses to Watch

When running multiple instances of the same kind of controller, you must narrow each instance's scope so it processes only the Ingresses of the class it owns. ingress-nginx provides several arguments for this.

--ingress-class / class Settings

Assign an owned class to each controller instance. An example of ingress-nginx Helm values follows.

controller:
  ingressClassResource:
    name: nginx-external
    enabled: true
    default: false
    controllerValue: "k8s.io/ingress-nginx"
  ingressClass: nginx-external
  electionID: ingress-nginx-external-leader

Deploy the internal controller with separate values.

controller:
  ingressClassResource:
    name: nginx-internal
    enabled: true
    default: false
    controllerValue: "k8s.io/ingress-nginx"
  ingressClass: nginx-internal
  electionID: ingress-nginx-internal-leader

It is important to give a different electionID per instance here. Sharing the same leader-election key can cause the two instances to interfere with each other.

watch-namespace

You can also restrict a controller to watch Ingresses in a specific namespace only.

controller:
  scope:
    enabled: true
    namespace: team-a

This way, the team A controller processes only Ingresses in the team-a namespace. Combining class-based separation with namespace-based separation enables finer isolation.

Multi-Controller Scenarios

Here are three scenarios that frequently appear in practice.

1. Internal/External Separation

The most common pattern. The external controller uses a LoadBalancer exposed to the internet, while the internal controller uses a corporate-network-only LoadBalancer (or internal LB).

                Internet                       Internal Network
                   │                                  │
                   ▼                                  ▼
        ┌──────────────────┐              ┌──────────────────┐
        │  External LB      │              │  Internal LB      │
        └────────┬─────────┘              └────────┬─────────┘
                 │                                  │
                 ▼                                  ▼
   ┌──────────────────────┐          ┌──────────────────────┐
   │ ingress-nginx         │          │ ingress-nginx         │
   │ class: nginx-external │          │ class: nginx-internal │
   └──────────┬───────────┘          └──────────┬───────────┘
              │                                  │
              ▼                                  ▼
       public services                   admin/internal services

Separate external Ingresses with ingressClassName: nginx-external and internal ones with ingressClassName: nginx-internal. This reduces accidents like exposing a sensitive service such as an admin page to the internet by mistake.

2. Per-Team Separation

A pattern of giving each team an independent controller in a multi-tenant environment. Each team uses only its class (e.g., team-a-nginx, team-b-nginx), and the controller is scoped to watch only the team namespace. This reduces the impact of one team's traffic surge or misconfiguration on other teams.

3. Migration

While moving from ingress-nginx to a different controller (e.g., Traefik, Envoy-based), the two controllers briefly coexist. Keep existing services on the existing class, move only new services or some traffic to the new class, then transition gradually. In the 2026 context, even when moving to Gateway API, the Ingress controller and the Gateway controller run in parallel for a while.

Conflict Prevention

The core risk of multi-controller is that two controllers process the same Ingress simultaneously, or expose the same host redundantly. Prevent it with these principles.

  1. Set ingressClassName on every Ingress: Not relying on the default class removes the ambiguity of "which controller will claim it."
  2. At most one default class: Do not mark more than one as default.
  3. Separate electionID: Use a different leader-election key for each instance of the same kind of controller.
  4. No host duplication: Ensure two classes do not expose the same host. During migration in particular, keep hosts separate until cutover.
  5. Narrow the scope: If needed, clearly restrict a controller's jurisdiction with watch-namespace.

Cost and Operational Considerations

Multi-controller gives isolation and safety but brings cost and operational complexity.

  • Load balancer cost: Putting a LoadBalancer-type Service on each controller adds a cloud load balancer. You can cut cost by using an internal LB for the internal controller, or by branching on host behind a single LB.
  • Resource overhead: Each controller consumes its own pods, memory, and CPU. More controllers means more things to manage.
  • Operational consistency: If version, annotation syntax, and tuning parameters differ per controller, operations get harder. Where possible, splitting the same kind of controller into multiple classes is better for operational consistency.
  • Observability: Collect metrics/logs per controller separately, and make it clear via dashboards which class handles which traffic.

Also, given that ingress-nginx is trending toward maintenance mode as of 2026, it is reasonable to also consider Gateway API's multi-Gateway model when designing new multiple entry points. Because Gateway API distinguishes multiple Gateways by GatewayClass, it can express multiple entry points in a standard way.

Hands-On YAML

A full example creating two classes, internal and external, and attaching an Ingress to each.

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: nginx-external
spec:
  controller: k8s.io/ingress-nginx
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: nginx-internal
spec:
  controller: k8s.io/ingress-nginx
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: public-api
  namespace: shop
spec:
  ingressClassName: nginx-external
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: admin-panel
  namespace: shop
spec:
  ingressClassName: nginx-internal
  rules:
    - host: admin.internal.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: admin-service
                port:
                  number: 80

Verification commands after creation:

kubectl get ingressclass
kubectl get ingress -A -o custom-columns=NAME:.metadata.name,CLASS:.spec.ingressClassName,HOSTS:.spec.rules[*].host

Pitfalls and Troubleshooting

1. An Ingress is claimed by no controller. Either ingressClassName differs from the name of an actually existing IngressClass, or there is no default class, leaving class-unspecified Ingresses unhandled. Cross-check the IngressClass list against the Ingress's class setting.

kubectl get ingressclass
kubectl describe ingress admin-panel

2. Two controllers process the same Ingress. This can happen when controllers of the same kind do not narrow their class scope properly. Check that each controller's ingressClass setting and electionID are separated.

3. Behavior is unpredictable because there are two default classes. Check whether is-default-class: "true" is on two IngressClasses, and keep only one.

kubectl get ingressclass -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.ingressclass\.kubernetes\.io/is-default-class}{"\n"}{end}'

4. An internal service got exposed to the internet. This happens when an internal Ingress was wrongly given the external class. Re-check the ingressClassName of sensitive services, and consider enforcing it with policy (e.g., OPA/Kyverno).

5. Traffic mixes during migration. If the old class and new class expose the same host simultaneously, traffic gets split. Keep them separate by host or weight until cutover, then transition at once.

Closing

The starting point of multi-controller operation is understanding IngressClass precisely. Distinguish the controller kind with spec.controller, the instance with the IngressClass name, and specify which class handles it with the Ingress's spec.ingressClassName. Keep only one default class, separate same-kind controllers by ingressClass and electionID, and narrow jurisdiction with watch-namespace when needed.

Across the representative scenarios — internal/external separation, per-team isolation, and migration — multi-controller is a powerful tool. But it comes at the cost of load balancer expense and operational complexity, so it is important to keep to the conflict-prevention principles and to have observability. In the 2026 context, given the ingress-nginx maintenance trend, I recommend also considering Gateway API's multi-Gateway model for new multiple-entry-point designs.

References