Today's goal: take a simple Flask app running in Kubernetes and expose it to the outside world using Ingress, instead of relying on NodePort or LoadBalancer services. This post walks through the setup, the errors I hit along the way, and how each one was resolved.
Why Use Ingress Instead of NodePort or LoadBalancer?
Kubernetes gives you a few ways to expose a Service outside the cluster:
- NodePort — opens a fixed port (usually in the 30000–32767 range) on every node. Works, but the port numbers are ugly and it doesn't scale well for multiple applications.
-
LoadBalancer — provisions a cloud load balancer per Service. This has real drawbacks:
-
Cost — each
LoadBalancerService typically means a new cloud load balancer, billed separately. - Cloud lock-in — it depends on your cloud provider's integration (AWS, GCP, Azure, etc.).
- No advanced routing — no path-based or host-based routing, and no built-in SSL termination.
-
Cost — each
Ingress solves this with two separate pieces:
-
Ingress Resource — a YAML manifest where you declare routing rules (e.g., "requests for
example.com/should go to thehello-worldService"). - Ingress Controller — the component that actually watches the API server for Ingress Resources and configures a real proxy (in this case, NGINX) to implement those rules.
You write the rules; the controller enforces them. Without a running Ingress Controller, an Ingress Resource does nothing on its own.
Step 0: Building and Pushing the Docker Image
Before any of the Kubernetes manifests would work, the Flask app first had to be containerized and pushed to a registry, Kubernetes only pulls and runs images, not raw source code. This step happens before everything else, even though it isn't captured in the terminal history below.
app.py — a minimal Flask app:
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
return 'Hello, World!'
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
Dockerfile:
# Use the official image as a parent image
FROM python:3.7-slim
# Set the working directory in the container
WORKDIR /app
# Copy the dependencies file to the working directory
COPY requirements.txt .
# Install any needed packages specified in requirements.txt
RUN pip install -r requirements.txt
# Copy the content of the local src directory to the working directory
COPY . .
# Run app.py when the container launches
CMD ["python", "app.py"]
requirements.txt:
Flask==2.2.5
This image is built and pushed to Docker Hub as oyebobs/ingress-demo:v1 — the same tag referenced in deployment.yaml below. If this step is skipped, kubectl will still accept the Deployment manifest, but the Pod will get stuck in ImagePullBackOff because there's nothing to pull.
Step 1: The Kubernetes Manifests
Three manifests, each with a distinct job.
deployment.yaml — defines the Pod and keeps it running:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-world
labels:
app: hello-world
spec:
replicas: 1
selector:
matchLabels:
app: hello-world
template:
metadata:
labels:
app: hello-world
spec:
containers:
- name: hello-world
image: oyebobs/ingress-demo:v1
ports:
- containerPort: 80
service.yaml — gives the Pod(s) a stable internal address:
apiVersion: v1
kind: Service
metadata:
name: hello-world
spec:
selector:
app: hello-world
ports:
- protocol: TCP
port: 80
targetPort: 80
ingress.yaml — defines the routing rule for external traffic:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-world
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: "example.com"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-world
port:
number: 80
The ingressClassName: nginx field matters: in a cluster with more than one Ingress Controller installed, this tells Kubernetes which controller should handle this particular Ingress Resource.
After applying the Deployment and Service, both looked healthy:
❯ k get po
NAME READY STATUS RESTARTS AGE
curl-test 1/1 Running 1 (44s ago) 3m43s
hello-world-c8b6f8dbd-9t9n6 1/1 Running 1 33m
❯ k get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-world ClusterIP 10.96.74.127 <none> 80/TCP 26m
Step 2: Debugging Internal Connectivity
A curl-test Pod was already running to test connectivity to the hello-world Service. The first attempt failed:
❯ k exec -it curl-test -- curl 10.96.74.127
command terminated with exit code 137
Exit code 137 means the container was OOM-killed (killed for exceeding its memory limit) — this is not a networking error. Running kubectl describe pod curl-test confirmed repeated restarts with Last State: Terminated, Reason: Error.
To check whether the app itself was actually working, I shelled directly into the hello-world Pod:
❯ kubectl exec -it hello-world-c8b6f8dbd-9t9n6 -- curl localhost:80
error: ... exec: "curl": executable file not found in $PATH
The python:3.7-slim base image doesn't include curl — "slim" images intentionally leave out extras like this. As a workaround, I used Python's built-in urllib module instead:
# python3 -c "import urllib.request; print(urllib.request.urlopen('http://localhost:80').read())"
b'Hello, World!'
This confirmed the app itself was working correctly inside its own Pod. The issue was isolated to the curl-test Pod repeatedly getting OOM-killed, not the app or the network.
Fix: rather than continuing to debug a resource-constrained, long-running Pod, I switched to a disposable Pod using kubectl run --rm -it, which creates a temporary Pod, opens a shell, and deletes the Pod automatically on exit:
❯ kubectl run curl-test --image=curlimages/curl -it --rm -- curl -v 10.244.3.2:80
...
< HTTP/1.1 200 OK
< Server: Werkzeug/2.2.3 Python/3.7.17
...
Hello, World!
The same worked against the Service's ClusterIP directly:
❯ kubectl run curl-test --image=curlimages/curl -it --rm -- curl -v 10.96.74.127:80
< HTTP/1.1 200 OK
Hello, World!
Takeaway: internal networking and the app were fine the entire time. The failures were caused by one Pod being killed for memory usage, not by a cluster-wide issue. When only one Pod misbehaves and everything else works, the Pod itself is usually the place to look first.
Step 3: Applying the Ingress — Address Stays Empty
With internal connectivity confirmed, I applied the Ingress resource:
❯ k apply -f ingress.yaml
ingress.networking.k8s.io/hello-world created
❯ k get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
hello-world nginx example.com 80 8s
The ADDRESS column stayed empty. This is expected at this stage: the Ingress Resource exists, but no Ingress Controller is running yet to act on it.
Step 4: Installing the NGINX Ingress Controller
❯ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/aws/deploy.yaml
namespace/ingress-nginx created
...
deployment.apps/ingress-nginx-controller created
...
ingressclass.networking.k8s.io/nginx created
This manifest creates a dedicated namespace, RBAC rules, the controller Deployment, and admission webhooks. The controller Pod came up successfully:
❯ k get po -A | grep nginx
ingress-nginx ingress-nginx-controller-7d65c586d6-kvn9s 1/1 Running 0 7m24s
Its logs confirmed it detected the Ingress resource and reloaded its configuration:
I0723 16:19:18.803001 ... "Found valid IngressClass" ingress="default/hello-world" ingressclass="nginx"
I0723 16:19:19.839235 ... "Backend successfully reloaded"
The controller was running and had picked up the routing rule. The next question was why the Ingress still had no address.
Step 5: LoadBalancer Stuck on <pending>
❯ k get svc -n ingress-nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller LoadBalancer 10.96.101.127 <pending> 80:31003/TCP,443:30437/TCP
EXTERNAL-IP stayed on <pending> indefinitely. This happens because a type: LoadBalancer Service depends on a Cloud Controller Manager to provision an actual external load balancer and assign it an IP. On a self-managed kind/kubeadm cluster with no cloud provider integration, there's nothing available to fulfill that request, so it never resolves.
Fix: change the Service type to NodePort, which doesn't require any cloud integration:
❯ k edit svc ingress-nginx-controller -n ingress-nginx
service/ingress-nginx-controller edited
❯ k get svc -n ingress-nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller NodePort 10.96.101.127 <none> 80:31003/TCP,443:30437/TCP
After deleting and re-applying ingress.yaml, the ADDRESS field populated:
❯ k get ing -w
NAME CLASS HOSTS ADDRESS PORTS AGE
hello-world nginx example.com 10.96.101.127 80 44s
Testing directly from the local machine, however, still failed:
❯ curl 10.96.101.127
curl: (7) Failed to connect to 10.96.101.127 port 80 after 21024 ms: Could not connect to server
Step 6: Investigating the CNI (Calico Installation)
While troubleshooting the connection timeout, I also installed Calico as the cluster's CNI (Container Network Interface) plugin, replacing the default kindnet networking that kind clusters ship with. Calico is a common choice in real-world clusters because of its more detailed network policy support and easier debugging tools.
❯ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.3/manifests/tigera-operator.yaml
namespace/tigera-operator created
...
deployment.apps/tigera-operator created
❯ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.3/manifests/custom-resources.yaml
resource mapping not found for name: "default" ... no matches for kind "Installation" in version "operator.tigera.io/v1"
ensure CRDs are installed first
This error occurs when the Calico custom resources are applied before the tigera-operator has finished installing its Custom Resource Definitions (CRDs) — the CRDs need to exist before Kubernetes can accept objects of that kind. Fix: wait for the operator Pod to reach Running, then re-apply:
❯ kubectl get pods -n tigera-operator -w
tigera-operator-75c7c596d9-tsmbz 1/1 Running 0 2m4s
❯ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.3/manifests/custom-resources.yaml
installation.operator.tigera.io/default created
apiserver.operator.tigera.io/default created
...
Calico's components came up correctly:
❯ k get po -A -n calico-system | grep calico
calico-apiserver calico-apiserver-64cf87458d-qrwnb 1/1 Running 0 3m58s
calico-apiserver calico-apiserver-64cf87458d-tqw44 1/1 Running 0 3m58s
Even with Calico running, curling the Ingress IP from the local machine still timed out:
❯ curl 10.96.101.127
curl: (7) Failed to connect to 10.96.101.127 port 80 after 21007 ms: Could not connect to server
Important clarification: 10.96.101.127 is a ClusterIP, which is only routable inside the cluster's network — it is not reachable from an external machine like a laptop, regardless of which CNI is installed. Swapping the CNI plugin did not (and could not) resolve this particular timeout, since the address itself is not meant to be reachable externally. The correct way to test was to send traffic from inside the cluster, or through the exposed NodePort.
Step 7: Testing From Inside the Cluster
To test correctly, I ran a temporary Pod inside the cluster and issued requests from there, which reflects how real traffic would actually reach the Service:
❯ kubectl run curl --rm -it --image=curlimages/curl -- sh
~ $ curl http://hello-world
Hello, World!
This confirmed internal DNS-based service discovery was working. Next, hitting the Ingress Service's ClusterIP directly by IP, without specifying a hostname:
~ $ curl http://10.96.101.127
<html>
<head><title>404 Not Found</title></head>
<body><center><h1>404 Not Found</h1></center>
<hr><center>nginx</center></body>
</html>
This 404 is expected behavior, not an error. The Ingress rule only matches requests where the Host header is example.com. Curling the raw IP sends no Host header, so NGINX has no matching rule and returns a 404.
To simulate a request with the correct hostname, I used curl's --resolve flag to manually map example.com to the Ingress IP. The first attempt used the wrong syntax:
~ $ curl example.com --resolve example.com:80 http://10.96.101.127
curl: (49) Could not parse CURLOPT_RESOLVE entry 'example.com:80'
--resolve expects the format host:port:ip, not a separate URL. Corrected:
~ $ curl example.com --resolve example.com:80:10.96.101.127
Hello, World!
~ $ curl http://example.com
Hello, World!
This confirmed host-based routing was working as configured: the same Ingress IP serves different responses depending on the requested hostname — the core capability that distinguishes Ingress from a plain NodePort or LoadBalancer setup.
Step 8: Mapping example.com in /etc/hosts
--resolve is convenient for one-off curl requests, but it only applies to that single command — it doesn't help if you want to open http://example.com in a browser or run multiple tools against the same hostname. For that, the standard approach is to edit /etc/hosts directly, which tells the local machine to resolve example.com to the Ingress IP for every request, not just one curl call.
❯ sudo nano /etc/hosts
Inside the file, a line was added mapping the hostname example.com to the Ingress controller's IP address:
10.96.101.127 example.com
After saving the file, any tool on the machine — curl, a browser, etc. — resolves example.com to that IP without needing --resolve or any other flag:
❯ curl http://example.com
Hello, World!
This simulates what a real DNS record would do in production: pointing a domain name at the Ingress controller's IP. In production, you'd create an actual DNS A record for example.com pointing at the Ingress controller's external IP; /etc/hosts is the local-machine equivalent for testing without needing a real, registered domain.
One thing worth noting: this entry only affects DNS resolution on the local machine — it doesn't change how anyone else resolves the domain. It's also worth removing once testing is done, so it doesn't cause confusion later if the Ingress IP changes.
Top comments (0)