𝔩𝔢𝔩𝕠𝔭𝔢𝔷
Theme
Connect With Me on LinkedIn Buy Me a Coffee

Homelab

MetalLB, Longhorn, and Ingress-NGINX

Deploying Core Kubernetes Infrastructure: MetalLB Load Balancer, Longhorn Storage, and NGINX Ingress

Overview

Deploying core Kubernetes infrastructure via GitOps - the foundation for all applications. MetalLB provides load balancing, NGINX handles ingress routing, and Longhorn adds distributed storage.

Tip:Having trouble? See v0.8.0 for reference.

Before You Begin

Prerequisites

What We're Setting Up

  1. MetalLB - Load balancer for bare-metal clusters, assigns IPs from a pool to LoadBalancer services
  2. NGINX Ingress - Routes HTTP/HTTPS traffic to services based on hostnames and paths
  3. Longhorn - Distributed block storage with replication, snapshots, and backup to NFS

Why This Approach

  • MetalLB gives services real IPs (not just ClusterIP) accessible from your network
  • Ingress provides hostname-based routing without exposing every service
  • Longhorn enables pod mobility - volumes follow workloads across nodes

Directory Structure

Here's what we're building. Understanding the structure helps follow along:

k8s/core/
├── kustomization.yaml           # Lists all core services
├── tailscale/                   # From article 07
├── metallb/
│   ├── kustomization.yaml       # Lists files (NOT config/ directory)
│   ├── namespace.yaml
│   ├── helmrepository.yaml
│   ├── helmrelease.yaml
│   ├── config.flux.yaml         # Flux Kustomization → manages config/
│   └── config/
│       └── config.yaml          # IPAddressPool + L2Advertisement (CRDs)
├── ingress-nginx.flux.yaml      # Flux Kustomization → manages ingress-nginx/
├── ingress-nginx/
│   ├── kustomization.yaml
│   ├── namespace.yaml
│   ├── helmrepository.yaml
│   └── helmrelease.yaml
└── longhorn/
    ├── kustomization.yaml
    ├── namespace.yaml
    ├── helmrepository.yaml
    └── helmrelease.yaml

Why the different patterns?

  • MetalLB: config.flux.yaml inside the directory because it manages CRDs that the HelmRelease installs. The dependency is internal to MetalLB.
  • Ingress-NGINX: ingress-nginx.flux.yaml in core/ because the entire service depends on another service (MetalLB). The dependency is external.
  • Longhorn: No Flux Kustomization wrapper needed - it has no CRD dependencies and doesn't depend on other services.

How Flux Dependency Chain Helps

Tailscale introduced CRDs and the *.flux.yaml wrapper pattern for waiting on HelmReleases. This article adds a second pattern: cross-service dependencies.

All services deploy from a single git push. Flux resolves dependencies automatically:

sync (parent)
  ├── tailscale/ (HelmRelease installs CRDs)
  │     └── tailscale-connector (waits via healthChecks)
  │
  ├── metallb/ (HelmRelease installs CRDs)
  │     └── metallb-config (waits via healthChecks)
  │           └── ingress-nginx (waits via dependsOn)  ← NEW: cross-service
  │
  └── longhorn/ (independent, no wrapper needed)

When you need a *.flux.yaml wrapper:

  1. CRD dependency (MetalLB, Tailscale) - HelmRelease installs CRDs, wrapper waits via healthChecks before applying CRD instances.

  2. Cross-service dependency (Ingress-NGINX) - Service needs another service operational first. Wrapper waits via dependsOn for another Kustomization.

When you don't need one:

  • Independent services (Longhorn) - No CRDs to wait for, no dependency on other services.

Prepare Directory

Init Workspace

cd ~/homelab
export KUBECONFIG=$(pwd)/talos/clusterconfig/kubeconfig
mkdir -p k8s/core/metallb/config k8s/core/ingress-nginx k8s/core/longhorn
Note:The KUBECONFIG export only applies to your current terminal session. If you open a new terminal, re-run the cd and export commands.

Create Dev Branch

Create a dev branch to iterate without triggering Flux reconciliation on every commit:

git checkout -b dev

Configure MetalLB

MetalLB1 provides LoadBalancer services in bare-metal environments by assigning IPs from a pool.

Talos Note:All three services (MetalLB, Ingress-NGINX, Longhorn) require privileged containers. Talos enforces Pod Security Admission (PSA) at baseline level, so each namespace needs privileged labels.

Namespace

k8s/core/metallb/namespace.yaml:

---
apiVersion: v1
kind: Namespace
metadata:
  name: metallb-system
  labels:
    # Required for Talos - allows privileged pods
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged

HelmRepository

k8s/core/metallb/helmrepository.yaml:

---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: metallb
  namespace: flux-system
spec:
  interval: 1h
  url: https://metallb.github.io/metallb

