How to Deploy and Scale Strapi on a Kubernetes Cluster 1/2
Tags: strapi, kubernetes, docker, deployment
Published: 2023-02-03 16:05:36

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](https://www.cncf.io/wp-content/uploads/2022/02/CNCF-AR_FINAL-edits-15.2.21.pdf) 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](https://strapi.io/blog/how-to-deploy-and-scale-strapi-on-a-kubernetes-cluster-2-2), focuses on a highly-available deployment and tries to cover more advanced topics. 

Strapi is [the leading open-source headless CMS](https://strapi.io/) 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](https://www.docker.com)
- [node v18.11.0](https://nodejs.org/en/)
- [yarn v1.22.19](https://yarnpkg.com)
- [k3d v5.4.6](https://k3d.io/v5.4.6/)
- [kubectl v1.25.3](https://kubernetes.io/docs/tasks/tools/#kubectl)
- [kubectx + kubens v0.9.4](https://github.com/ahmetb/kubectx)
- [helm v3.10.1](https://helm.sh)
- [stern v1.22.0](https://github.com/stern/stern)
- [vegeta v12.8.4](https://github.com/tsenart/vegeta)

### Project Setup

The Strapi project was created following the [Quick Start Guide - Part A](https://docs.strapi.io/developer-docs/latest/getting-started/quick-start.html) by running the following command:

```shell
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](https://github.com/chepeftw/strapi-k8s-blog-post).
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](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/docker.html#development-and-or-staging-environments), with some slight modifications.

First, for the [Development Dockerfile](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/docker.html#development-and-or-staging-environments) (or any Dockerfile), there should always be a [.dockerignore](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/.dockerignore) in the same location as the [Dockerfile](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/Dockerfile) with content similar to this:

```text
.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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/Dockerfile#L16) 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](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/docker.html#development-and-or-staging-environments).
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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/.env.example) as a reference.
Just keep in mind that to run the [docker-compose](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/docker-compose.yaml), you should use the command:

```shell
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](https://docs.docker.com/compose/environment-variables/).
And you could also do some cleaning to the [docker-compose file](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/docker-compose.yaml) and use it like this:
```yaml
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 to `services.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 the `container_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 in `services.strapiDB.environment`, you can find more information in the [Docker MySQL image official docs](https://hub.docker.com/_/mysql).
- The `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](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/docker.html#production-environments). 

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](https://github.com/strapi-community/strapi-tool-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:

```shell
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:

```shell
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](https://strapi.io/blog/deploying-and-scaling-the-official-strapi-demo-app-foodadvisor-with-kubernetes).
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](https://k3d.io/v5.4.6/) by [Rancher](https://www.rancher.com).
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](https://medium.com/47billion/playing-with-kubernetes-using-k3d-and-rancher-78126d341d23) 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](https://k3d.io/v5.4.1/usage/configfile/).

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:
```shell
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`:

```shell
mkdir -p ~/strapi-k8s
# or create your Strapi project: yarn create strapi-app strapi-k8s
cd strapi-k8s
```

Create a file [mycluster.yaml](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/mycluster.yaml) with the following content:

```yaml
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:
```shell
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:
```shell
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:
```shell
kubectx k3d-mycluster
```

To stop/start the cluster once it's created, you can use the following commands:
```shell
k3d cluster stop mycluster
k3d cluster start mycluster
```

Finally, you should install `Rancher`, by following steps [3. Deploying Rancher and 4. Creating the nodeport](https://medium.com/47billion/playing-with-kubernetes-using-k3d-and-rancher-78126d341d23) 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](https://localhost:8901/dashboard/c/local/explorer/apps.deployment), [pods](https://localhost:8901/dashboard/c/local/explorer/pod), [persistent volumes](https://localhost:8901/dashboard/c/local/explorer/persistentvolume), 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](https://kops.sigs.k8s.io) with your own instances.
- [Amazon Elastic Kubernetes Service (EKS)](https://aws.amazon.com/eks/).
- [Azure Kubernetes Service (AKS)](https://learn.microsoft.com/en-us/azure/aks/).
- [Google Kubernetes Engine (GKE)](https://cloud.google.com/kubernetes-engine/).
- [Kubernetes The Hard Way](https://github.com/kelseyhightower/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`.

```shell
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](https://docs.strapi.io/developer-docs/latest/getting-started/troubleshooting.html#why-can-t-i-create-or-update-content-types-in-production-staging).
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](https://www.geeksforgeeks.org/introduction-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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/Dockerfile.prod) from the [Docker deployment docs](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/docker.html#production-environments).
So let's build the Strapi production image:

```shell
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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/mycluster.yaml#L25-L34) 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:

```shell
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](https://kubernetes.io/docs/concepts/configuration/configmap/).
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](https://kubernetes.io/docs/concepts/configuration/secret/).
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:

```shell
echo -n 123456 | base64
# MTIzNDU2
```

Let's create the file [conf.yaml](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/k8s/conf.yaml) inside our `k8s` folder, with the following content:

```yaml
# ~/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` 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.

### DB Deployment

So now, we need a [Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/) for our database.
Technically speaking, you could also deploy a database using a [StatefulSet](https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/). Still, I'm leaving that debate for another day (or you can also [check this blog in case you are curious](https://kubernetes.io/docs/tasks/run-application/run-replicated-stateful-application/)).
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](https://kubernetes.io/docs/concepts/storage/persistent-volumes/), but this is a very complicated topic that we'll expand on later in this article.
Let's create the file [db.yaml](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/k8s/db.yaml) inside our `k8s` folder, with the following content:

```yaml
# ~/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](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) 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](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) 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](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/), which refers to the `mysql:5.7` image, exposes port `3306`, uses the proper conf and mounts our Persistent Volume Claim in the path `/var/lib/mysql`.
- Finally, a [Service](https://kubernetes.io/docs/concepts/services-networking/service/) which exposes the actual port to the rest of the cluster via dns. Due to some [DNS conventions](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/), we could reach the mysql Deployment via this Service from the same namespace: `strapi-db`; and from another namespace: `strapi-db.<namespace>`,  or `strapi-db.<namespace>.svc.cluster.local`.

### App Deployment

So now, we need a [Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/k8s/app.yaml) inside our `k8s` folder, with the following content:

```yaml
# ~/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](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/), which refers to the `mystrapiapp-prod:0.0.1` image from the registry `app-registry:5050`, exposes port `1337`, and it uses the proper conf.
- Second and last, a [Service](https://kubernetes.io/docs/concepts/services-networking/service/) exposes the actual port to the rest of the cluster via dns. The same [DNS conventions](https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/) 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:
```shell
kubectl apply -f k8s/
```

Now, let's wait for everything to start, we can do that by running:
```shell
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:

```text
http://localhost:1337/
```

If you want to see the logs of those pods, you can do:
```shell
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](https://helm.sh).
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`.

```shell
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](https://helm.sh/docs/intro/quickstart/).

We can start by creating our own chart by running the following command:
```shell
cd helm
helm create strapi-chart
```

This creates the following folder structure:
```text
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](https://helm.sh/docs/topics/charts/).

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:

```shell
# 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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/claim.yaml), with the following content:
```gotemplate
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/values.yaml#L87-L94) file:
```yaml
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/deployment.yaml#L79-L83), the `volume` section, watch out for the indentation:

```gotemplate
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/deployment.yaml#L50-L54) 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**):
```gotemplate
# ~/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:

```shell
helm template mysql strapi-chart
```

Now we need to activate the storage, let's run it again but let's enable our storage:

```shell
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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/configmap.yaml), with the following content:

```gotemplate
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/secret.yaml), with the following content:

```gotemplate
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/values.yaml#L96-L102) file:

```yaml
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
configMap:
  enabled: false
  data: {}

secret:
  enabled: false
  data: {}
```

Finally, in the [templates/deployment.yaml](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/deployment.yaml#L55-L65) 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**):

```gotemplate
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/values.yaml#L39-L44) file, add `portName`, `containerPort`, and `nodePort` to the `service` key, so it looks like this:

```yaml
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/deployment.yaml#L36-L39) file, update the values `spec.template.spec.containers[0].ports[0].name` and `spec.template.spec.containers[0].ports[0].containerPort`, like the following:

```gotemplate
# ~/strapi-k8s/helm/strapi-chart/templates/deployment.yaml
# ...
          ports:
            - name: {{ .Values.service.portName }}
              containerPort: {{ .Values.service.containerPort }}
              protocol: TCP
# ...
```

And, in the [service.yaml](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/service.yaml#L9-L16), update the `ports` spec, like the following:

```gotemplate
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/values.yaml#L104-L112) file, add `livenessProbe` and `readinessProbe` keys, so it looks like this:

```yaml
# ~/strapi-k8s/helm/strapi-chart/values.yaml
# ...
livenessProbe: {}
#  httpGet:
#    path: /
#    port: http

readinessProbe: {}
#  httpGet:
#    path: /
#    port: http
```

And, in the [deployment.yaml](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/strapi-chart/templates/deployment.yaml#L40-L47) file, update the values `spec.template.spec.containers[0].livenessProbe` and `spec.template.spec.containers[0].readinessProbe`, and replace them with the following code:

```gotemplate
# ~/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](https://github.com/AGWA/git-crypt) or another encryption tool.
- Store the Secrets in a vault like [Hashicorp Vault](https://www.hashicorp.com/products/vault), [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/), [GCP Secret Manager](https://cloud.google.com/secret-manager), etc., and then use an operator like [External Secrets Operator](https://github.com/external-secrets/external-secrets) to add them to your K8s cluster.
- Use [Sealed Secrets Operator](https://github.com/bitnami-labs/sealed-secrets).
- Use [Helm Secrets](https://github.com/jkroepke/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/db.yaml), which will override the default values for our DB, with the following content:

```yaml
# ~/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:

```shell
# ~/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](https://github.com/chepeftw/strapi-k8s-blog-post/blob/main/part1/strapi-k8s/helm/app.yaml), with the following content:

```yaml
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:
```shell
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:
```shell
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:
```shell
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:

```text
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](https://strapi.io/blog/how-to-deploy-and-scale-strapi-on-a-kubernetes-cluster-2-2), we'll try to answer these questions to achieve a more advanced and robust deployment.