KubeKanvas Logo
  • Features
  • Pricing
  • Templates
    • How KubeKanvas works
    • Downloads
    • Blog
    • Tutorials
  • FAQs
  • Contact
  • Features
  • Pricing
  • Templates
    • How KubeKanvas works
    • Downloads
    • Blog
    • Tutorials
  • FAQs
  • Contact

Building and Deploying a Modern Blog Platform with Next.js, Strapi, and Kubernetes – Part 3: Production Database and Security

This is Part 3 of our 3-part tutorial series designed to help you build a modern, scalable, and secure blog platform using Strapi, Next.js, and Kubernetes.
Shamaila Mahmood
Shamaila Mahmood
December 29, 2025
Cloud-Native Applications
Building and Deploying a Modern Blog Platform with Next.js, Strapi, and Kubernetes – Part 3: Production Database and Security

In this final part, we'll upgrade from SQLite to a production PostgreSQL database, implement proper security with ConfigMaps and Secrets, enable HTTPS with Let's Encrypt, and add all the production-ready features your blog needs.

If you haven't completed Part 1 and Part 2, be sure to go through them before proceeding.


The Problem with SQLite

Remember in Part 1 when we mentioned that using SQLite means "data disappears when the pod restarts"? Now it's time to fix that!

While SQLite was perfect for getting started quickly, it has some serious limitations for production:

  • Data loss: All content disappears when pods restart
  • No scaling: Can't handle multiple Strapi replicas
  • No concurrent writes: Performance issues under load
  • Limited features: Missing advanced database capabilities

The solution? PostgreSQL - a robust, production-ready database that will keep your blog content safe and enable your application to scale.


Step 1: Deploy PostgreSQL Database

Let's add a proper database to our blog platform. PostgreSQL is an excellent choice for Strapi applications.

Understanding Persistent Storage

Before we deploy PostgreSQL, let's understand why we need persistent storage in Kubernetes:

The Problem:

Pod Restart → Container Filesystem Reset → Data Lost 

The Solution:

Pod + Persistent Volume → Data Survives Restarts 

A PersistentVolumeClaim (PVC) allows us to attach durable storage to a pod, ensuring our database files survive pod restarts and rescheduling.

1.1: Understanding Storage Classes

Before creating storage, you need to understand what storage options are available in your cluster. Different Kubernetes environments provide different storage classes.

What is a Storage Class? A StorageClass defines the "class" of storage offered by your cluster administrator. It tells Kubernetes what type of disk to provision (SSD, HDD, cloud storage, etc.) and how to provision it.

Discover Your Available Storage Classes:

# List all storage classes in your cluster
kubectl get storageclass

# Get detailed information about storage classes
kubectl describe storageclass

Common Storage Class Names by Platform:

  • Docker Desktop: hostpath (default)
  • Minikube: standard (default)

Find Your Default Storage Class:

# Look for the storage class marked as (default)
kubectl get storageclass
# Output will show something like:
# NAME                 PROVISIONER             RECLAIMPOLICY   ...
# hostpath (default)   docker.io/hostpath      Delete          ...

Check Available Persistent Volumes:

# See what persistent volumes exist (if any)
kubectl get pv

# Get detailed information about persistent volumes
kubectl describe pv

1.2: Understanding StatefulSet Storage

Since we'll be using a StatefulSet for PostgreSQL (the recommended approach for databases), we don't need to create a separate PVC. StatefulSets use volumeClaimTemplates which automatically create a PVC for each pod.

Benefits of StatefulSet volumeClaimTemplates:

  • Automatic PVC creation: Each pod gets its own dedicated storage
  • Stable storage identity: Storage follows the pod even if it's rescheduled
  • Simplified management: No need to manually create and manage PVCs
  • Scaling support: New pods automatically get new storage

However, you still need to know your storage class name for the volumeClaimTemplates configuration. Use the commands from the previous section to discover available storage classes.

1.3: Deploy PostgreSQL

Now let's deploy PostgreSQL with persistent storage using a StatefulSet. StatefulSets are the recommended approach for databases because they provide:

  • Stable network identities for each pod (postgres-0, postgres-1, etc.)
  • Ordered deployment and scaling which is crucial for database operations
  • Persistent storage per pod that moves with the pod
  • Graceful termination ensuring proper database shutdown
# postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  labels:
    app: postgres
spec:
  serviceName: postgres-headless
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:15-alpine
          env:
            - name: POSTGRES_DB
              value: strapi
            - name: POSTGRES_USER
              value: strapi
            - name: POSTGRES_PASSWORD
              value: strapi123  # We'll secure this with Secrets later!
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          ports:
            - containerPort: 5432
              name: postgres
          volumeMounts:
            - name: postgres-storage
              mountPath: /var/lib/postgresql/data
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          # Add readiness and liveness probes for better health monitoring
          readinessProbe:
            exec:
              command:
                - /bin/sh
                - -c
                - pg_isready -U strapi -d strapi
            initialDelaySeconds: 15
            periodSeconds: 10
          livenessProbe:
            exec:
              command:
                - /bin/sh
                - -c
                - pg_isready -U strapi -d strapi
            initialDelaySeconds: 30
            periodSeconds: 30
  volumeClaimTemplates:
  - metadata:
      name: postgres-storage
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 10Gi
      storageClassName: standard  # ⚠️ REPLACE with your actual storage class name usually 'hostpath' 

---

# postgres-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres-headless
  labels:
    app: postgres
spec:
  clusterIP: None  # Headless service for StatefulSet
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432
      name: postgres

The headless service is not what you might be thinking. It is not to connect strapi to the database. For strapi to be able to connect to postgres we still need a clusterIP type service. So what is this stateful service for?

  • Purpose: Required by StatefulSet for pod identity and networking
  • No load balancing: Doesn't provide a single IP - instead gives direct access to individual pods
  • Pod discovery: Allows direct connection to specific pods (postgres-0, postgres-1, etc.)
  • DNS records: Creates DNS entries like postgres-0.postgres-headless.default.svc.cluster.local

Why Both Services?

  1. StatefulSet requirement: StatefulSets must have a headless service defined in serviceName
  2. Application convenience: Regular service provides a simple hostname for applications to use
  3. Different use cases:
    • Headless: Used by StatefulSet controller for pod management
    • Regular: Used by applications (Strapi) for database connections

Analogy

Think of it like this:

  • Headless service: Like having individual phone numbers for each database server (postgres-0, postgres-1)
  • Regular service: Like having a main company phone number that routes to available database servers

Here is the manifest for ClusterIP service to access the database.

# postgres-service.yaml (for external access)
apiVersion: v1
kind: Service
metadata:
  name: postgres
  labels:
    app: postgres
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432
  type: ClusterIP

1.4: Deploy the Database

Apply these configurations:


# Deploy PostgreSQL StatefulSet
kubectl apply -f postgres-statefulset.yaml

# Verify everything is running
kubectl get statefulset postgres
kubectl get pods -l app=postgres
kubectl get pvc -l app=postgres

# Check the StatefulSet status
kubectl describe statefulset postgres

You should see output like:

NAME       READY   AGE
postgres   1/1     30s

NAME         READY   STATUS    RESTARTS   AGE
postgres-0   1/1     Running   0          30s

NAME                              STATUS   VOLUME                     CAPACITY   ACCESS MODES   STORAGECLASS
postgres-storage-postgres-0       Bound    pvc-abc123-def456-789...   10Gi       RWO            standard

Understanding StatefulSet Naming:

  • Pod: postgres-0 (not random like deployments)
  • PVC: postgres-storage-postgres-0 (follows pattern: volumeClaimTemplate-podName)
  • Service: Accessible via postgres-0.postgres-headless.default.svc.cluster.local

1.5: Update Strapi to Use PostgreSQL

Now we need to update our Strapi deployment to connect to PostgreSQL instead of SQLite. Update your Strapi deployment:

# strapi-deployment.yaml (updated)
apiVersion: v1
kind: Secret
metadata:
  name: strapi-secrets
type: Opaque
stringData:
  ADMIN_JWT_SECRET: "your-admin-jwt-secret-value"
  API_TOKEN_SALT: "your-api-token-salt-value"
  APP_KEYS: "your-app-keys-value"
  JWT_SECRET: "your-jwt-secret-value"
  
  DATABASE_CLIENT: "postgres"
  DATABASE_HOST: "postgres-headless"
  DATABASE_PORT: "5432"
  DATABASE_NAME: "strapi"
  DATABASE_USERNAME: "strapi"
  DATABASE_PASSWORD: "strapi123"
  
  NODE_ENV: "production"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: strapi
  labels:
    app: strapi
