When running a Kubernetes cluster that hosts both internet-facing and internal services, the cleanest approach is to split incoming traffic at the ingress controller level. You can do this by deploying two separate Traefik instances: one that handles public ingress, and one that handles private ingress.
This guide walks through that setup. You will deploy two Helm releases of Traefik with different IngressClasses, then route traffic to public and internal services accordingly.
Two independent Traefik instances run in the cluster:
traefik-public processes only Ingress resources with ingressClassName: publictraefik-private processes only Ingress resources with ingressClassName: privateEach instance creates its own LoadBalancer service with different parameters:
This lets you use a single cluster for both external and internal applications without mixing their entry points.
Before you begin, make sure you have:
Both Traefik instances are deployed from the same Helm chart, but with separate values files.
Create a file named traefik-public-values.yaml with the following content:
fullnameOverride: traefik-public
ingressClass:
enabled: true
isDefaultClass: false
name: public
service:
enabled: true
type: LoadBalancer
providers:
kubernetesCRD:
enabled: true
allowCrossNamespace: false
ingressClass: public
kubernetesIngress:
enabled: true
ingressClass: public
publishedService:
enabled: true
This configures the public Traefik instance to handle ingress resources of the public class and create a standard LoadBalancer with an external IP.
Key settings to note:
ingressClass.name: public sets the class name that public Ingress resources will referenceproviders.kubernetesCRD.ingressClass and providers.kubernetesIngress.ingressClass restrict Traefik to resources of its own classpublishedService.enabled: true enables correct publishing of the ingress service addressCreate a file named traefik-private-values.yaml with the following content:
fullnameOverride: traefik-private
ingressClass:
enabled: true
isDefaultClass: false
name: private
service:
enabled: true
type: LoadBalancer
annotations:
k8s.hostman.com/attached-loadbalancer-no-external-ip: "true"
providers:
kubernetesCRD:
enabled: true
allowCrossNamespace: false
ingressClass: private
kubernetesIngress:
enabled: true
ingressClass: private
publishedService:
enabled: true
This configures the private Traefik instance to handle ingress resources of the private class and create an internal load balancer with no public IP.
The key parameter here is k8s.hostman.com/attached-loadbalancer-no-external-ip: "true", which tells the platform to provision an internal load balancer without a public IP address.
Add the Traefik Helm repository:
helm repo add traefik https://helm.traefik.io/traefik
helm repo update
Deploy the public instance:
helm install traefik-public traefik/traefik \
-n traefik-public --create-namespace \
-f traefik-public-values.yaml
Deploy the private instance:
helm install traefik-public traefik/traefik \
-n traefik-public --create-namespace \
-f traefik-public-values.yaml
Once both are installed, check that the LoadBalancer services have been created:
kubectl get svc -n traefik-public
kubectl get svc -n traefik-private
Load balancer provisioning can take up to 10 minutes. The
EXTERNAL-IPcolumn may showpendingwhile the resource is being created.
Expected behavior differs between the two:
traefik-public should receive an external IP addresstraefik-private will not receive an external IP, since it uses an internal load balancerYou can also check the status of both load balancers from the Hostman control panel. Wait until both have finished provisioning before proceeding.
At this stage, also confirm that both ingress classes are registered in the cluster:
kubectl get ingressclass
The output should list both public and private.
Once both Traefik instances are running, routing is as simple as setting the appropriate ingressClassName in each Ingress manifest.
For a public service:
spec:
ingressClassName: public
For a private service:
spec:
ingressClassName: private
All traffic is then routed through the corresponding ingress controller automatically.
The following manifests demonstrate the full setup. The example includes:
service1 and service2service3IngressClassesCreate config-map.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: service-config
namespace: ingress-example
data:
service1.html: |
<html>
<head><title>Service 1</title></head>
<body><h1>Welcome to Service 1!</h1></body>
</html>
service2.html: |
<html>
<head><title>Service 2</title></head>
<body><h1>Welcome to Service 2!</h1></body>
</html>
service3.html: |
<html>
<head><title>Service 3</title></head>
<body><h1>Welcome to Service 3!</h1></body>
</html>
Create service1-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: service1
namespace: ingress-example
spec:
replicas: 2
selector:
matchLabels:
app: service1
template:
metadata:
labels:
app: service1
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
volumeMounts:
- name: config-volume
mountPath: /usr/share/nginx/html
volumes:
- name: config-volume
configMap:
name: service-config
items:
- key: service1.html
path: service1.html
---
apiVersion: v1
kind: Service
metadata:
name: service1
namespace: ingress-example
spec:
selector:
app: service1
ports:
- protocol: TCP
port: 80
targetPort: 80
Create service2-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: service2
namespace: ingress-example
spec:
replicas: 2
selector:
matchLabels:
app: service2
template:
metadata:
labels:
app: service2
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
volumeMounts:
- name: config-volume
mountPath: /usr/share/nginx/html
volumes:
- name: config-volume
configMap:
name: service-config
items:
- key: service2.html
path: service2.html
---
apiVersion: v1
kind: Service
metadata:
name: service2
namespace: ingress-example
spec:
selector:
app: service2
ports:
- protocol: TCP
port: 80
targetPort: 80
Create service3-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: service3
namespace: ingress-example
spec:
replicas: 2
selector:
matchLabels:
app: service3
template:
metadata:
labels:
app: service3
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
volumeMounts:
- name: config-volume
mountPath: /usr/share/nginx/html
volumes:
- name: config-volume
configMap:
name: service-config
items:
- key: service3.html
path: index.html
---
apiVersion: v1
kind: Service
metadata:
name: service3
namespace: ingress-example
spec:
selector:
app: service3
ports:
- protocol: TCP
port: 80
targetPort: 80
Create ingress-public.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-public-ingress
namespace: ingress-example
spec:
ingressClassName: public
rules:
- host: ingress1.example.com
http:
paths:
- path: /service1
pathType: Prefix
backend:
service:
name: service1
port:
number: 80
- path: /service2
pathType: Prefix
backend:
service:
name: service2
port:
number: 80
Note that spec.ingressClassName is set to public, which means this Ingress will be handled by the public ingress controller.
Create ingress-private.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-private-ingress
namespace: ingress-example
spec:
ingressClassName: private
rules:
- host: ingress2.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: service3
port:
number: 80
Here spec.ingressClassName is set to private, so this Ingress will be handled by the private ingress controller.
Create the ingress-example namespace:
kubectl create namespace ingress-example
Apply all manifests:
kubectl apply -f config-map.yaml
kubectl apply -f service1-deployment.yaml
kubectl apply -f service2-deployment.yaml
kubectl apply -f service3-deployment.yaml
kubectl apply -f ingress-public.yaml
kubectl apply -f ingress-private.yaml
Verify that deployments, services, and ingress resources were created:
kubectl get deploy,svc,ingress -n ingress-example
You should see three services, three deployments, and two ingress resources. If anything is missing, the corresponding manifest either was not applied or contains an error.
With this setup:
ingress1.example.com/service1... are routed to service1ingress1.example.com/service2... are routed to service2ingress2.example.com/ are routed to service3, accessible only from within the private network via the internal Traefik load balancerBefore sending test requests, check that each ingress has the correct class assigned:
kubectl describe ingress example-public-ingress -n ingress-example
kubectl describe ingress example-private-ingress -n ingress-example
Confirm that example-public-ingress shows Ingress Class: public and example-private-ingress shows Ingress Class: private. If the class does not match, Traefik will not process that resource.
The example uses
ingress1.example.comandingress2.example.comas placeholder hostnames. You can use them withcurl --resolvewithout configuring real DNS. If you want to open the public ingress in a browser, replaceingress1.example.comwith a real domain and configure a DNS record pointing to the public load balancer IP.
Once the public load balancer has an external IP, test the routes:
curl http://ingress1.example.com/service1.html \
--resolve ingress1.example.com:80:PUBLIC_LB_EXTERNAL_IP
curl http://ingress1.example.com/service2.html \
--resolve ingress1.example.com:80:PUBLIC_LB_EXTERNAL_IP
Where PUBLIC_LB_EXTERNAL_IP is the external IP assigned to the public load balancer.
If you receive a 404, double-check that you are using the hostname ingress1.example.com and the paths /service1 or /service2.
To test the private service, run the following from a machine inside the same private network as the cluster:
curl http://ingress2.example.com/ \
--resolve ingress2.example.com:80:PRIVATE_LB_IP
Where PRIVATE_LB_IP is the private IP of the internal load balancer.
You should receive an HTML page served by service3.
If the private route does not respond, confirm that the request is coming from within the private network. External internet traffic cannot reach this ingress, even if the cluster resources are configured correctly.