Software requirements

To continue with this guide you need to install some open-source software:

  1. docker: https://docs.docker.com/engine/install/
  2. minikube: https://minikube.sigs.k8s.io/docs/start/
  3. kubectl: https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/
  4. helm3: https://helm.sh/docs/intro/install/
  5. helm diff plugin: https://github.com/databus23/helm-diff#install (Workaround for Windows if there are problems with installation )
  6. helmfile: https://helmfile.readthedocs.io/en/latest/#installation

All software can be installed on any popular operating systems, such as Windows, macOS or Linux. The following examples were tested on Ubuntu 22.04.

You may use following bash scripts to install the all necessary utilities

docker
#!/bin/bash

# docker

# remove old version
sudo apt-get remove docker docker-engine docker.io containerd runc

# set up repository
sudo apt-get update

sudo apt-get install \
  ca-certificates \
  curl \
  gnupg \
  lsb-release

# Add Docker’s official GPG key
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg

# set up the repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null

# install docker engine
sudo apt-get update
sudo chmod a+r /etc/apt/keyrings/docker.gpg
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
minikube
#!/bin/bash

# minikube

curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 &&
	chmod +x minikube
sudo mkdir -p /usr/local/bin/
sudo install minikube /usr/local/bin/
kubectl
#!/bin/bash

# kubectl
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
curl -LO "https://dl.k8s.io/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl.sha256"
echo "$(cat kubectl.sha256)  kubectl" | sha256sum --check

chmod +x kubectl
mkdir -p ~/.local/bin
mv ./kubectl ~/.local/bin/kubectl
export PATH="$/home/${USER}/bin:$PATH"
helm
#!/bin/bash

# helm3

curl https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg >/dev/null
sudo apt-get install apt-transport-https --yes
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt-get update
sudo apt-get install helm
helm diff
#!/bin/bash

# helm diff plugin
helm plugin install https://github.com/databus23/helm-diff
helmfile
#!/bin/bash

# helmfile
curl -s -L https://github.com/helmfile/helmfile/releases/download/v0.154.0/helmfile_0.154.0_linux_amd64.tar.gz | tar xvz -C /tmp
sudo mv /tmp/helmfile ~/.local/bin/helmfile

Introduction to Kubernetes

Resources listed below will help you understand how Kubernetes works.

  • Kubernetes concepts - Kubernetes official page. It will allow you to learn about pods, deployments, services and more.
  • Very simple introduction - short overview of how Kubernetes works and what its benefits are.
  • A Kubernetes quick start for people who know just enough about Docker - quick guide that can come in handy when you already understand Docker a little and want to master Kubernetes.

Prepare environment

For our task, we will use a minimalist local Kubernetes cluster with one node - a minikube. In order for it to work, you need an installed container or virtual machine manager, like Docker,  QEMU, KVM, VMWare Workstation or VirtualBox

To start minikube cluster, run the following command from a terminal with administrator access (but not logged in as root):

minikube start

If minikube fails to start, сheck out the official guide by link

Make sure that minikube is running and check availability of necessary tools:

$ minikube status
minikube
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured

# check the helm version (it should be v3 or higher)
$ helm version
version.BuildInfo{Version:"v3.13.0", GitCommit:"825e86f6a7a38cef1112bfa606e4127a706749b1", GitTreeState:"clean", GoVersion:"go1.20.8"}

# check if "helm diff" plugin was installed successfully
helm plugin list
NAME   	VERSION  	DESCRIPTION                                                                  
diff   	3.6.0    	Preview helm upgrade changes as a diff

# check if "helmfile" was installed successfully
$ helmfile version

▓▓▓ helmfile

  Version            0.154.0
  Git Commit         c498af3
  Build Date         24 May 23 02:31 EEST (4 months ago)
  Commit Date        24 May 23 02:29 EEST (4 months ago)
  Dirty Build        no
  Go version         1.20.4
  Compiler           gc
  Platform           linux/amd64

Application development

Docker image requirements

  • The application should not store SSL certificates and keys inside the container. TLS processing is provided by Kubernetes tools. Your application should work with HTTP

If you already have a ready-made docker image, you can skip this point


If you already have a program, but do not yet have a docker image, skip this point and go to "Create dockerfile..." 

This is the sample application we will use to demonstrate how to create HELM chart. It is very simple API written in Node.js. The example is taken from here, and modified according to our requirements.
This sample application will require two environment variables for its operation. Later in this guide we will show how these variables are set in deployment.yaml after being read from custom values.yaml file.

Create a directory for project.

~$ mkdir api-project && cd api-project # create a directory for project and go to this directory
~/api-project$ mkdir api && cd api	   # create a directory to develop api and go to this directory  
~/api-project/api$ touch server.js	   # create a main file that will describe the operation of the API

Let's write the code that describes the simple operation of our API, which depends on two environment variables

// server.js
const express = require("express");
const app = express();

let env1 = "Default value of env1" // default value for variable env1
let env2 = "Default value of env2" // default value for variable env2

if (process.env.ENV1) {
    env1 = process.env.ENV1		  // if the environment variable ENV1 exists, set env1 to ENV1
}
if (process.env.ENV2) {
    env2 = process.env.ENV2    	  // if the environment variable ENV2 exists, set env2 to ENV2
}

app.get("/env1", (req, res) => {
    res.json({
        env1: `${env1}`
    });
});

app.get("/env2", (req, res) => {
    res.json({
        env2: `${env2}`
    });
});

app.listen(8081, () => {
    console.log("Server running on port 8081");
});

If you want to check the example, install node.js and npm as well

Let's download all the necessary modules

~/api-project/api$ npm init
~/api-project/api$ npm install express --save

Create Dockerfile, build your application image push it to the registry

If you are familiar with docker and already know how to create docker images, you can skip this point

Let's create a simple Dockerfile to build the application image

~/api-project/api$ touch Dockerfile

Select appropriate base image for your application

