# Bridging the Gap: Implementing External Access for Stateful Workloads in Kubernetes

Today, we will learn how to set up a backend API container with PostgreSQL on Kubernetes. In this tutorial, you will learn how to write a multi-stage Docker file, install the MetalLB load balancer, set up the Rancher local path provisioner for storage, and install the NGINX Ingress controller.

### Lab Purpose

I want to share my past Kubernetes experience in this lab. **Disclaimer:** In this lab, I created a simple backend CRUD API using Gemini AI. To be honest, I'm not a developer, so my backend code is not perfect.

The primary objective of this lab is to architect and deploy a production-ready **SMS API** on a bare-metal Kubernetes environment. This exercise demonstrates how to bridge the gap between internal cluster resources and external network accessibility without relying on managed cloud services.

If you don’t know how to setup Kubernetes Cluster. You can use below link for install and setup.

Here is the [Kubernetes Cluster Installation](https://devopscube.com/setup-kubernetes-cluster-kubeadm/)

Here is my [Code Repo](https://github.com/frandi-devops-learn/demo-sms-api)

| **Tools** | **Purpose of Use** | **Link** |
| --- | --- | --- |
| Docker | To package applications and their dependencies into a standardized unit called a container | [https://www.docker.com/](https://www.docker.com/) |
| Docker Hub | To store the container images | [https://hub.docker.com/](https://hub.docker.com/) |
| Kubernets | To automate the deployment, scaling, and management of containerized applications, enabling, reliable, and efficient operations across clusters of machines | [https://kubernetes.io/](https://kubernetes.io/) |
| Helm | Helm is the package manager for Kubernetes, designed to simplify deployment, management, and scaling of complex applications by bundling YAML configuration files into reusable, versioned packages called Charts | [https://helm.sh/](https://helm.sh/) |
| MetalLB | It addresses the lack of native cloud-provider load balancers in **on-premise** environments by assigning IP addresses from a configured pool | [https://metallb.io/](https://metallb.io/) |
| Rancher Loacal path provisioner | To dynamically provision Persistent Volumes (PVs) using the local storage on each Kubernetes node | [https://github.com/rancher/local-path-provisioner](https://github.com/rancher/local-path-provisioner) |
| Nginx Ingress Controller | Specialized load balancer and reverse proxy that manages external access to services within a Kubernetes cluster | [https://github.com/kubernetes/ingress-nginx](https://github.com/kubernetes/ingress-nginx) |

### 1\. Multistage docker file

**Why we need to do multistage docker file?**

Multistage Docker builds are a game-changer for anyone tired of bloated, heavy container images. In a nutshell, they allow you to use multiple **FROM** statements in a single Dockerfile, effectively separating the **build environment** from the **runtime environment**.

Here is why they are considered a best practice.

1. **Significant Size Reduction**
    
    In a standard Dockerfile, everything you use to compile your app (compilers, build tools, header files) remains in the final image. With multistage builds, you "leave the trash behind." You compile your app in the first stage and then copy only the final executable into a tiny, production-ready image.
    
2. **Enhanced Security**
    
    A smaller image has a smaller **attack surface**. By removing package managers (like apt or npm), source code, and shell utilities from your final production image, you give hackers fewer tools to work with if they manage to gain access to your container.
    
3. **Simplified CI/CD Pipelines**
    
    Before multistage builds, developers often had to maintain two separate Dockerfiles (e.g., Dockerfile.build and Dockerfile.production) and use shell scripts to move artifacts between them. Multistage builds allow you to keep all that logic in **one file**, making your CI/CD pipeline much cleaner.
    

Here is my Dockerfile using multistage.

```plaintext
# Builder stage
FROM node:22-alpine AS builder
WORKDIR /app

RUN apk add --no-cache openssl libc6-compat curl
COPY package*.json ./
COPY prisma ./prisma/

RUN npm install
RUN npx prisma generate

# Runner stage
FROM node:22-alpine AS runner
WORKDIR /app
RUN apk add --no-cache openssl libc6-compat curl

COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma

# Copy application source
COPY package*.json ./
COPY server.js ./
COPY entrypoint.sh ./

RUN chmod +x entrypoint.sh

EXPOSE 3000

ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]
```

Clone github repo to my local computer.

```plaintext
git clone https://github.com/frandi-devops-learn/demo-sms-api.git

cd demo-sms-api/

git branch -a

git checkout -b devops

git pull origin devops
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769254615974/334f4088-6969-486d-aedb-ab49fb8dca6a.png align="center")

### 2\. Docker image push to DockerHub

After cloning the repo, we need to build this Docker image and push it to Docker Hub. In my lab, I use the public Docker Hub to store my container image. In this lab, I build Docker images for two types of architecture, so you can run either arm64 or amd64.

```plaintext
docker buildx build --platform Linux/arm64,Linux/amd64 -t zinlinhtetdevops/demo-sms-api:latest . --push
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769330644875/f068dfe0-7ba5-4d5d-96dd-b54417d27010.png align="center")

You can pull my ready to use container image.

```plaintext
docker pull zinlinhtetdevops/demo-sms-api:latest

docker images
```

### 3 . Installation of Helm

```plaintext
curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4

chmod 700 get_helm.sh

sudo ./get_helm.sh
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769252906082/995886cd-ae9a-48ee-9333-ba9e4c9815b9.png align="center")

### 4\. Installation of MetalLB

I use helm for install MetalLB on my kubernetes node. I create namespace for MetalLB.

```plaintext
helm repo list

helm repo add metallb https://metallb.github.io/metallb

helm install metallb metallb/metallb --create-namespace --namespace metallb-system
```

Now you can see MetalLB pod is successfully run.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769254039527/651f4137-0f90-42a5-aa15-cf06a5a167ef.png align="center")

Here is a breakdown of what this configuration does and a small missing piece you might need.

### 4.1. IPAddressPool

This is the "bucket" of IP addresses that MetalLB is allowed to hand out.

1. **Range:** 192.168.1.200 - 192.168.1.205.
    
2. **Purpose:** When you create a Service with **type: LoadBalancer**, MetalLB will grab one of these 6 IPs and assign it to that service.
    
3. **Crucial Requirement:** These IPs **must not** be in use by your router's DHCP server. If your router hands out **.200** to your phone, and MetalLB hands it to your API, you'll have an IP conflict.
    

### 4.2. L2Advertisement

This tells MetalLB how to announce those IPs to your local network.

* **Layer 2 (L2):** This is the simplest mode. It uses standard ARP (Address Resolution Protocol).
    
* **How it works:** One of your nodes will basically shout to your router: "Hey, I am now the owner of 192.168.1.200!" The router then sends all traffic for that IP to that node.
    

```plaintext
kubectl create -f metallb.yml
```

```plaintext
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: first-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.1.200-192.168.1.205
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: advertisement
  namespace: metallb-system
spec:
  ipAddressPools:
  - first-pool
```

### 5\. Installation of Rancher Local path provisioner

In a local Kubernetes environment (like K3s, Minikube, or Kind), managing storage can be frustrating. By default, Kubernetes doesn't automatically "give" you storage just because you ask for a Persistent Volume Claim (PVC). You usually have to manually create a Persistent Volume (PV) first.

The **Rancher Local Path Provisioner** solves this by providing **Dynamic Provisioning** for local storage.

Without this provisioner, if you want to deploy a database like MySQL or PostgreSQL, you have to:

1. Manually create a directory on your laptop/server.
    
2. Manually write a YAML for a Persistent Volume pointing to that path.
    
3. Then create your Persistent Volume Claim.
    

With the **Local Path Provisioner**, you skip steps 1 and 2. You just ask for a PVC, and the provisioner automatically creates a unique folder on your host machine and handles the PV creation for you.

Install the rancher local path provisioner using kubectl command.

```plaintext
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml

kubectl get namespace

kubectl get all -n local-path-storage
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769257519906/5d6df77c-7cfb-4fb3-aedc-bef6d2eb4044.png align="center")

### 6\. Create Namespace

In Kubernetes, a **Namespace** is essentially a "virtual cluster" within your physical cluster. If you only have one small app, you might not notice the need for them, but as soon as your project grows, namespaces become your best friend for organization and security. And then I chage the default namespace to demo-sms namespace.

```plaintext
kubectl create namespace dmeo-sms

kubectl get namespace

kubectl config set-context --current --namespace demo-sms

kubectl config get-contexts
```

### 7\. Deploy PostgreSQL database on Kubernetes

It’s time to deploy the PostgreSQL database. In this lab, you need to deploy a StatefulSet for the database. Even if the database pod restarts, your data needs to exist and be persistent.

In Kubernetes, a **Deployment** is great for "stateless" things like your web app or an API where it doesn't matter which pod is which. But a database like **PostgreSQL** is "stateful"—it cares deeply about its identity and its data. Using a **StatefulSet** provides the specific "guardrails" that a database needs to run safely without corrupting data.

The first step I need to create a Persistent Volume Claim to attach a vloume in PostgreSQL database container. So let’s create a pvc. Here is the pvc.yml file.

```plaintext
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-pvc
  namespace: demo-sms
spec:
  accessModes:
    - ReadWriteOnce
  volumeMode: Filesystem
  resources:
    requests:
      storage: 10Gi
  storageClassName: local-path
```

```plaintext
kubectl get pvc
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769259129399/ae137161-9f5c-4fd4-a158-ced9c81be3f4.png align="center")

Before creating the database, we need to create a secret for the database password. I don't want to use a hardcoded value in the PostgreSQL StatefulSet. So how to encode you database password. I will show you.

```plaintext
echo -n "yourpassword" | base64

kubectl create -f sec.yml

kubectl get secrets
```

```plaintext
apiVersion: v1
data:
  postgres-password: ZGJhZG1pbjEyMw== ( Put your base64 encoded database password )
kind: Secret
metadata:
  name: postgres-secret
  namespace: demo-sms
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769259711555/057e700c-ee20-4aef-9b7f-4080be55220f.png align="center")

So let’s deploy PostgreSQL database StatfulSet container.

```plaintext
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: demo-sms-db
  namespace: demo-sms
spec:
  serviceName: "demo-sms-db"
  replicas: 1
  selector:
    matchLabels:
      app: demo-sms-db
  template:
    metadata:
      labels:
        app: demo-sms-db
    spec:
      containers:
        - name: demo-sms-db
          image: postgres:16-alpine
          env:
           - name: POSTGRES_DB
             value: "uatdb"
           - name: POSTGRES_USER
             value: "dbadmin"
           - name: POSTGRES_PASSWORD
             valueFrom: 
               secretKeyRef:
                 name: postgres-secret
                 key: postgres-password
          ports:
          - containerPort: 5432
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
          volumeMounts:
            - mountPath: /var/lib/postgresql/data
              name: db-storage
              subPath: postgres-data
      volumes:
        - name: db-storage
          persistentVolumeClaim:
            claimName: db-pvc
```

```plaintext
kubectl create -f stateful-set.yml

kubectl get pods

kubectl get pvc

kubectl get statefulset
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769326744286/cc665da6-c821-4a01-98e5-82c76569c247.png align="center")

Once the StatefulSet is running properly, I expose my PostgreSQL service using the ClusterIP type since we only need to access this database within the cluster. However, I also want to connect to my database from outside the cluster, so I expose it again using the NodePort type. I will show you how to expose a service with both ClusterIP and NodePort types.

**This is the ClusterIP type.**

```plaintext
apiVersion: v1
kind: Service
metadata:
  labels:
    app: demo-sms-db
  name: demo-sms-db-svc
  namespace: demo-sms
spec:
  ports:
  - port: 5432
    protocol: TCP
    targetPort: 5432
  selector:
    app: demo-sms-db
  type: ClusterIP
status:
  loadBalancer: {}
```

```plaintext
kubectl get svc

kubectl create -f cluster-svc.yml

kubectl get svc
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769327304156/6b8461b6-1447-49b8-bd57-73c23b552ae0.png align="center")

**This is the NodePort type.** I use the node port number 32110.

```plaintext
apiVersion: v1
kind: Service
metadata:
  labels:
    app: demo-sms-db
  name: demo-sms-db-extenal
  namespace: demo-sms
spec:
  ports:
  - port: 5432
    protocol: TCP
    targetPort: 5432
    nodePort: 32110
  selector:
    app: demo-sms-db
  type: NodePort
status:
  loadBalancer: {}
```

```plaintext
kubectl create -f node-svc.yml

kubectl get svc
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769327882932/80deceae-b440-424c-a564-2aeaf503473e.png align="center")

I use pgAdmin4 to connect my Database. 192.168.1.108 is my Kubernetes IP. You need to use the NodePort number, not the actual PostgreSQL port. Then you can connect your database from pgAdmin.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769328137859/93d89737-b0c8-41a8-9a7f-1ce78e904fa7.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769328266246/84d0654f-526b-417d-88e2-491599f4183b.png align="center")

You can see there are no tables because the Prisma database migration has not been applied. But after the backend API container is deployed, you will see the tables.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769328296543/d91b4e1d-1c42-4434-919b-f437004f3e31.png align="center")

### 8\. Deploy Backend API on Kubernetes

After the database is successfully deployed, we will deploy the backend API on Kubernetes. In this deployment, I set the replica count to 2 and specify resource requests and limits. I also use an init container to make sure the database is ready before the web app tries to connect.

Explanation of the deployment file

1. **namespace: demo-sms** : isolating this app into its own virtual cluster.
    
2. **strategy: RollingUpdate** : This ensures **zero downtime**.
    
    * **maxSurge** **:** **1** : Kubernetes will start 1 new Pod before killing an old one.
        
    * **maxUnavailable: 0** : Kubernetes will never have fewer than 2 Pods running during an update.
        
3. **Requests** : What the Pod is guaranteed to get (0.25 CPU and 128MB RAM).
    
4. **Limits** : The maximum it can consume. If it tries to use more than 256MB RAM, Kubernetes will kill it (OOMKilled) to protect the rest of the cluster.
    

This is the deploy.yml file.

```plaintext
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: demo-sms-api
  name: demo-sms-api
  namespace: demo-sms
spec:
  replicas: 2
  selector:
    matchLabels:
      app: demo-sms-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: demo-sms-api
    spec:
      initContainers:
        - name: wait-for-db-up
          image: busybox:1.28
          command: ['sh', '-c', "until nc -zv demo-sms-db-svc 5432; do echo 'Waiting for database...'; sleep 2; done"]
      containers:
      - image: zinlinhtetdevops/demo-sms-api:latest
        name: demo-sms-api
        ports:
        - containerPort: 3000
        env:
          - name: DB_HOST
            value: "demo-sms-db-svc"
          - name: DB_USER
            value: "dbadmin"
          - name: DB_NAME
            value: "uatdb"
          - name: DB_PASSWORD
            valueFrom:
              secretKeyRef:
                name: postgres-secret
                key: postgres-password
        resources:
          requests:
            memory: "128Mi"
            cpu: "250m"
          limits:
            memory: "256Mi"
            cpu: "500m"
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 15
          periodSeconds: 20
```

```plaintext
kubectl get pods

kubectl create -f deploy.yml

kubectl get deployments

kubectl get pods

kubectl logs -f pods/demo-sms-api-7bb5bc6c97-dtdh9 -c wait-for-db-up

kubectl logs -f pods/demo-sms-api-7bb5bc6c97-dtdh9
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769330820596/ef0392f3-4a3a-4353-abce-578b1a77094d.png align="center")

Now that we have successfully deployed the backend API, it's time to expose the backend service using the ClusterIP type. However, I want to use a domain name, like **api.demo-sms.local**, so I will configure the Nginx Ingress Controller.

**Why do we need to use the Nginx Ingress Controller?**

With MetalLB alone, each service you want to expose (API, Frontend, Admin Dashboard) needs its own unique IP from your **192.168.1.200-205** range. You will run out of IPs very quickly. With Ingress, you use **one** LoadBalancer IP (e.g., 192.168.1.200) for the NGINX Controller, and it routes traffic to many different services based on the URL.

**How it works with MetalLB**

In my local setup, the relationship looks like this:

1. **MetalLB** provides a stable IP (e.g., 192.168.1.200) to the **NGINX Ingress Service**.
    
2. My **Domain Name** (or **/etc/hosts** file) points to that IP.
    
3. **NGINX Ingress** looks at the incoming request and sends it to my **demo-sms-api** Pod.
    

So, install and set up the Nginx Ingress Controller on Kubernetes. First we need to expose our backed api service. API service should now be a **ClusterIP** (internal only), because the Ingress will handle the external traffic.

Here is the svc-cluster.yml file.

```plaintext
apiVersion: v1
kind: Service
metadata:
  labels:
    app: demo-sms-api
  name: demo-sms-api-cluster
  namespace: demo-sms
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 3000
  selector:
    app: demo-sms-api
  type: ClusterIP
```

```plaintext
kubectl create -f svc-cluster.yml

kubectl get svc
```

Let’s start to install Nginx Ingress Controller.

```plaintext
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/cloud/deploy.yaml
```

```plaintext
kubectl get namespace

kubectl get all -n ingress-nginx

kubectl get svc -n ingress-nginx
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769336976089/d37bd0da-3c9f-47c2-a6ce-f79efdab0a2f.png align="center")

Now you can see that MetalLB has assigned an IP (**192.168.1.200**) to the Nginx Ingress Controller load balancer.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769337228675/e1033203-ba6f-4fed-aecc-724fd46aa992.png align="center")

Now create the Ingress resource. This is the routing rule that tells NGINX to send traffic to API. Here is the ingress.yml file.

```plaintext
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-sms-ingress
  namespace: demo-sms
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
  - host: api.demo-sms.local
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: demo-sms-api-cluster
            port:
              number: 80
```

```plaintext
kubectl create -f ingress.yml

kubectl get ingress -n demo-sms
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769337696099/ff393edf-e0a4-4c99-afa9-37364f72a9c7.png align="center")

It's time to access our backend API through the Nginx Ingress Controller. However, you need to map the IP address to the domain name in the /etc/hosts file. Without this, you won't be able to access it using the domain name in local.

```plaintext
sudo vi /etc/hosts

192.168.1.200 api.demo-sms.local
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769338219894/440e2929-a314-4cd8-a18e-f558e193b9d8.png align="center")

### 9\. Testing with Postman

Now open your browser and enter the domain name. You should see the backend API working properly.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769338332409/5b7f867e-859f-4f85-9032-a79dfb23e383.png align="center")

I use Postman to test the basic CRUD operations on my backend API.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769339499299/ee8bcf82-3ea2-421a-9710-ea25cb033ce9.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769339434687/b1b36092-3de4-400e-a834-6e820ea4ba53.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769339465104/dc4cbf85-f6e8-4ab7-b6db-9e22684340ec.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769339871294/18654830-8326-4309-9b62-5b7954d81347.png align="center")

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1769339903377/c0a1c64b-f34f-4b16-8a06-9f5851b3a083.png align="center")

By completing this lab, you will have established a robust, scalable, and externally accessible API infrastructure that adheres to Kubernetes best practices for stateful applications and on-premise networking.

Congratulations you did it. It looks good. This lab was successfully completed without any errors. See you in next articles. If you have any issues please let me know I will be happy to assist you. Stay tuned and learn together. If you find my article useful, please kindly like and share it.
