Deploying a NestJS API to Kubernetes with Ingress, TLS and MetalLB
A multi-stage image, a Deployment and Service, then the three pieces that turn a NodePort into something you would actually put a domain in front of: NGINX Ingress, cert-manager and MetalLB.
Addresses in this guide are placeholders in 10.0.0.0/24 and the domain is example.dev. Everything else is verbatim from the working notes.
- 01
Build a small image
Three stages: production dependencies, a build, then a runtime that copies only what it needs. The build toolchain never reaches the final image.
Dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev FROM node:20-alpine AS builder WORKDIR /app COPY . . RUN npm ci RUN npm run build FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist EXPOSE 3000 CMD ["node","dist/main.js"]docker build -t YOUR_DOCKERHUB_USER/nest-api:v1 . docker login docker push YOUR_DOCKERHUB_USER/nest-api:v1 - 02
Deployment and Service
nest-api.yaml apiVersion: v1 kind: Namespace metadata: name: demo --- apiVersion: apps/v1 kind: Deployment metadata: name: nest-api namespace: demo spec: replicas: 1 selector: matchLabels: app: nest-api template: metadata: labels: app: nest-api spec: containers: - name: api image: YOUR_DOCKERHUB_USER/nest-api:v1 ports: - containerPort: 3000 --- apiVersion: v1 kind: Service metadata: name: nest-api namespace: demo spec: type: NodePort selector: app: nest-api ports: - port: 3000 targetPort: 3000 nodePort: 30080kubectl apply -f nest-api.yaml # a NodePort answers on every node, not just the one running the pod curl http://10.0.0.80:30080/ curl http://10.0.0.81:30080/ - 03
NGINX Ingress and cert-manager
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx helm repo update helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx --create-namespace \ --set controller.service.type=NodePort \ --set controller.service.nodePorts.http=30080 \ --set controller.service.nodePorts.https=30443 helm repo add jetstack https://charts.jetstack.io helm repo update helm upgrade --install cert-manager jetstack/cert-manager \ --namespace cert-manager --create-namespace \ --set crds.enabled=trueA self-signed issuer is enough on a lab cluster apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: selfsigned spec: selfSigned: {} - 04
Route a hostname to the service
sslip.ioresolves any embedded address back to itself, which gives you a real hostname on a network with no DNS server — useful precisely because Ingress rules match on host.ingress.yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nest-api namespace: demo annotations: nginx.ingress.kubernetes.io/backend-protocol: "HTTP" spec: ingressClassName: nginx rules: - host: api.10.0.0.80.sslip.io http: paths: - path: / pathType: Prefix backend: service: name: nest-api port: number: 3000 - 05
MetalLB, for an actual LoadBalancer
On bare metal,
type: LoadBalancerstays<pending>forever — there is no cloud controller to assign an address. MetalLB is what fills that gap: hand it a spare range on your LAN and it answers ARP for those addresses.kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.5/config/manifests/metallb-native.yaml kubectl -n metallb-system wait --for=condition=Available deploy/controller --timeout=120sThe range must be outside your DHCP scope apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: pool namespace: metallb-system spec: addresses: - 10.0.0.200-10.0.0.210 --- apiVersion: metallb.io/v1beta1 kind: L2Advertisement metadata: name: l2 namespace: metallb-system spec: {} - 06
Prove it holds
A deployment that has never been put under load is a deployment you are guessing about.
wrk -t8 -c200 -d60s https://api.10.0.0.200.sslip.io/health hey -z 60s -c 100 https://api.10.0.0.200.sslip.io/health # confirm requests actually spread across pods for i in $(seq 1 10); do curl -sk https://api.10.0.0.200.sslip.io/whoami; echo; done