How to Deploy and Scale Strapi on a Kubernetes Cluster 1/2
Strapi
Posted on February 3, 2023
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.
Pre-requisites
Tooling
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:
- Docker v4.13.1
- node v18.11.0
- yarn v1.22.19
- k3d v5.4.6
- kubectl v1.25.3
- kubectx + kubens v0.9.4
- helm v3.10.1
- stern v1.22.0
- vegeta v12.8.4
Project Setup
The Strapi project was created following the Quick Start Guide - Part A by running the following command:
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
You can check out the source code of this article on GitHub.
The code is into two folders, one for each part.
On The Shoulders of Giants
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.
Strapi + Docker
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:
.idea
node_modules
npm-debug.log
yarn-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:
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)
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:
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:
The following was done to it:
- We removed the
services.strapi.environment
section since it's redundant due toservices.strapi.env_file
. - The
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 thecontainer_name
. - The
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 inservices.strapiDB.environment
, you can find more information in the Docker MySQL image official docs. - The
networks.strapi
key should not containname
nordriver
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.
Dockerize
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:
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
This tool will generate multiple files, so afterward you can run:
docker-compose --env-file .env up
Nonetheless, it's important that you review all the Docker related files and adapt them to your needs.
Strapi + K8s
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.
Kubernetes setup
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:
mkdir -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
:
mkdir -p ~/strapi-k8s
# or create your Strapi project: yarn create strapi-app strapi-k8s
cd strapi-k8s
Create a file mycluster.yaml with the following content:
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)
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:
k3d 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:
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
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:
kubectx k3d-mycluster
To stop/start the cluster once it's created, you can use the following commands:
k3d cluster stop mycluster
k3d 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.
Production
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:
- kOps with your own instances.
- Amazon Elastic Kubernetes Service (EKS).
- Azure Kubernetes Service (AKS).
- Google Kubernetes Engine (GKE).
- Kubernetes The Hard Way if you like challenges, although it could get complicated to manage in the long run π .
Basic Deployment
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:
- Docker images
- Environment variables
- DB Deployment
- App Deployment
To organize ourselves, let us create a folder inside our strapi-k8s
folder, called k8s
.
mkdir k8s
Development flow
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.
Docker images
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:
docker 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:
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
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.
Environment variables
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:
echo -n 123456 | base64
# MTIzNDU2
Let's create the file conf.yaml inside our k8s
folder, with the following content:
# ~/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
Breaking down the objects:
-
strapi-database-conf
andstrapi-app-conf
, contain environment variables that will configure their respective service. The values are not sensitive. -
strapi-database-secret
andstrapi-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.
DB Deployment
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:
# ~/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
Let's break it down:
- We have a Persistent Volume with 5Gb of storage, from the storage driver
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. - Then we have a Persistent Volume Claim which, as the word suggests, claims a volume by using the same access mode, size, and type. And with this claim, we can finally mount it in our Pod.
- After that, we have our mysql Deployment, which refers to the
mysql:5.7
image, exposes port3306
, uses the proper conf and mounts our Persistent Volume Claim in the path/var/lib/mysql
. - Finally, a Service which exposes the actual port to the rest of the cluster via dns. Due to some DNS conventions, we could reach the mysql Deployment via this Service from the same namespace:
strapi-db
; and from another namespace:strapi-db.<namespace>
, orstrapi-db.<namespace>.svc.cluster.local
.
App Deployment
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:
# ~/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
Once again, let's break it down:
- We have our Strapi Deployment, which refers to the
mystrapiapp-prod:0.0.1
image from the registryapp-registry:5050
, exposes port1337
, and it uses the proper conf. - Second and last, a Service exposes the actual port to the rest of the cluster via dns. The same DNS conventions mentioned before apply for this as well.
Deploying everything
Now let's apply all of our files.
To apply all the files in our k8s
folder, we can run:
kubectl apply -f k8s/
Now, let's wait for everything to start, we can do that by running:
watch 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:
http://localhost:1337/
If you want to see the logs of those pods, you can do:
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
Let's sum up what we did and achieved:
- We created a 3-node K8s cluster via
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. - We built our docker image, set up some proper versioning, and pushed it to our "external" registry.
- We deployed mysql with persistent storage.
- Finally, we deployed our app, and we can access it from a browser. We are finally ready to start creating content, uploading assets, and publishing our website.
Even though all of this awesome work, there are some things not very convincing and that could be improved, like:
- There are a lot of files with a lot of repeated code (like the labels).
- How do we deploy a second environment? Do we have to deploy all those files all over again? What if we want to change something?
- What about the assets?
- What about high availability?
Improved Deployment
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
.
mkdir helm
Helm chart
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:
cd helm
helm create strapi-chart
This creates the following folder structure:
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
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:
# from the "helm" folder
# helm template <NAME (any name)> <CHART_PATH>
helm 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.
Helm chart customization
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:
# ~/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 }}
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:
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
storage:
claim:
enabled: false
capacity: 5Gi
accessModes:
- ReadWriteOnce
storageClassName: local-path
mountPath: "/tmp"
Finally, add to the end of the templates/deployment.yaml, the volume
section, watch out for the indentation:
# ~/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 }}
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):
# ~/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 }}
# ...
Ok, let's run our template command again:
helm template mysql strapi-chart
Now we need to activate the storage, let's run it again but let's enable our storage:
helm 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:
# ~/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 }}
Now, in the templates folder, create another file with the name secret.yaml, with the following content:
# ~/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 }}
Then, let's add at the end of the values.yaml file:
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
configMap:
enabled: false
data: {}
secret:
enabled: false
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):
# ~/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 }}
# ...
We are almost there, in the values.yaml file, add portName
, containerPort
, and nodePort
to the service
key, so it looks like this:
# ~/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
# ...
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:
# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
# ...
ports:
- name: {{ .Values.service.portName }}
containerPort: {{ .Values.service.containerPort }}
protocol: TCP
# ...
And, in the service.yaml, update the ports
spec, like the following:
# ~/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 }}
# ...
The last change, at the end of the values.yaml file, add livenessProbe
and readinessProbe
keys, so it looks like this:
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
livenessProbe: {}
# httpGet:
# path: /
# port: http
readinessProbe: {}
# httpGet:
# path: /
# 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:
# ~/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 }}
# ...
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.
Parenthesis Regarding the Secrets
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:
- Store the Secrets in a repo using gitcrypt or another encryption tool.
- Store the Secrets in a vault like Hashicorp Vault, AWS Secrets Manager, GCP Secret Manager, etc., and then use an operator like External Secrets Operator to add them to your K8s cluster.
- Use Sealed Secrets Operator.
- Use Helm Secrets.
DB Values
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:
# ~/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
Let's do a quick debug to see if we are on the right track, run the following command:
# ~/strapi-k8s/helm
helm 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.
App values
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:
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
Let's do a quick debug to see if we are on the right track, run the following command:
helm template strapi strapi-chart -f app.yaml
Deploying Everything
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:
- The nodePort from the app Service, so they don't both try to run at
31337
. - Add another port mapping for the cluster.
- And lastly but optional, deploy them to different namespaces.
To install our charts, we can run the following commands:
helm install mysql strapi-chart -f db.yaml --atomic
helm install strapi strapi-chart -f app.yaml --atomic
Now, let's wait for everything to start, we can do that by running:
watch 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:
http://localhost:1337/admin
Conclusions
Let's sum up what we did and what we achieved:
- We created a Helm chart, that allows us to re-utilize our K8s configuration.
- We created the proper override configuration for MySQL and Strapi. In the long run, this allows us to install our chart with different configuration and deploy it to multiple environments and/or multiple clusters.
- Finally, we deployed our app, and we can access it from a browser.
But, are we done yet? Not yet, there are some things not very convincing and that could be improved, like:
- The secrets. But we already discuss this, so please never commit secrets to a repo and find a solution that fits your needs.
- What happened to those livenessProbe and readinessProbe that we sort of commented out?
- We still don't know about the assets, what's up with that?
- And, again, what about high availability?
In part 2 of this tutorial series, we'll try to answer these questions to achieve a more advanced and robust deployment.
Posted on February 3, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.