Specify it in "Dockerfile" with FROM command. In order to choose the right base docker image, you need to follow the following recommendations

  • Check whether the necessary docker image already exists (for example, on dockerhub). If it exists, it is better to use a ready-made docker image. This is relevant, for example, for databases. There are ready-made Docker images for databases that are supported by their developers;
  • If you need a full-fledged environment then choose images of stable LTS versions of well-known Linux distributions such as Ubuntu, Centos, Fedora or others;
  • Depending on the programming language in which you develop you can choose optimized base images of Python, NodeJS or others;
  • If you do not need large images, you can use the simplest images, such as alpine or busybox, but it is important to note that if you need to debug the operation of your program, it will be difficult to do this, because such small images contain very limited functionality.

Copy additional files from your local file system into the image

Edit "Dockerfile" with ADD or COPY commands in the file. There are two ways to copy files from the local file system to the image

  • Use ADD instruction
    Copies new files, directories, or remote file URLs from <src> and adds them to the file system of the image at the path <dest>.
  • Use COPY instruction
    Copies new files or directories from <src> and adds them to the file system of the image at the path <dest>
ADD test.txt relativeDir/
ADD test.txt /absoluteDir/

COPY test.txt relativeDir/
COPY test.txt /absoluteDir/

Read more about the difference between ADD and COPY

Install extra software dependencies (if needed)

In order to install necessary software dependencies, use the RUN instruction.

For example, if our image contains the apt  package manager, you can install Python

RUN apt-get update
RUN apt install -y python3-pip

However, it is recommended to use as few instructions as possible, because each instruction creates its own layer and our image will have a larger size. Therefore, it is necessary to combine several commands into one where possible

RUN apt-get update \
	&& apt install -y python3-pip

As a result, when your container is built, it will have Python3 installed

Specify command which should be launched on container start

There are two instructions that can be used to run commands inside a running container. These are CMD  and ENTRYPOINT  instructions.

Copy the code below into your Dockerfile. This is the sample Dockerfile that we created for our test application.

# Dockerfile
# choose node as the base image, because we use NodeJS for developing
FROM node:19.4.0

# change work directory. All subsequent commands will be executed from this directory
WORKDIR /usr/src/app

# copy package*.json files to /usr/src/app
COPY package*.json ./

# install required libraries
RUN npm install

# copy all files from local directory to /usr/src/app
COPY . .

# expose 8081 port
EXPOSE 8081

# run your JS application
CMD ["node", "server.js"]

Build image from Dockerfile

$ docker build -t <your-repo>/backend-api:1.0.0 .
#example
~/api-project/api$ docker build -t registry.portaone.com/backend-api:1.0.0 .

If you don't have a private registry, you can create an account in the largest public registry Docker Hub.

Before uploading an image to the registry, you need to log in to the registry. Run the command docker login:

$ docker login <private-registry-url>
#if you are using private registry
$ docker login registry.portaone.com
#if you are using docker hub
$ docker login

Push image to your repository.

$ docker push <your-repo>/backend-api:1.0.0
#example
$ docker push registry.portaone.com/backend-api:1.0.0

Start helming

To check whether everything is ready to work, you can create a basic helm chart at link and deploy it to a Kubernetes cluster.
You can also read a detailed guide on creating a helm chart. All basic steps are described there, after reading it you will understand the structure of a helm chart and learn how to deploy and roll back changes.

Creating helm chart for your application

Helm chart requirements

  1. We use Oracle cloud provider. If you are creating some specific applications, you should read the Oracle documentation (use cases such as using load balancers, etc.)
  2. Traefik is used as an ingress controller. You should use such resources as IngressRoutes, etc. (default "Ingress" resources will not work).
  3. You cannot use Services with the NodePort type (our nodes are in a private network).
  4. You should retain the ability to add common annotations or labels to all helm chart resources (this may be necessary to identify applications).

How to install traefik in minikube 

It can be deployed in two ways.
1 Method.

Create helmfile-traefik.yaml  file with the following configuration

helmfile.yaml
repositories:
  - name: traefik
    url: https://helm.traefik.io/traefik


releases:
  - name: traefik
    namespace: default
    chart: traefik/traefik
    version: 24.0.0 #remove the version to automatically install the latest version
    values:
      - providers:
          kubernetesCRD:
            allowCrossNamespace: true # Allows IngressRoute to reference resources in namespace other than theirs
      - ingressRoute:
          dashboard:
            entryPoints: ["web", "traefik"] # Allows to access the dashboard on the "web" entrypoint

Deploy traefik to minikube cluster with the following command:

~/api-project$ helmfile -f helmfile-traefik.yaml apply 

2 Method.

Deploy using the helm command

Add repository

helm repo add traefik https://traefik.github.io/charts

Install chart

$ helm install traefik traefik/traefik \ 
      --version 24.0.0 \
      --set providers.kubernetesCRD.allowCrossNamespace=true \
      --set ingressRoute.dashboard.entryPoints="{"web","traefik"}"


In order to check whether the traffic is working, you can open its dashboard

In a separate console, run the command (and leave this command running, it should be running all the time)

minikube tunnel 

The command requires the user to enter a password a few seconds after it is launched


See External-IP

$ kubectl get service traefik
NAME      TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)                      AGE
traefik   LoadBalancer   10.97.210.197   10.97.210.197   80:31977/TCP,443:31863/TCP   62s


Open traefik dashboard in browser at the url: http://10.97.210.197/dashboard/ . The dashboard looks like this in the figure below

Development of the chart

We have created a basic chart that meets our requirements and you can use it as an example to create your own personal chart.

basic-chart-1.0.0.tgz


Let's extract files from archive with the following command

#create separate directory
~/api-chart$ mkdir chart && cd chart

#extract archive
~/api-chart/chart$ tar -xvzf basic-chart-1.0.0.tgz
basic-chart/Chart.yaml
basic-chart/values.yaml
basic-chart/templates/_helpers.tpl
basic-chart/templates/configmap-app-config.yaml
basic-chart/templates/configmap-app-envs.yaml
basic-chart/templates/deployment.yaml
basic-chart/templates/ingressroute.yaml
basic-chart/templates/secret-app-config.yaml
basic-chart/templates/secret-app-envs.yaml
basic-chart/templates/service.yaml
basic-chart/templates/tls-secret.yaml
basic-chart/.helmignore

