DEV Community

Kaushik Mitra
Kaushik Mitra

Posted on Fully Autonomous

Installing NGINX Ingress Controller in Docker Desktop: A Step-by-Step Guide

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 the docs-service (Swagger/OpenAPI documentation)
  • Requests for /api/add to the add-service
  • Requests for /api/multiply to the multiply-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:

  1. Ingress Resource (kind: Ingress): This is just a set of routing rules written in YAML (e.g., "forward /api/add to service A"). By itself, this YAML file does nothing!
  2. 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 --classic or sudo apt-get install helm
  • Windows: winget install Helm.Helm or choco 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
Enter fullscreen mode Exit fullscreen mode

Update your local Helm repository cache:

helm repo update
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

What did Helm just create?

By default, the Helm chart creates:

  • The ingress-nginx namespace.
  • The NGINX Ingress Controller deployment and replica pods.
  • RBAC roles, cluster roles, service accounts, and admission webhooks.
  • A LoadBalancer service that Docker Desktop automatically binds to ports 80 and 443 on localhost.

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
Enter fullscreen mode Exit fullscreen mode

Check the pods in the ingress-nginx namespace:

kubectl get pods -n ingress-nginx
Enter fullscreen mode Exit fullscreen mode

Output:

NAME                                        READY   STATUS      RESTARTS   AGE
ingress-nginx-controller-7c444fc6cf-djw7s   1/1     Running     0          45s
Enter fullscreen mode Exit fullscreen mode

Now let's check the Service:

kubectl get svc -n ingress-nginx
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Notice: The EXTERNAL-IP is listed as localhost (or an internal bridge IP like 172.18.0.x). This means traffic sent to http://localhost:80 will 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
Enter fullscreen mode Exit fullscreen mode

Breaking Down the Configuration

Let's dissect the important parts of this manifest:

  1. ingressClassName: nginx:
    Tells Kubernetes that this Ingress resource should be managed by the NGINX Ingress Controller we just installed.

  2. nginx.ingress.kubernetes.io/use-regex: "true":
    Enables regular expression matching on the paths.

  3. 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/add and rewrites the incoming request to /$2 before forwarding it to our Node.js pod!
  1. Serving Swagger Documentation at Root (/ via /()(.*)): For our docs-service, we match /()(.*).
    • Group 1 is empty ().
    • Group 2 (.*) captures the rest of the path (e.g. / becomes /, /swagger-ui.css becomes /swagger-ui.css, /openapi.json becomes /openapi.json).
    • NGINX matches the longer paths (/api/add and /api/multiply) first, and falls back to our Swagger UI documentation for all root and documentation requests!

Step 4: Apply the Ingress and Test on Localhost

Let's apply our Ingress manifest:

kubectl apply -f k8s/05-nginx-ingress-controller.yaml
Enter fullscreen mode Exit fullscreen mode

Verify that the Ingress resource was created:

kubectl get ingress -n calculator-app
Enter fullscreen mode Exit fullscreen mode

Output:

NAME                     CLASS   HOSTS   ADDRESS     PORTS   AGE
calculator-api-ingress   nginx   *       localhost   80      10s
Enter fullscreen mode Exit fullscreen mode

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/"
Enter fullscreen mode Exit fullscreen mode

Response:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Enter fullscreen mode Exit fullscreen mode

🌐 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"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "service": "add-service",
  "operation": "addition",
  "a": 10,
  "b": 20,
  "result": 30
}
Enter fullscreen mode Exit fullscreen mode

3. Test Multiplication Service:

curl -s "http://localhost/api/multiply?a=5&b=6"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "service": "multiply-service",
  "operation": "multiplication",
  "a": 5,
  "b": 6,
  "result": 30
}
Enter fullscreen mode Exit fullscreen mode

4. Test Health Checks:

curl -s "http://localhost/api/add/health"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "status": "UP",
  "service": "add-service"
}
Enter fullscreen mode Exit fullscreen mode

All three services are working seamlessly through a single local entry point on standard port 80!


Troubleshooting Common Gotchas

  1. Port 80 Already in Use on Host: If the ingress controller pod stays in Pending or 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
Enter fullscreen mode Exit fullscreen mode
  1. Getting a 404 from NGINX vs Node.js:
    • If the response header contains Server: openresty or Server: nginx and returns HTML 404 Not Found, your Ingress path regex didn't match the URL.
    • If the response returns JSON Cannot GET /api/add, your rewrite-target annotation is missing or misconfigured, and the un-rewritten path reached Express.

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)