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?
- StatefulSet requirement: StatefulSets must have a headless service defined in
serviceName - Application convenience: Regular service provides a simple hostname for applications to use
- 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