Adjust the helm chart to the configuration you need

Helm syntax explanation

Quote from helm documentation:

While we talk about the "Helm template language" as if it is Helm-specific, it is actually a combination of the Go template language, some extra functions, and a variety of wrappers to expose certain objects to the templates. Many resources on Go templates may be helpful as you learn about templating. 

Let's look at the basics:

  1. A template directive is enclosed in {{  and }}  blocks.
  2. There are basic built-in objects like Release  and Values  via which you can access your parameters (please read more here)
  3. All configuration parameters are passed via values files. You can access values in templates like this
    name: {{ .Values.app.name }}
    And In values.yaml it looks like this:
    app:
      name: "App Name"
  4. Template Functions and Pipelines. The most used:
    a) default  - This function allows you to specify a default value inside of the template, in case the value is omitted.
    name: {{ .Values.app.name | default "App Name" }}
     If app.name  value is omitted "App Name" will be used.  
    b) quote  - Wraps content in double quotes
    name: {{ quote .Values.app.name }}
     
    #or using pipeline
     
    name: {{ .Values.app.name | quote }}

    When injecting strings from the .Values  object into the template, we ought to quote these strings (best pratices). This helps to avoid unexpected data conversion. 

    c) include  - The include function allows you to bring in another template, and then pass the results to other template functions. 
     {{ include "toYaml" $value | indent 2 }}
    The above includes a template called toYaml, passes it $value, and then passes the output of that template to the indent function.
    d) printf  - Returns a string based on a formatting string and the arguments to pass to it in order.
     printf "https://%s:%d" .Values.app.host .Values.app.port


Open directory basic-chart   in any text editor and make the following changes. Usually, the repository and port of your application do not change, so it is worth changing it to the default values ​​so that it is not specified every time the application is deployed.


basic-chart/values.yaml
app:
....
  image:
    repository: ""  #please specify the repository (like docker.io/mongo)
    pullPolicy: IfNotPresent
    tag: ""         # please specify a default tag
....
  service:
    type: ClusterIP
    port: 8081      # Specify the port that is exposed in your docker container

