Strapi is a headless CMS with an intuitive admin dashboard, custom content types, and role-based permissions, well-suited to Kubernetes for its scalability and multi-environment support. This guide builds a Strapi app, containerizes it, and deploys it to Kubernetes behind an Nginx Ingress Controller with cert-manager TLS.
Prerequisites: a Kubernetes cluster with kubectl/helm configured, a PostgreSQL database, Docker on your workstation, a domain (this guide uses strapi.example.com) pointed at the ingress load balancer once provisioned.
Build the Strapi Application
1. Install Node.js:
$ curl -sL https://deb.nodesource.com/setup_22.x | sudo -E bash -
$ sudo apt install -y nodejs
2. Configure Git if not already set up:
$ git config --global user.name "Your Full Name"
$ git config --global user.email "email@example.com"
3. Scaffold a project:
$ npx create-strapi-app@latest strapi-project --quickstart --js
Once it's running, press Ctrl+C to stop the dev server and continue configuring it.
Containerize the Application
1. Point it at PostgreSQL — edit .env in the project directory:
$ cd strapi-project
$ nano .env
DATABASE_CLIENT=postgres
DATABASE_HOST=your-db-host
DATABASE_PORT=5432
DATABASE_NAME=strapi_db
DATABASE_USERNAME=user
DATABASE_PASSWORD=password
DATABASE_SSL=true
DATABASE_SSL_REJECT_UNAUTHORIZED=false
DATABASE_SCHEMA=public
DATABASE_CONNECTION_TIMEOUT=60000
2. Replace config/server.js:
$ mv config/server.js config/server.ORIG
$ nano config/server.js
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('STRAPI_URL'),
app: {
keys: env.array('APP_KEYS'),
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});
3. Set the URL, install the PostgreSQL driver, rebuild, run:
$ export STRAPI_URL="http://127.0.0.1:1337"
$ npm install pg --save
$ npm run build
$ npm run start &
Visit http://<server-ip>:1337 and confirm the admin registration screen loads.
Build and Push the Docker Image
1. Dockerfile:
$ nano Dockerfile
FROM node:22-alpine
RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev nasm bash vips-dev
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /opt/
COPY package.json package-lock.json ./
RUN npm install
ENV PATH=/opt/node_modules/.bin:$PATH
WORKDIR /opt/app
COPY . .
RUN chown -R node:node /opt/app
USER node
RUN ["npm", "run", "build"]
EXPOSE 1337
CMD ["npm", "run", "start"]
2. .dockerignore:
$ nano .dockerignore
node_modules/
3. Build, tag, push:
$ docker build -t strapi:latest .
$ docker login -u <username> -p <password>
$ docker tag strapi:latest <username>/strapi:latest
$ docker push <username>/strapi:latest
Prepare the Cluster
Install the Nginx Ingress Controller
$ helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
$ helm repo update
$ kubectl create namespace ingress-nginx
$ helm install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx
A LoadBalancer provisions automatically:
$ kubectl get services ingress-nginx-controller --namespace ingress-nginx
It may take a few minutes for EXTERNAL-IP to populate.
Install cert-manager and a ClusterIssuer
$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml
Point your domain's A record at the Ingress controller's EXTERNAL-IP, then:
$ nano clusterissuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: "hello@example.com"
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
$ kubectl apply -f clusterissuer.yaml
$ kubectl get clusterissuer letsencrypt-prod
READY: True confirms it's live.
Deploy Strapi
1. Deployment (2 replicas):
$ nano strapi-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: strapi-deployment
spec:
replicas: 2
selector:
matchLabels:
name: strapi-app
template:
metadata:
labels:
name: strapi-app
spec:
imagePullSecrets:
- name: regcred
containers:
- name: strapi
image: <username>/strapi:latest
imagePullPolicy: Always
command: ["sh", "-c", "npm run build && npm run start"]
ports:
- containerPort: 1337
env:
- name: STRAPI_URL
value: "strapi.example.com"
regcred is a pull secret if your image lives in a private registry.
$ kubectl apply -f strapi-deployment.yaml
$ kubectl get deployment strapi-deployment
2. Service:
$ nano strapi-service.yaml
apiVersion: v1
kind: Service
metadata:
name: strapi-service
spec:
ports:
- name: http
port: 80
protocol: TCP
targetPort: 1337
selector:
name: strapi-app
$ kubectl apply -f strapi-service.yaml
3. Ingress with TLS:
$ nano strapi-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: strapi-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- secretName: strapi-tls
hosts:
- strapi.example.com
rules:
- host: strapi.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: strapi-service
port:
number: 80
$ kubectl apply -f strapi-ingress.yaml
$ kubectl get ingress
Visit https://strapi.example.com — you should hit the login page over HTTPS.
Scale the Deployment
$ kubectl scale deployment/strapi-deployment --replicas=4
$ kubectl get deployment strapi-deployment
$ kubectl get pods
Scale back down:
$ kubectl scale deployment/strapi-deployment --replicas=2
Test fault tolerance by deleting a pod. Kubernetes replaces it and the Ingress routes around the gap with no downtime:
$ kubectl delete pod <pod-name>
Next Steps
Strapi is running on Kubernetes with TLS termination and horizontal scaling. From here:
- Add a
HorizontalPodAutoscalerto scale on CPU/memory instead of manually - Move media uploads to S3-compatible object storage via a Strapi upload provider plugin
- Set resource requests/limits on the Deployment to avoid noisy-neighbor scheduling issues
For the full guide, visit the original article on Vultr Docs.
Top comments (0)