spec:
  replicas: 1
  selector:
    matchLabels:
      app: strapi
  template:
    metadata:
      labels:
        app: strapi
    spec:
      containers:
        - name: strapi
          image: kubekanvas/strapi
          ports:
            - containerPort: 1337
          envFrom:
            - secretRef:
                name: strapi-secrets
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
  name: strapi-service
spec:
  type: LoadBalancer 
  selector:
    app: strapi
  ports:
    - protocol: TCP
      port: 80
      targetPort: 1337

Apply the updated deployment:

kubectl apply -f strapi-deployment.yaml

1.6: Verify the Database Connection

Check that Strapi connects successfully to PostgreSQL:

# Watch Strapi logs
kubectl logs -f deployment/strapi

# Check PostgreSQL pod directly
kubectl logs postgres-0

# Test database connectivity from within the cluster
kubectl exec -it postgres-0 -- psql -U strapi -d strapi -c "SELECT version();"

# Look for messages like:
# [2025-06-21 14:15:32.123] info: Database connection established
# [2025-06-21 14:15:33.456] info: Server started on port 1337

StatefulSet-specific Troubleshooting:

# Check StatefulSet status
kubectl get statefulset postgres

# Describe the StatefulSet for detailed events
kubectl describe statefulset postgres

# Check if the persistent volume was created
kubectl get pvc postgres-storage-postgres-0

# Check pod events
kubectl describe pod postgres-0

🎉 Success! Your blog now has a production-ready database that will persist your content across pod restarts.


Step 2: Secure Configuration with ConfigMaps and Secrets

Let's say you're deploying to multiple environments (dev, staging, prod). Rather than hard-coding different values into each deployment YAML, you define a ConfigMap for each environment. If a password or hostname changes, you update the Secret or ConfigMap—no need to touch your application manifests.

Also, storing passwords or tokens as plain environment variables is a security risk. Kubernetes Secrets base64-encode them and allow you to restrict access via RBAC.


Create ConfigMap and Secret

Here's how you can define your configuration and sensitive data:

# strapi-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: strapi-config
  namespace: default
data:
  DATABASE_CLIENT: postgres
  DATABASE_HOST: postgres
  DATABASE_PORT: "5432"
  DATABASE_NAME: strapi
  NODE_ENV: production
---

apiVersion: v1
kind: Secret
metadata:
  name: strapi-secret
  namespace: default
type: Opaque
stringData:
  DATABASE_USERNAME: strapi
  DATABASE_PASSWORD: strapi123

Update Deployment to Use Them

In your Strapi deployment, you'll now reference these values like this:

# Updated strapi-deployment.yaml
apiVersion: v1
kind: Secret
metadata:
  name: strapi-secrets
type: Opaque
stringData:
  ADMIN_JWT_SECRET: "your-admin-jwt-secret-value"
  API_TOKEN_SALT: "your-api-token-salt-value"
  APP_KEYS: "your-app-keys-value"
  JWT_SECRET: "your-jwt-secret-value"
  
  NODE_ENV: "production"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: strapi
  labels:
    app: strapi
spec:
  replicas: 1
  selector:
    matchLabels:
      app: strapi
  template:
    metadata:
      labels:
        app: strapi
    spec:
      containers:
        - name: strapi
          image: kubekanvas/strapi
          ports:
            - containerPort: 1337
          envFrom:
            - configMapRef:
                name: strapi-config
            - secretRef:
                name: strapi-secrets
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
  name: strapi-service
spec:
  type: LoadBalancer 
  selector:
    app: strapi
  ports:
    - protocol: TCP
      port: 80
      targetPort: 1337

Apply the new configuration:

kubectl apply -f strapi-config.yaml
kubectl apply -f strapi-deployment.yaml

Similarly update your postgres deployment to use these configurations.


apiVersion: apps/v1
kind: Deployment
metadata:
  name: strapi
  labels:
    app: strapi
