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.
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:
The solution? PostgreSQL - a robust, production-ready database that will keep your blog content safe and enable your application to scale.
Let's add a proper database to our blog platform. PostgreSQL is an excellent choice for Strapi applications.
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.
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:
hostpath (default)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
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:
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.
Now let's deploy PostgreSQL with persistent storage using a StatefulSet. StatefulSets are the recommended approach for databases because they provide:
# 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?
postgres-0.postgres-headless.default.svc.cluster.localserviceNameThink of it like this:
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
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:
postgres-0 (not random like deployments)postgres-storage-postgres-0 (follows pattern: volumeClaimTemplate-podName)postgres-0.postgres-headless.default.svc.cluster.localNow 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
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.
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.
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
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.
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:
We'll use cert-manager to automate certificate provisioning with Let's Encrypt.
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-http
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: [email protected]
privateKeySecretRef:
name: letsencrypt-private-key
solvers:
- http01:
ingress:
class: nginx
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.
In production, Strapi needs persistent storage for uploaded media files. Without it, files uploaded through the admin interface will be lost when pods restart.
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
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.
Production deployments need proper resource limits and health monitoring to ensure stability and efficient resource usage.
# 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 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
As your blog grows, you'll need to scale automatically based on traffic.
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
Regular backups are crucial for data protection in production.
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
Problem: Strapi pod fails to start Solution: Check logs and verify database connectivity:
kubectl logs deployment/strapi kubectl describe pod <strapi-pod-name>
Problem: "connection refused" errors Solution: Verify service names and ports match your configuration:
kubectl get services
kubectl exec -it <strapi-pod> -- nslookup postgres
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
Problem: Uploaded files disappearing Solution: Verify PVC is properly mounted and has sufficient space:
kubectl get pvc kubectl describe pvc strapi-uploads-pvc
Congratulations! You've completed a comprehensive production-ready deployment that includes: