Module 4: Networking Ingress And Networkpolicy

5. Hands-on: `Ingress` for `status-api-service`

Description

This is the lesson where, for the first time in this entire guide, you talk to andes-cargo-status-api without kubectl port-forward. You're going to declare status-api-ingress, confirm ingress-nginx — installed and healthy in lesson 4 — detected it, and really curl it against the exact same host and port any external client would use. You're also going to confirm, again with honesty, that /shipments/<id> still has no real data to connect to — Ingress doesn't resolve that limit, and this lesson explains why before you confuse it with a new error.

Connection to the module

This lesson brings together everything lessons 2-4 built: the networking model that makes it possible for ingress-nginx to reach any Pod (lesson 2), the Ingress object explained in theory (lesson 3), and the real controller running on the correct node (lesson 4). It's the first time you see, with real evidence, this module's complete promise: a permanent HTTP door, with no dependence on any open terminal.


Step 1 — The Ingress object: ingress.yaml

# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: status-api-ingress
  namespace: andes-cargo
spec:
  ingressClassName: nginx
  rules:
    - host: andes-cargo.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: status-api-service
                port:
                  number: 80

Five fields deserve explanation, each directly connected to what you already built:

  • ingressClassName: nginx — the exact value lesson 4's manifest created as the IngressClass. Without this field, a cluster with more than one Ingress controller installed (Module 7's case, with the AWS Load Balancer Controller) wouldn't know which of the two this rule belongs to.
  • host: andes-cargo.local — the name a client has to use in its HTTP request's Host header for this rule to apply. It's not a real public domain and requires no DNS configured — this lesson is going to resolve it directly against 127.0.0.1 in Step 3 — it's just the value ingress-nginx uses to decide "this request is for Andes Cargo."
  • path: / with pathType: Prefix — any path starting with / (that is, all of them) routes to the same backend. andes-cargo-status-api doesn't need different routing rules per path yet — /health and /shipments/<id> go to the same place — so a single path: / is enough.
  • backend.service.name: status-api-service — exactly Module 2's Service, with no change. As lesson 3 already confirmed, an Ingress never points directly at Pods, always through an existing Service.
  • port.number: 80 — the same port (not targetPort) you already used with kubectl port-forward in Module 2 — Ingress talks to the Service on its declared port, and it's the Service that decides, underneath, which container targetPort to forward to.
kubectl apply -f ingress.yaml

What to expect:

ingress.networking.k8s.io/status-api-ingress created

Step 2 — Verify: the Ingress and its state

kubectl get ingress -n andes-cargo

What to expect (ADDRESS may take a few seconds to appear; on kind it fills in with localhost, unlike lesson 4's Service's EXTERNAL-IP: <pending> — the mechanism by which ingress-nginx calculates this value is internal to the controller, not relevant for the rest of this lesson):

NAME                 CLASS   HOSTS               ADDRESS     PORTS   AGE
status-api-ingress   nginx   andes-cargo.local   localhost   80      0s

Confirm the rule got registered fully, with the correct backend and its real Endpoints:

kubectl describe ingress status-api-ingress -n andes-cargo