Change the name of the chart to match the name of your application

  1. Modify file basic-chart/Chart.yaml
    Chart.yaml
    apiVersion: v2
    name: basic-chart # Set an appropriate <chart-name> here
    description: A Helm chart for Kubernetes
    version: 1.0.0 

    Version

    Please pay attention to the "version" field.
    We recommend setting the same version as the version of the docker image (if your helm chart describes a single container deployment). If your chart describes more than one deployment, we recommend using a version of one of the main docker images. It is also not recommended to change the version of an existing chart. That is, if the configuration changes, then you should release a new version of the chart with corrections.

  2. Change directory name
    ~/api-project/chart$ mv basic-chart/ <chart-name>/
  3. Change templates
    ~/api-project/chart$ sed -i 's/basic-chart/<chart-name>/g' <chart-name>/templates/*

Application configuration methods

To begin, let's create our custom configuration file in which we will store our settings for the application

~/api-project$ touch custom-values.yaml
Via environment variables

For non-sensitive variables, you should use Configmap. Please open the file templates/configmap-app-envs.yaml and specify the variables which are required for your application. For example, we want to pass two environment variables and the file should be like this.

{{- if .Values.app.confEnvs }}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-app-envs
  labels:
{{ include "basic-chart.labels" . | indent 4 }}
data:
  ENV1: {{ .Values.app.confEnvs.env1 | quote }} # where ENV1 is the name of the variable
  ENV2: {{ .Values.app.confEnvs.env2 | quote }}
{{- end }}

Then in file custom-values.yaml  you should specify values. For example

app:
  confEnvs:
    env1: "value 1"
    env2: "value 2"

For sensitive variables, you should use Secret. Please open the file templates/secret-app-envs.yaml  and specify the variables which are required for your application.

 {{- if .Values.app.confSecretEnvs }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ .Release.Name }}-app-secret-envs
  labels:
    {{- include "basic-chart.labels" . | nindent 4 }}
type: Opaque
stringData:
  ENV1: {{ .Values.app.confSecretEnvs.env1 | quote }}
  ENV2: {{ .Values.app.confSecretEnvs.env2 | quote }}
{{- end }}

Then in file custom-values.yaml  you should specify secret values. For example

app:
  confSecretEnvs:
    env1: "secret value 1"
    env2: "secret value 2"

If you want two specify both sensitive and non-sensitive variables you can combain it in your custom-values.yaml.

app:
  confEnvs:
    env1: "value 1"
    env2: "value 2"
  confSecretEnvs:
    env1: "secret value 1"
    env2: "secret value 2"
Via configuration file

If your file doesn't contain sensitive data you should use Configmap. The file can have any extension. For example, you have some config.xml  file. Then your Configmap will look like this

{{- if .Values.app.confFileParams }}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-xml-config
  labels:
{{ include "basic-chart.labels" . | indent 4 }}
data:
  config.xml: |-
    <?xml version="1.0"?>
    <catalog>
       <book id="book1">
          <author>{{ .Values.app.confFileParams.book1.author }}</author>
          <title>{{ .Values.app.confFileParams.book1.title }}</title>
       </book>
       <book id="book2">
          <author>{{ .Values.app.confFileParams.book2.author }}</author>
          <title>{{ .Values.app.confFileParams.book2.title }}</title>
       </book>
    </catalog>
{{- end }} 

Then in file custom-values.yaml  you should specify values which will be substituted to config.xml . For example

app: 
  confFileParams:
    book1:
      author: "Harry Potter"
      title: "J.K. Rowling"
    book2:
      author: "J.R.R. Tolkien"
      title: "The Lord of the Rings" 


If your file contains sensitive data you should use Secret. The file can have any extension. For example, we have some config.ini  file. Then your Secret will look like this

{{- if .Values.app.confFileSecretParams }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ .Release.Name }}-ini-secret-config
  labels:
    {{- include "basic-chart.labels" . | nindent 4 }}
type: Opaque
stringData:
  config.ini: |-
    [server]
    port = {{ .Values.app.confFileSecretParams.server.port }}
    host = {{ .Values.app.confFileSecretParams.server.host }}

    [database]
    host = {{ .Values.app.confFileSecretParams.database.host }}
    port = {{ .Values.app.confFileSecretParams.database.port }}
    login = {{ .Values.app.confFileSecretParams.database.login }}
    password = {{ .Values.app.confFileSecretParams.database.password }}
{{- end }} 

Then in file custom-values.yaml  you should specify values which will be substituted to config.ini . For example

app:
  confFileSecretParams:
    server:
      port: "8080"
      host: "localhost"
    database:
      host: "localhost"
      port: "27017"
      login: "user"
      password: "password123"


If your file contains even one sensitive parameter, you must fully describe it in Secret. It is not possible to describe part of the file in the Configmap and part in the Secret, as this could be done with environment variables

Configuration notes

After you have decided how your application is configured, you should update the templates.
Possible cases:

  1. If you don't use configuration files at all you should:
    a) Delete files templates/configmap-app-config.yaml  and templates/secret-app-config.yaml 
    b) Delete this section from the file templates/deployment.yaml 
           {{- if or .Values.app.confFileParams .Values.app.confFileSecretParams }}
              volumeMounts:
              {{- if .Values.app.confFileSecretParams }}
                - name: config-from-secret
                  mountPath: /app/config.ini
                  subPath: config.ini
              {{- end }}
              {{- if .Values.app.confFileParams }}
                - name: config-from-configmap
                  mountPath: /app/config.xml
                  subPath: config.xml
              {{- end }}
          volumes:
          {{- if .Values.app.confFileSecretParams }}
           - name: config-from-secret
             secret:
               secretName: {{ .Release.Name }}-ini-secret-config #
          {{- end }}
          {{- if .Values.app.confFileParams }}
           - name: config-from-configmap
             configMap:
                name: {{ .Release.Name }}-xml-config
          {{- end }}
          {{- end }}
  2. If you use configuration files, you should pay attention to the following points
    a) You must specify the correct names for the configuration files in deployment.yaml  
           {{- if or .Values.app.confFileParams .Values.app.confFileSecretParams }}
              volumeMounts:
              {{- if .Values.app.confFileSecretParams }}
                - name: config-from-secret   # mount by local name, which is described below
                  mountPath: /app/config.ini # path where your file will be mounted
                  subPath: config.ini        # name of the file you specified in Secret
              {{- end }}
              {{- if .Values.app.confFileParams }}
                - name: config-from-configmap
                  mountPath: /app/config.xml # path where your file will be mounted
                  subPath: config.xml        # name of the file you specified in Configmap
              {{- end }}
          volumes:
          {{- if .Values.app.confFileSecretParams }}
           - name: config-from-secret # local name for the volume within this deployment
             secret:
               secretName: {{ .Release.Name }}-ini-secret-config # the name of the secret that the file contains
          {{- end }}
          {{- if .Values.app.confFileParams }}
           - name: config-from-configmap
             configMap:
                name: {{ .Release.Name }}-xml-config
          {{- end }}
          {{- end }}
    b) Specify the correct file names in Secret or Configmap
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: {{ .Release.Name }}-xml-config
      labels:
    {{ include "basic-chart.labels" . | indent 4 }}
    data: # config.xml is the file name that will be specified during mounting as subPath
      config.xml: |-
        <?xml version="1.0"?>
        ......


  3. Pay attention to the ingresroute.yaml  file
    a) Our infrastructure can provide self-signed Let's Encrypt certificates. You can read more about it on your own here. Please do not delete this section from the configuration file.
     {{- if .Values.app.ingress.tls.secretAutoCreate }}
    #The Certificate resource allows you to use self-signed Let's Encrypt certificates
    #In order to use it, you need to install a cert-manager 
    #Installation guide: https://cert-manager.io/docs/installation/helm/
    apiVersion: cert-manager.io/v1
    kind: Certificate
    metadata:
      name: {{ .Values.app.ingress.host.name }}
    spec:
      secretName: {{ .Values.app.ingress.tls.secretName }}
      secretTemplate:
        annotations:
          meta.helm.sh/release-name: {{ .Release.Name }}
          meta.helm.sh/release-namespace: {{ .Release.Namespace }}
          app.kubernetes.io/managed-by: Helm
        labels:
          app.kubernetes.io/managed-by: Helm
      issuerRef:
        name: {{ .Values.app.ingress.tls.issuerName }}
        kind: Issuer
      commonName: {{ .Values.app.ingress.host.name }}
      dnsNames:
      - {{ .Values.app.ingress.host.name }}
    {{- end }}
    b) IngressRoute - provides an opportunity to make your service available from the Internet. For example, an ingressroute without tls is given - you can use it for local testing. You only need to configure DNS for that domain on the IP of the load balancer that creates the traefik (see the section "How to deploy traefik")
    ---
    apiVersion: traefik.io/v1alpha1
    kind: IngressRoute
    metadata:
      name: {{ .Release.Name }}-http
      annotations:
        kubernetes.io/ingress.class: {{ .Values.app.ingress.class }}
        {{- include "basic-chart.ingressRouteAnnotations" . | nindent 4 }}
    spec:
      entryPoints:
        - web
      routes:
        - match: Host(`{{ .Values.app.ingress.host.name }}`) && PathPrefix(`{{ .Values.app.ingress.host.path }}`)
          kind: Rule
          services:
            - name: {{ .Release.Name }}
              namespace: {{ .Release.Namespace }}
              port: {{ .Values.app.service.port }}
          middlewares:
            - name: {{ .Release.Name }}-strip-prefix
    
  4. The configuration should be stored in a separate file (in our case, it is custom-values.yaml)
    For example:
    #the application is configured using environment variables
    app:
      confEnvs:    # non-sensitive
        env1: env1
      image:
        repository: "docker.io/vovan4/example"
        pullPolicy: IfNotPresent
        tag: "1.0.0"
      ingress:
        enabled: true
        host:
          path: "/app"
          name: "my-domain.com"

Push new/updated helm chart to the remote helm repository

In order for the chart to be used not only locally, it needs to be uploaded to a remote helm chart repository.


Login to the helm registry.

$ helm registry login <repository> --username <username> --password ******
$ helm registry login registry.portaone.com --username <username> --password ******

Create a helm package

$ helm package <path-to-local-chart-directory>
 ~/api-project/chart$ helm package ./basic-chart
Successfully packaged chart and saved it to: /home/<username>/api-project/chart/basic-chart-1.0.0.tgz

Push your chart to the remote registry.

$ helm push <path-to-local-chart-archive>.tgz oci://<registry>/<project>/charts
~/api-project/chart$ helm push ./basic-chart.tgz oci://<registry>/<project>/charts

In this way, you can push both new charts and release new versions of already existing charts.

Please, if you update the chart, release it with the new version. It is not recommended to change a chart version that is already in use.

And to check, you can pull the chart from the remote registry using the command

$ helm pull oci://<registry>/<project>/charts/basic-chart --version 1.0.0

Deploy to Kubernetes using helm

Create secret with docker credentials

Here you can read how to correctly pass credentials for docker in Kubernetes. In short, a "secret" is created that contains credentials to the docker registry.

If you are currently logged in using the "docker login" command in the registry that will be used, you can use the following command. If you are not logged in, please log in first.

kubectl create secret generic dockercred \
    --from-file=.dockerconfigjson=<path/to/.docker/config.json> \
    --type=kubernetes.io/dockerconfigjson

On Windows, this command may not always work well, so it is better to use the method described below

You can also create a secret by providing credentials on the command line

kubectl create secret docker-registry dockercred --docker-server=<your-registry-server> \ 
	--docker-username=<your-name> \
	--docker-password=<your-password> \
	--docker-email=<your-email> 

"dockercred" is the name of the secret. You need to remember its name
Usually the path to the file is ~/.docker/config.json

Specify created secret with credentials in custom-values.yaml  to ensure pulling the image from the registry.

custom-values.yaml
...
# add this line (specify the name of the secret that contains credentials)
imagePullSecrets:
  - name: dockercred
... 

Now you can deploy your API to Kubernetes cluster using helm install command.

$ helm install -f <custom-values-file> <name-of-release> <path-to-chart>
~/api-project$ helm install -f ./custom-values.yaml api ./chart/basic-chart

This method is complicated, because the more settings, the longer the command. Therefore, for ease of understanding, we recommend using the helmfile utility, which is described below

Uninstall helm release from Kubernetes using helm

In order to remove the release from the cluster, you need to execute the "helm uninstall" command

$ helm uninstall <release-name> -n <optional-namespace>
#example
$ helm uninstall api

Create helmfile

It is sufficient to use helm chart to deploy and destroy application in the cluster. However if you work with many helm charts you might want to consider deploying with help of "helmfile"

Helmfile is a declarative spec for deploying helm charts. It lets you…

    • Keep a directory of chart value files and maintain changes in version control.
    • Apply CI/CD to configuration changes.
    • Periodically sync to avoid skew in environments.

Create file api-project/helmfile.yaml and add the following content to it.

~/api-project$ touch helmfile.yaml
#helmfile.yaml
releases:
  - name: api
    chart: ./chart/basic-chart
    installed: true
    values:
      - custom-values.yaml

Deploy to Kubernetes using helmfile

Let's run the command to check if everything is correct(from the directory where helmfile.yaml is located)

~/api-project$ helmfile diff
~/api-project$ helmfile diff
Building dependency release=api, chart=basic-chart
Comparing release=api, chart=basic-chart
********************

	Release was not present in Helm.  Diff will show entire contents as new.

********************
default, api, Deployment (apps) has been added:
- 
+ # Source: basic-chart/templates/deployment.yaml
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: api
+   labels:
+     helm.sh/chart: basic-chart-1.0.0
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
+     app.kubernetes.io/managed-by: Helm
+ spec:
+   replicas: 1
+   selector:
+     matchLabels:
+       app.kubernetes.io/name: basic-chart
+       app.kubernetes.io/instance: api
+   template:
+     metadata:
+       annotations:
+         checksum/configmap-app-envs: b8766ba94b06d735b6fb88dd7064fce1b3d97efd71e3f45ea918e85fd9aed794
+       labels:
+         app.kubernetes.io/name: basic-chart
+         app.kubernetes.io/instance: api
+     spec:
+       containers:
+         - name: api
+           image: "docker.io/<your-account>/example:1.0.0"
+           imagePullPolicy: IfNotPresent
+           ports:
+             - name: http
+               containerPort: 8081
+               protocol: TCP
+           envFrom:
+             - configMapRef:
+                 name: api-app-envs
+           resources:
+             {}
default, api, Service (v1) has been added:
- 
+ # Source: basic-chart/templates/service.yaml
+ apiVersion: v1
+ kind: Service
+ metadata:
+   name: api
+   labels:
+     helm.sh/chart: basic-chart-1.0.0
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
+     app.kubernetes.io/managed-by: Helm
+ spec:
+   type: ClusterIP
+   ports:
+     - port: 8081
+       targetPort: http
+       protocol: TCP
+       name: http
+   selector:
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
default, api-app-envs, ConfigMap (v1) has been added:
- 
+ # Source: basic-chart/templates/configmap-app-envs.yaml
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+   name: api-app-envs
+   labels:
+     helm.sh/chart: basic-chart-1.0.0
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
+     app.kubernetes.io/managed-by: Helm
+ data:
+   ENV1: "env1"
+   ENV2: ""
default, api-http, IngressRoute (traefik.io) has been added:
- 
+ # Source: basic-chart/templates/ingressroute.yaml
+ apiVersion: traefik.io/v1alpha1
+ kind: IngressRoute
+ metadata:
+   name: api-http
+   annotations:
+     kubernetes.io/ingress.class: ipaas-ing
+ spec:
+   entryPoints:
+     - web
+   routes:
+     - match: Host(`my-domain.com`) && PathPrefix(`/app`)
+       kind: Rule
+       services:
+         - name: api
+           namespace: default
+           port: 8081
+       middlewares:
+         - name: api-strip-prefix
default, api-redirect-scheme, Middleware (traefik.io) has been added:
- 
+ # Source: basic-chart/templates/ingressroute.yaml
+ #To redirect HTTP traffic to HTTPS
+ apiVersion: traefik.io/v1alpha1
+ kind: Middleware
+ metadata:
+   name: api-redirect-scheme
+   annotations:
+     kubernetes.io/ingress.class: ipaas-ing
+ spec:
+   redirectScheme:
+     scheme: https
+     permanent: true
+     port: "443"
default, api-strip-prefix, Middleware (traefik.io) has been added:
- 
+ # Source: basic-chart/templates/ingressroute.yaml
+ # Use a StripPrefix middleware if your backend listens on the root path (/) but should be exposed on a specific prefix.
+ # doc: https://doc.traefik.io/traefik/master/middlewares/http/stripprefix/
+ apiVersion: traefik.io/v1alpha1
+ kind: Middleware
+ metadata:
+   name: api-strip-prefix
+   annotations:
+     kubernetes.io/ingress.class: ipaas-ing
+ spec:
+   stripPrefix:
+     prefixes:
+       - "/app"
+     forceSlash: true


If the diff plugin worked without errors, you can run the command bellow to deploy our API in the cluster

~/api-project$ helmfile apply
~/api-project$ helmfile apply
uilding dependency release=api, chart=basic-chart
Comparing release=api, chart=basic-chart
********************

	Release was not present in Helm.  Diff will show entire contents as new.

********************
default, api, Deployment (apps) has been added:
- 
+ # Source: basic-chart/templates/deployment.yaml
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: api
+   labels:
+     helm.sh/chart: basic-chart-1.0.0
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
+     app.kubernetes.io/managed-by: Helm
+ spec:
+   replicas: 1
+   selector:
+     matchLabels:
+       app.kubernetes.io/name: basic-chart
+       app.kubernetes.io/instance: api
+   template:
+     metadata:
+       annotations:
+         checksum/configmap-app-envs: b8766ba94b06d735b6fb88dd7064fce1b3d97efd71e3f45ea918e85fd9aed794
+       labels:
+         app.kubernetes.io/name: basic-chart
+         app.kubernetes.io/instance: api
+     spec:
+       containers:
+         - name: api
+           image: "docker.io/<your-account>/example:1.0.0"
+           imagePullPolicy: IfNotPresent
+           ports:
+             - name: http
+               containerPort: 8081
+               protocol: TCP
+           envFrom:
+             - configMapRef:
+                 name: api-app-envs
+           resources:
+             {}
default, api, Service (v1) has been added:
- 
+ # Source: basic-chart/templates/service.yaml
+ apiVersion: v1
+ kind: Service
+ metadata:
+   name: api
+   labels:
+     helm.sh/chart: basic-chart-1.0.0
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
+     app.kubernetes.io/managed-by: Helm
+ spec:
+   type: ClusterIP
+   ports:
+     - port: 8081
+       targetPort: http
+       protocol: TCP
+       name: http
+   selector:
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
default, api-app-envs, ConfigMap (v1) has been added:
- 
+ # Source: basic-chart/templates/configmap-app-envs.yaml
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+   name: api-app-envs
+   labels:
+     helm.sh/chart: basic-chart-1.0.0
+     app.kubernetes.io/name: basic-chart
+     app.kubernetes.io/instance: api
+     app.kubernetes.io/managed-by: Helm
+ data:
+   ENV1: "env1"
+   ENV2: ""
default, api-http, IngressRoute (traefik.io) has been added:
- 
+ # Source: basic-chart/templates/ingressroute.yaml
+ apiVersion: traefik.io/v1alpha1
+ kind: IngressRoute
+ metadata:
+   name: api-http
+   annotations:
+     kubernetes.io/ingress.class: ipaas-ing
+ spec:
+   entryPoints:
+     - web
+   routes:
+     - match: Host(`my-domain.com`) && PathPrefix(`/app`)
+       kind: Rule
+       services:
+         - name: api
+           namespace: default
+           port: 8081
+       middlewares:
+         - name: api-strip-prefix
default, api-redirect-scheme, Middleware (traefik.io) has been added:
- 
+ # Source: basic-chart/templates/ingressroute.yaml
+ #To redirect HTTP traffic to HTTPS
+ apiVersion: traefik.io/v1alpha1
+ kind: Middleware
+ metadata:
+   name: api-redirect-scheme
+   annotations:
+     kubernetes.io/ingress.class: ipaas-ing
+ spec:
+   redirectScheme:
+     scheme: https
+     permanent: true
+     port: "443"
default, api-strip-prefix, Middleware (traefik.io) has been added:
- 
+ # Source: basic-chart/templates/ingressroute.yaml
+ # Use a StripPrefix middleware if your backend listens on the root path (/) but should be exposed on a specific prefix.
+ # doc: https://doc.traefik.io/traefik/master/middlewares/http/stripprefix/
+ apiVersion: traefik.io/v1alpha1
+ kind: Middleware
+ metadata:
+   name: api-strip-prefix
+   annotations:
+     kubernetes.io/ingress.class: ipaas-ing
+ spec:
+   stripPrefix:
+     prefixes:
+       - "/app"
+     forceSlash: true

Upgrading release=api, chart=basic-chart
Release "api" does not exist. Installing it now.
NAME: api
LAST DEPLOYED: Mon Oct  9 10:46:59 2023
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None

Listing releases matching ^api$
api 	default  	1       	2023-10-09 10:46:59.500783363 +0300 EEST	deployed	basic-chart-1.0.0	           


UPDATED RELEASES:
NAME   CHART           VERSION   DURATION
api    ./basic-chart   1.0.0           0s

Uninstall helm release from Kubernetes using helmfile

If you are using a helmfile, then to uninstall the release you need to perform the following actions:

    • change the release option "installed" to false
  • #helmfile.yaml
    releases:
      - name: api
        chart: ./chart/basic-chart
        installed: false
        values:
          - custom-values.yaml


    • apply configuration
  • ~/api-project$ helmfile apply
    Listing releases matching ^api$
    api 	default  	1       	2023-10-09 10:46:59.500783363 +0300 EEST	deployed	basic-chart-1.0.0	           
    
    Deleting api
    release "api" uninstalled
    
    
    DELETED RELEASES:
    NAME   DURATION
    api          0s
    
    

Checking result

Initially, our pod will be in ContainerCreating status. It will take some time until our application image is downloaded from the repository

$ kubectl get pods
NAME                       READY   STATUS              RESTARTS        AGE
api-7cc7fb489d-c88k8       1/1     ContainerCreating   0               26s
traefik-5c7ff68df4-xmnq9   1/1     Running             2 (7m55s ago)   4d

After the image is downloaded, the pod will start and have a status of Running

$ kubectl get pods
NAME                       READY   STATUS    RESTARTS       AGE
api-7cc7fb489d-c88k8       1/1     Running   0              92s
traefik-5c7ff68df4-xmnq9   1/1     Running   2 (9m1s ago)   4d

Also, let's check that the service has been created

$ kubectl get services
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)                      AGE
api          ClusterIP      10.109.171.59   <none>          8081/TCP                     7m3s
kubernetes   ClusterIP      10.96.0.1       <none>          443/TCP                      9d
traefik      LoadBalancer   10.97.210.197   10.97.210.197   80:31977/TCP,443:31863/TCP   4d

Let's check our ingressroute

$ kubectl get ingressroutes.traefik.io
NAME                AGE
api-http            8m
traefik-dashboard   4dh

Find out the IP address of the traefik load balancer (Don't forget to run minikube tunnel  command in a separate console)

$ kubectl get service traefik
NAME      TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)                      AGE
traefik   LoadBalancer   10.97.210.197   10.97.210.197   80:31977/TCP,443:31863/TCP   4d

Copy the value of the External-Ip address and add to the file /etc/hosts using command or manually.

$ sudo sh -c "echo '10.97.210.197    my-domain.com' >> /etc/hosts"
#/etc/hosts

10.97.210.197    my-domain.com

Make requests to our API

$ curl http://my-domain.com/app/env1
{"env1":"env1"}

$ curl http://my-domain.com/app/env2
{"env2":"Default value of env2"}


As a result, we can see that our API is working correctly. As you can see that one query returns the env1  value that we passed to the container via custom-values.yaml . And the another query returns the default value since we didn't pass any value.

How to update the application and helm chart to a new version?

Updating the logic of the application

For example you want to update your API and for that you need to add a new environment variable. First, you need to update the logic of the application. So, change the code of the server.js file as follows

~$ cd ~/api-project/api #change directory to api and update server.js
// server.js
const express = require("express");
const app = express();

let env1 = "Default value of env1" // default value for variable env1
let env2 = "Default value of env2" // default value for variable env2
let env3 = "Default value of env3" // default value for variable env3

if (process.env.ENV1) {
    env1 = process.env.ENV1		  // if the environment variable ENV1 exists, set env1 to ENV1
}
if (process.env.ENV2) {
    env2 = process.env.ENV2    	  // if the environment variable ENV2 exists, set env2 to ENV2
}
if (process.env.ENV3) {
    env3 = process.env.ENV3    	  // if the environment variable ENV3 exists, set env3 to ENV3
}

app.get("/env1", (req, res) => {
    res.json({
        env1: `${env1}`
    });
});

app.get("/env2", (req, res) => {
    res.json({
        env2: `${env2}`
    });
});

app.get("/env3", (req, res) => {
    res.json({
        env3: `${env3}`
    });
});

app.listen(8081, () => {
    console.log("Server running on port 8081");
});

Updating the docker image

Build a new image with a new tag of 2.0.0 (Sometimes it is possible to update a docker image with the same tag of 1.0.0, that is, to roll out a hotfix, but then this requires more skill from the developer).

Build a new image.

$ docker build -t <your-repo>/backend-api:2.0.0 .
#example
~/api-project/api$ docker build -t registry.portaone.com/backend-api:2.0.0 .

Push the image to the registry

$ docker push <your-repo>/backend-api:2.0.0
#example
$ docker push registry.portaone.com/backend-api:2.0.0

Updating the helm chart

If your application has changes that require additional environment variables or configs, you need to modify the helm chart. In our case, we only add one new environment variable. Therefore, first you need to change the deployment.yaml file.

Add a new variable to Configmap (file "basic-chart/templates/configmap-app-envs.yaml").

configmap-app-envs.yaml
{{- if .Values.app.confEnvs }}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-app-envs
  labels:
{{ include "basic-chart.labels" . | indent 4 }}
data:
  ENV1: {{ .Values.app.confEnvs.env1 | quote }}
  ENV2: {{ .Values.app.confEnvs.env2 | quote }}
  ENV3: {{ .Values.app.confEnvs.env3 | quote }} # new variable
{{- end }}
 


It is also desirable to add the default value to the "basic-chart/values.yaml" file.

values.yaml
nameOverride: ""
...
app:
  
  confEnvs:
    env1: "" #
    env2: "" #
    env3: "" # new variable
...


You also need to set a value for the new environment variable and change tag for image in the "api-project/custom-values.yaml" settings file

custom-values.yaml
#the application is configured using environment variables
app:
  confEnvs:    # non-sensitive
    env1: env1
    env3: "env3 value" # new value
  image:
    repository: "docker.io/<your-account>/example"
    pullPolicy: IfNotPresent
    tag: "2.0.0" # new tag
  ingress:
    enabled: true
    host:
      path: "/app"
      name: "my-domain.com" 


It is also highly recommended to change the version of the helm chart itself. This version can be specified in the "api-chart/Chart.yaml" file, which is the main file of the chart. So change the default version 1.0.0 to 2.0.0. This will be more clear when installing a new release. The value of the "version" parameter means the version of the chart.

Chart.yaml
apiVersion: v2
name: basic-chart
description: A Helm chart for Kubernetes
version: 2.0.0 


Deployment and testing of the application

Verify your changes using the helmfile diff command.

~/api-project$ helmfile diff
Building dependency release=api, chart=basic-chart
Comparing release=api, chart=basic-chart
default, api, Deployment (apps) has changed:
  # Source: basic-chart/templates/deployment.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: api
    labels:
-     helm.sh/chart: basic-chart-1.0.0
+     helm.sh/chart: basic-chart-2.0.0
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
      app.kubernetes.io/managed-by: Helm
  spec:
    replicas: 1
    selector:
      matchLabels:
        app.kubernetes.io/name: basic-chart
        app.kubernetes.io/instance: api
    template:
      metadata:
        annotations:
-         checksum/configmap-app-envs: b8766ba94b06d735b6fb88dd7064fce1b3d97efd71e3f45ea918e85fd9aed794
+         checksum/configmap-app-envs: a1b3a3fd4eedcaf61e04f74a2702fddbb724c962966ab98381478c04766ace41
        labels:
          app.kubernetes.io/name: basic-chart
          app.kubernetes.io/instance: api
      spec:
        containers:
          - name: api
-           image: "docker.io/<your-account>/example:1.0.0"
+           image: "docker.io/<your-account>/example:2.0.0"
            imagePullPolicy: IfNotPresent
            ports:
              - name: http
                containerPort: 8081
                protocol: TCP
            envFrom:
              - configMapRef:
                  name: api-app-envs
            resources:
              {}
default, api, Service (v1) has changed:
  # Source: basic-chart/templates/service.yaml
  apiVersion: v1
  kind: Service
  metadata:
    name: api
    labels:
-     helm.sh/chart: basic-chart-1.0.0
+     helm.sh/chart: basic-chart-2.0.0
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
      app.kubernetes.io/managed-by: Helm
  spec:
    type: ClusterIP
    ports:
      - port: 8081
        targetPort: http
        protocol: TCP
        name: http
    selector:
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
default, api-app-envs, ConfigMap (v1) has changed:
  # Source: basic-chart/templates/configmap-app-envs.yaml
  apiVersion: v1
  kind: ConfigMap
  metadata:
    name: api-app-envs
    labels:
-     helm.sh/chart: basic-chart-1.0.0
+     helm.sh/chart: basic-chart-2.0.0
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
      app.kubernetes.io/managed-by: Helm
  data:
    ENV1: "env1"
    ENV2: ""
+   ENV3: "env3 value"

If everything is correct, you can try to deploy the new version of the chart. If there are errors, go back to the previous points and check if you did everything correctly.

~/api-project$ helmfile apply
Building dependency release=api, chart=basic-chart
Comparing release=api, chart=basic-chart
default, api, Deployment (apps) has changed:
  # Source: basic-chart/templates/deployment.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: api
    labels:
-     helm.sh/chart: basic-chart-1.0.0
+     helm.sh/chart: basic-chart-2.0.0
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
      app.kubernetes.io/managed-by: Helm
  spec:
    replicas: 1
    selector:
      matchLabels:
        app.kubernetes.io/name: basic-chart
        app.kubernetes.io/instance: api
    template:
      metadata:
        annotations:
-         checksum/configmap-app-envs: b8766ba94b06d735b6fb88dd7064fce1b3d97efd71e3f45ea918e85fd9aed794
+         checksum/configmap-app-envs: a1b3a3fd4eedcaf61e04f74a2702fddbb724c962966ab98381478c04766ace41
        labels:
          app.kubernetes.io/name: basic-chart
          app.kubernetes.io/instance: api
      spec:
        containers:
          - name: api
-           image: "docker.io/<your-account>/example:1.0.0"
+           image: "docker.io/<your-account>/example:2.0.0"
            imagePullPolicy: IfNotPresent
            ports:
              - name: http
                containerPort: 8081
                protocol: TCP
            envFrom:
              - configMapRef:
                  name: api-app-envs
            resources:
              {}
default, api, Service (v1) has changed:
  # Source: basic-chart/templates/service.yaml
  apiVersion: v1
  kind: Service
  metadata:
    name: api
    labels:
-     helm.sh/chart: basic-chart-1.0.0
+     helm.sh/chart: basic-chart-2.0.0
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
      app.kubernetes.io/managed-by: Helm
  spec:
    type: ClusterIP
    ports:
      - port: 8081
        targetPort: http
        protocol: TCP
        name: http
    selector:
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
default, api-app-envs, ConfigMap (v1) has changed:
  # Source: basic-chart/templates/configmap-app-envs.yaml
  apiVersion: v1
  kind: ConfigMap
  metadata:
    name: api-app-envs
    labels:
-     helm.sh/chart: basic-chart-1.0.0
+     helm.sh/chart: basic-chart-2.0.0
      app.kubernetes.io/name: basic-chart
      app.kubernetes.io/instance: api
      app.kubernetes.io/managed-by: Helm
  data:
    ENV1: "env1"
    ENV2: ""
+   ENV3: "env3 value"

Upgrading release=api, chart=basic-chart
false
Release "api" has been upgraded. Happy Helming!
NAME: api
LAST DEPLOYED: Mon Oct  9 12:02:43 2023
NAMESPACE: default
STATUS: deployed
REVISION: 2
TEST SUITE: None

Listing releases matching ^api$
api 	default  	2       	2023-10-09 12:02:43.634998185 +0300 EEST	deployed	basic-chart-2.0.0	           


UPDATED RELEASES:
NAME   CHART           VERSION   DURATION
api    ./basic-chart   2.0.0           0s

Check functionality with old and new parameters.

$ curl http://my-domain.com/app/env1
{"env1":"env1"}

$ curl http://my-domain.com/app/env2
{"env2":"Default value of env2"}

$ curl http://my-domain.com/app/env3
{"env3":"env3 value"}