Skip to content

Ingress Configuration

Standard pattern for exposing web applications in Kubernetes using NGINX Ingress Controller.

Prerequisites: - Kubernetes cluster with NGINX Ingress Controller deployed - Understanding of Kubernetes Services - DNS configuration access (OPNsense or /etc/hosts)

See Also: - NGINX Ingress Documentation - NGINX Ingress Controller details - Production Deployment - Complete deployment workflow - App Conventions - Application structure standards


When to Use Ingress

Use Ingress for: - HTTP/HTTPS web applications - Apps that can share an IP with hostname-based routing - Standard web services (dashboards, APIs, etc.)

Use LoadBalancer for: - Non-HTTP protocols (PostgreSQL, Redis, custom TCP/UDP) - Infrastructure services requiring dedicated IPs - Apps with complex multi-port requirements


Ingress Manifest Pattern

Standard manifest for web applications:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {app}
  namespace: {app}
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
  ingressClassName: nginx
  rules:
  - host: {app}.internal
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: {app}
            port:
              number: 80

Key fields: - metadata.name: Application name (must match namespace convention) - spec.ingressClassName: Always nginx for our NGINX Ingress Controller - rules.host: DNS hostname (format: {app}.internal) - backend.service.name: Service name (typically matches app name) - backend.service.port.number: Service port (typically 80)


Service Configuration for Ingress

When using Ingress, change Service type to ClusterIP (not LoadBalancer):

apiVersion: v1
kind: Service
metadata:
  name: {app}
  namespace: {app}
spec:
  type: ClusterIP  # ← Changed from LoadBalancer
  selector:
    app: {app}
  ports:
  - name: http
    port: 80
    targetPort: 3000  # Your container port
    protocol: TCP

Why ClusterIP: - Ingress Controller handles external access - Service only needs internal cluster networking - No external IP allocation required - All apps share the Ingress Controller IP (10.89.97.220)


DNS Configuration

OPNsense Setup (Recommended): 1. Navigate to: Services → Unbound DNS → Overrides 2. Add Host Override: - Host: {app} - Domain: internal - IP: 10.89.97.220 (NGINX Ingress Controller IP)

Alternative (/etc/hosts for testing):

10.89.97.220 {app}.internal

Result: {app}.internal resolves to the NGINX Ingress Controller, which routes by hostname to the correct service.


Verification

# Check Ingress created
kubectl get ingress -n {app}

# Check routing (without DNS)
curl -H "Host: {app}.internal" http://10.89.97.220

# With DNS configured:
curl http://{app}.internal

# Check Ingress details
kubectl describe ingress {app} -n {app}

Expected output: - Ingress shows ADDRESS: 10.89.97.220 - curl returns your application's response - No connection errors or 404s


Common Ingress Annotations

Path rewriting:

annotations:
  # Rewrite paths (e.g., /api/v1/foo → /foo)
  nginx.ingress.kubernetes.io/rewrite-target: /

File uploads:

annotations:
  # Client body size (default is 1m)
  nginx.ingress.kubernetes.io/proxy-body-size: "100m"

Long-running requests:

annotations:
  # Timeouts for streaming or long operations
  nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
  nginx.ingress.kubernetes.io/proxy-send-timeout: "600"

CORS headers:

annotations:
  # Enable CORS
  nginx.ingress.kubernetes.io/enable-cors: "true"
  nginx.ingress.kubernetes.io/cors-allow-origin: "*"

Complete annotation reference: NGINX Ingress Annotations


Migration from LoadBalancer to Ingress

If you have an existing app using LoadBalancer:

Step-by-step migration:

  1. Create Ingress manifest (use pattern above)

  2. Apply Ingress:

    kubectl apply -f ingress.yaml
    

  3. Test both routes work (LoadBalancer + Ingress)

    # Test LoadBalancer (existing IP)
    curl http://{old-ip}:3000
    
    # Test Ingress (with Host header)
    curl -H "Host: {app}.internal" http://10.89.97.220
    

  4. Change Service to ClusterIP:

    kubectl patch svc {app} -n {app} -p '{"spec":{"type":"ClusterIP"}}'
    

  5. Configure DNS (OPNsense or /etc/hosts)

  6. Test access:

    curl http://{app}.internal
    

  7. Update documentation to reflect new URL


