In our previous post, we deployed three microservices (add-service, multiply-service, and docs-service) and exposed them using Kubernetes ClusterIP services. While everything was running smoothly, there was one major limitation: ClusterIP services are internal to the cluster. To access them, we had to rely on kubectl port-forward.
In a real-world scenario, you want a single entry point (usually port 80 or 443) that acts as an intelligent reverse proxy, routing:
- Requests for
/to thedocs-service(Swagger/OpenAPI documentation) - Requests for
/api/addto theadd-service - Requests for
/api/multiplyto themultiply-service
If you've read my previous article on how DDEV and Lando use Traefik for routing, this concept will feel very familiar. In Kubernetes, this reverse-proxy layer is handled by an Ingress Controller.
In this guide, we'll install the official NGINX Ingress Controller in Docker Desktop and configure path-based routing with URL rewrites.
📦 Source Code: All the manifests, Dockerfiles, and architecture diagrams used in this post are available on GitHub: MitraKumar/kube-prac-calculator-app.
Ingress Resource vs. Ingress Controller
Before we run any commands, let's clarify an important distinction that trips up many beginners:
-
Ingress Resource (
kind: Ingress): This is just a set of routing rules written in YAML (e.g., "forward/api/addto service A"). By itself, this YAML file does nothing! - Ingress Controller: This is the actual software engine (like NGINX, Traefik, or HAProxy) running inside your cluster. It watches for Ingress resources, reads the rules, and automatically configures its internal reverse-proxy routing tables.
To route traffic, you must have an Ingress Controller running.
Step 1: Install NGINX Ingress Controller using Helm
While you can install the Ingress Controller using static YAML manifests, the recommended and industry-standard way to manage it is using Helm (the package manager for Kubernetes).
Using Helm makes upgrading, modifying configurations, and uninstalling as simple as running a single command.
💡 Don't have Helm installed? You can install Helm in seconds:
- macOS:
brew install helm- Ubuntu/Debian:
sudo snap install helm --classicorsudo apt-get install helm- Windows:
winget install Helm.Helmorchoco install kubernetes-helm
1. Add the Ingress-NGINX Helm Repository
First, add the official NGINX Ingress repository to Helm:
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
Update your local Helm repository cache:
helm repo update
2. Install the Helm Chart
Now, install the chart into its own dedicated namespace (ingress-nginx):
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace
Output:
NAME: ingress-nginx
LAST DEPLOYED: Sat Oct 3 20:08:22 2026
NAMESPACE: ingress-nginx
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
The ingress-nginx controller has been installed.
It may take a few minutes for the LoadBalancer IP to be available.
What did Helm just create?
By default, the Helm chart creates:
- The
ingress-nginxnamespace. - The NGINX Ingress Controller deployment and replica pods.
- RBAC roles, cluster roles, service accounts, and admission webhooks.
- A
LoadBalancerservice that Docker Desktop automatically binds to ports 80 and 443 onlocalhost.
Step 2: Verify the Controller is Running
Let's monitor the deployment and wait until the controller pod is in the Running state:
kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=120s
Check the pods in the ingress-nginx namespace:
kubectl get pods -n ingress-nginx
Output:
NAME READY STATUS RESTARTS AGE
ingress-nginx-controller-7c444fc6cf-djw7s 1/1 Running 0 45s
Now let's check the Service:
kubectl get svc -n ingress-nginx
Output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
ingress-nginx-controller LoadBalancer 10.96.231.158 localhost 80:30905/TCP,443:30913/TCP 60s
ingress-nginx-controller-admission ClusterIP 10.96.233.245 <none> 443/TCP 60s
Notice: The
EXTERNAL-IPis listed aslocalhost(or an internal bridge IP like172.18.0.x). This means traffic sent tohttp://localhost:80will now be routed directly to the NGINX Ingress Controller!
Step 3: Create the Ingress Rules (05-nginx-ingress-controller.yaml)
Now that the controller is listening, let's give it rules to route our calculator microservices.
Here is k8s/05-nginx-ingress-controller.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: calculator-api-ingress
namespace: calculator-app
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /api/add(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: add-service-cluster-ip
port:
number: 3000
- path: /api/multiply(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: multiply-service-cluster-ip
port:
number: 3000
- path: /()(.*)
pathType: ImplementationSpecific
backend:
service:
name: docs-service-cluster-ip
port:
number: 3000
Breaking Down the Configuration
Let's dissect the important parts of this manifest:
ingressClassName: nginx:
Tells Kubernetes that this Ingress resource should be managed by the NGINX Ingress Controller we just installed.nginx.ingress.kubernetes.io/use-regex: "true":
Enables regular expression matching on the paths.Path Matching & Rewrite Target (
/$2):
Our microservices are simple Express apps that listen for requests on the root path/(e.g.,/?a=10&b=20).
However, our public URL is/api/add?a=10&b=20.
If NGINX forwarded /api/add directly to the container, Express would return a 404 Not Found because it doesn't have an /api/add route!
Here is where regex capture groups save the day:
-
/api/add(/|$)(.*)matches the addition route. Group 1 is(/|$), Group 2 is(.*). -
/api/multiply(/|$)(.*)matches the multiplication route. -
rewrite-target: /$2: NGINX strips the prefix/api/addand rewrites the incoming request to/$2before forwarding it to our Node.js pod!
-
Serving Swagger Documentation at Root (
/via/()(.*)): For ourdocs-service, we match/()(.*).- Group 1 is empty
(). - Group 2
(.*)captures the rest of the path (e.g./becomes/,/swagger-ui.cssbecomes/swagger-ui.css,/openapi.jsonbecomes/openapi.json). - NGINX matches the longer paths (
/api/addand/api/multiply) first, and falls back to our Swagger UI documentation for all root and documentation requests!
- Group 1 is empty
Step 4: Apply the Ingress and Test on Localhost
Let's apply our Ingress manifest:
kubectl apply -f k8s/05-nginx-ingress-controller.yaml
Verify that the Ingress resource was created:
kubectl get ingress -n calculator-app
Output:
NAME CLASS HOSTS ADDRESS PORTS AGE
calculator-api-ingress nginx * localhost 80 10s
Testing the Endpoints!
No port-forwarding needed anymore! We can query http://localhost directly on port 80:
1. Test Swagger UI Documentation (Root Route /):
curl -sI "http://localhost/"
Response:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
🌐 You can now open
http://localhost/directly in your browser to view and interact with the Swagger UI documentation!
2. Test Addition Service:
curl -s "http://localhost/api/add?a=10&b=20"
Response:
{
"service": "add-service",
"operation": "addition",
"a": 10,
"b": 20,
"result": 30
}
3. Test Multiplication Service:
curl -s "http://localhost/api/multiply?a=5&b=6"
Response:
{
"service": "multiply-service",
"operation": "multiplication",
"a": 5,
"b": 6,
"result": 30
}
4. Test Health Checks:
curl -s "http://localhost/api/add/health"
Response:
{
"status": "UP",
"service": "add-service"
}
All three services are working seamlessly through a single local entry point on standard port 80!
Troubleshooting Common Gotchas
-
Port 80 Already in Use on Host:
If the ingress controller pod stays in
Pendingor crashes with port binding errors, another application on your machine (Apache, system Nginx, IIS, Skype, or local dev tools) is already listening on port 80. Stop that service or inspect who is using port 80 with:
sudo lsof -i :80
-
Getting a 404 from NGINX vs Node.js:
- If the response header contains
Server: openrestyorServer: nginxand returns HTML404 Not Found, your Ingress path regex didn't match the URL. - If the response returns JSON
Cannot GET /api/add, yourrewrite-targetannotation is missing or misconfigured, and the un-rewritten path reached Express.
- If the response header contains
Conclusion & Next Step
We now have a complete, professional local Kubernetes environment running on Docker Desktop:
- Multi-service backend in its own Namespace.
- Stable internal routing via ClusterIP Services.
- External path routing and URL rewrites via NGINX Ingress Controller.
Our next big step is taking this to the cloud. But before we can deploy to Google Kubernetes Engine (GKE), GKE needs a way to download our container images.
In the next post, we will set up Google Cloud Artifact Registry, configure Docker authentication, and push our microservice images to GCP!
Leave a comment below if you ran into any regex or routing issues!
Top comments (0)