DEV Community

Cover image for Setting Up Ingress for a Flask App
Adeoye Malumi
Adeoye Malumi

Posted on

Setting Up Ingress for a Flask App

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 LoadBalancer Service 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.

Ingress solves this with two separate pieces:

  1. Ingress Resource — a YAML manifest where you declare routing rules (e.g., "requests for example.com/ should go to the hello-world Service").
  2. 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)
Enter fullscreen mode Exit fullscreen mode

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

requirements.txt:

Flask==2.2.5
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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)