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.

What Each Access Method Targets Comparison of port-forward binding to one pod against an ingress routing to a service's current endpoints. What Each Access Method Targets kubectl port-forward ingress controller resolves svc to one pod routes to live endpoints dies when that pod goes survives rollouts one port per service one host per service no TLS, no hostnames TLS and .localhost names
The forward pins one pod; the ingress follows the service as pods come and go.

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

  1. 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
  1. Route each browser-facing service by hostname with an Ingress. *.localhost resolves 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 } }
  1. Add a trusted certificate from mkcert as the local-tls secret, 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
  1. 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'])
Which Access Method for Which Job Table matching common local access needs to port-forward, Tilt-managed forward or ingress. Which Access Method for Which Job Need Use Why browser app, many services ingress stable hostnames OAuth redirect, cookies ingress real domain and TLS psql to the database Tilt forward re-attaches on restart one-off debug port port-forward pod-specific
Browser traffic goes through the ingress; ad-hoc tools use forwards.

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

  1. Document one URL per service in the README, pointing at the ingress hostnames, so developers stop inventing forwards with conflicting local ports.

  2. 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.

  3. 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.

Request Path Through the Local Ingress Flow of a browser request from a .localhost hostname through the kind port mapping and ingress to the current pods. Request Path Through the Local Ingress browser https://api.localhost kind port map 127.0.0.1:443 ingress-nginx Host rule Service endpoints ready pods
Every hop is long-lived, so rollouts behind the Service are invisible to the browser.

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.1 port 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.