Benefits of Ingress

vs. LoadBalancer: - Fewer IP addresses consumed: All apps share 10.89.97.220 (single Ingress Controller IP) - Easier SSL management: Single TLS termination point with cert-manager - Standard HTTP routing patterns: Hostname-based routing, path-based routing - Professional hostname-based access: home.internal, money.internal vs 10.89.97.241:3000 - Better resource usage: No per-app external IP allocation

Production considerations: - All web apps should use Ingress unless specific requirements prevent it - Reserve LoadBalancer for infrastructure services (databases, message queues) - Use cert-manager for automatic TLS certificate management (future enhancement)


Troubleshooting

Issue: Ingress shows no ADDRESS

kubectl get ingress -n {app}
# NAME   CLASS   HOSTS           ADDRESS   PORTS
# app    nginx   app.internal              80
Cause: Ingress Controller not running Solution: Check NGINX Ingress Controller pods:
kubectl get pods -n ingress-nginx

Issue: 404 Not Found

curl http://{app}.internal
# <html><body>404 Not Found</body></html>
Cause: Service name mismatch or Service not running Solution: Verify Service exists and matches Ingress backend:
kubectl get svc -n {app}
kubectl describe ingress {app} -n {app}

Issue: Connection refused

curl http://{app}.internal
# curl: (7) Failed to connect to app.internal port 80: Connection refused
Cause: DNS not configured or Ingress Controller not accessible Solution: - Check DNS resolution: nslookup {app}.internal - Test with IP and Host header: curl -H "Host: {app}.internal" http://10.89.97.220 - Verify Ingress Controller IP: kubectl get svc -n ingress-nginx


Supabase Storage External Access

When browser clients need to access Supabase Storage (e.g., uploaded icons, images), they can't use the internal cluster IP. Instead, expose storage via Ingress.

Use case: Next.js apps where users upload files to Supabase Storage that must be displayed in the browser.

Problem: NEXT_PUBLIC_SUPABASE_URL is typically an internal IP (e.g., http://10.89.97.214:8000) that browsers outside the cluster can't access.

Solution: Create a dedicated storage ingress with a publicly accessible hostname.

Storage Ingress Manifest

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: supabase-storage
  namespace: supabase
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"  # Adjust for max upload size
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - storage.bogocat.com
      secretName: wildcard-bogocat-tls
  rules:
    - host: storage.bogocat.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: kong
                port:
                  number: 8000

Setup Steps

  1. Copy TLS certificate to supabase namespace:

    kubectl get secret wildcard-bogocat-tls -n default -o yaml | \
      sed 's/namespace: default/namespace: supabase/' | \
      kubectl apply -f -
    

  2. Apply ingress:

    kubectl apply -f supabase-storage-ingress.yaml
    

  3. Add DNS override in OPNsense:

  4. Host: storage
  5. Domain: bogocat.com
  6. IP: 10.89.97.220 (NGINX Ingress Controller)

  7. Add environment variable in app:

    # .env.local
    NEXT_PUBLIC_SUPABASE_STORAGE_URL=https://storage.bogocat.com
    

  8. Update storage URL usage in components:

    // Use dedicated storage URL for browser access
    const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_STORAGE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL
    const storageUrl = `${baseUrl}/storage/v1/object/public/${path}`
    

Why Separate URLs

URL Purpose Accessible From
NEXT_PUBLIC_SUPABASE_URL API calls (server-side, PostgREST) Server-side, internal network
NEXT_PUBLIC_SUPABASE_STORAGE_URL Storage URLs for browser display Client-side, external

Keep them separate because: - API calls from Next.js server-side can use internal IPs (faster, no TLS overhead) - Storage URLs embedded in <img src> must be accessible to the user's browser - Mixing them could break either API calls (if external) or image loading (if internal)


See Also