Strapi
Published in

Strapi

How to Deploy and Scale Strapi on a Kubernetes Cluster 1/2

Discover how to integrate Strapi with Kubernetes through a comprehensive, step-by-step tutorial series. Explore the process from building your image to deploying a highly available and robust application.

Pre-requisites

Tooling

Project Setup

yarn create strapi-app strapi-k8s
# using the following configuration
? Choose your installation type Custom (manual settings)
? Choose your preferred language JavaScript
? Choose your default database client mysql
? Database name: strapi-k8s
? Host: 127.0.0.1
? Port: 3306
? Username: strapi
? Password: ****** # please always use strong passwords
? Enable SSL connection: No

Source Code

On The Shoulders of Giants

Strapi + Docker

.idea
node_modules
npm-debug.log
yarn-error.log
docker-compose build --no-cache # to force the build of the image without cache
docker-compose --env-file .env up
docker-compose stop # to stop
docker-compose down # to completely remove it (it won't delete any created volumes)
version: '3'
services:
strapi:
build: .
image: mystrapiapp:latest
restart: unless-stopped
env_file:
- .env
volumes:
- ./config:/opt/app/config
- ./src:/opt/app/src
- ./package.json:/opt/package.json
- ./yarn.lock:/opt/yarn.lock
- ./public/uploads:/opt/app/public/uploads
- ./.env:/opt/app/.env
ports:
- '1337:1337'
networks:
- strapi
depends_on:
- strapiDB

strapiDB:
platform: linux/amd64 #for platform error on Apple M1 chips
restart: unless-stopped
image: mysql:5.7
command: --default-authentication-plugin=mysql_native_password
environment:
MYSQL_USER: ${DATABASE_USERNAME}
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD}
MYSQL_PASSWORD: ${DATABASE_PASSWORD}
MYSQL_DATABASE: ${DATABASE_NAME}
volumes:
- strapi-data:/var/lib/mysql
ports:
- '3306:3306'
networks:
- strapi

volumes:
strapi-data:

networks:
strapi:

Dockerize

npx @strapi-community/dockerize
# complete the steps
✔ Do you want to create a docker-compose file? 🐳 … Yes
✔ What environments do you want to configure? › Both
✔ Whats the name of the project? … strapi-k8s
✔ What database do you want to use? › MySQL
✔ Database Host … localhost
✔ Database Name … strapi-k8s
✔ Database Username … strapi
✔ Database Password … ***********
✔ Database Port … 3306
docker-compose --env-file .env up

Strapi + K8s

Kubernetes setup

mkdir -p /tmp/k3d
mkdir -p ~/strapi-k8s
# or create your Strapi project: yarn create strapi-app strapi-k8s
cd strapi-k8s
apiVersion: k3d.io/v1alpha4 # this will change in the future as we make everything more stable
kind: Simple # internally, we also have a Cluster config, which is not yet available externally
metadata:
name: mycluster # name that you want to give to your cluster (will still be prefixed with `k3d-`)
servers: 1 # same as `--servers 1`
agents: 2 # same as `--agents 2`
ports:
- port: 8900:30080 # same as `--port '8080:80@loadbalancer'`
nodeFilters:
- agent:0
- port: 8901:30081 # just in case
nodeFilters:
- agent:0
- port: 8902:30082
nodeFilters:
- agent:0
- port: 1337:31337 # for Strapi
nodeFilters:
- agent:0
volumes: # repeatable flags are represented as YAML lists
- volume: /tmp/k3d:/var/lib/rancher/k3s/storage # same as `--volume '/my/host/path:/path/in/node@server:0;agent:*'`
nodeFilters:
- server:0
- agent:*
registries: # define how registries should be created or used
create: # creates a default registry to be used with the cluster; same as `--registry-create registry.localhost`
name: app-registry
host: "0.0.0.0"
hostPort: "5050"
config: | # define contents of the `registries.yaml` file (or reference a file); same as `--registry-config /path/to/config.yaml`
mirrors:
"localhost:5050":
endpoint:
- http://app-registry:5050
options:
k3d: # k3d runtime settings
wait: true # wait for cluster to be usable before returning; same as `--wait` (default: true)
timeout: "60s" # wait timeout before aborting; same as `--timeout 60s`
disableLoadbalancer: false # same as `--no-lb`
kubeconfig:
updateDefaultKubeconfig: true # add new cluster to your default Kubeconfig; same as `--kubeconfig-update-default` (default: true)
switchCurrentContext: true # also set current-context to the new cluster's context; same as `--kubeconfig-switch-context` (default: true)
k3d cluster create -c mycluster.yaml
kubectl get nodes
# the output should be similar to this:
NAME STATUS ROLES AGE VERSION
k3d-mycluster-server-0 Ready control-plane,master 24s v1.24.4+k3s1
k3d-mycluster-agent-0 Ready <none> 20s v1.24.4+k3s1
k3d-mycluster-agent-1 Ready <none> 19s v1.24.4+k3s1
kubectx k3d-mycluster
k3d cluster stop mycluster
k3d cluster start mycluster

Production

Basic Deployment

mkdir k8s

Development flow

Docker images

docker build -t mystrapiapp-prod:0.0.1 -f Dockerfile.prod .
docker tag mystrapiapp-prod:0.0.1 localhost:5050/mystrapiapp-prod:0.0.1
docker push localhost:5050/mystrapiapp-prod:0.0.1
# or build it with the tag
docker build -t mystrapiapp-prod:0.0.1 -t localhost:5050/mystrapiapp-prod:0.0.1 -f Dockerfile .
docker push localhost:5050/mystrapiapp-prod:0.0.1

Environment variables

echo -n 123456 | base64
# MTIzNDU2
# ~/strapi-k8s/k8s/conf.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: strapi-database-conf
data:
MYSQL_USER: strapi
MYSQL_DATABASE: strapi-k8s
---
apiVersion: v1
kind: Secret
metadata:
name: strapi-database-secret
type: Opaque
data:
# please NEVER use these passwords, always use strong passwords
MYSQL_ROOT_PASSWORD: c3RyYXBpLXN1cGVyLXNlY3VyZS1yb290LXBhc3N3b3Jk # echo -n strapi-super-secure-root-password | base64
MYSQL_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64
---
apiVersion: v1
kind: ConfigMap
metadata:
name: strapi-app-conf
data:
HOST: 0.0.0.0
PORT: "1337"
NODE_ENV: production

# we'll explain the db host later
DATABASE_HOST: strapi-db
DATABASE_PORT: "3306"
DATABASE_USERNAME: strapi
DATABASE_NAME: strapi-k8s
---
apiVersion: v1
kind: Secret
metadata:
name: strapi-app-secret
type: Opaque
data:
# use the proper values in here
APP_KEYS: <APP keys in base64>
API_TOKEN_SALT: <API token salt in base64>
ADMIN_JWT_SECRET: <admin JWT secret in base64>
JWT_SECRET: <JWT secret in base64>
# please NEVER use these passwords, always use strong passwords
DATABASE_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64

DB Deployment

