Deploying a Full-Stack Application on Kubernetes with kind

12 min read
kuberneteskinddockerpostgresqldevops
Deploying a Full-Stack Application on Kubernetes with kind cover

In this project, I deployed a simple full-stack application on Kubernetes using kind.

The application has three main parts:

  • Frontend: React/Vite application served by Nginx
  • Backend: Node.js API application
  • Database: PostgreSQL running inside the Kubernetes cluster

The frontend and backend images are pulled from Docker Hub, while PostgreSQL runs directly as a Kubernetes workload with persistent storage.

Final Architecture

The final deployment looks like this:

                         Internet
                            |
                            | EC2_PUBLIC_IP:8080
                            v
                      AWS EC2 Host
                            |
                     Docker / kind
                            |
                    Host 8080 -> 30080
                            |
                            v
                 +---------------------+
                 |  frontend-service   |
                 |  NodePort :30080    |
                 +----------+----------+
                            |
                 selector: app=incident-frontend
                            |
                  +---------+---------+
                  v                   v
           Frontend Pod 1      Frontend Pod 2
              Nginx :80           Nginx :80
                  |
                  | /api/*
                  v
                 CoreDNS
                  |
                  | backend-service:5000
                  v
                 +---------------------+
                 |   backend-service   |
                 |    Service :5000    |
                 +----------+----------+
                            |
                  +---------+---------+
                  v                   v
            Backend Pod 1       Backend Pod 2
               :5000               :5000
                  |                   |
                  +---------+---------+
                            |
                            | postgres-service:5432
                            v
                    +----------------+
                    |postgres-service|
                    | ClusterIP:5432 |
                    +-------+--------+
                            |
                            v
                      PostgreSQL Pod
                           :5432
                            |
                            v
                      postgres-pvc
                            |
                            v
                     PersistentVolume

Project Structure

I organized the Kubernetes files like this:

incident-k8s/
|
├── kind-config.yml
├── namespace.yml
|
├── postgres-secret.yml
├── postgres-pvc.yml
├── postgres-deployment.yml
├── postgres-service.yml
|
├── backend-deployment.yml
├── backend-service.yml
|
├── frontend-deployment.yml
└── frontend-service.yml

Each file has one specific responsibility. That makes the deployment easier to reason about and debug.

Creating the kind Cluster

The first file is kind-config.yml. This creates the Kubernetes cluster using kind, which means the Kubernetes nodes run as Docker containers.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4

nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 30080
        hostPort: 8080
        listenAddress: "0.0.0.0"
        protocol: TCP

      - containerPort: 30081
        hostPort: 8081
        listenAddress: "0.0.0.0"
        protocol: TCP

  - role: worker

  - role: worker

From the EC2/Docker perspective, kind creates Docker containers:

AWS EC2
   |
   +-- Docker
        |
        +-- incident-control-plane
        +-- incident-worker
        +-- incident-worker2

But from the Kubernetes perspective, those containers are nodes:

kubectl get nodes

The important part is the port mapping:

EC2 :8080 -> kind :30080
EC2 :8081 -> kind :30081

Port 30080 is used by the frontend NodePort service. Port 30081 is used by the backend NodePort service.

Create the cluster with:

kind create cluster --name incident --config kind-config.yml

kind-config.yml is not a Kubernetes manifest. It is configuration consumed by kind before Kubernetes resources are applied.

Creating the Namespace

The namespace creates a logical boundary for all resources that belong to this application.

apiVersion: v1
kind: Namespace

metadata:
  name: incident

Apply it:

kubectl apply -f namespace.yml

After this, every manifest uses:

metadata:
  namespace: incident

Conceptually, the namespace contains:

incident
|
├── PostgreSQL
├── Backend
└── Frontend

Creating the PostgreSQL Secret

PostgreSQL requires database credentials. Instead of putting them directly inside the Deployment, I used a Kubernetes Secret.

apiVersion: v1
kind: Secret

metadata:
  name: postgres-secret
  namespace: incident

type: Opaque

stringData:
  POSTGRES_USER: incident_user
  POSTGRES_PASSWORD: incident_password
  POSTGRES_DB: incident_db

This provides:

  • POSTGRES_USER
  • POSTGRES_PASSWORD
  • POSTGRES_DB

The PostgreSQL container reads these values when it starts. The backend also reuses the same Secret for its database configuration.

postgres-secret
       |
       +----------------+
       v                v
PostgreSQL Pod     Backend Pods

For a learning environment, this is fine. In a real production setup, secrets should be handled more carefully, and real credentials should not be committed to Git.

Creating Persistent Storage

Containers are temporary. If PostgreSQL stores everything only inside the container filesystem, data can be lost when the container is removed or recreated.

Postgres Pod deleted
        |
        v
Container deleted
        |
        v
Database data potentially lost

So I created a PersistentVolumeClaim.

apiVersion: v1
kind: PersistentVolumeClaim

metadata:
  name: postgres-pvc
  namespace: incident

spec:
  accessModes:
    - ReadWriteOnce

  resources:
    requests:
      storage: 1Gi

The PVC requests 1Gi of persistent storage.

PostgreSQL Pod
      |
      | mount
      v
postgres-pvc
      |
      v
PersistentVolume

At first, the PVC can appear as Pending. In kind, the default StorageClass may use:

WaitForFirstConsumer

That means Kubernetes waits until a Pod actually needs the volume before provisioning and binding it.

PVC created
   |
   v
Pending
   |
   v
Postgres Pod scheduled
   |
   v
Volume provisioned
   |
   v
PVC Bound

Deploying PostgreSQL

Now we can create PostgreSQL itself.

apiVersion: apps/v1
kind: Deployment

metadata:
  name: postgres
  namespace: incident

spec:
  replicas: 1

  selector:
    matchLabels:
      app: postgres

  template:
    metadata:
      labels:
        app: postgres

    spec:
      containers:
        - name: postgres
          image: postgres:16

          ports:
            - containerPort: 5432

          env:
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_USER

            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_PASSWORD

            - name: POSTGRES_DB
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_DB

          volumeMounts:
            - name: postgres-storage
              mountPath: /var/lib/postgresql/data

      volumes:
        - name: postgres-storage
          persistentVolumeClaim:
            claimName: postgres-pvc

This connects three things:

postgres-secret
       |
       | credentials
       v
PostgreSQL Pod
       |
       | database files
       v
postgres-pvc

The PostgreSQL container listens on port 5432, and its database files are stored under:

/var/lib/postgresql/data

That path is mounted to the PVC, so the data can survive Pod restarts and replacements.

Creating the PostgreSQL Service

The PostgreSQL Pod has its own Pod IP, but the backend should never depend on that IP. Pods can be recreated, and their IPs can change.

So I created a stable Service:

apiVersion: v1
kind: Service

metadata:
  name: postgres-service
  namespace: incident

spec:
  type: ClusterIP

  selector:
    app: postgres

  ports:
    - protocol: TCP
      port: 5432
      targetPort: 5432

The Service finds the PostgreSQL Pod using this selector:

selector:
  app: postgres

That works because the Pod template has this label:

labels:
  app: postgres

The relationship is:

postgres-service
       |
       | selector: app=postgres
       v
PostgreSQL Pod

I used ClusterIP because PostgreSQL only needs to be reachable from inside the Kubernetes cluster. There is no reason to expose the database directly to the internet.

The backend can now connect to:

postgres-service:5432

instead of knowing the PostgreSQL Pod IP.

Deploying the Backend

The backend Docker image is:

shanto78/incident-backend:latest

The Deployment creates two backend replicas.

apiVersion: apps/v1
kind: Deployment

metadata:
  name: incident-backend
  namespace: incident

spec:
  replicas: 2

  selector:
    matchLabels:
      app: incident-backend

  template:
    metadata:
      labels:
        app: incident-backend

    spec:
      containers:
        - name: incident-backend
          image: shanto78/incident-backend:latest

          ports:
            - containerPort: 5000

          env:
            - name: PORT
              value: "5000"

            - name: NODE_ENV
              value: "production"

            - name: DB_HOST
              value: "postgres-service"

            - name: DB_PORT
              value: "5432"

            - name: DB_NAME
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_DB

            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_USER

            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: POSTGRES_PASSWORD

The most important configuration is:

DB_HOST=postgres-service
DB_PORT=5432

In Docker Compose, I previously used:

DB_HOST=database

because database was the Docker Compose service name.

In Kubernetes, the equivalent stable name is the Service name:

Docker Compose       Kubernetes
database       ->     postgres-service

CoreDNS allows the backend Pods to resolve that Service name.

incident-backend Deployment
           |
           v
       ReplicaSet
           |
      replicas: 2
           |
     +-----+-----+
     v           v
Backend Pod   Backend Pod
   :5000         :5000

Creating the Backend Service

The two backend Pods need one stable endpoint.

apiVersion: v1
kind: Service

metadata:
  name: backend-service
  namespace: incident

spec:
  type: NodePort

  selector:
    app: incident-backend

  ports:
    - protocol: TCP
      port: 5000
      targetPort: 5000
      nodePort: 30081

The Service finds Pods labeled:

app=incident-backend

so both backend Pods become Service endpoints.

              backend-service
                   :5000
                     |
             selector:
          app=incident-backend
                     |
              +------+------+
              v             v
         Backend Pod    Backend Pod
            :5000          :5000

Because the Service uses:

nodePort: 30081

and kind maps:

EC2 8081 -> kind 30081

the backend can be reached externally during testing:

EC2_PUBLIC_IP:8081
       |
       v
NodePort :30081
       |
       v
backend-service :5000
       |
       v
Backend Pod :5000

This external backend exposure is useful for learning and testing. The frontend reverse proxy does not require browser traffic to access the backend directly.

Configuring Frontend Nginx

The frontend is a static application served through Nginx. Its Docker image includes an Nginx configuration like this:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://backend-service:5000/api/;

        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

This is an important part of the architecture.

Requests for / serve the frontend. Requests for /api/* are proxied to:

backend-service:5000

So the flow becomes:

Browser
   |
   | /api/incidents
   v
Frontend Nginx
   |
   | backend-service:5000/api/incidents
   v
Backend Service
   |
   v
Backend Pod

The browser never needs to understand Kubernetes DNS. Nginx is running inside the cluster, so it can resolve backend-service through CoreDNS.

Deploying the Frontend

The frontend Docker image is:

shanto78/incident-frontend:latest

The frontend Deployment also runs two replicas.

apiVersion: apps/v1
kind: Deployment

metadata:
  name: incident-frontend
  namespace: incident

spec:
  replicas: 2

  selector:
    matchLabels:
      app: incident-frontend

  template:
    metadata:
      labels:
        app: incident-frontend

    spec:
      containers:
        - name: incident-frontend
          image: shanto78/incident-frontend:latest

          ports:
            - containerPort: 80

Nginx runs inside each frontend Pod and serves the built frontend files.

Deployment
    |
    v
ReplicaSet
    |
+---+----------+
|              |
v              v
Frontend     Frontend
Pod 1        Pod 2
:80          :80

Creating the Frontend Service

Finally, I exposed the frontend.

apiVersion: v1
kind: Service

metadata:
  name: frontend-service
  namespace: incident

spec:
  type: NodePort

  selector:
    app: incident-frontend

  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
      nodePort: 30080

The Service finds frontend Pods using:

app=incident-frontend
frontend-service
       |
       | selector: app=incident-frontend
       v
 +-----+---------+
 |               |
 v               v
Frontend       Frontend
Pod 1          Pod 2

The kind configuration maps:

EC2 :8080 -> kind :30080

and the Service maps:

NodePort :30080 -> Service :80 -> Pod :80

Therefore, the complete external path is:

Browser
   |
   v
EC2_PUBLIC_IP:8080
   |
   v
kind mapping 8080 -> 30080
   |
   v
frontend-service
   |
   v
Frontend Pod

Complete Request Flow

When a user opens:

http://EC2_PUBLIC_IP:8080

the frontend request follows this path:

Browser
   |
   v
EC2 :8080
   |
   v
kind port mapping
   |
   v
NodePort :30080
   |
   v
frontend-service
   |
   v
Frontend Nginx Pod
   |
   v
index.html / JS / CSS
   |
   v
Browser

Now suppose the frontend requests:

/api/incidents

The request becomes:

Browser
   |
   | /api/incidents
   v
EC2 :8080
   |
   v
frontend-service
   |
   v
Frontend Nginx Pod
   |
   | proxy_pass
   v
backend-service:5000
   |
   | CoreDNS resolves Service
   v
Backend Pod
   |
   | SQL query
   v
postgres-service:5432
   |
   | CoreDNS resolves Service
   v
PostgreSQL Pod
   |
   v
Database

The response travels back through the same chain:

PostgreSQL
   |
   v
Backend
   |
   v
Frontend Nginx
   |
   v
Browser

That is the full request lifecycle of the deployed application.

Deployment Order

For a completely fresh environment, I used this order:

# 1. Cluster
kind create cluster --name incident --config kind-config.yml

# 2. Namespace
kubectl apply -f namespace.yml

# 3. PostgreSQL configuration/storage
kubectl apply -f postgres-secret.yml
kubectl apply -f postgres-pvc.yml

# 4. PostgreSQL
kubectl apply -f postgres-deployment.yml
kubectl apply -f postgres-service.yml

# 5. Backend
kubectl apply -f backend-deployment.yml
kubectl apply -f backend-service.yml

# 6. Frontend
kubectl apply -f frontend-deployment.yml
kubectl apply -f frontend-service.yml

Conceptually:

Cluster
   |
   v
Namespace
   |
   v
Secrets + Storage
   |
   v
Database
   |
   v
Database Service
   |
   v
Backend
   |
   v
Backend Service
   |
   v
Frontend
   |
   v
Frontend Service
   |
   v
Application accessible

Useful Verification Commands

During deployment, these are the commands worth remembering:

# Everything in the namespace
kubectl get all -n incident

# Pods
kubectl get pods -n incident -o wide

# Deployments
kubectl get deployments -n incident

# Services
kubectl get svc -n incident

# Persistent storage
kubectl get pvc -n incident

# Service endpoints
kubectl get endpointslices -n incident

# Pod details/errors
kubectl describe pod <pod-name> -n incident

# Service details
kubectl describe svc <service-name> -n incident

# Application logs
kubectl logs <pod-name> -n incident

# Follow logs
kubectl logs -f <pod-name> -n incident

Debugging Lessons

One useful lesson from this deployment was knowing which command to reach for based on the failure mode.

If the Pod is Running, but the application is failing:

Pod Running but application failing
        |
        v
kubectl logs

If the Pod shows CreateContainerConfigError:

CreateContainerConfigError
        |
        v
kubectl describe pod
        |
        v
Check Events

If the Pod shows ErrImagePull:

ErrImagePull
        |
        v
kubectl describe pod
        |
        v
Check image/tag/platform

I encountered both configuration and image-platform issues during this deployment, so these were not just theoretical troubleshooting steps.

Final Thoughts

This deployment helped connect several Kubernetes concepts together:

  • A kind cluster can expose NodePorts through Docker port mappings.
  • Deployments manage replicas through ReplicaSets.
  • Services provide stable network names for changing Pods.
  • CoreDNS allows applications to communicate using Service names.
  • PVCs keep database storage separate from the PostgreSQL container lifecycle.
  • Nginx can act as the frontend server and internal API reverse proxy.

The most important mindset shift was moving from “container names” in Docker Compose to “Service names” in Kubernetes. Once that clicked, the full-stack request flow became much easier to understand.