A step-by-step tutorial series on how to integrate Strapi with Kubernetes. It will cover the journey from building your image to deploying a highly available and robust application. The first part will focus on the building blocks and an intermediate deployment.
The container world has revolutionized the software industry in a very short time. Although the underlying technologies are not new, the combination and automation of them is. Among this revolution's key players we have Kubernetes, initially released in 2014 by Google.
The Cloud Native Computing Foundation (CNCF), a Linux Project founded in 2015 to help push and align the container industry. Its founding members include Google, CoreOs, Red Hat, Intel, Cisco, etc., and one of its first projects was Kubernetes. Since 2016 it has been conducting annual surveys to determine the state of the container world. The Annual Survey of 2021 states that 96% of organizations are either using or evaluating Kubernetes. And 90% of them rely on cloud-managed alternatives. So it's safe to agree that Kubernetes can be qualified as a stable and mainstream technology.
Kubernetes is, in a big summary an operative system for container orchestration. It allows teams to deploy software easier, faster and more efficiently. The learning curve can seem steep, but the ROI is high, plus there are many cloud-managed alternatives that can help you a lot. To use Kubernetes, it is essential to understand containers and the surrounding concepts such as images, runtimes, and/or orchestration.
The goal of this series of articles, divided into two parts, is to give a comprehensive guide on how to integrate Strapi with Kubernetes. It will cover the journey from building your image to deploying a highly available, and robust deployment. The first part will focus on the building blocks as well as an intermediate deployment. While the second part, the following article, focuses on a highly-available deployment and tries to cover more advanced topics.
Strapi is the leading open-source headless CMS based on NodeJS, and its projects can vary a lot between themselves, but also Kubernetes provides a lot of flexibility. Therefore, it's worth investing some time in the best practices to integrate them.
All the presented work was done on a MacBook Pro with macOS Ventura 13.2, for there will be macOS specific commands which should be easily translated to Linux.
The following tools and versions are used; any path or minor updated version should work as well:
The Strapi project was created following the Quick Start Guide - Part A by running the following command:
1yarn create strapi-app strapi-k8s
2# using the following configuration
3? Choose your installation type Custom (manual settings)
4? Choose your preferred language JavaScript
5? Choose your default database client mysql
6? Database name: strapi-k8s
7? Host: 127.0.0.1
8? Port: 3306
9? Username: strapi
10? Password: ****** # please always use strong passwords
11? Enable SSL connection: No
You can check out the source code of this article on GitHub. The code is into two folders, one for each part.
Given that we should never (with some exceptions of course) re-invent the wheel, we'll be relying on the existing Strapi docs and many good articles from the internet.
The first part of the documentation to keep in mind is Running Strapi in a Docker container, with some slight modifications.
First, for the Development Dockerfile (or any Dockerfile), there should always be a .dockerignore in the same location as the Dockerfile with content similar to this:
1.idea
2node_modules
3npm-debug.log
4yarn-error.log
This will avoid the duplication of node_modules
folder inside the container, which the Dockerfile itself already generates. This could cause problems if your "local" node_modules
was generated with a different version, architecture, or something like that.
Second, to run it locally to test your docker image, you should use docker-compose following the (Optional) Docker Compose section.
Don't forget to properly configure your .env
file with the matching DB values you configured in your app, you can use the .env.example as a reference.
Just keep in mind that to run the docker-compose, you should use the command:
1docker-compose build --no-cache # to force the build of the image without cache
2docker-compose --env-file .env up
3docker-compose stop # to stop
4docker-compose down # to completely remove it (it won't delete any created volumes)
We need to pass the --env-file
flag, because we are using environment variables in the compose file.
And you could also do some cleaning to the docker-compose file and use it like this:
1version: '3'
2services:
3 strapi:
4 build: .
5 image: mystrapiapp:latest
6 restart: unless-stopped
7 env_file:
8 - .env
9 volumes:
10 - ./config:/opt/app/config
11 - ./src:/opt/app/src
12 - ./package.json:/opt/package.json
13 - ./yarn.lock:/opt/yarn.lock
14 - ./public/uploads:/opt/app/public/uploads
15 - ./.env:/opt/app/.env
16 ports:
17 - '1337:1337'
18 networks:
19 - strapi
20 depends_on:
21 - strapiDB
22
23 strapiDB:
24 platform: linux/amd64 #for platform error on Apple M1 chips
25 restart: unless-stopped
26 image: mysql:5.7
27 command: --default-authentication-plugin=mysql_native_password
28 environment:
29 MYSQL_USER: ${DATABASE_USERNAME}
30 MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD}
31 MYSQL_PASSWORD: ${DATABASE_PASSWORD}
32 MYSQL_DATABASE: ${DATABASE_NAME}
33 volumes:
34 - strapi-data:/var/lib/mysql
35 ports:
36 - '3306:3306'
37 networks:
38 - strapi
39
40volumes:
41 strapi-data:
42
43networks:
44 strapi:
The following was done to it:
services.strapi.environment
section since it's redundant due to services.strapi.env_file
.services.*.container_name
key was removed, this could stay, but it's cleaner this way. The docker-compose cmd can abstract any potential use you need for the container_name
. services.strapiDB.env_file
key was removed because it's not needed since none of those environment variables on that file are used, only the ones passed in services.strapiDB.environment
, you can find more information in the Docker MySQL image official docs.networks.strapi
key should not contain name
nor driver
for our use. It's better to use the default.Finally, don't use the latest
tag for Building the production container.
It is highly discouraged in the K8s world since it doesnβt tell you anything. While developing, itβs super useful and flexible in your local machine, but once you move your code to a shared environment, it should be clear which version you are using.
On top of that, K8s, by default, will cache the images, which will never guarantee that you are actually pulling the latest image. Spoiler alert: there are some workarounds to use the latest tag in K8s, but the industry agrees that you should not use the latest tag in a shared environment like K8s, even less in production.
Another great alternative to generate the Dockerfile
, docker-compose.yaml
and all the Docker related files is to use dockerize. This tool will automatically detect your project and help you add docker support via a nice CLI UI.
From your project root folder, you need to run the following:
1npx @strapi-community/dockerize
2# complete the steps
3β Do you want to create a docker-compose file? π³ β¦ Yes
4β What environments do you want to configure? βΊ Both
5β Whats the name of the project? β¦ strapi-k8s
6β What database do you want to use? βΊ MySQL
7β Database Host β¦ localhost
8β Database Name β¦ strapi-k8s
9β Database Username β¦ strapi
10β Database Password β¦ ***********
11β Database Port β¦ 3306
This tool will generate multiple files, so afterward you can run:
1docker-compose --env-file .env up
Nonetheless, it's important that you review all the Docker related files and adapt them to your needs.
The second article to keep in mind is Deploying and Scaling the Official Strapi Demo App "Foodadvisor" with Kubernetes & Docker. This article provides a good foundation on the K8s concepts, but we will try to go deeper into the K8s rabbit-hole and go beyond the discussed topics. We won't be using minikube
either, for no reason.
Since the app is not the focus of this article, and it should work with any app, assuming that all adjustments are made if it's using customizations.
Itβs highly encouraged that you push your images to a docker registry, as the two previous articles recommended. For all production deployments, this is a requirement, but you donβt have to do it for this article.
For this article, we will use k3d by Rancher. In summary, this project allows us to deploy a lightweight production-ready Kubernetes cluster with docker containers.
If you are already comfortable with K8s, you can use your preferred K8s local setup (e.g., K8s from Docker Desktop, minikube, a cloud provisioned cluster, etc.). Remember to take care of the private registry, volumes, and port forwarding.
You can check this article Playing with Kubernetes using k3d and Rancher to get everything installed. With some modifications, first, install the binaries (via brew for macOS would be the easiest). To create the cluster and in the name of the declarative configuration, you can use a file (as configuration) to create the cluster.
Make sure to have a folder ready to use as your storage, for this article, we'll use /tmp
, so make sure to run the command:
1mkdir -p /tmp/k3d
Then create a folder to work where you should create a Strapi project, or create a Strapi project and use it as your working directory. Let's create a folder (or project) called strapi-k8s
:
1mkdir -p ~/strapi-k8s
2# or create your Strapi project: yarn create strapi-app strapi-k8s
3cd strapi-k8s
Create a file mycluster.yaml with the following content:
1apiVersion: k3d.io/v1alpha4 # this will change in the future as we make everything more stable
2kind: Simple # internally, we also have a Cluster config, which is not yet available externally
3metadata:
4 name: mycluster # name that you want to give to your cluster (will still be prefixed with `k3d-`)
5servers: 1 # same as `--servers 1`
6agents: 2 # same as `--agents 2`
7ports:
8 - port: 8900:30080 # same as `--port '8080:80@loadbalancer'`
9 nodeFilters:
10 - agent:0
11 - port: 8901:30081 # just in case
12 nodeFilters:
13 - agent:0
14 - port: 8902:30082
15 nodeFilters:
16 - agent:0
17 - port: 1337:31337 # for Strapi
18 nodeFilters:
19 - agent:0
20volumes: # repeatable flags are represented as YAML lists
21 - volume: /tmp/k3d:/var/lib/rancher/k3s/storage # same as `--volume '/my/host/path:/path/in/node@server:0;agent:*'`
22 nodeFilters:
23 - server:0
24 - agent:*
25registries: # define how registries should be created or used
26 create: # creates a default registry to be used with the cluster; same as `--registry-create registry.localhost`
27 name: app-registry
28 host: "0.0.0.0"
29 hostPort: "5050"
30 config: | # define contents of the `registries.yaml` file (or reference a file); same as `--registry-config /path/to/config.yaml`
31 mirrors:
32 "localhost:5050":
33 endpoint:
34 - http://app-registry:5050
35options:
36 k3d: # k3d runtime settings
37 wait: true # wait for cluster to be usable before returning; same as `--wait` (default: true)
38 timeout: "60s" # wait timeout before aborting; same as `--timeout 60s`
39 disableLoadbalancer: false # same as `--no-lb`
40 kubeconfig:
41 updateDefaultKubeconfig: true # add new cluster to your default Kubeconfig; same as `--kubeconfig-update-default` (default: true)
42 switchCurrentContext: true # also set current-context to the new cluster's context; same as `--kubeconfig-switch-context` (default: true)
This file can be overwhelming, but it will make sense as we progress in this blog post. We are defining three nodes (one main server and two agents), ports for easy port-forwarding, volumes for storage, and a registry for our docker images inside the cluster.
Then run the command:
1k3d cluster create -c mycluster.yaml
Now you should have a fully functional K8s cluster running in your local machine (you might need to give 5-10 min for everything to converge).
To test it, run the command:
1kubectl get nodes
2# the output should be similar to this:
3NAME STATUS ROLES AGE VERSION
4k3d-mycluster-server-0 Ready control-plane,master 24s v1.24.4+k3s1
5k3d-mycluster-agent-0 Ready <none> 20s v1.24.4+k3s1
6k3d-mycluster-agent-1 Ready <none> 19s v1.24.4+k3s1
If something fails, or you have other clusters configured, you can always make sure that you are using the proper K8s context by running the command:
1kubectx k3d-mycluster
To stop/start the cluster once it's created, you can use the following commands:
1k3d cluster stop mycluster
2k3d cluster start mycluster
Finally, you should install Rancher
, by following steps 3. Deploying Rancher and 4. Creating the nodeport from the article mentioned. It will give you a web interface to see the status of your cluster. The web app is intuitive, and you can check your deployments, pods, persistent volumes, etc., by varying the namespace.
We have everything set up and are ready to have some K8s fun.
It's worth mentioning that k3d is great for local experimentation and many other applications, but for production (or more enterprise setups), you should use a different solution, for example:
Let us start our journey by trying to deploy the simplest of applications, our starter app, with the "same" setup we had with docker-compose but in K8s. To achieve this, let's put everything we have discussed so far together. Therefore we need:
To organize ourselves, let us create a folder inside our strapi-k8s
folder, called k8s
.
1mkdir k8s
One of the most popular words in the IT/Software world could be "environment". It's intended to describe a set of objects (hardware and cloud, network, software, configuration, etc.) that align to fulfill a purpose.
This word is also contextual, meaning that depending on which set of objects or purposes can be different. For example, you could have, at your infrastructure level, 2 environments named "lower-envs" (dev for short) and "higher-envs" (prod for short).
Inside each, you could be hosting a K8s cluster, which has the environments dev
, int
and test
for the "lower-envs" and staging
and prod
for "higher-envs". And in each of those environments, you could have a NodeJS app running with its node environment set to development
, staging
or production
given different criteria.
So those are 3 different contexts of environments, and they all refer to different objects and purposes. The important takeaway is that we need to be aware of the context whenever we are talking about the "environment".
Strapi defines 3 major "environments": development, staging and production. Development should be the developers local environment on their host machine. Staging and production should be the environments where "final" content is created.
This means that, whenever you create or update content-types, you should do it in a "Strapi development environment". Afterward, it should be pushed to a source control for further team validation and control.
Finally, it can be considered Strapi production and be shipped to the content creators for its final journey to the end user. All of this being said, for this article, we will assume that the proper workflow is in place.
Extending the same concept, it's not recommended to deploy a "Strapi development" environment to K8s. The developers, architects, and stakeholders should carefully create or update content types before moving this into K8s. Once this workflow is defined, you can align it with semantic versioning for your Docker images.
Creating your Docker images is also tightly coupled with this development flow. You can use any CI/CD tools to achieve this, e.g., Jenkins, Github Actions, GitLab CI/CD, etc. Therefore, any Strapi version update, code update, or content-type update, should be versioned properly through the Docker image tag.
It's also worth adding, that this process should be tailored to each company's requirements. For example, a company whose content-types are not changing that often might be good by using "Strapi production environment" in their K8s environment.
But maybe another company whose content-types change more regularly, or they have a different type of quality pipeline might want to have "Strapi staging environment" in dev
and int
K8s environment, and "Strapi production" in the rest. The bottom line, both options are good as long as they work for everybody involved in the process.
For this article's purposes, we'll assume that all of Strapi's environments will be in production regardless of the environment they are deployed in K8s.
As mentioned earlier, we must build our images with the proper versioning. We will be using the Dockerfile.prod from the Docker deployment docs.
So let's build the Strapi production image:
1docker build -t mystrapiapp-prod:0.0.1 -f Dockerfile.prod .
But this will only work in our local machine, not in our K8s cluster. Sadly, K8s running via K3d (or in the cloud) don't know of our local Docker registry, so we added a section of registries to the mycluster.yaml
when creating the K3d cluster.
So we need to tag the docker image and push it to that registry as follows:
1docker tag mystrapiapp-prod:0.0.1 localhost:5050/mystrapiapp-prod:0.0.1
2docker push localhost:5050/mystrapiapp-prod:0.0.1
3# or build it with the tag
4docker build -t mystrapiapp-prod:0.0.1 -t localhost:5050/mystrapiapp-prod:0.0.1 -f Dockerfile .
5docker push localhost:5050/mystrapiapp-prod:0.0.1
Please note that we are tagging it with localhost:5050
, which according to our conf will mirror app-registry:5050
inside the cluster.
Alternatively, you can create a DockerHub account (or your preferred docker registry), then tag and push your images to that registry.
If you do this, you can re-create your K3d cluster without the registries
section.
And whenever this article references mystrapiapp-prod
docker image, make sure to use your registry's URL.
We need to configure our app, and we have used environment variables so far. Kubernetes provides a mechanism to configure env vars via ConfigMaps. We need one for the database (DB) and another for the app.
But, if you think about it, among those env vars we have the DB password, which is sensitive data. Therefore it should be somewhere protected. So for those env vars in particular, we will use Secrets. Remember that each value added to a Secret must be base64 encoded.
For example, if you want to store the string "123456", you can run the command:
1echo -n 123456 | base64
2# MTIzNDU2
Let's create the file conf.yaml inside our k8s
folder, with the following content:
1# ~/strapi-k8s/k8s/conf.yaml
2apiVersion: v1
3kind: ConfigMap
4metadata:
5 name: strapi-database-conf
6data:
7 MYSQL_USER: strapi
8 MYSQL_DATABASE: strapi-k8s
9---
10apiVersion: v1
11kind: Secret
12metadata:
13 name: strapi-database-secret
14type: Opaque
15data:
16 # please NEVER use these passwords, always use strong passwords
17 MYSQL_ROOT_PASSWORD: c3RyYXBpLXN1cGVyLXNlY3VyZS1yb290LXBhc3N3b3Jk # echo -n strapi-super-secure-root-password | base64
18 MYSQL_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64
19---
20apiVersion: v1
21kind: ConfigMap
22metadata:
23 name: strapi-app-conf
24data:
25 HOST: 0.0.0.0
26 PORT: "1337"
27 NODE_ENV: production
28
29 # we'll explain the db host later
30 DATABASE_HOST: strapi-db
31 DATABASE_PORT: "3306"
32 DATABASE_USERNAME: strapi
33 DATABASE_NAME: strapi-k8s
34---
35apiVersion: v1
36kind: Secret
37metadata:
38 name: strapi-app-secret
39type: Opaque
40data:
41 # use the proper values in here
42 APP_KEYS: <APP keys in base64>
43 API_TOKEN_SALT: <API token salt in base64>
44 ADMIN_JWT_SECRET: <admin JWT secret in base64>
45 JWT_SECRET: <JWT secret in base64>
46 # please NEVER use these passwords, always use strong passwords
47 DATABASE_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64
Breaking down the objects:
strapi-database-conf
and strapi-app-conf
, contain environment variables that will configure their respective service. The values are not sensitive.strapi-database-secret
and strapi-app-secret
, also contain environment variables that will configure their respective service. But these values are sensitive values. Therefore, they are stored in a more proper K8s object. These values won't be encrypted by K8s, but you can later restrict their access using RBAC.So now, we need a Deployment for our database. Technically speaking, you could also deploy a database using a StatefulSet. Still, I'm leaving that debate for another day (or you can also check this blog in case you are curious). So let's write the deployment file and ensure it uses the proper ConfigMap and Secret.
If you remember, back from our docker-compose file, the DB section used a docker volume to write its data, so we need something similar here. We can achieve basic storage with Persistent Volumes and Persistent Volume Claims, but this is a very complicated topic that we'll expand on later in this article.
Let's create the file db.yaml inside our k8s
folder, with the following content:
1# ~/strapi-k8s/k8s/db.yaml
2apiVersion: v1
3kind: PersistentVolume
4metadata:
5 name: strapi-database-pv
6 labels:
7 type: local
8spec:
9 capacity:
10 storage: 5Gi
11 accessModes:
12 - ReadWriteOnce
13 storageClassName: local-path
14 hostPath:
15 path: "/var/lib/rancher/k3s/storage/strapi-database-pv" # the path we configured in the conf file to create the cluster + sub path
16---
17apiVersion: v1
18kind: PersistentVolumeClaim
19metadata:
20 name: strapi-database-pvc
21spec:
22 accessModes:
23 - ReadWriteOnce
24 resources:
25 requests:
26 storage: 5Gi
27---
28apiVersion: apps/v1
29kind: Deployment
30metadata:
31 name: strapi-db
32spec:
33 replicas: 1
34 selector:
35 matchLabels:
36 app: strapi-db
37 template:
38 metadata:
39 labels:
40 app: strapi-db
41 spec:
42 containers:
43 - name: mysql
44 image: mysql:5.7
45 securityContext:
46 runAsUser: 1000
47 allowPrivilegeEscalation: false
48 ports:
49 - containerPort: 3306
50 name: mysql
51 envFrom:
52 - configMapRef:
53 name: strapi-database-conf # the name of our ConfigMap for our db.
54 - secretRef:
55 name: strapi-database-secret # the name of our Secret for our db.
56 volumeMounts:
57 - name: mysql-persistent-storage
58 mountPath: /var/lib/mysql
59 volumes:
60 - name: mysql-persistent-storage
61 persistentVolumeClaim:
62 claimName: strapi-database-pvc # the name of our PersistentVolumeClaim
63---
64apiVersion: v1
65kind: Service
66metadata:
67 name: strapi-db # this is the name we use for DATABASE_HOST
68spec:
69 selector:
70 app: strapi-db
71 ports:
72 - name: mysql
73 protocol: TCP
74 port: 3306
75 targetPort: mysql # same name defined in the Deployment path spec.template.spec.containers[0].ports[0].name
Let's break it down:
local-path
, which creates a volume by using a local path in only one of the nodes, and therefore it can only be read/write by one Pod at a time.mysql:5.7
image, exposes port 3306
, uses the proper conf and mounts our Persistent Volume Claim in the path /var/lib/mysql
.strapi-db
; and from another namespace: strapi-db.<namespace>
, or strapi-db.<namespace>.svc.cluster.local
.So now, we need a Deployment for our application. Finally! π
As with the DB deployment, we must pass the proper ConfigMap and Secret with the required environmental variables.
Let's create the file app.yaml inside our k8s
folder, with the following content:
1# ~/strapi-k8s/k8s/app.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: strapi-app
6spec:
7 replicas: 1
8 selector:
9 matchLabels:
10 app: strapi-app
11 template:
12 metadata:
13 labels:
14 app: strapi-app
15 spec:
16 containers:
17 - name: strapi
18 image: app-registry:5050/mystrapiapp-prod:0.0.1 # this is a custom image, therefore we are using the "custom" registry
19 ports:
20 - containerPort: 1337
21 name: http
22 envFrom:
23 - configMapRef:
24 name: strapi-app-conf # the name of our ConfigMap for our app.
25 - secretRef:
26 name: strapi-app-secret # the name of our Secret for our app.
27---
28apiVersion: v1
29kind: Service
30metadata:
31 name: strapi-app
32spec:
33 type: NodePort
34 selector:
35 app: strapi-app
36 ports:
37 - name: http
38 protocol: TCP
39 port: 1337
40 nodePort: 31337 # we are using this port, to match the cluster port forwarding section from mycluster.yaml
41 targetPort: http # same name defined in the Deployment path spec.template.spec.containers[0].ports[0].name
Once again, let's break it down:
mystrapiapp-prod:0.0.1
image from the registry app-registry:5050
, exposes port 1337
, and it uses the proper conf.Now let's apply all of our files. To apply all the files in our k8s
folder, we can run:
1kubectl apply -f k8s/
Now, let's wait for everything to start, we can do that by running:
1watch kubectl get pods
Eventually, the status of both pods should be Running
and the ready column should be 1/1
. Once everything is stable, you can open your browser and navigate to:
1http://localhost:1337/
If you want to see the logs of those pods, you can do:
1kubectl logs --selector app=strapi-app --tail=50 --follow # for the app
2kubectl logs --selector app=strapi-db --tail=50 --follow # for the db
Let's sum up what we did and achieved:
k3d
with some predefined port-forwarding. It mounts a disk to each of the nodes, which is mapped to our temp folder. And, it also deploys an "external" docker registry to pull the images.Even though all of this awesome work, there are some things not very convincing and that could be improved, like:
To take our journey one step further, we need the help of a very important tool in the K8s world: Helm.
From their website: "Helm helps you manage Kubernetes applications β Helm Charts helps you define, install, and upgrade even the most complex Kubernetes application." Therefore, we need a "helm chart" to improve our deployment.
To organize ourselves, inside our strapi-k8s
folder, let us create a folder called helm
.
1mkdir helm
Helm provides us with the proper tooling to manage K8s applications, everything circles around "charts". These are a collection of yaml template files and proper tooling to reuse code and package our K8s yaml files.
Most of the K8s tools have a helm chart that you can install in your cluster, like the Rancher
tool we suggested you deploy in the previous section. Therefore, you can also search for other tools through a helm repository and install or reuse them, you can find more information in the Helm's Quickstart Guide.
We can start by creating our own chart by running the following command:
1cd helm
2helm create strapi-chart
This creates the following folder structure:
1strapi-chart
2βββ Chart.yaml
3βββ charts
4βββ templates
5β βββ NOTES.txt
6β βββ _helpers.tpl
7β βββ deployment.yaml
8β βββ hpa.yaml
9β βββ ingress.yaml
10β βββ service.yaml
11β βββ serviceaccount.yaml
12β βββ tests
13β βββ test-connection.yaml
14βββ values.yaml
As a brief explanation:
Chart.yaml
, contains global chart information like name, description, version, app version, and dependencies.templates/
, contains all the templates, which are rendered using go templates.values.yaml
, contains the global values used by the templates, which can be overwritten from the outside.
You can find more in-deep information about these files and all the good stuff about charts in their docs.If you want to quickly see how all of these files connect to K8s, or you just want to debug, you can run the command:
1# from the "helm" folder
2# helm template <NAME (any name)> <CHART_PATH>
3helm template strapi strapi-chart
This will output the yaml files generated based on the chart's templates. And as you can see, there aren't so many objects after all, it should create by default a ServiceAccount, Service, Deployment and a test connection Pod.
If you wander around the files, you will notice that this chart can also generate an Ingress and an HorizontalPodAutoscaler, we will discuss them, but later, for now, we'll ignore them.
Since we can override the default values, we could reuse this chart and generate the same files for the DB and the app.
If we look at our non-reusable files in the k8s
folder, you can notice that the only real difference between both is the persistence storage configuration (PersistentVolume and PersistentVolumeClaim). So let's add that, with the same "logic" as the Ingress or the HorizontalPodAutoscaler, so they will work only if we enable them.
Let's create a file under the templates folder with the name claim.yaml, with the following content:
1# ~/strapi-k8s/helm/strapi-chart/templates/claim.yaml
2
3{{- if .Values.storage.claim.enabled }}
4apiVersion: v1
5kind: PersistentVolumeClaim
6metadata:
7 name: {{ include "strapi-chart.fullname" . }}-pvc
8 labels:
9 {{- include "strapi-chart.labels" . | nindent 4 }}
10spec:
11 accessModes: {{ .Values.storage.accessModes }}
12 storageClassName: {{ .Values.storage.storageClassName }}
13 resources:
14 requests:
15 storage: {{ .Values.storage.capacity }}
16{{- end }}
We will not add the PersistentVolume, since the "local-path" storage provisioner will take care of that. Previously we did, but in reality, we can omit this step, assuming that we are using the default values.
Then, let's add at the end of the values.yaml file:
1# ~/strapi-k8s/helm/strapi-chart/values.yaml
2# ...
3storage:
4 claim:
5 enabled: false
6 capacity: 5Gi
7 accessModes:
8 - ReadWriteOnce
9 storageClassName: local-path
10 mountPath: "/tmp"
Finally, add to the end of the templates/deployment.yaml, the volume
section, watch out for the indentation:
1# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
2# ...
3 {{- if .Values.storage.claim.enabled }}
4 volumes:
5 - name: {{ include "strapi-chart.fullname" . }}-storage
6 persistentVolumeClaim:
7 claimName: {{ include "strapi-chart.fullname" . }}-pvc
8 {{- end }}
And, in the same templates/deployment.yaml file, between the spec.template.spec.containers[0].resources
and spec.template.spec.nodeSelector
, add the volumeMounts
section (the following code has the "surrounding" code for references):
1# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
2# ...
3 resources:
4 {{- toYaml .Values.resources | nindent 12 }}
5 {{- if .Values.storage.claim.enabled }}
6 volumeMounts:
7 - name: {{ include "strapi-chart.fullname" . }}-storage
8 mountPath: {{ .Values.storage.mountPath }}
9 {{- end }}
10 {{- with .Values.nodeSelector }}
11 nodeSelector:
12 {{- toYaml . | nindent 8 }}
13 {{- end }}
14# ...
Ok, let's run our template command again:
1helm template mysql strapi-chart
Now we need to activate the storage, let's run it again but let's enable our storage:
1helm template mysql strapi-chart --set storage.volume.enabled=true --set storage.claim.enabled=true
This is amazing if you compare the k8s/db.yaml
file and this output, which is almost identical, but we still have some work to do.
Let us add the environment variables configuration.
In the templates folder, create a file with the name configmap.yaml, with the following content:
1# ~/strapi-k8s/helm/strapi-chart/templates/configmap.yaml
2
3{{- if .Values.configMap.enabled }}
4apiVersion: v1
5kind: ConfigMap
6metadata:
7 name: {{ include "strapi-chart.fullname" . }}
8data:
9{{- toYaml .Values.configMap.data | nindent 2 }}
10{{- end }}
Now, in the templates folder, create another file with the name secret.yaml, with the following content:
1# ~/strapi-k8s/helm/strapi-chart/templates/secret.yaml
2
3{{- if .Values.secret.enabled }}
4apiVersion: v1
5kind: Secret
6metadata:
7 name: {{ include "strapi-chart.fullname" . }}
8type: Opaque
9data:
10{{- toYaml .Values.secret.data | nindent 2 }}
11{{- end }}
Then, let's add at the end of the values.yaml file:
1# ~/strapi-k8s/helm/strapi-chart/values.yaml
2# ...
3configMap:
4 enabled: false
5 data: {}
6
7secret:
8 enabled: false
9 data: {}
Finally, in the templates/deployment.yaml file, between the spec.template.spec.containers[0].volumeMounts
(previously added) and spec.template.spec.nodeSelector
, add the envFrom
section (the following code has the "surrounding" code for references):
1# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
2# ...
3 {{- if .Values.storage.claim.enabled }}
4 volumeMounts:
5 - name: {{ include "strapi-chart.fullname" . }}-storage
6 mountPath: {{ .Values.storage.mountPath }}
7 {{- end }}
8 {{- if or .Values.configMap.enabled .Values.secret.enabled }}
9 envFrom:
10 {{- if .Values.configMap.enabled }}
11 - configMapRef:
12 name: {{ include "strapi-chart.fullname" . }}
13 {{- end }}
14 {{- if .Values.secret.enabled }}
15 - secretRef:
16 name: {{ include "strapi-chart.fullname" . }}
17 {{- end }}
18 {{- end }}
19 {{- with .Values.nodeSelector }}
20 nodeSelector:
21 {{- toYaml . | nindent 8 }}
22 {{- end }}
23# ...
We are almost there, in the values.yaml file, add portName
, containerPort
, and nodePort
to the service
key, so it looks like this:
1# ~/strapi-k8s/helm/strapi-chart/values.yaml
2# ...
3service:
4 type: ClusterIP
5 port: 80
6 portName: http
7 containerPort: 80
8 nodePort: # for a Service of type NodePort, and yes, we'll leave it empty
9# ...
And, in the deployment.yaml file, update the values spec.template.spec.containers[0].ports[0].name
and spec.template.spec.containers[0].ports[0].containerPort
, like the following:
1# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
2# ...
3 ports:
4 - name: {{ .Values.service.portName }}
5 containerPort: {{ .Values.service.containerPort }}
6 protocol: TCP
7# ...
And, in the service.yaml, update the ports
spec, like the following:
1# ~/strapi-k8s/helm/strapi-chart/templates/service.yaml
2# ...
3 ports:
4 - port: {{ .Values.service.port }}
5 targetPort: {{ .Values.service.containerPort }}
6 protocol: TCP
7 name: {{ .Values.service.portName }}
8 {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
9 nodePort: {{ .Values.service.nodePort }}
10 {{- end }}
11# ...
The last change, at the end of the values.yaml file, add livenessProbe
and readinessProbe
keys, so it looks like this:
1# ~/strapi-k8s/helm/strapi-chart/values.yaml
2# ...
3livenessProbe: {}
4# httpGet:
5# path: /
6# port: http
7
8readinessProbe: {}
9# httpGet:
10# path: /
11# port: http
And, in the deployment.yaml file, update the values spec.template.spec.containers[0].livenessProbe
and spec.template.spec.containers[0].readinessProbe
, and replace them with the following code:
1# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
2# ...
3 {{- if .Values.livenessProbe }}
4 livenessProbe:
5 {{- toYaml .Values.livenessProbe | nindent 12 }}
6 {{- end }}
7 {{- if .Values.readinessProbe }}
8 readinessProbe:
9 {{- toYaml .Values.readinessProbe | nindent 12 }}
10 {{- end }}
11# ...
Don't worry, we'll get back to those probes later.
Ok, we are done customizing our chart for now, it's time to put it to use.
For the purposes of this article, we'll not get involved in securing our secrets. You might have seen or assumed by now that if we write the Secrets into a repo (even base64 encoded), that's a huge security risk. So you need a safe way to handle them.
The options vary, and you can choose the one that fits you the most, or a combination of some options. But some potential options are:
Given the customizations we did to our Helm chart, we are ready to use it.
Let's create a file, under the helm
folder, with the name db.yaml, which will override the default values for our DB, with the following content:
1# ~/strapi-k8s/helm/db.yaml
2image:
3 repository: mysql
4 tag: 5.7
5
6securityContext:
7 runAsUser: 1000
8 allowPrivilegeEscalation: false
9
10service:
11 port: 3306
12 portName: mysql
13 containerPort: 3306
14
15storage:
16 claim:
17 enabled: true
18 mountPath: "/var/lib/mysql"
19
20configMap:
21 enabled: true
22 data:
23 MYSQL_USER: strapi
24 MYSQL_DATABASE: strapi-k8s
25
26secret:
27 enabled: true
28 data:
29 # please never use these passwords, always use strong passwords, AND remember the section "(Parenthesis regarding the Secrets)"
30 MYSQL_ROOT_PASSWORD: c3RyYXBpLXN1cGVyLXNlY3VyZS1yb290LXBhc3N3b3Jk # echo -n strapi-super-secure-root-password | base64
31 MYSQL_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64
Let's do a quick debug to see if we are on the right track, run the following command:
1# ~/strapi-k8s/helm
2helm template mysql strapi-chart -f db.yaml
Everything should look amazing, if you compare the files (from our k8s
folder and the helm output), they should be almost the same and contain only slight differences in the order of some keys, some other labels and some other minor differences.
Ok, now let's go for our main Strapi application. Let's create a file under the helm
folder with the name app.yaml, with the following content:
1image:
2 repository: app-registry:5050/mystrapiapp-prod
3 tag: 0.0.1
4
5service:
6 type: NodePort
7 port: 1337
8 containerPort: 1337
9 nodePort: 31337
10
11configMap:
12 enabled: true
13 data:
14 HOST: 0.0.0.0
15 PORT: "1337"
16 NODE_ENV: production
17
18 DATABASE_HOST: mysql-strapi-chart # notice that this name changed
19 DATABASE_PORT: "3306"
20 DATABASE_USERNAME: strapi
21 DATABASE_NAME: strapi-k8s
22
23secret:
24 enabled: true
25 data:
26 # use the proper values in here in base64
27 APP_KEYS: <APP keys in base64>
28 API_TOKEN_SALT: <API token salt in base64>
29 ADMIN_JWT_SECRET: <admin JWT secret in base64>
30 JWT_SECRET: <JWT secret in base64>
31
32 DATABASE_PASSWORD: c3RyYXBpLXBhc3N3b3Jk # echo -n strapi-password | base64
Let's do a quick debug to see if we are on the right track, run the following command:
1helm template strapi strapi-chart -f app.yaml
Make sure you deleted all the previous resources from the last chapter in your K8s cluster.
If you want to run both at the same time, you need to change in either of the k8s
or helm
example, the following:
31337
.To install our charts, we can run the following commands:
1helm install mysql strapi-chart -f db.yaml --atomic
2helm install strapi strapi-chart -f app.yaml --atomic
Now, let's wait for everything to start, we can do that by running:
1watch kubectl get pods
Eventually, the status of both pods should be Running
and the ready should be 1/1
.
Once everything is stable, you can open your browser and navigate to:
1http://localhost:1337/admin
Let's sum up what we did and what we achieved:
But, are we done yet? Not yet, there are some things not very convincing and that could be improved, like:
In part 2 of this tutorial series, we'll try to answer these questions to achieve a more advanced and robust deployment.
The Strapi experience has always been about flexibility, customization, and open-source innovation. But we understand that managing infrastructure and scaling your application can sometimes be a daunting task, diverting your focus from what you do best: developing web experiences.
That's why we're excited to introduce Strapi Cloud, so you can leverage the same robust Strapi capabilities you love, but without the hassle of managing servers, worrying about uptime, or scaling infrastructure as your project grows. It will allow you to future-proof your apps with a CMS that meets the content management needs of all your stakeholders, no matter your use case, services or devices.
Strapi remains committed to open-source values, and self-hosting will always be a viable option. We believe in offering choices that align with your project's unique needs. Strapi Cloud is an additional resource for those who want to focus solely on development, leaving the infrastructure to us.
Aw are here to support you every step of the way, no matter which hosting option you choose. You can reach out to us and the community on our Discord if you have any questions!
Senior DevOps Engineer at Sonos with a Ph.D. in Computer Science. He is a K8s-certified specialist (CKA, CKAD, CKS) and a Linux-certified specialist (LFCS, LFCE). He has worked as a developer, but always with a close relation to infrastructure.