spec:
  replicas: 1
  selector:
    matchLabels:
      app: strapi
  template:
    metadata:
      labels:
        app: strapi
    spec:
      containers:
        - name: strapi
          image: kubekanvas/strapi
          imagePullPolicy: Never
          ports:
            - containerPort: 1337
          resources:
            requests:
              memory: 512Mi
              cpu: 250m
            limits:
              memory: 1Gi
              cpu: 500m
          envFrom:
            - configMapRef:
                name: strapi-config
            - secretRef:
                name: strapi-secret

This change cleans up your deployment YAML and keeps your configuration centralized. At this point now you have created a persistent storage for your blogs and you have achieved another milestone in the project. Your blog can be accessed via Ingress, your frontend is ready to display blogs, your strapi data is reboot save. Let's continue fun.


Step 3: Enable HTTPS with Let's Encrypt

Since Part 2 of this article series your frontend and backend are accessible via Ingress, it's time to secure your application with HTTPS.

Why HTTPS is Essential:

  • Encrypts all communication between your users and your app
  • Builds trust (browsers now mark HTTP-only sites as "Not Secure")
  • Improves SEO ranking
  • Is required for many modern web features (e.g., PWAs, service workers)

We'll use cert-manager to automate certificate provisioning with Let's Encrypt.

Install cert-manager

kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

Create ClusterIssuer

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-http
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-private-key
    solvers:
      - http01:
          ingress:
            class: nginx

Update Ingress to Use TLS

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: strapi-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-http
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - your-domain.com
      secretName: blog-tls
  rules:
    - host: your-domain.com
      http:
        paths:
          - path: /admin
            pathType: Prefix
            backend:
              service:
                name: strapi
                port:
                  number: 1337
          - path: /
            pathType: Prefix
            backend:
              service:
                name: blog-frontend
                port:
                  number: 3000

Once applied, cert-manager will obtain and renew certificates for your domain automatically.


Step 4: Configure Persistent Storage for Media Files

In production, Strapi needs persistent storage for uploaded media files. Without it, files uploaded through the admin interface will be lost when pods restart.

Create Persistent Volume Claim

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: strapi-uploads-pvc
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: standard  # Use your cluster's default storage class

Update Strapi Deployment

Add the volume mount to your Strapi deployment:

spec:
  template:
    spec:
      containers:
      - name: strapi
        # ...existing container config...
        volumeMounts:
        - name: uploads
          mountPath: /opt/app/public/uploads
      volumes:
      - name: uploads
        persistentVolumeClaim:
          claimName: strapi-uploads-pvc

This ensures all uploaded files persist across pod restarts and are shared between multiple Strapi replicas.


Step 5: Add Resource Management and Health Checks

Production deployments need proper resource limits and health monitoring to ensure stability and efficient resource usage.

Update Deployments with Resource Limits

# For Strapi deployment
resources:
  requests:
    memory: "512Mi"
    cpu: "250m"
  limits:
    memory: "1Gi"
    cpu: "500m"

# For Next.js frontend
resources:
  requests:
    memory: "256Mi"
    cpu: "100m"
  limits:
    memory: "512Mi"
    cpu: "250m"

# For PostgreSQL
resources:
  requests:
    memory: "256Mi"
    cpu: "250m"
  limits:
    memory: "512Mi"
    cpu: "500m"

Add Health Checks

# Add to Strapi container
livenessProbe:
  httpGet:
    path: /admin
    port: 1337
  initialDelaySeconds: 60
  periodSeconds: 30
  timeoutSeconds: 10
readinessProbe:
  httpGet:
    path: /admin
    port: 1337
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 5

# Add to Next.js container
livenessProbe:
  httpGet:
    path: /
    port: 3000
  initialDelaySeconds: 30
  periodSeconds: 30
readinessProbe:
  httpGet:
    path: /
    port: 3000
  initialDelaySeconds: 15
  periodSeconds: 10

Step 6: Enable Horizontal Pod Autoscaling

As your blog grows, you'll need to scale automatically based on traffic.

Create HPA for Strapi

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: strapi-hpa
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: strapi
  minReplicas: 1
  maxReplicas: 5
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80

Step 7: Set Up Database Backups

Regular backups are crucial for data protection in production.

Create Backup CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: postgres-backup
  namespace: default
