
Introduction
Earlier, we covered backend engineering-essentials, database-design patterns and Nest.js for API development. Now it’s time to level up your infrastructure skills.
That’s where Kubernetes comes in — and in this article, we’re not going to learn it abstractly. We’re going to spin up a real 3-node cluster on your local machine using Multipass + k3s, containerize a NestJS API with TypeScript, connect it to a managed PostgreSQL and Redis running inside the cluster, and take the whole thing from a single Pod all the way to production-grade patterns.
This is a backend engineer’s Kubernetes guide. Not DevOps theory — real infrastructure, real code.

Warning
As a backend engineer, you don’t need to be a Kubernetes expert — but you do need to understand how it works under the hood, and how to deploy and operate your applications on it. This article is designed to give you that practical, hands-on experience with Kubernetes in a way that’s directly relevant to your work as a backend engineer. I am also learning Kubernetes myself, and this is the path I am following to get up to speed as quickly as possible. If you have any feedback on how to make it better, please let me know!
Contact me directly: @mrmeaow on Telegram
Why k3s and Multipass
Multipass is a lightweight VM manager from Canonical. It spins up Ubuntu VMs on macOS, Windows, or Linux in seconds — no VirtualBox setup, no Docker Desktop overhead.
k3s is a CNCF-certified, production-grade Kubernetes distribution that’s lightweight enough to run on a Raspberry Pi or a $5 VPS. It ships as a single binary, has a fast startup time, and uses containerd as its runtime. Critically, it’s full Kubernetes — not a toy. Companies run k3s in production for edge workloads and resource-constrained environments.
Together, they give you a real multi-node Kubernetes cluster on your laptop with almost no friction. By the end of setup, you’ll have this topology:
k3s-master 10.46.7.61 control-plane
k3s-worker-1 10.46.7.183 worker
k3s-worker-2 10.46.7.206 worker
A Note on Podman vs Docker
Throughout this article, Podman is our recommended tool for building and pushing container images. Podman is daemonless, rootless, and Docker-compatible — it runs containers without a background daemon process, which is safer and cleaner. It’s the default container tool on RHEL/Fedora and is rapidly becoming the standard in enterprise and government environments.
Every Podman command has a direct Docker equivalent. Wherever you see podman, you can substitute docker — the flags and syntax are identical for everything we do here.
# Podman (recommended)
podman build -t myapp:latest .
podman push myapp:latest
# Docker (equivalent)
docker build -t myapp:latest .
docker push myapp:latest
Install Podman:
# Ubuntu / Debian
sudo apt install -y podman
# macOS
brew install podman
podman machine init
podman machine start
# Windows (via WSL2 or installer)
# https://podman.io/docs/installation
Setting Up the Lab
Install Multipass
# macOS
brew install --cask multipass
# Ubuntu / Linux
sudo snap install multipass
# Windows
# Download installer from https://multipass.run
Spin Up Three VMs
multipass launch --name k3s-master --cpus 2 --memory 2G --disk 6G
multipass launch --name k3s-worker-1 --cpus 2 --memory 2G --disk 6G
multipass launch --name k3s-worker-2 --cpus 2 --memory 2G --disk 6G
Each VM has 2 CPUs, 2GB RAM, and 6GB disk — more than enough for our lab. Adjust resources as needed based on your machine e.g.
--memory 4Gif you have 16GB RAM or--disk 16Gif you want more room for images and logs.
Check they’re running:
multipass list
Name State IPv4
k3s-master Running 10.46.7.61
k3s-worker-1 Running 10.46.7.183
k3s-worker-2 Running 10.46.7.206
Install k3s on the Master
multipass exec k3s-master -- bash -c "
curl -sfL https://get.k3s.io | sh -
"
Grab the node token and master IP — you’ll need both to join workers:
K3S_TOKEN=$(multipass exec k3s-master -- sudo cat /var/lib/rancher/k3s/server/node-token)
MASTER_IP=$(multipass info k3s-master | grep IPv4 | awk '{print $2}')
echo "Master IP : $MASTER_IP"
echo "Token : $K3S_TOKEN"
Join the Workers
for worker in k3s-worker-1 k3s-worker-2; do
multipass exec $worker -- bash -c "
curl -sfL https://get.k3s.io | K3S_URL=https://${MASTER_IP}:6443 K3S_TOKEN=${K3S_TOKEN} sh -
"
done
Configure kubectl on Your Host
# Copy the kubeconfig from master
multipass exec k3s-master -- sudo cat /etc/rancher/k3s/k3s.yaml > ~/.kube/k3s-config
# Replace the loopback address with the actual master IP
sed -i "s/127.0.0.1/${MASTER_IP}/g" ~/.kube/k3s-config
# Export it
export KUBECONFIG=~/.kube/k3s-config
# Verify — you should see 3 nodes
kubectl get nodes
NAME STATUS ROLES AGE VERSION
k3s-master Ready control-plane,master 2m v1.32.4+k3s1
k3s-worker-1 Ready <none> 1m v1.32.4+k3s1
k3s-worker-2 Ready <none> 1m v1.32.4+k3s1
Your cluster is live. Three nodes, full Kubernetes, running on your laptop.
The Demo App: a NestJS Tasks API
We’ll build a small but realistic backend — a Tasks API with NestJS and TypeScript. It talks to PostgreSQL (for persistence) and Redis (for caching). This is intentionally shaped like something you’d actually build at work.
Scaffold the Project
npm install -g @nestjs/cli
nest new tasks-api --package-manager npm
cd tasks-api
Install dependencies:
npm install @nestjs/typeorm typeorm pg ioredis @nestjs/config class-validator class-transformer
npm install -D @types/node
The Task Entity
// src/tasks/task.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum TaskStatus {
OPEN = 'OPEN',
IN_PROGRESS = 'IN_PROGRESS',
DONE = 'DONE',
}
@Entity('tasks')
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ nullable: true })
description: string;
@Column({ type: 'enum', enum: TaskStatus, default: TaskStatus.OPEN })
status: TaskStatus;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
The Tasks Module
// src/tasks/tasks.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll() {
return this.tasksService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.tasksService.findOne(id);
}
@Post()
create(@Body() createTaskDto: CreateTaskDto) {
return this.tasksService.create(createTaskDto);
}
@Patch(':id/status')
updateStatus(@Param('id') id: string, @Body('status') status: string) {
return this.tasksService.updateStatus(id, status);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.tasksService.remove(id);
}
}
// src/tasks/tasks.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Task, TaskStatus } from './task.entity';
import { CreateTaskDto } from './dto/create-task.dto';
import { CacheService } from '../cache/cache.service';
@Injectable()
export class TasksService {
constructor(
@InjectRepository(Task)
private readonly taskRepo: Repository<Task>,
private readonly cacheService: CacheService,
) {}
async findAll(): Promise<Task[]> {
const cached = await this.cacheService.get('tasks:all');
if (cached) return JSON.parse(cached);
const tasks = await this.taskRepo.find({ order: { createdAt: 'DESC' } });
await this.cacheService.set('tasks:all', JSON.stringify(tasks), 60);
return tasks;
}
async findOne(id: string): Promise<Task> {
const task = await this.taskRepo.findOne({ where: { id } });
if (!task) throw new NotFoundException(`Task ${id} not found`);
return task;
}
async create(dto: CreateTaskDto): Promise<Task> {
const task = this.taskRepo.create(dto);
const saved = await this.taskRepo.save(task);
await this.cacheService.del('tasks:all');
return saved;
}
async updateStatus(id: string, status: string): Promise<Task> {
const task = await this.findOne(id);
task.status = status as TaskStatus;
const updated = await this.taskRepo.save(task);
await this.cacheService.del('tasks:all');
return updated;
}
async remove(id: string): Promise<void> {
const task = await this.findOne(id);
await this.taskRepo.remove(task);
await this.cacheService.del('tasks:all');
}
}
// src/tasks/dto/create-task.dto.ts
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
title: string;
@IsString()
@IsOptional()
description?: string;
}
The Cache Service (Redis)
// src/cache/cache.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Injectable()
export class CacheService implements OnModuleInit, OnModuleDestroy {
private client: Redis;
constructor(private readonly config: ConfigService) {}
onModuleInit() {
this.client = new Redis({
host: this.config.get('REDIS_HOST', 'localhost'),
port: this.config.get<number>('REDIS_PORT', 6379),
});
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, value);
} else {
await this.client.set(key, value);
}
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async onModuleDestroy() {
await this.client.quit();
}
}
App Module Wiring
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Task } from './tasks/task.entity';
import { TasksModule } from './tasks/tasks.module';
import { CacheModule } from './cache/cache.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 5432),
username: config.get('DB_USER', 'postgres'),
password: config.get('DB_PASSWORD', 'postgres'),
database: config.get('DB_NAME', 'tasksdb'),
entities: [Task],
synchronize: config.get('NODE_ENV') !== 'production',
}),
inject: [ConfigService],
}),
TasksModule,
CacheModule,
],
})
export class AppModule {}
Health Check Endpoint
Add a simple health check — Kubernetes will use this for liveness and readiness probes:
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Controller('health')
export class HealthController {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
@Get('live')
liveness() {
return { status: 'ok' };
}
@Get('ready')
async readiness() {
const dbReady = this.dataSource.isInitialized;
if (!dbReady) {
throw new Error('Database not ready');
}
return { status: 'ok', db: 'connected' };
}
}
Containerizing the API
The Dockerfile
# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main"]
.dockerignore
node_modules
dist
.env
*.local
.git
Build the Image
# Podman (recommended)
podman build -t tasks-api:latest .
# Docker
docker build -t tasks-api:latest .
Verify it runs locally before pushing to a registry:
# Podman
podman run --rm -p 3000:3000 \
-e DB_HOST=host.containers.internal \
-e REDIS_HOST=host.containers.internal \
tasks-api:latest
# Docker
docker run --rm -p 3000:3000 \
-e DB_HOST=host.docker.internal \
-e REDIS_HOST=host.docker.internal \
tasks-api:latest
Push to a Registry
For this article, we’ll use Docker Hub. Substitute with GitHub Container Registry (ghcr.io) or any private registry if you prefer.
# Podman
podman login docker.io
podman tag tasks-api:latest docker.io/yourusername/tasks-api:latest
podman push docker.io/yourusername/tasks-api:latest
# Docker
docker login
docker tag tasks-api:latest yourusername/tasks-api:latest
docker push yourusername/tasks-api:latest
Using a private registry with k3s? You’ll need to create an
imagePullSecretin Kubernetes and reference it in your Deployment spec. We’ll cover that in the Deployment section.
Namespaces First
Before deploying anything, let’s create a dedicated namespace. This keeps our workloads isolated from k3s system components.
kubectl create namespace tasks-app
Set it as your default context so you don’t have to type -n tasks-app every time:
kubectl config set-context --current --namespace=tasks-app
Managed PostgreSQL in the Cluster
In a real managed cloud environment (RDS, Cloud SQL, Supabase), you’d point your app at an external connection string. In our lab, we simulate that with a StatefulSet — the Kubernetes object designed exactly for databases: stable pod identity, ordered scaling, and persistent volumes.
Namespace-scoped Secret for DB credentials
# k8s/postgres/postgres-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
namespace: tasks-app
type: Opaque
stringData:
POSTGRES_USER: tasksuser
POSTGRES_PASSWORD: str0ngP@ssw0rd
POSTGRES_DB: tasksdb
kubectl apply -f k8s/postgres/postgres-secret.yaml
stringDatalets you write plain text — Kubernetes base64-encodes it automatically on storage. Usedatawhen you already have base64 values.
PersistentVolumeClaim
# k8s/postgres/postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: tasks-app
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: local-path # k3s ships with this by default
kubectl apply -f k8s/postgres/postgres-pvc.yaml
Verify the PVC is bound:
kubectl get pvc -n tasks-app
# STATUS should be Bound, not Pending
PostgreSQL StatefulSet
# k8s/postgres/postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: tasks-app
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: postgres-secret
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
subPath: pgdata # prevents permission issues with postgres image
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
exec:
command: ["pg_isready", "-U", "tasksuser", "-d", "tasksdb"]
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-pvc
Headless Service for PostgreSQL
A headless Service (clusterIP: None) is the correct way to expose a StatefulSet. It creates a stable DNS entry without load balancing — postgres.tasks-app.svc.cluster.local will always resolve to your postgres Pod.
# k8s/postgres/postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: tasks-app
spec:
clusterIP: None
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
Apply everything:
kubectl apply -f k8s/postgres/
Watch the pod come up:
kubectl get pods -n tasks-app -w
# postgres-0 0/1 Pending → ContainerCreating → Running
Connect to verify it’s working:
kubectl exec -it postgres-0 -n tasks-app -- psql -U tasksuser -d tasksdb
\l -- list databases
\q -- quit
Managed Redis in the Cluster
Redis follows the same pattern — a Deployment (not StatefulSet, since Redis without clustering doesn’t need stable identity) backed by a PVC.
# k8s/redis/redis-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-pvc
namespace: tasks-app
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: local-path
# k8s/redis/redis-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: tasks-app
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"]
ports:
- containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "200m"
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-pvc
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: tasks-app
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
kubectl apply -f k8s/redis/
Verify Redis is up:
kubectl exec -it deploy/redis -n tasks-app -- redis-cli ping
# PONG
Deploying the API
Now we have PostgreSQL and Redis running. Let’s deploy the NestJS API and connect them all.
ConfigMap and Secret for the API
# k8s/api/api-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: api-config
namespace: tasks-app
data:
NODE_ENV: "production"
PORT: "3000"
DB_HOST: "postgres.tasks-app.svc.cluster.local"
DB_PORT: "5432"
DB_NAME: "tasksdb"
REDIS_HOST: "redis.tasks-app.svc.cluster.local"
REDIS_PORT: "6379"
# k8s/api/api-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: api-secret
namespace: tasks-app
type: Opaque
stringData:
DB_USER: tasksuser
DB_PASSWORD: str0ngP@ssw0rd
The API Deployment
# k8s/api/api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: tasks-api
namespace: tasks-app
labels:
app: tasks-api
spec:
replicas: 2
selector:
matchLabels:
app: tasks-api
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # never let availability drop during rollout
template:
metadata:
labels:
app: tasks-api
spec:
containers:
- name: api
image: yourusername/tasks-api:latest
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: api-config
- secretRef:
name: api-secret
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "300m"
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
A few things worth calling out here. maxUnavailable: 0 means Kubernetes will never terminate an old Pod before a new one is ready — zero-downtime rolling updates. The livenessProbe tells Kubernetes when to restart a Pod that’s crashed or deadlocked. The readinessProbe tells Kubernetes when a Pod is actually ready to receive traffic — so new Pods don’t get traffic until the DB connection is established.
The API Service
# k8s/api/api-service.yaml
apiVersion: v1
kind: Service
metadata:
name: tasks-api-svc
namespace: tasks-app
spec:
selector:
app: tasks-api
ports:
- port: 80
targetPort: 3000
type: ClusterIP
Apply everything:
kubectl apply -f k8s/api/
Watch the rollout:
kubectl rollout status deployment/tasks-api -n tasks-app
# Waiting for deployment "tasks-api" rollout to finish: 0 of 2 updated replicas are available...
# deployment "tasks-api" successfully rolled out
Verify both replicas are running and ready:
kubectl get pods -n tasks-app
NAME READY STATUS RESTARTS AGE
postgres-0 1/1 Running 0 5m
redis-6d8b7c4f9-xk2lm 1/1 Running 0 3m
tasks-api-7b9f6d5c8-4jpqr 1/1 Running 0 1m
tasks-api-7b9f6d5c8-nwt8s 1/1 Running 0 1m
Quick smoke test from inside the cluster:
kubectl run -it --rm test --image=curlimages/curl --restart=Never -n tasks-app -- \
curl -s http://tasks-api-svc/health/ready
# {"status":"ok","db":"connected"}
Ingress — Exposing the API
Our API is reachable inside the cluster. To expose it externally with proper HTTP routing, we need an Ingress Controller. k3s ships with Traefik by default, but NGINX is more commonly used in production, so let’s use that.
Install NGINX Ingress Controller:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
For our local lab, the Ingress Controller needs a real IP. Install MetalLB to act as a software load balancer:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/main/config/manifests/metallb-native.yaml
Configure MetalLB with an IP pool from your Multipass network range:
# k8s/metallb/metallb-config.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: lab-pool
namespace: metallb-system
spec:
addresses:
- 10.46.7.240-10.46.7.250 # adjust to your Multipass subnet
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: lab-l2
namespace: metallb-system
kubectl apply -f k8s/metallb/
Now define the Ingress for the API:
# k8s/api/api-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tasks-api-ingress
namespace: tasks-app
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: tasks-api.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tasks-api-svc
port:
number: 80
kubectl apply -f k8s/api/api-ingress.yaml
Get the Ingress external IP:
kubectl get ingress -n tasks-app
# NAME CLASS HOSTS ADDRESS PORTS AGE
# tasks-api-ingress nginx tasks-api.local 10.46.7.240 80 1m
Add an /etc/hosts entry on your machine:
echo "10.46.7.240 tasks-api.local" | sudo tee -a /etc/hosts
Now hit the API from your local machine:
curl http://tasks-api.local/health/ready
# {"status":"ok","db":"connected"}
curl -X POST http://tasks-api.local/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Ship the k8s article","description":"Kubernetes from pods to production"}'
curl http://tasks-api.local/tasks
Your NestJS API, backed by PostgreSQL and Redis, is live on a real 3-node Kubernetes cluster.
Scaling the API
Manual Scaling
kubectl scale deployment tasks-api --replicas=4 -n tasks-app
kubectl get pods -n tasks-app # watch 2 new pods spin up
Traffic is automatically distributed across all 4 replicas by the Service.
Horizontal Pod Autoscaler
HPA scales your Deployment automatically based on CPU or memory utilization.
# k8s/api/api-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: tasks-api-hpa
namespace: tasks-app
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: tasks-api
minReplicas: 2
maxReplicas: 8
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70
kubectl apply -f k8s/api/api-hpa.yaml
kubectl get hpa -n tasks-app
The HPA polls metrics every 15 seconds by default. If CPU across all API pods exceeds 60%, it adds replicas. If it drops below, it scales back down — always keeping at least 2 replicas as our baseline.
Rolling Updates and Rollbacks
When you build a new version of the API, the release flow is:
# Build and push the new image
podman build -t yourusername/tasks-api:v2 .
podman push yourusername/tasks-api:v2
# Docker equivalent
docker build -t yourusername/tasks-api:v2 .
docker push yourusername/tasks-api:v2
# Trigger a rolling update
kubectl set image deployment/tasks-api api=yourusername/tasks-api:v2 -n tasks-app
# Watch it happen — old pods go down only after new ones are ready
kubectl rollout status deployment/tasks-api -n tasks-app
Something’s wrong with v2? Roll back instantly:
kubectl rollout undo deployment/tasks-api -n tasks-app
# Or roll back to a specific revision
kubectl rollout history deployment/tasks-api -n tasks-app
kubectl rollout undo deployment/tasks-api --to-revision=2 -n tasks-app
Because we set maxUnavailable: 0 in our Deployment strategy, users never experience downtime during either the update or the rollback.
Deployment Strategies
Blue/Green Deployment
Run two identical environments simultaneously. Switch traffic by updating the Service selector — instant cutover, instant rollback.
# api-deployment-green.yaml — identical to blue but with version: green label
metadata:
name: tasks-api-green
spec:
template:
metadata:
labels:
app: tasks-api
version: green # the new label
spec:
containers:
- name: api
image: yourusername/tasks-api:v2 # new version
# Deploy green alongside blue
kubectl apply -f k8s/api/api-deployment-green.yaml -n tasks-app
# Smoke test green directly
kubectl port-forward deploy/tasks-api-green 3001:3000 -n tasks-app
curl http://localhost:3001/health/ready
# Switch Service to green (zero-downtime cutover)
kubectl patch service tasks-api-svc -n tasks-app \
-p '{"spec":{"selector":{"version":"green"}}}'
# All good? Clean up blue
kubectl delete deployment tasks-api-blue -n tasks-app
# Something wrong? Roll back in seconds
kubectl patch service tasks-api-svc -n tasks-app \
-p '{"spec":{"selector":{"version":"blue"}}}'
Canary Deployment
Route a fraction of traffic to the new version — test with real users before full rollout.
# 4 replicas on v1, 1 replica on v2 = ~20% canary
kubectl scale deployment tasks-api --replicas=4 -n tasks-app
kubectl apply -f k8s/api/api-deployment-v2.yaml -n tasks-app # 1 replica
kubectl scale deployment tasks-api-v2 --replicas=1 -n tasks-app
# Both share selector: app: tasks-api, so the Service distributes 80/20
kubectl get pods -n tasks-app -l app=tasks-api
# Monitor. Happy? Shift all traffic.
kubectl scale deployment tasks-api-v2 --replicas=5 -n tasks-app
kubectl scale deployment tasks-api --replicas=0 -n tasks-app
Observability: Logs, Metrics, Traces
Logs
# Tail logs from all API pods simultaneously
kubectl logs -l app=tasks-api -n tasks-app --follow --prefix
# Logs from a specific pod
kubectl logs tasks-api-7b9f6d5c8-4jpqr -n tasks-app
# Previous container (crashed pods)
kubectl logs tasks-api-7b9f6d5c8-4jpqr -n tasks-app --previous
Prometheus + Grafana Stack
Install via Helm — this one command gives you Prometheus, Grafana, Alertmanager, and pre-built dashboards for node and pod metrics:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install kube-prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set grafana.adminPassword=admin123
Access Grafana:
kubectl port-forward svc/kube-prometheus-grafana 3000:80 -n monitoring
# Open http://localhost:3000 — admin / admin123
To expose NestJS metrics to Prometheus, add @willsoto/nestjs-prometheus to your app and annotate your Deployment:
# Add these annotations to the pod template metadata in api-deployment.yaml
metadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3000"
prometheus.io/path: "/metrics"
Resource Usage
kubectl top pods -n tasks-app
kubectl top nodes
NAME CPU(cores) MEMORY(bytes)
postgres-0 18m 87Mi
redis-6d8b7c4f9-xk2lm 4m 12Mi
tasks-api-7b9f6d5c8-4jpqr 23m 96Mi
tasks-api-7b9f6d5c8-nwt8s 21m 94Mi
Exec, Debug, and Troubleshooting
These are the commands you reach for when something’s wrong:
# Describe a pod — shows events, probe failures, image pull errors
kubectl describe pod tasks-api-7b9f6d5c8-4jpqr -n tasks-app
# Shell into a running container
kubectl exec -it tasks-api-7b9f6d5c8-4jpqr -n tasks-app -- sh
# Run a one-off debug pod with curl and dig
kubectl run -it --rm debug --image=nicolaka/netshoot --restart=Never -n tasks-app
# Inside the debug pod — test DNS resolution
dig postgres.tasks-app.svc.cluster.local
curl http://tasks-api-svc/health/ready
# Check recent cluster events (sorted)
kubectl get events -n tasks-app --sort-by='.lastTimestamp'
Operational Rules for Multipass + k3s
Shutting Down Cleanly
# Always stop VMs cleanly — never suspend a Kubernetes cluster
multipass stop --all
Suspending VMs breaks Flannel networking, expires certificates, and can corrupt etcd quorum. Stop, don’t suspend.
Starting Back Up
multipass start --all
# Give the cluster 30-60 seconds, then verify
kubectl get nodes
kubectl get pods -A
If a Node Doesn’t Rejoin
# Check the k3s agent service on the worker
multipass exec k3s-worker-1 -- sudo systemctl status k3s-agent
multipass exec k3s-worker-1 -- sudo systemctl restart k3s-agent
How This All Fits Together
A practical progression for a backend engineer in the Kubernetes world:
- Lab setup: Multipass + k3s 3-node cluster. Your own real Kubernetes, not a managed toy.
- Containerize your app: Podman (or Docker) to build and push. A proper multi-stage Dockerfile.
- Stateful services first: Deploy PostgreSQL and Redis as StatefulSet/Deployment with PVCs. Get the data layer right.
- Deploy the API: Deployment with proper liveness/readiness probes, resource limits, rolling update strategy.
- Connect the dots: ConfigMaps for config, Secrets for credentials, DNS for service discovery.
- Expose it: Ingress Controller + MetalLB. Real domain-based routing, not NodePort hacks.
- Scale it: HPA for automatic scaling, rolling updates and blue/green for zero-downtime releases.
- Observe it: Prometheus + Grafana. You can’t operate what you can’t see.
Conclusion
Kubernetes has a reputation for being overwhelming — and if you approach it as a giant pile of YAML to memorize, it is. But approached the way we did here — grounding every concept in a real app, a real cluster, and real data — it clicks fast. The NestJS API we built, the PostgreSQL and Redis we deployed, the Ingress that routes traffic to it — that’s a real production-shaped system. The patterns scale directly: same YAML structure, same operational commands, same mental model whether you’re running 3 nodes on your laptop or 300 nodes in a big datacenter.
Start with the lab. Get the API talking to the database. Break things and fix them. The rest follows naturally.
Note
Soon I would love to do a follow-up article on simulating microservices with multiple interdependent APIs, and how to manage that complexity in Kubernetes. But this single API + DB + cache is already a huge step up from the typical “hello world” examples, and gives you a solid foundation to build on. For example,
Using Nest.js microservices monorepo with multiple APIs, and deploying that to Kubernetes with shared Redis and PostgreSQL — that would be a fantastic next step.