Port-Forwarding vs Ingress for Local Kubernetes
The browser tab that was working a minute ago now shows ERR_CONNECTION_REFUSED, and the terminal running kubectl port-forward svc/api 8080:8080 has printed error: lost connection to pod and exited. Every redeploy breaks the forward, and developers keep a pile of terminals open to re-run them. Port-forwarding is the fastest way to reach one service in a local cluster, but it is a debugging tool, not an access layer. This page compares it with an ingress controller and shows when to use each, under local Kubernetes development.
Both approaches are legitimate. The mistake is using port-forwards as the permanent way a browser-facing, multi-service application is reached, which is where the fragility comes from.
Diagnostic
Reproduce the drop by restarting the target while a forward is active:
#!/usr/bin/env bash
set -euo pipefail
kubectl --context kind-shop port-forward svc/api 8080:8080 >/tmp/pf.log 2>&1 &
pf=$!
sleep 2
curl -sS -o /dev/null -w 'before restart: %{http_code}\n' http://localhost:8080/health
kubectl --context kind-shop rollout restart deployment/api >/dev/null
kubectl --context kind-shop rollout status deployment/api --timeout=90s >/dev/null
curl -sS -o /dev/null -w 'after restart: %{http_code}\n' http://localhost:8080/health || true
tail -2 /tmp/pf.log
kill "$pf" 2>/dev/null || true
Expected bad output:
before restart: 200
curl: (7) Failed to connect to localhost port 8080 after 0 ms: Couldn't connect to server
after restart: 000
E0918 10:21:44.118 portforward.go:413] an error occurred forwarding 8080 -> 8080: error forwarding port 8080 to pod 1d2c..., uid : network namespace for sandbox "1d2c..." is closed
error: lost connection to pod
The forward targeted a specific pod. When that pod was replaced, the forward had nothing to connect to and exited.
Root cause
kubectl port-forward svc/api looks like it forwards to a Service, but it does not: kubectl resolves the Service to one backing pod at start and opens a tunnel through the API server and kubelet to that pod's network namespace. Load balancing, readiness and endpoint changes are ignored. When the pod is deleted — by a rollout, a crash, an eviction or Tilt's live update falling back to a rebuild — the tunnel's target disappears and kubectl exits. Tunnelling through the API server also adds latency and limits throughput, which is noticeable for large responses and WebSocket-heavy frontends. An ingress controller, by contrast, runs in the cluster, watches Service endpoints continuously and routes each request to whichever pods are ready, so its address stays valid across any number of restarts.
Port-forwarding remains the right tool for things that should not be exposed permanently: a database for a one-off query, a metrics endpoint, a debug port on a single pod. For those, its pod-specific nature is a feature — you know exactly which instance you are talking to.
Resolution
- Install an ingress controller using the host ports mapped in the kind config (80 and 443 on
127.0.0.1):
#!/usr/bin/env bash
set -euo pipefail
kubectl --context kind-shop apply -f https://kind.sigs.k8s.io/examples/ingress/deploy-ingress-nginx.yaml
kubectl --context kind-shop -n ingress-nginx wait --for=condition=ready pod \
--selector=app.kubernetes.io/component=controller --timeout=120s
- Route each browser-facing service by hostname with an Ingress.
*.localhostresolves to loopback in browsers without any DNS setup:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 50m
spec:
ingressClassName: nginx
tls:
- hosts: [app.localhost, api.localhost]
secretName: local-tls
rules:
- host: api.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: api, port: { number: 8080 } }
- host: app.localhost
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: web, port: { number: 5173 } }
- Add a trusted certificate from mkcert as the
local-tlssecret, so HTTPS works without warnings:
#!/usr/bin/env bash
set -euo pipefail
mkcert -cert-file /tmp/local.pem -key-file /tmp/local-key.pem localhost "*.localhost"
kubectl --context kind-shop create secret tls local-tls --cert=/tmp/local.pem --key=/tmp/local-key.pem \
--dry-run=client -o yaml | kubectl --context kind-shop apply -f -
rm -f /tmp/local.pem /tmp/local-key.pem
- Keep port-forwards for debugging only, and when you need a resilient one, let Tilt manage it — Tilt re-establishes forwards automatically when pods are replaced:
k8s_resource('postgres', port_forwards='15432:5432', labels=['infra'])
Expected output
$ curl -sS -o /dev/null -w '%{http_code}\n' https://api.localhost/health
200
$ kubectl --context kind-shop rollout restart deployment/api && kubectl --context kind-shop rollout status deployment/api >/dev/null
$ curl -sS -o /dev/null -w '%{http_code}\n' https://api.localhost/health
200
The same URL answers before and after the rollout, over trusted HTTPS, with no terminal kept open for a tunnel.
The ingress also brings the local environment closer to production in ways that matter for application code. Requests now arrive with X-Forwarded-For, X-Forwarded-Proto: https and a real Host header, so code that builds absolute URLs, enforces HTTPS redirects or trusts proxy headers runs the same branches as in production. Cookies set with Secure and a parent domain work across app.localhost and api.localhost. Request size limits and timeouts are applied by the ingress exactly where production applies them, which is why the example sets proxy-body-size explicitly — a file upload that fails in production because of the default 1 MB nginx limit now fails locally first.
With port-forwards none of that happens: requests reach the pod directly with Host: localhost:8080, over plain HTTP, and every proxy-dependent code path is skipped. Keeping forwards for debugging and the ingress for everyday browsing gets the benefits of both without the fragility.
Prevention
Document one URL per service in the README, pointing at the ingress hostnames, so developers stop inventing forwards with conflicting local ports.
Smoke-test ingress routes in CI after installing the chart into the CI cluster; a missing or misspelled host rule fails the pull request instead of a developer's afternoon.
Reserve a local port range for Tilt-managed forwards (for example 15000–15999) so they never collide with services developers run directly on the host.
Platform caveats
Apple Silicon (ARM64): the kind ingress-nginx manifest pulls multi-arch images, so no emulation is involved.
macOS (Docker Desktop): ports 80 and 443 mapped by kind conflict with any other local proxy, such as a Compose Traefik instance. Stop one, or map the kind cluster to 8080 and 8443 and include the port in URLs.
WSL2:
127.0.0.1port mappings in the kind config are reachable from Windows browsers through WSL's localhost forwarding. Port-forwards started inside WSL bind to WSL's loopback and are also forwarded, but drop when the WSL VM idles; the ingress does not.
Rollback
Remove the Ingress and controller and return to forwards; nothing in the application depends on them:
#!/usr/bin/env bash
set -euo pipefail
kubectl --context kind-shop delete ingress shop --ignore-not-found
kubectl --context kind-shop delete -f https://kind.sigs.k8s.io/examples/ingress/deploy-ingress-nginx.yaml --ignore-not-found
Frequently Asked Questions
Why does kubectl port-forward svc/... die when the pod restarts?
kubectl resolves the Service to one pod when it starts and tunnels to that pod only. When the pod is replaced, the tunnel's target disappears. Re-run the command, let Tilt manage it, or use an ingress for anything long-lived.
Is an ingress controller heavy for a laptop?
ingress-nginx uses roughly 100–150 MB of memory. That is small compared with a typical application and removes the need for several forwarding processes.
Can I use a LoadBalancer Service instead of an ingress in kind?
kind has no built-in load balancer; you would need cloud-provider-kind or MetalLB. An ingress with host rules is simpler locally and matches how most production clusters expose HTTP services.
Do port-forwards work for WebSockets and gRPC?
Yes, both work through a port-forward, but long-lived connections are cut when the pod restarts, and throughput is limited by the API server tunnel. An ingress handles them without those limits.