spec:
  schedule: "0 2 * * *"  # Daily at 2 AM
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: postgres-backup
            image: postgres:14
            env:
            - name: PGPASSWORD
              valueFrom:
                secretKeyRef:
                  name: strapi-secret
                  key: DATABASE_PASSWORD
            command:
            - /bin/bash
            - -c
            - |
              pg_dump -h postgres -U strapi strapi > /backup/backup-$(date +%Y%m%d-%H%M%S).sql
              # Keep only last 7 days of backups
              find /backup -name "backup-*.sql" -mtime +7 -delete
            volumeMounts:
            - name: backup-storage
              mountPath: /backup
          volumes:
          - name: backup-storage
            persistentVolumeClaim:
              claimName: postgres-backup-pvc
          restartPolicy: OnFailure

---

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-backup-pvc
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi

Troubleshooting Common Issues

Pod Startup Failures

Problem: Strapi pod fails to start Solution: Check logs and verify database connectivity:

kubectl logs deployment/strapi
kubectl describe pod <strapi-pod-name>

Database Connection Issues

Problem: "connection refused" errors Solution: Verify service names and ports match your configuration:

kubectl get services
kubectl exec -it <strapi-pod> -- nslookup postgres

Certificate Issues

Problem: Let's Encrypt certificates not issuing Solution: Check cert-manager logs and challenge status:

kubectl logs -n cert-manager deployment/cert-manager
kubectl describe certificate blog-tls
kubectl describe challengerequest

Storage Issues

Problem: Uploaded files disappearing Solution: Verify PVC is properly mounted and has sufficient space:

kubectl get pvc
kubectl describe pvc strapi-uploads-pvc

What You've Built

Congratulations! You've completed a comprehensive production-ready deployment that includes:

Architecture Overview

  • Strapi CMS - A powerful, headless content management system
  • Next.js Frontend - A modern, high-performance React framework
  • PostgreSQL Database - Reliable, ACID-compliant data storage
  • Kubernetes Orchestration - Container management and scaling

Production Features Implemented

  • Security: ConfigMaps/Secrets, HTTPS certificates, network policies
  • Reliability: Health checks, resource limits, persistent storage
  • Scalability: Horizontal pod autoscaling, load balancing
  • Monitoring: Automated backups, troubleshooting capabilities

Key Benefits You've Achieved

  • Zero-downtime deployments with rolling updates
  • Automatic scaling based on traffic demands
  • Secure communication with Let's Encrypt certificates
  • Data persistence across pod restarts and updates
  • Disaster recovery with automated database backups
Cloud NativeContainer OrchestrationContainersDockerHeadless CMSIngress ControllerKubernetesKubernetes ArchitectureNext.jsOpen Source

Related Articles

DynamoDB Fundamentals
DynamoDB Fundamentals
Guide to designing dynamodb tables with single table design - part 1
Khurram Mahmood
Khurram Mahmood
November 30, 2025
Cloud-Native Applications
Top 6 Kubernetes IDEs & Dashboards (2025 Edition): Best Kubernetes Tools for Kubernetes Projects
Top 6 Kubernetes IDEs & Dashboards (2025 Edition): Best Kubernetes Tools for Kubernetes Projects
Discover the best Kubernetes tools of 2025, including top Kubernetes management tools and Kubernetes...
Rafay Siddiquie
November 13, 2025
Kubernetes
Why KubeKanvas is the Go-To Tool for Kubernetes Beginners and Seasoned Experts Alike
Why KubeKanvas is the Go-To Tool for Kubernetes Beginners and Seasoned Experts Alike
Empowering CIOs and CTOs, this blog explores why KubeKanvas is the essential tool for simplifying Ku...
Rafay Siddiquie
September 10, 2025
C-Suite & Strategy
Introducing Custom Resource Support in KubeKanvas: Extend Your Kubernetes Definitions
Introducing Custom Resource Support in KubeKanvas: Extend Your Kubernetes Definitions
Discover how KubeKanvas now supports Custom Resource Definitions (CRDs) and Custom Resources (CRs), ...
Shamaila Mahmood
Shamaila Mahmood
September 3, 2025
KubeKanvas
KubeKanvas Logo
Visual Kubernetes cluster design tool that helps you create, manage, and deploy your applications with ease.

Product

  • Features
  • Pricing
  • Templates

Resources

  • Blog
  • Tutorials

Company

  • About Us
  • Contact
  • Terms of Service
  • Privacy Policy
  • Impressum
XGitHubLinkedIn
© 2025 KubeKanvas. All rights reserved.