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.
هذه الأدلّة محفوظة بالإنجليزية. فالأوامر وملفّات الإعداد واحدة في كلّ اللغات، وتوزيعها على ثلاث نسخ لا يضيف إلّا احتمال تخلّف إحداها عن التحديث.
العناوين في هذا الدليل عناوين بديلة ضمن النطاق 10.0.0.0/24 والنطاق المستخدم هو example.dev. وما عدا ذلك منقول حرفياً عن الملاحظات الأصلية.
- 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