Overview and What You Will Learn
Without an Ingress Controller, exposing services in Kubernetes means creating a separate cloud load balancer for every single service — expensive, unmanageable, and impossible to maintain at scale. This lab walks you through deploying and configuring the NGINX Ingress Controller on a production Kubernetes cluster, setting up path-based and host-based routing, enabling SSL termination, and applying rate limiting to protect your services.
By the end of this guide you will be able to:
- Deploy the NGINX Ingress Controller using Helm on a production cluster
- Configure host-based and path-based routing rules for multiple services
- Terminate SSL using Kubernetes TLS secrets and cert-manager
- Apply rate limiting and connection throttling annotations to protect APIs
- Troubleshoot common Ingress misconfiguration issues with real kubectl commands
Why This Matters in Production
Consider Razorpay running dozens of internal microservices — payments, dashboard, settlements, webhooks, and reporting. Without Ingress, each service needs its own cloud load balancer IP, multiplying infrastructure costs and SSL certificate management overhead. A single NGINX Ingress Controller handles all external traffic through one IP, routes it to the correct service based on hostname or path, terminates SSL centrally, and applies security policies uniformly.
At the scale of platforms like PhonePe or CRED, a misconfigured Ingress can mean routing payment traffic to the wrong backend, or leaving an internal admin service accidentally exposed to the public internet. Getting Ingress right is a fundamental production skill.
Core Principles
How traffic flows through an NGINX Ingress Controller:
External User Request (https://api.razorpay.in/v1/payments) │ ▼Cloud Load Balancer (single external IP — provisioned by cloud provider) │ ▼NGINX Ingress Controller Pod (reads Ingress rules from API server) │ ├─► Host: api.razorpay.in + Path: /v1/payments │ └─► payments-service:4000 │ ├─► Host: api.razorpay.in + Path: /v1/dashboard │ └─► dashboard-service:8080 │ └─► Host: admin.razorpay.in └─► admin-service:9000 (internal only)Key components every engineer must understand:
- Ingress Controller — the actual NGINX pod that processes routing rules. Must be deployed separately — Kubernetes does not ship with one by default.
- Ingress Resource — the YAML object that defines the routing rules. Useless without a running controller to read and apply them.
- IngressClass — tells Kubernetes which controller should process a given Ingress resource. Critical when running multiple controllers in one cluster.
- Annotations — NGINX-specific configuration applied per Ingress resource to enable SSL redirect, rate limiting, custom timeouts, and more.
Detailed Step-by-Step Practical Lab
Step 1 — Deploy NGINX Ingress Controller Using Helm
# Add the official ingress-nginx Helm repositoryhelm repo add ingress-nginx https://kubernetes.github.io/ingress-nginxhelm repo update # Deploy NGINX Ingress Controller into its own namespacehelm install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx \ --create-namespace \ --set controller.replicaCount=2 \ --set controller.nodeSelector."kubernetes\.io/os"=linux \ --set controller.service.externalTrafficPolicy=Local # Verify the controller pods are runningkubectl get pods -n ingress-nginx # Get the external IP assigned by the cloud load balancerkubectl get service ingress-nginx-controller -n ingress-nginx# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)# ingress-nginx-controller LoadBalancer 10.96.12.100 34.93.45.201 80:31080/TCP,443:31443/TCPREMEMBER THIS**Remember:** The `EXTERNAL-IP` shown above is what your DNS records must point to. All your domain names (api.razorpay.in, dashboard.razorpay.in) should have A records pointing to this single IP.
Step 2 — Create a Basic Ingress Resource with Path-Based Routing
# ingress-basic.yaml — route traffic to two backend servicesapiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: razorpay-api-ingress namespace: production annotations: nginx.ingress.kubernetes.io/ssl-redirect: "true" # Force HTTPS nginx.ingress.kubernetes.io/use-regex: "true" # Enable regex paths nginx.ingress.kubernetes.io/proxy-body-size: "10m" # Max request body size nginx.ingress.kubernetes.io/proxy-connect-timeout: "30" # Connection timeout seconds nginx.ingress.kubernetes.io/proxy-read-timeout: "60" # Read timeout secondsspec: ingressClassName: nginx # Must match your installed IngressClass rules: - host: api.razorpay.in http: paths: - path: /v1/payments pathType: Prefix backend: service: name: payments-service port: number: 4000 - path: /v1/dashboard pathType: Prefix backend: service: name: dashboard-service port: number: 8080 - path: /v1/webhooks pathType: Prefix backend: service: name: webhook-service port: number: 5000kubectl apply -f ingress-basic.yaml # Verify Ingress was created and has an addresskubectl get ingress -n production# NAME CLASS HOSTS ADDRESS PORTS AGE# razorpay-api-ingress nginx api.razorpay.in 34.93.45.201 80, 443 2mStep 3 — Enable SSL with cert-manager and Let's Encrypt
# Install cert-manager for automatic SSL certificate provisioninghelm repo add jetstack https://charts.jetstack.iohelm repo update helm install cert-manager jetstack/cert-manager \ --namespace cert-manager \ --create-namespace \ --set installCRDs=true # Verify cert-manager pods are runningkubectl get pods -n cert-manager# cluster-issuer.yaml — configure Let's Encrypt production certificate issuerapiVersion: cert-manager.io/v1kind: ClusterIssuermetadata: name: letsencrypt-productionspec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: rahul@devopsnetwork.in # Must be a real monitored email privateKeySecretRef: name: letsencrypt-production-key solvers: - http01: ingress: class: nginxkubectl apply -f cluster-issuer.yaml# ingress-ssl.yaml — Ingress with automatic SSL certificateapiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: razorpay-api-ingress namespace: production annotations: nginx.ingress.kubernetes.io/ssl-redirect: "true" cert-manager.io/cluster-issuer: "letsencrypt-production" # Auto-provision SSLspec: ingressClassName: nginx tls: - hosts: - api.razorpay.in - dashboard.razorpay.in secretName: razorpay-tls-secret # cert-manager stores the cert here rules: - host: api.razorpay.in http: paths: - path: / pathType: Prefix backend: service: name: payments-service port: number: 4000 - host: dashboard.razorpay.in http: paths: - path: / pathType: Prefix backend: service: name: dashboard-service port: number: 8080kubectl apply -f ingress-ssl.yaml # Watch the SSL certificate being issued — takes 60-120 secondskubectl get certificate -n production -w# NAME READY SECRET AGE# razorpay-tls-secret True razorpay-tls-secret 90sPLACEMENT PRO TIP**Tip:** If the certificate stays in `False` state for more than 5 minutes, check the CertificateRequest and Order objects: `kubectl describe certificaterequest -n production` — it will show exactly which ACME challenge step failed.
Step 4 — Apply Rate Limiting to Protect APIs
# ingress-ratelimit.yaml — protect the payments API from abuseapiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: payments-ingress-protected namespace: production annotations: nginx.ingress.kubernetes.io/ssl-redirect: "true" cert-manager.io/cluster-issuer: "letsencrypt-production" # Rate limiting — critical for payment APIs nginx.ingress.kubernetes.io/limit-rps: "20" # Max 20 requests/second per IP nginx.ingress.kubernetes.io/limit-connections: "10" # Max 10 concurrent connections per IP nginx.ingress.kubernetes.io/limit-burst-multiplier: "5" # Allow bursts up to 100 rps briefly # Security headers nginx.ingress.kubernetes.io/configuration-snippet: | more_set_headers "X-Frame-Options: DENY"; more_set_headers "X-Content-Type-Options: nosniff"; more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";spec: ingressClassName: nginx tls: - hosts: - api.razorpay.in secretName: razorpay-tls-secret rules: - host: api.razorpay.in http: paths: - path: /v1/payments pathType: Prefix backend: service: name: payments-service port: number: 4000Step 5 — Configure Host-Based Routing for Multiple Domains
# ingress-multidomain.yaml — separate domains to separate servicesapiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: razorpay-multidomain-ingress namespace: production annotations: nginx.ingress.kubernetes.io/ssl-redirect: "true" cert-manager.io/cluster-issuer: "letsencrypt-production"spec: ingressClassName: nginx tls: - hosts: - api.razorpay.in - admin.razorpay.in - webhooks.razorpay.in secretName: razorpay-multidomain-tls rules: - host: api.razorpay.in # Public API — internet facing http: paths: - path: / pathType: Prefix backend: service: name: public-api-service port: number: 4000 - host: admin.razorpay.in # Admin panel — restrict via firewall rules http: paths: - path: / pathType: Prefix backend: service: name: admin-service port: number: 9000 - host: webhooks.razorpay.in # Webhook receiver — high timeout needed http: paths: - path: / pathType: Prefix backend: service: name: webhook-service port: number: 5000COMMON MISTAKE / WARNING**Security:** Never expose admin dashboards through a public Ingress without IP whitelisting. Add `nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,YOUR_OFFICE_IP/32"` to the admin Ingress annotation block to restrict access to known IPs only.
Step 6 — Verify and Troubleshoot the Ingress
# Check Ingress resource status and backend addresseskubectl describe ingress razorpay-api-ingress -n production # Check NGINX Ingress Controller logs for routing errorskubectl logs -n ingress-nginx \ -l app.kubernetes.io/name=ingress-nginx \ --tail=100 -f # Test routing from inside the cluster using a debug podkubectl run curl-test --image=curlimages/curl -it --rm -n production -- sh # Inside the debug pod — test internal routingcurl -H "Host: api.razorpay.in" http://10.96.12.100/v1/paymentsProduction Best Practices & Common Pitfalls
- Always run at least 2 replicas of the Ingress Controller with a PodDisruptionBudget. A single controller pod is a single point of failure for all cluster traffic.
- Use
externalTrafficPolicy: Localon the Ingress Controller service to preserve the real client IP in access logs — essential for rate limiting and security auditing. - Set resource requests and limits on the Ingress Controller pods. An unthrottled controller under traffic spike can OOMKill and drop all traffic simultaneously.
- Always use
IngressClassexplicitly. Relying on the legacykubernetes.io/ingress.classannotation causes unpredictable behaviour in newer Kubernetes versions. - Separate Ingress resources per service rather than one monolithic Ingress. Smaller resources are easier to audit, roll back, and debug independently.
COMMON MISTAKE / WARNING**Common Mistake:** Creating an Ingress resource without installing an Ingress Controller first. The Ingress object will be accepted by the API server with no errors but traffic will never be routed. Always confirm the controller is running with `kubectl get pods -n ingress-nginx` before creating Ingress resources.
Quick Reference & Troubleshooting Commands
| Command | Purpose |
|---|---|
kubectl get ingress -n <ns> |
List all Ingress resources and their addresses |
kubectl describe ingress <name> -n <ns> |
Full Ingress config with backend endpoint status |
kubectl get ingressclass |
List available IngressClass controllers in cluster |
kubectl logs -n ingress-nginx -l app.kubernetes.io/name=ingress-nginx |
NGINX controller logs for routing errors |
kubectl get certificate -n <ns> |
SSL certificate issuance status |
kubectl describe certificaterequest -n <ns> |
Debug failed SSL certificate provisioning |
kubectl get events -n ingress-nginx --sort-by='.lastTimestamp' |
Ingress controller event timeline |
helm upgrade ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx |
Upgrade NGINX Ingress Controller version |
kubectl exec -it <nginx-pod> -n ingress-nginx -- nginx -t |
Validate generated NGINX config inside controller |