# ~/strapi-k8s/k8s/db.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: strapi-database-pv
labels:
type: local
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
storageClassName: local-path
hostPath:
path: "/var/lib/rancher/k3s/storage/strapi-database-pv" # the path we configured in the conf file to create the cluster + sub path
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: strapi-database-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: strapi-db
spec:
replicas: 1
selector:
matchLabels:
app: strapi-db
template:
metadata:
labels:
app: strapi-db
spec:
containers:
- name: mysql
image: mysql:5.7
securityContext:
runAsUser: 1000
allowPrivilegeEscalation: false
ports:
- containerPort: 3306
name: mysql
envFrom:
- configMapRef:
name: strapi-database-conf # the name of our ConfigMap for our db.
- secretRef:
name: strapi-database-secret # the name of our Secret for our db.
volumeMounts:
- name: mysql-persistent-storage
mountPath: /var/lib/mysql
volumes:
- name: mysql-persistent-storage
persistentVolumeClaim:
claimName: strapi-database-pvc # the name of our PersistentVolumeClaim
---
apiVersion: v1
kind: Service
metadata:
name: strapi-db # this is the name we use for DATABASE_HOST
spec:
selector:
app: strapi-db
ports:
- name: mysql
protocol: TCP
port: 3306
targetPort: mysql # same name defined in the Deployment path spec.template.spec.containers[0].ports[0].name

App Deployment

# ~/strapi-k8s/k8s/app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: strapi-app
spec:
replicas: 1
selector:
matchLabels:
app: strapi-app
template:
metadata:
labels:
app: strapi-app
spec:
containers:
- name: strapi
image: app-registry:5050/mystrapiapp-prod:0.0.1 # this is a custom image, therefore we are using the "custom" registry
ports:
- containerPort: 1337
name: http
envFrom:
- configMapRef:
name: strapi-app-conf # the name of our ConfigMap for our app.
- secretRef:
name: strapi-app-secret # the name of our Secret for our app.
---
apiVersion: v1
kind: Service
metadata:
name: strapi-app
spec:
type: NodePort
selector:
app: strapi-app
ports:
- name: http
protocol: TCP
port: 1337
nodePort: 31337 # we are using this port, to match the cluster port forwarding section from mycluster.yaml
targetPort: http # same name defined in the Deployment path spec.template.spec.containers[0].ports[0].name

Deploying everything

kubectl apply -f k8s/
watch kubectl get pods
http://localhost:1337/
kubectl logs --selector app=strapi-app --tail=50 --follow # for the app
kubectl logs --selector app=strapi-db --tail=50 --follow # for the db

Recap

Improved Deployment

mkdir helm

Helm chart

cd helm
helm create strapi-chart
strapi-chart
├── Chart.yaml
├── charts
├── templates
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── service.yaml
│ ├── serviceaccount.yaml
│ └── tests
│ └── test-connection.yaml
└── values.yaml
# from the "helm" folder
# helm template <NAME (any name)> <CHART_PATH>
helm template strapi strapi-chart

Helm chart customization

# ~/strapi-k8s/helm/strapi-chart/templates/claim.yaml

{{- if .Values.storage.claim.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "strapi-chart.fullname" . }}-pvc
labels:
{{- include "strapi-chart.labels" . | nindent 4 }}
spec:
accessModes: {{ .Values.storage.accessModes }}
storageClassName: {{ .Values.storage.storageClassName }}
resources:
requests:
storage: {{ .Values.storage.capacity }}
{{- end }}
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
storage:
claim:
enabled: false
capacity: 5Gi
accessModes:
- ReadWriteOnce
storageClassName: local-path
mountPath: "/tmp"
# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
# ...
{{- if .Values.storage.claim.enabled }}
volumes:
- name: {{ include "strapi-chart.fullname" . }}-storage
persistentVolumeClaim:
claimName: {{ include "strapi-chart.fullname" . }}-pvc
{{- end }}
# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
# ...
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- if .Values.storage.claim.enabled }}
volumeMounts:
- name: {{ include "strapi-chart.fullname" . }}-storage
mountPath: {{ .Values.storage.mountPath }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
# ...
helm template mysql strapi-chart
helm template mysql strapi-chart --set storage.volume.enabled=true --set storage.claim.enabled=true
# ~/strapi-k8s/helm/strapi-chart/templates/configmap.yaml

{{- if .Values.configMap.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "strapi-chart.fullname" . }}
data:
{{- toYaml .Values.configMap.data | nindent 2 }}
{{- end }}
# ~/strapi-k8s/helm/strapi-chart/templates/secret.yaml

{{- if .Values.secret.enabled }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "strapi-chart.fullname" . }}
type: Opaque
data:
{{- toYaml .Values.secret.data | nindent 2 }}
{{- end }}
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
configMap:
enabled: false
data: {}

