16.02.2026 • 19 min read

Learn Kubernetes with k3s & Multipass as a Backend Engineer

Cover Image

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.

Let's dive in image

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 4G if you have 16GB RAM or --disk 16G if 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 imagePullSecret in 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

stringData lets you write plain text — Kubernetes base64-encodes it automatically on storage. Use data when 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:

  1. Lab setup: Multipass + k3s 3-node cluster. Your own real Kubernetes, not a managed toy.
  2. Containerize your app: Podman (or Docker) to build and push. A proper multi-stage Dockerfile.
  3. Stateful services first: Deploy PostgreSQL and Redis as StatefulSet/Deployment with PVCs. Get the data layer right.
  4. Deploy the API: Deployment with proper liveness/readiness probes, resource limits, rolling update strategy.
  5. Connect the dots: ConfigMaps for config, Secrets for credentials, DNS for service discovery.
  6. Expose it: Ingress Controller + MetalLB. Real domain-based routing, not NodePort hacks.
  7. Scale it: HPA for automatic scaling, rolling updates and blue/green for zero-downtime releases.
  8. 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.