What to expect (the three IPs in the last line are your variable values — they correspond to the Deployment's three Pods; the rest is literal):

Name:             status-api-ingress
Labels:           <none>
Namespace:        andes-cargo
Address:          
Ingress Class:    nginx
Default backend:  <default>
Rules:
  Host               Path  Backends
  ----               ----  --------
  andes-cargo.local  
                     /   status-api-service:80 (10.244.2.2:8080,10.244.1.2:8080,10.244.2.3:8080)
Annotations:         <none>
Events:
  Type    Reason  Age   From                      Message
  ----    ------  ----  ----                      -------
  Normal  Sync    0s    nginx-ingress-controller  Scheduled for sync

The Backends line is this lesson's most important confirmation: ingress-nginx already resolved status-api-service all the way down to the three Pods' real IPs — the same Endpoints mechanism you already know from Module 2 — and has them ready to route traffic to, before you've done any curl yet.


Step 3 — Real curl, no port-forward

This is this lesson's central moment. kind-config.yaml (lesson 4) already forwards your machine's ports 80/443 toward andes-cargo-cluster-control-plane, where ingress-nginx runs. All that's left is telling curl to resolve andes-cargo.local toward your own machine, with no need to edit /etc/hosts:

curl -i -s --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/health

What to expect (literal, executed — Date is your variable value; notice something important: no kubectl port-forward running in any terminal):

HTTP/1.1 200 OK
Date: Fri, 14 Aug 2026 20:38:49 GMT
Content-Type: application/json
Content-Length: 51
Connection: keep-alive

{"service":"andes-cargo-status-api","status":"ok"}

--resolve andes-cargo.local:80:127.0.0.1 tells curl: "when someone asks you for andes-cargo.local on port 80, resolve it toward 127.0.0.1, with no real DNS lookup" — the same result as editing /etc/hosts, with no need for admin permissions or leaving a permanent change on your system. The Host: andes-cargo.local header curl automatically generates from the URL is the piece ingress-nginx reads to decide which rule to apply — exactly the host field you declared in ingress.yaml.

Compare this result against Module 2's lesson 7: the same JSON body, the same 200 code, but this time with no temporary tunnel in the middle — the complete path (your machine → Docker → andes-cargo-cluster-control-planeingress-nginxstatus-api-service → a real Pod) works end to end, permanently, as long as the cluster keeps running.


Step 4 — The honest limit, again: /shipments/<id>

curl -i -s --max-time 30 --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/shipments/4471

What to expect (literal, executed — the request takes several seconds for the same reason Module 3 already documented; this result remains the correct, expected behavior at this point in the guide):

HTTP/1.1 500 INTERNAL SERVER ERROR
Date: Fri, 14 Aug 2026 20:43:24 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 265
Connection: keep-alive

<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>

None of this is an Ingress error — it's the same NameResolutionError/EndpointConnectionError Module 3 diagnosed in depth, now traveling through an additional layer (ingress-nginx) that changes nothing about the cause: andes-cargo-status-api still has no LocalStack running inside the cluster to connect to. Ingress resolves how traffic reaches the Pod — it never resolves where the data that Pod needs comes from.

              WHAT CHANGED BETWEEN MODULE 2 AND THIS LESSON

  Module 2, lesson 7         curl via port-forward     /health → 200
                                                         /shipments/<id> → 500
                                                         (NoCredentialsError)

  Module 3, lesson 4          curl via port-forward     /health → 200
                                                         /shipments/<id> → 500
                                                         (NameResolutionError)

  Module 4, lesson 5          curl via Ingress,          /health → 200
  (this lesson)                NO port-forward            /shipments/<id> → 500
                                                         (same cause: no LocalStack)

  What's still missing, and why it stays missing:
    - LocalStack running inside the cluster itself      ──▶  outside this guide's $0 scope

Step 5 — A look at the "wrong header" error

It's worth seeing, even once, what happens when the Host header does not match any Ingress rule — because the error message is different from any you've seen in this guide, and recognizing it saves diagnostic time:

curl -i -s --max-time 8 http://localhost:80/health

What to expect (literal, executed — without --resolve, curl sends Host: localhost, which no status-api-ingress rule recognizes):

HTTP/1.1 404 Not Found
Date: Fri, 14 Aug 2026 20:38:49 GMT
Content-Type: text/html
Content-Length: 146
Connection: keep-alive

<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

This 404 doesn't come from andes-cargo-status-api — Flask never got the request — it comes from ingress-nginx itself, acting as the "default backend" when no rule matches the received Host header. It's direct proof that Ingress routes by host, not just by port: reaching the correct port 80 isn't enough if the Host header doesn't match any declared rule.


Analogy: the visitor who arrives at the front desk, asking by name

Picking back up the building analogy: --resolve andes-cargo.local:80:127.0.0.1 is the equivalent of telling a taxi "take me to the building's address, and once there, ask for 'Andes Cargo'" — the taxi (the TCP connection) drops you at the correct door thanks to extraPortMappings (lesson 4), but the receptionist (ingress-nginx) still needs to hear the exact name (Host: andes-cargo.local) to know which floor to send you to. Asking for a name that isn't on their list of registered companies (Host: localhost, Step 5) rightfully gets you a "no one's here by that name" — the 404 the receptionist themself generated, with no company in the building even finding out you asked.


Common mistakes

Forgetting --resolve (or not editing /etc/hosts) and getting an unexpected 404, not understanding why (this lesson's most common mistake, already previewed on purpose in Step 5). What happens: someone runs curl http://andes-cargo.local/health directly, with no --resolve and no /etc/hosts entry, and curl fails with a DNS resolution error — it doesn't even reach Step 5's 404, because andes-cargo.local isn't a real domain any public DNS knows. How to spot it: curl's error mentions "could not resolve host." How to fix it: use --resolve andes-cargo.local:80:127.0.0.1 in every command (this lesson's pattern), or add a 127.0.0.1 andes-cargo.local line to /etc/hosts if you prefer not to repeat the flag on every command.

Confusing ingress-nginx's 404 (Step 5) with a 404 from the Flask application (diagnostics). What happens: someone sees a 404 and starts reviewing app.py's routes, assuming Flask doesn't recognize the requested URL. How to spot it: look at the response body — ingress-nginx's 404 has <center>nginx</center> in the HTML; a real Flask 404 would have a completely different shape (the same one you'd see if you requested a route that doesn't exist in app.py, like /does-not-exist). How to fix it: if you see nginx's generic HTML, the problem is Ingress routing (wrong Host header, or no matching rule) — the request never reached andes-cargo-status-api.

Expecting /shipments/<id> to work now that there's Ingress (continuity, already anticipated in this module's lesson 1). What happens: someone, seeing curl work for the first time with no port-forward, expects the second endpoint to also respond with real data. How to spot it: if you're surprised to see the same 500 Modules 2 and 3 left. How to fix it: Ingress resolved the "how traffic arrives" — the root cause behind /shipments/<id> (no LocalStack running in the cluster) didn't change, and isn't going to change in any module of this guide: the data layer stays outside its $0 scope. /health is the complete network signal; /shipments/<id> stays representative through the capstone.