secret:
enabled: false
data: {}
# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
# ...
{{- if .Values.storage.claim.enabled }}
volumeMounts:
- name: {{ include "strapi-chart.fullname" . }}-storage
mountPath: {{ .Values.storage.mountPath }}
{{- end }}
{{- if or .Values.configMap.enabled .Values.secret.enabled }}
envFrom:
{{- if .Values.configMap.enabled }}
- configMapRef:
name: {{ include "strapi-chart.fullname" . }}
{{- end }}
{{- if .Values.secret.enabled }}
- secretRef:
name: {{ include "strapi-chart.fullname" . }}
{{- end }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
# ...
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
service:
type: ClusterIP
port: 80
portName: http
containerPort: 80
nodePort: # for a Service of type NodePort, and yes, we'll leave it empty
# ...
# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
# ...
ports:
- name: {{ .Values.service.portName }}
containerPort: {{ .Values.service.containerPort }}
protocol: TCP
# ...
# ~/strapi-k8s/helm/strapi-chart/templates/service.yaml
# ...
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.containerPort }}
protocol: TCP
name: {{ .Values.service.portName }}
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}
# ...
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
livenessProbe: {}
# httpGet:
# path: /
# port: http

readinessProbe: {}
# httpGet:
# path: /
# port: http
# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
# ...
{{- if .Values.livenessProbe }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
{{- end }}
{{- if .Values.readinessProbe }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
{{- end }}
# ...

Parenthesis Regarding the Secrets

DB Values

# ~/strapi-k8s/helm/db.yaml
image:
repository: mysql
tag: 5.7

securityContext:
runAsUser: 1000
allowPrivilegeEscalation: false

service:
port: 3306
portName: mysql
containerPort: 3306

storage:
claim:
enabled: true
mountPath: "/var/lib/mysql"

configMap:
enabled: true
data:
MYSQL_USER: strapi
MYSQL_DATABASE: strapi-k8s

secret:
enabled: true
data:
# please never use these passwords, always use strong passwords, AND remember the section "(Parenthesis regarding the Secrets)"
MYSQL_ROOT_PASSWORD: c3RyYXBpLXN1cGVyLXNlY3VyZS1yb290LXBhc3N3b3Jk # echo -n strapi-super-secure-root-password | base64
MYSQL_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64
# ~/strapi-k8s/helm
helm template mysql strapi-chart -f db.yaml

App values

image:
repository: app-registry:5050/mystrapiapp-prod
tag: 0.0.1

service:
type: NodePort
port: 1337
containerPort: 1337
nodePort: 31337

configMap:
enabled: true
data:
HOST: 0.0.0.0
PORT: "1337"
NODE_ENV: production

DATABASE_HOST: mysql-strapi-chart # notice that this name changed
DATABASE_PORT: "3306"
DATABASE_USERNAME: strapi
DATABASE_NAME: strapi-k8s

secret:
enabled: true
data:
# use the proper values in here in base64
APP_KEYS: <APP keys in base64>
API_TOKEN_SALT: <API token salt in base64>
ADMIN_JWT_SECRET: <admin JWT secret in base64>
JWT_SECRET: <JWT secret in base64>

DATABASE_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64
helm template strapi strapi-chart -f app.yaml

Deploying Everything

helm install mysql strapi-chart -f db.yaml --atomic
helm install strapi strapi-chart -f app.yaml --atomic
watch kubectl get pods
http://localhost:1337/admin

Conclusions

--

--

Strapi is the leading open-source headless CMS. It’s 100% Javascript, fully customizable and developer-first. Unleash your content with Strapi.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store