HelmRelease

k8s/core/metallb/helmrelease.yaml:

---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: metallb
  namespace: metallb-system
spec:
  interval: 1h
  chart:
    spec:
      chart: metallb
      version: "0.15.x"  # Current stable series
      sourceRef:
        kind: HelmRepository
        name: metallb
        namespace: flux-system
  install:
    remediation:
      retries: 3
  upgrade:
    remediation:
      retries: 3

IP Pool Configuration

The IPAddressPool and L2Advertisement CRDs are installed by the MetalLB HelmRelease. We use a separate Flux Kustomization with dependsOn to wait for the operator - same pattern as Tailscale Connector.

k8s/core/metallb/config.flux.yaml (Flux Kustomization CRD):

---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: metallb-config
  namespace: flux-system
spec:
  interval: 10m
  retryInterval: 1m
  path: ./k8s/core/metallb/config
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  dependsOn:
    - name: sync
  healthChecks:
    - apiVersion: helm.toolkit.fluxcd.io/v2
      kind: HelmRelease
      name: metallb
      namespace: metallb-system

k8s/core/metallb/config/config.yaml:

---
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: default-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.40-192.168.1.79  # From [UniFi Flat Network Setup](/blog/homelab-v2-02-unifi-flat-network-setup)
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default
  namespace: metallb-system
spec:
  ipAddressPools:
    - default-pool

Kustomization

k8s/core/metallb/kustomization.yaml:

---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml
  - helmrepository.yaml
  - helmrelease.yaml
  - config.flux.yaml
git add k8s/core/metallb/
git commit -m "feat(metallb): add load balancer with IP pool config"

Configure NGINX Ingress

NGINX Ingress2 routes HTTP/HTTPS traffic to services based on hostnames and paths. The LoadBalancer service needs MetalLB to assign an IP, so we use a Flux Kustomization wrapper with dependsOn.

k8s/core/ingress-nginx.flux.yaml:

---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: ingress-nginx
  namespace: flux-system
spec:
  interval: 10m
  retryInterval: 1m
  path: ./k8s/core/ingress-nginx
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  dependsOn:
    - name: metallb-config

Namespace

k8s/core/ingress-nginx/namespace.yaml:

---
apiVersion: v1
kind: Namespace
metadata:
  name: ingress-nginx
  labels:
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged

HelmRepository

k8s/core/ingress-nginx/helmrepository.yaml:

---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: ingress-nginx
  namespace: flux-system
spec:
  interval: 1h
  url: https://kubernetes.github.io/ingress-nginx

HelmRelease

k8s/core/ingress-nginx/helmrelease.yaml:

---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: ingress-nginx
  namespace: ingress-nginx
spec:
  interval: 1h
  chart:
    spec:
      chart: ingress-nginx
      version: "4.x"  # Current stable series
      sourceRef:
        kind: HelmRepository
        name: ingress-nginx
        namespace: flux-system
  install:
    remediation:
      retries: 3
  upgrade:
    remediation:
      retries: 3
  values:
    controller:
      service:
        type: LoadBalancer  # MetalLB will assign an IP from the pool
      watchIngressWithoutClass: true
      metrics:
        enabled: true

Kustomization

k8s/core/ingress-nginx/kustomization.yaml:

---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml
  - helmrepository.yaml
  - helmrelease.yaml
git add k8s/core/ingress-nginx.flux.yaml k8s/core/ingress-nginx/
git commit -m "feat(ingress-nginx): add ingress controller"

Configure Longhorn

Longhorn3 provides distributed block storage with replication across nodes. No Flux Kustomization wrapper needed - it has no CRD dependencies. For Talos-specific considerations, see the installation guide4.

Namespace

k8s/core/longhorn/namespace.yaml:

---
apiVersion: v1
kind: Namespace
metadata:
  name: longhorn-system
  labels:
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged

HelmRepository

k8s/core/longhorn/helmrepository.yaml:

---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: longhorn
  namespace: flux-system
spec:
  interval: 1h
  url: https://charts.longhorn.io

HelmRelease

k8s/core/longhorn/helmrelease.yaml:

---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: longhorn
  namespace: longhorn-system
spec:
  interval: 1h
  chart:
    spec:
      chart: longhorn
      version: "1.7.x"  # Current stable series
      sourceRef:
        kind: HelmRepository
        name: longhorn
        namespace: flux-system
  install:
    remediation:
      retries: 3
  upgrade:
    remediation:
      retries: 3
  values:
    defaultSettings:
      # Start with 2 replicas (we have 2 nodes, will add 3rd)
      defaultReplicaCount: 2
    persistence:
      # Retain volumes when PVC deleted (safer, manual cleanup)
      reclaimPolicy: Retain
      defaultClassReplicaCount: 2
    # Make Longhorn the default StorageClass
    defaultClass: true