Exercises

Exercise 1 — Reconstruct the whole flow from memory. Without going back to the lesson, list the steps, in order, from declaring ingress.yaml to confirming /health with 200 and no port-forward.

See solution
  1. Declare ingress.yaml with ingressClassName: nginx, host: andes-cargo.local, path: / (Prefix), backend status-api-service:80, and apply it.
  2. Verify with kubectl get ingress -n andes-cargo (the assigned ADDRESS) and kubectl describe ingress (confirm the real Backends).
  3. curl -i --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/health, confirming 200 OK with no kubectl port-forward running.

Exercise 2 — Explain ingress-nginx's 404 to a colleague without using the word "error." A colleague sees a 404 with <center>nginx</center> in the body and asks you what it means. Explain it to them in two or three sentences, focusing on what ingress-nginx checked before rejecting the request.

See solution

A reasonable explanation: "That 404 doesn't come from our application — it comes straight from the receptionist (ingress-nginx), who checked your request's Host header and found no registered rule for that name. It's like walking up to a building's front desk and asking for a company that isn't in their directory — they tell you no one's here by that name, with no need to call any floor."

Exercise 3 — Predict what would happen with a second Ingress, same host, different path. If you added a second Ingress object with the same host: andes-cargo.local, but path: /admin pointing at a different (imaginary, don't create it) Service, would you expect ingress-nginx to combine both rules, or for the second to replace the first? Justify your answer with what you know about the path field.

See solution

It would combine them — ingress-nginx can have multiple rules for the same host, each with a different path, and it evaluates all of them: a request to andes-cargo.local/health would still go to status-api-service (matches path: /), while a request to andes-cargo.local/admin would go to the second Ingress's imaginary Service. This is, in fact, exactly the pattern that makes Ingress useful compared to exposing each Service separately — lesson 3's table anticipated it: several routing rules, all behind the same entry door.


Summary and next step

In this lesson andes-cargo-status-api received, for the first time in this guide, real traffic with no temporary tunnel: you declared status-api-ingress, confirmed ingress-nginx resolved the real Backends, and really curled it against http://andes-cargo.local/health, with a 200 OK response — the exact same body you already saw in Module 2, now served through a permanent HTTP door. /shipments/4471 kept responding 500, for the exact same cause Module 3 documented (no LocalStack in the cluster), and you saw, along the way, what a 404 generated by ingress-nginx itself looks like when the Host header doesn't match any rule.

Before moving on you should be able to: explain every field in ingress.yaml without looking at it; tell an ingress-nginx 404 apart from a real Flask 404 by the response body; and confirm real curl against your cluster, with no kubectl port-forward running.

Next lesson: NetworkPolicy, by default everyone talks to everyone — and why that doesn't last. This module's "incoming traffic" half is resolved. Lesson 6 opens the second half: who, inside the cluster, can talk to status-api-service — and why, today, the answer is "anyone."

Resources

  1. Kubernetes — Ingress — the same reference from lesson 3, now confirmed with real evidence, including the complete rules/pathType syntax.
  2. curl — --resolve — official reference for the flag used in Step 3, the privilege-free alternative to editing /etc/hosts.
  3. ingress-nginx — Custom errors — official documentation for Step 5's "default backend" behavior, when no rule matches.
  4. kubernetes-and-eks-in-production-guide (NIEVA), Module 3, lesson 4 — the complete NameResolutionError/EndpointConnectionError traceback this lesson confirms unchanged.