Kustomization

k8s/core/longhorn/kustomization.yaml:

---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - namespace.yaml
  - helmrepository.yaml
  - helmrelease.yaml
git add k8s/core/longhorn/
git commit -m "feat(longhorn): add distributed storage"

Update Core Kustomization

k8s/core/kustomization.yaml:

---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - tailscale              # From article 07
  - metallb                # Directory (includes config.flux.yaml for CRD dependency)
  - ingress-nginx.flux.yaml  # Flux Kustomization CRD (dependsOn metallb-config)
  - longhorn               # Directory (no dependencies)
git add k8s/core/kustomization.yaml
git commit -m "feat(core): register new services in kustomization"

Deploy Everything

Merge to main and push to trigger Flux:

git checkout main
git merge --ff-only dev
git push
git branch -d dev

Watch Flux Reconcile

# Force immediate reconciliation
flux reconcile kustomization sync

# Watch all Flux Kustomizations
flux get kustomizations -w

Wait for all Kustomizations to show Ready: True:

  • sync - main sync, applies immediately
  • tailscale-connector - waits for Tailscale operator
  • metallb-config - waits for MetalLB operator
  • ingress-nginx - waits for metallb-config
# Watch HelmReleases deploy
flux get helmreleases -A -w

Wait until all HelmReleases show Ready: True. This may take 2-3 minutes as Helm charts are fetched and installed.

Verify All Services

MetalLB

kubectl get pods -n metallb-system
kubectl get ipaddresspool -n metallb-system

All pods should be Running. IPAddressPool should show your range.

NGINX Ingress

kubectl get pods -n ingress-nginx
kubectl get svc -n ingress-nginx

Note the EXTERNAL-IP assigned by MetalLB to the ingress controller.

Longhorn

kubectl get pods -n longhorn-system
kubectl get storageclass

Should see many Longhorn pods (manager, driver, CSI components) and longhorn StorageClass as default.

Access Longhorn UI (temporary port-forward):

kubectl port-forward svc/longhorn-frontend 8080:80 -n longhorn-system

Open http://localhost:8080 to see the Longhorn dashboard.

Test the Services

Test LoadBalancer (MetalLB)

# Create test deployment
kubectl create deployment nginx-test --image=nginx
kubectl expose deployment nginx-test --type=LoadBalancer --port=80

# Watch for EXTERNAL-IP (Ctrl+C when assigned)
kubectl get svc nginx-test -w

Should get an IP from the pool (192.168.1.40-79). Test it:

curl http://<EXTERNAL-IP>

Test Ingress

# Deploy test app
kubectl create deployment hello --image=gcr.io/google-samples/hello-app:1.0
kubectl expose deployment hello --port=8080

# Create ingress
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello-ingress
spec:
  rules:
  - host: hello.local
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: hello
            port:
              number: 8080
EOF

# Get ingress controller IP
kubectl get svc -n ingress-nginx ingress-nginx-controller \
  -o jsonpath='{.status.loadBalancer.ingress[0].ip}'

Test from your machine:

# Add to /etc/hosts (use ingress controller's EXTERNAL-IP)
echo "<INGRESS-IP> hello.local" | sudo tee -a /etc/hosts

curl http://hello.local

Test Storage (Longhorn)

# Create a test PVC
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: test-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
  # storageClassName: longhorn  # Optional - Longhorn is default
EOF

# Watch for Bound status
kubectl get pvc test-pvc -w

Should show Bound within a minute. Check the volume in Longhorn UI.

Clean Up Tests

kubectl delete pvc test-pvc
kubectl delete ingress hello-ingress
kubectl delete svc hello nginx-test
kubectl delete deployment hello nginx-test

# Verify cleanup
kubectl get all

Only service/kubernetes should remain.

Next Steps

With core infrastructure in place, we can configure the GPU for hardware transcoding.

See: Intel Arc Kubernetes DRA

For MetalLB troubleshooting on all-control-plane clusters:

See: MetalLB Talos L2 Fix

Resources

Footnotes

  1. MetalLB, "MetalLB Documentation," metallb.universe.tf. Accessed: Dec. 16, 2025. [Online]. Available: https://metallb.universe.tf/

  2. Kubernetes, "NGINX Ingress Controller," kubernetes.github.io. Accessed: Dec. 16, 2025. [Online]. Available: https://kubernetes.github.io/ingress-nginx/

  3. Longhorn, "Longhorn Documentation," longhorn.io. Accessed: Dec. 16, 2025. [Online]. Available: https://longhorn.io/docs/

  4. Josh Noll, "Installing Longhorn on Talos With Helm," joshrnoll.com. Accessed: Dec. 16, 2025. [Online]. Available: https://joshrnoll.com/installing-longhorn-on-talos-with-helm/

Previous
Tailscale Kubernetes Subnet Router