Even more powerful Helming with Helmfile

Leverage Helmfile declarative approach to bring visibility and reproductibility to Helm releases states and ease team work. Get familiar with Helmfile and explore powerful features for managing a bunch of Helm releases easily and securely.

Even more powerful Helming with Helmfile
Photo by Elliott Engelmann / Unsplash

What is Helmfile

Helmfile is a tool that allows Helm Charts management declaratively. If you are not familiar with Helm, have a look at this. Necessary info required to create a release (repositories, release names, charts, versions, values, ...) are declared inside state files. Declarations in state files are then used to create releases resources inside target Kubernetes clusters. It uses Helm under the hood and bring other additional interesting features.

Helmfile advantages

  • A declarative approach that brings a better experience and an as code feeling:
    • an easy way to manage a bunch of Helm releases
    • possibility to use a version control system like Git to store releases states and track changes, which increases visibility, reproductibility and ease team work
  • Built-in environments management (dev, staging, prod...) for apps deployment
  • Better release management experience and a clean releases history (if there is no change, a new release won't be created... uses the Helm-diff plugin to get changes diffs during deployments)
  • Secret management thanks to the Helm-secrets plugin allowing secrets values encryption
  • Possibility to treat values files as Go templates (.gotmpl vs .yaml):
  • and more

How helmfile works

Helmfile uses a declarative approach for mananing Helm releases. Releases and configurations are declared inside the 'helmfile.yaml' file and use the configuration directives listed here.

Releases states could also be declared in multiple '.yaml' files inside a 'helmfile.d' directory. Helmfile looks for that directory in case the helmfile.yaml file does not exist. helmfile.d/*.yaml files are treated as independent helmfile.yaml files and processed in alphabetical order. The processing order of those files could also be controlled by prefixing them with a two digit number ('00-database.yaml' processed before '01-webserver.yaml').

Inside states files we can define a list of releases (name, chart, namespace, version, values, values files paths...), required charts repositories, helm configuration settings per release or globally (releases history limit, wait, wait timeouts, kube context, automatic namespace creation...) and Helmfile environments settings (for declaring multiple apps deployment environments and corresponding values files).

Once desired states are defined, the 'helmfile apply' or 'helmfile sync' commands can then be used to reproduce that state inside target Kubernetes clusters (make sure declared releases are created with proper configurations). The first command creates/updates releases only when there are differences between state files and what is actually deployed, whereas the latter one will always deploy releases declared in state files (creates new revisions).

Installing Helmfile

  • Helmfile releases assets can be found here
  • To install Helmfile on Linux, do the following:
$ helmfile_version=0.167.1 # choose version
$ linux_arch=amd64 # choose OS arch

# Download Helmfile binary
$ wget https://github.com/helmfile/helmfile/releases/download/v${helmfile_version}/helmfile_${helmfile_version}_linux_${linux_arch}.tar.gz

# Install Helmfile binary
$ mkdir helmfile && tar xzvf helmfile_${helmfile_version}_linux_${linux_arch}.tar.gz -C helmfile
$ sudo mv helmfile/helmfile /usr/local/bin
$ sudo chmod +x /usr/local/bin/helmfile

# Verify
$ helmfile version
  • Helmfile requires the Helm-diff Helm plugin to work properly. Do the following to install it on Linux:
# Optionally set the version to install a specific one
# Helm-diff releases assets can be found here:
# https://github.com/databus23/helm-diff/releases

$ helm plugin install https://github.com/databus23/helm-diff [--version $plugin_version] 
Downloading https://github.com/databus23/helm-diff/releases/latest/download/helm-diff-linux-amd64.tgz
Preparing to install into /home/gmkziz/.local/share/helm/plugins/helm-diff
Installed plugin: diff

Managing Helmfile releases

Configuring releases charts repositories

We can declare one or more Charts repositories we need for deploying our Kubernetes apps by using the repositories keyword as follows:

# File: helmfile.yaml

repositories:
  - name: grafana
    url: https://grafana.github.io/helm-charts

Declared repositories will then be automatically added during releases creation.
We can also tell helmfile to immediately add declared repositories using the helmfile repos command.

Configuring releases environments

In this environments declaration example, the application we are going to deploy uses two environments: staging and prod. For each declared environment, we specify the path (relative to the helmfile.yaml file) to the Helmfile environment values files:

# File: helmfile.yaml

environments: 
  staging:
    values:
      - environments/all.yaml
      - environments/staging/grafana.yaml
  prod:
    values:
      - environments/all.yaml
      - environments/prod/grafana.yaml
      
--- # <= this in mandatory between environments declaration configuration
    # and other configuration directives inside the helmfile.yaml file

repositories:
  (...)

The environments/all.yaml file will contain the settings that are common to both staging and prod environments and environments/$env/grafana.yaml the environments specific settings.

For a given environment, the declared 'helmfile environments values' files will be merged. If there are identical keys inside different 'helmfile environments values' files, the key/value pair from the latest declared file will override the others. Here is an example:

# File: environments/all.yaml
grafana:
  customReplicaSetting: 1
  otherSetting1: 1
  
# File: environments/staging/grafana.yaml
grafana:
  customReplicaSetting: 2
  otherSetting2: 2
  
# Then, final value for 'grafana.customReplicaSetting' will be '2' 
# (from the latest declared environment values file). 
# Both 'grafana.otherSetting1' and 'grafana.otherSetting2' will be 
# available as environment values (merged from the two files)
grafana:
  customReplicaSetting: 2
  otherSetting1: 1
  otherSetting2: 2

'Helmfile environments values' are not Charts values. They are custom user-defined values that can then be used inside templated Charts values files (.gotmpl) as input variables. They will be available inside the .Values object. Here is an example:

# File: values.yaml.gotmpl
# A Helmfile Templated Grafana Chart values file. 
# Default Charts values taken from here: 
# https://github.com/grafana/helm-charts/blob/main/charts/grafana/values.yaml 

rbac:
  create: false

# Here we set the Chart 'replicas' value to the value of the
# 'grafana.customReplicaSetting' Helmfile environment value
# sourced from the environments/$env/grafana.yaml file
replicas: {{ .Values.grafana.customReplicaSetting }} 

During releases deployments using the helmfile command, we can choose the target deployment environment (staging or prod). That way, Helmfile is able to source the proper 'Helmfile environments values'.

With that configuration, we are able to use the same templated Charts values for our releases in all environments and leverage Helmfile environments features to inject environments specific configurations. We will see that in details next, keep reading.

Configuring releases

# File: helmfile.yaml

environments:
  (...)
  
---

repositories:
 (...)
 
releases:
  - name: grafana # The name of the release
    chart: grafana/grafana # repository/chart for using charts from locally added repositories
                           # or helmfile.yaml relative path to a local chart directory
    namespace: grafana-{{ .Environment.Name }} # Namespace where the release will be created
                                               # Will be dynamically suffixed with the target 
                                               # Helmfile environment name (staging or prod) 
                                               # thanks to the built-in Helmfile .Environment 
                                               # object, containing the name of the environment  
    createNamespace: true # automatically creates the release namespace inside the target 
                          # Kubernetes cluster, if it does not exist
    labels: # could be used to select only this release when using the helmfile command, 
            # with the --selector or -l flags followed by app=grafana
      app: grafana
    version: 8.4.5 # The version of the Chart
    # values next is a list containing either Charts values files paths
    # (absolute or relative to this current helmfile.yaml file), indicating
    # the files containing the user-defined values to use or inline values 
    # (see commented rbac.create below). 
    # {{ `{{ .Release.Name }}` }} will be replaced by grafana
    values:   
      - config/releases/{{ `{{ .Release.Name }}` }}/values.yaml.gotmpl
    # - rbac:
    #     create: false
   # The set config next can also be used to set values. It is equivalent to the 
   # Helmfile/helm '--set rbac.create=false' in command line
   #set:
   #  name: rbac.create
   #  value: false

Values specified using the 'values' configuration keyword are added to temporary files and injected using the Helm '--values' command line flag. Values specified using the 'set' configuration keyword are injected using the '--set name=value' command line flag.

Go template is also available for setting values inside the configured values files or through the 'values' and 'set' configuration directives directly. Inline values style is great for setting few values very quick. Prefer using values files when you have a bunch of values to set.

The values file path configured in the above configuration sample, with the '.Release.Name' helmfile built-in object can be reused for other releases we add inside the helmfile.yaml configuration file. That way, for each release we configure, we simply have to create the config/releases/$release_name/values.yaml.gotmpl for configuring the values and add the corresponding files paths for the 'Helmfile environment values' inside the 'environments' configuration section:

# File: helmfile.yaml

environments: 
  staging:
    values:
      - environments/all.yaml
      - environments/staging/grafana.yaml
      - environments/staging/vmcluster.yaml # <= added for the vmcluster release
  prod:
    values:
      - environments/all.yaml
      - environments/prod/grafana.yaml
      - environments/prod/vmcluster.yaml # <= added for the vmcluster release
---

(...)
  releases:
    - name: grafana
      (...)
      values:
        - config/releases/{{ `{{ .Release.Name }}` }}/values.yaml.gotmpl
      (...)
    - name: vmcluster
      (...)
      values:
        - config/releases/{{ `{{ .Release.Name }}` }}/values.yaml.gotmpl
      (...)

To avoid repeating the same values configuration for each release, we could create a configuration template and then reference that template. That way, we have a single place for configuring values for all releases. Here is how we do that:

# File: helmfile.yaml

environments:
  (...)
  
---

repositories:
  (...)
  
# Creating values configuration template
templates:
  values: &values
    values:
      - config/releases/{{ `{{ .Release.Name }}` }}/values.yaml.gotmpl

releases:
  - name: grafana
    chart: grafana/grafana
    namespace: grafana-{{ .Environment.Name }}
    createNamespace: true
    labels:
      app: grafana
    version: 8.4.5
    <<: *values # referencing values configuration template
  - name: prometheus
      (...)
    <<: *values # referencing values configuration template
    (...)

Configuring Helmfile default settings

There are a bunch of default parameters that can be configured for Helmfile.
The 'helmDefaults' parameter of the helmfile.yaml file can be used for that. Here is an example:

# File: helmfile.yaml

(...)

helmDefaults:
  historyMax: 10         # releases history limit
  wait: false            # wait for k8s resources like pods to be running
  timeout: 300           # wait timeout in seconds
  deleteWait: false      # wait for k8s resources effective deletion
  deleteTimeout: 300     # deletion wait timeout
  skipDeps: false        # skip runnning `helm dep up` and `helm dep build`
  createNamespace: true  # automatically create releases namespaces
  
(...)

For all the available parameters, look for 'helmDefaults' inside the reference helmfile.yaml configuration file.

Creating releases

Helmfile built-in functions | Helmfile built-in objects | Helmfile.yaml reference

Here is the content of the helmfile.yaml configuration we have previously created and explained:

# File: helmfile.yaml

environments: 
  staging:
    values:
      - environments/all.yaml
      - environments/staging/grafana.yaml
  prod:
    values:
      - environments/all.yaml
      - environments/prod/grafana.yaml
      
---

helmDefaults:
  historyMax: 10
  wait: false
  timeout: 300
  deleteWait: false
  deleteTimeout: 300
  skipDeps: false
  createNamespace: true

repositories:
  - name: grafana
    url: https://grafana.github.io/helm-charts

templates:
  values: &values
    values:
      - config/releases/{{ `{{ .Release.Name }}` }}/values.yaml.gotmpl

releases:
  - name: grafana
    chart: grafana/grafana
    namespace: grafana-{{ .Environment.Name }}
    labels:
      app: grafana
    version: 8.4.5
    <<: *values

Here are the contents of the 'Helmfile environments values' files:

# File: environments/all.yaml
grafana:
  image:
    repository: grafana/grafana
    tag: 11.1.4

# File: environments/staging/grafana.yaml
grafana:
  replicas: 2

# File: environments/prod/grafana.yaml
grafana:
  replicas: 4

And the content of the values file:

# File: config/releases/grafana/values.yaml.gotmpl
# Available Charts values taken from here: 
# https://github.com/grafana/helm-charts/blob/main/charts/grafana/values.yaml

rbac:
  create: false

replicas: {{ .Values.grafana.replicas }}

image:
  repository: {{ .Values.grafana.image.repository }} 
  tag: {{ .Values.grafana.image.tag }}

adminPassword: {{ requiredEnv "GRAFANA_ADMIN_PASSWORD" }} # Set the adminPassword value to 
                                   # the value of the GRAFANA_ADMIN_PASSWORD systems 
                                   # environment variable. The 'requiredEnv' function, 
                                   # unlike 'env', makes that variable required, 
                                   # meaning the release creation will fail if not set

We can check the helmfile.yaml file for errors using the 'helmfile lint' command as chown below:

# To shorten --environment, the -e flag can be used

# When everything is fine:

$ helmfile lint --environment staging
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Fetching grafana/grafana
Linting release=grafana, chart=/tmp/helmfile1932208801/grafana-staging/grafana/grafana/grafana/8.4.5/grafana
==> Linting /tmp/helmfile1932208801/grafana-staging/grafana/grafana/grafana/8.4.5/grafana

1 chart(s) linted, 0 chart(s) failed

# Let's unset the required systems environment variable
# for the Grafana admin password and check again:

$ unset GRAFANA_ADMIN_PASSWORD
$ helmfile lint --environment staging
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Fetching grafana/grafana
in ./helmfile.yaml: failed to render values files "config/releases/grafana/values.yaml.gotmpl": failed to render [config/releases/grafana/values.yaml.gotmpl], because of template: stringTemplate:15:18: executing "stringTemplate" at <requiredEnv "GRAFANA_ADMIN_PASSWORD">: error calling requiredEnv: required env var `GRAFANA_ADMIN_PASSWORD` is not set

We can also use the 'helmfile build' command to render the helmfile.yaml configuration file and see rendred values for our helmfile environments:

$ helmfile build -e staging
---
#  Source: /home/gmkziz/helmfile/helmfile.yaml

filepath: helmfile.yaml
helmBinary: helm
kustomizeBinary: kustomize
environments:
  prod:
    values:
    - environments/all.yaml
    - environments/prod/grafana.yaml
  staging:
    values:
    - environments/all.yaml
    - environments/staging/grafana.yaml
repositories:
- name: grafana
  url: https://grafana.github.io/helm-charts
releases:
- chart: grafana/grafana
  version: 8.4.5
  createNamespace: true
  name: grafana
  namespace: grafana-staging
  labels:
    app: grafana
  values:
  - config/releases/grafana/values.yaml.gotmpl
templates:
  values:
    values:
    - config/releases/{{ .Release.Name }}/values.yaml.gotmpl
renderedvalues: # <= Here are our Helmfile environments rendered values 
  grafana:
    image:
      repository: grafana/grafana
      tag: 11.1.4
    replicas: 2
    
# Let's have a look at the rendered helmfile 
# envrironments values for the production environement
$ helmfile build -e prod
(...)
renderedvalues:
  grafana:
    image:
      repository: grafana/grafana
      tag: 11.1.4
    replicas: 4

To render/preview the manifests of the Kubernetes resources that will be deployed after the release creation, we can use the 'helmfile template' command:

$ helmfile template --environment staging
Templating release=grafana, chart=grafana/grafana
---
# Source: grafana/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
automountServiceAccountToken: false
metadata:
  labels:
    helm.sh/chart: grafana-8.4.5
    app.kubernetes.io/name: grafana
    app.kubernetes.io/instance: grafana
    app.kubernetes.io/version: "11.1.4"
    app.kubernetes.io/managed-by: Helm
  name: grafana-staging
  namespace: grafana-staging
---
# Source: grafana/templates/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: grafana
  namespace: grafana-staging
  labels:
    helm.sh/chart: grafana-8.4.5
    app.kubernetes.io/name: grafana
    app.kubernetes.io/instance: grafana
    app.kubernetes.io/version: "11.1.4"
    app.kubernetes.io/managed-by: Helm
type: Opaque
(...)

To create the release, we can use the 'helmfile apply' command:

# Create staging environment release
$ helmfile apply -e staging
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Comparing release=grafana, chart=grafana/grafana, namespace=grafana-staging
********************

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

********************
grafana-staging, grafana, ConfigMap (v1) has been added:
- 
+ # Source: grafana/templates/configmap.yaml
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+   name: grafana
+   namespace: grafana-staging
+   labels:
+     helm.sh/chart: grafana-8.4.5
+     app.kubernetes.io/name: grafana
+     app.kubernetes.io/instance: grafana
+     app.kubernetes.io/version: "11.1.4"
+     app.kubernetes.io/managed-by: Helm
+ data:
+   
+   grafana.ini: |
+     [analytics]
+     check_for_updates = true
+     [grafana_net]
+     url = https://grafana.net
+     [log]
+     mode = console
+     [paths]
+     data = /var/lib/grafana/
+     logs = /var/log/grafana
+     plugins = /var/lib/grafana/plugins
+     provisioning = /etc/grafana/provisioning
+     [server]
+     domain = ''
grafana-staging, grafana, Deployment (apps) has been added:
- 
+ # Source: grafana/templates/deployment.yaml
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: grafana
+   namespace: grafana-staging
+   labels:
+     helm.sh/chart: grafana-8.4.5
+     app.kubernetes.io/name: grafana
+     app.kubernetes.io/instance: grafana
+     app.kubernetes.io/version: "11.1.4"
+     app.kubernetes.io/managed-by: Helm
+ spec:
+   replicas: 2
+   revisionHistoryLimit: 10
+   selector:
+     matchLabels:
+       app.kubernetes.io/name: grafana
+       app.kubernetes.io/instance: grafana
(...)

# Create production environment release
$ helmfile apply -e prod
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Comparing release=grafana, chart=grafana/grafana, namespace=grafana-prod
********************

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

********************
grafana-prod, grafana, ConfigMap (v1) has been added:
- 
+ # Source: grafana/templates/configmap.yaml
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+   name: grafana
+   namespace: grafana-prod
+   labels:
+     helm.sh/chart: grafana-8.4.5
+     app.kubernetes.io/name: grafana
+     app.kubernetes.io/instance: grafana
+     app.kubernetes.io/version: "11.1.4"
+     app.kubernetes.io/managed-by: Helm
+ data:
+   
+   grafana.ini: |
+     [analytics]
+     check_for_updates = true
+     [grafana_net]
+     url = https://grafana.net
+     [log]
+     mode = console
+     [paths]
+     data = /var/lib/grafana/
+     logs = /var/log/grafana
+     plugins = /var/lib/grafana/plugins
+     provisioning = /etc/grafana/provisioning
+     [server]
+     domain = ''
grafana-prod, grafana, Deployment (apps) has been added:
- 
+ # Source: grafana/templates/deployment.yaml
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+   name: grafana
+   namespace: grafana-prod
+   labels:
+     helm.sh/chart: grafana-8.4.5
+     app.kubernetes.io/name: grafana
+     app.kubernetes.io/instance: grafana
+     app.kubernetes.io/version: "11.1.4"
+     app.kubernetes.io/managed-by: Helm
+ spec:
+   replicas: 4
+   revisionHistoryLimit: 10
+   selector:
+     matchLabels:
+       app.kubernetes.io/name: grafana
+       app.kubernetes.io/instance: grafana
(...)

Now let's use 'helm' and 'kubectl' to do some checks. Note that the 'helmfile list' command only shows releases declared in state files, not releases that are actually deployed, that's why we will instead use 'helm list'.

$ helm list -n grafana-staging
NAME    NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
grafana grafana-staging 1               <release_deployment_date>               deployed        grafana-8.4.5   11.1.4     

$ helm list -n grafana-prod
NAME    NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
grafana grafana-prod    1               <release_deployment_date>               deployed        grafana-8.4.5   11.1.4

$ kubectl get pods -n grafana-staging
NAME                      READY   STATUS    RESTARTS   AGE
grafana-c66968f8f-lvkf9   1/1     Running   0          17m
grafana-c66968f8f-z4bsw   1/1     Running   0          17m

$ kubectl get pods -n grafana-prod
NAME                       READY   STATUS    RESTARTS   AGE
grafana-55f598c747-4tdvr   1/1     Running   0          13m
grafana-55f598c747-86jxc   1/1     Running   0          13m
grafana-55f598c747-msdns   1/1     Running   0          13m
grafana-55f598c747-s89s7   1/1     Running   0          13m

Updating releases

Modify any Helmfile state configuration, helmfile environment values file, releases values files... and rerun the 'helmfile apply' command:

# Without modifications, Helmfile does nothing
# The release revision stays the same

$ helmfile apply -e prod
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Comparing release=grafana, chart=grafana/grafana, namespace=grafana-prod

# After updating replicas number from 4 to 5 
# in environments/prod/grafana.yaml

$ helmfile apply -e prod
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Comparing release=grafana, chart=grafana/grafana, namespace=grafana-prod
grafana-prod, grafana, Deployment (apps) has changed:
  # Source: grafana/templates/deployment.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: grafana
    namespace: grafana-prod
    labels:
      helm.sh/chart: grafana-8.4.5
      app.kubernetes.io/name: grafana
      app.kubernetes.io/instance: grafana
      app.kubernetes.io/version: "11.1.4"
      app.kubernetes.io/managed-by: Helm
  spec:
-   replicas: 4
+   replicas: 5
    revisionHistoryLimit: 10
    selector:
      matchLabels:
        app.kubernetes.io/name: grafana
        app.kubernetes.io/instance: grafana
(...)

$ helm list -n grafana-prod
NAME    NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
grafana grafana-prod    2               <release_deployment_date>               deployed        grafana-8.4.5   11.1.4

Rolling back releases

Restore the desired Helmfile state and run the 'helmfile apply' command or use the 'helm rollback' command as shown here.

Deleting releases

$ helmfile delete grafana -e staging
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Listing releases matching ^grafana$
grafana grafana-staging 1               <release_deployment_date>         deployed        grafana-8.4.5   11.1.4     

Deleting grafana
release "grafana" uninstalled


DELETED RELEASES:
NAME      NAMESPACE         DURATION
grafana   grafana-staging         0s

# Helmfile still shows release from state file

$ helmfile list -e staging
NAME    NAMESPACE       ENABLED INSTALLED       LABELS          CHART           VERSION
grafana grafana-staging true    true            app:grafana     grafana/grafana 8.4.5  

$ helmfile list -n grafana-staging
NAME    NAMESPACE       ENABLED INSTALLED       LABELS          CHART           VERSION
grafana grafana-staging true    true            app:grafana     grafana/grafana 8.4.5  

# Release not shown anymore with Helm
$ helm list -n grafana-staging
NAME    NAMESPACE       REVISION        UPDATED STATUS  CHART   APP VERSION

Reorganizing helmfile.yaml config using multiple files

Helmfile built-in functions | Helmfile built-in objects | Helmfile.yaml reference

The helmfile.yaml 'bases' configuration directive can be used to specify other Helmfile state files that will be merged into the helmfile.yaml where that configuration is defined. Thanks to that configuration directive, we are able to segment the helmfile.yaml file into multiple files.

To illustrate that, we are going to segment the helmfile.yaml file we have used in the Creating releases section as follows:

  • put repositories configuration in bases/repositories.yaml
  • put environments configuration in bases/environments.yaml
  • put helmDefaults configuration in bases/defaults.yaml
  • put templates configuration in bases/templates.yaml

Here is the final directories tree:

├── bases
│   ├── defaults.yaml
│   ├── environments.yaml
│   ├── repositories.yaml
│   └── templates.yaml
├── config
│   └── releases
│       └── grafana
│           └── values.yaml.gotmpl
├── environments
│   ├── all.yaml
│   ├── prod
│   │   └── grafana.yaml
│   └── staging
│       └── grafana.yaml
└── helmfile.yaml

Here are the contents of the 'bases' files:

# File: bases/defaults.yaml

helmDefaults:
  historyMax: 10
  wait: false
  timeout: 300
  deleteWait: false
  deleteTimeout: 300
  skipDeps: false
  createNamespace: true
  
# File: bases/environments.yaml

environments: 
  staging:
    values:
      - environments/all.yaml
      - environments/staging/grafana.yaml
  prod:
    values:
      - environments/all.yaml
      - environments/prod/grafana.yaml
      
# File: bases/repositories.yaml

repositories:
  - name: grafana
    url: https://grafana.github.io/helm-charts
    
# File: bases/templates.yaml

templates:
  values: &values
    values:
      - config/releases/{{ .Release.Name }}/values.yaml.gotmpl

Here is the content of the helmfile.yaml file using the state files from the 'bases' directory. The state files will be merged in the order they are specified:

bases:
  - bases/defaults.yaml
  - bases/repositories.yaml
  - bases/environments.yaml

{{ readFile "bases/templates.yaml" }} # anchor used below doesn't work when 
                                      # 'bases/templates.yaml' is declared
                                      # using 'bases'. That's why we are using
                                      # the 'readFile' function instead

releases:
  - name: grafana
    chart: grafana/grafana
    namespace: grafana-{{ .Environment.Name }}
    labels:
      app: grafana
    version: 8.4.5
    <<: *values  # <= anchor

Testing:

# Linting works

$ helmfile lint --environment prod
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Fetching grafana/grafana
Linting release=grafana, chart=/tmp/helmfile3200030168/grafana-prod/grafana/grafana/grafana/8.4.5/grafana
==> Linting /tmp/helmfile3200030168/grafana-prod/grafana/grafana/grafana/8.4.5/grafana

1 chart(s) linted, 0 chart(s) failed

# Deployment also works as before
$ helmfile apply --environment prod
helmfile apply --environment prod
Adding repo grafana https://grafana.github.io/helm-charts
"grafana" has been added to your repositories

Comparing release=grafana, chart=grafana/grafana, namespace=grafana-prod
********************

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

********************
grafana-prod, grafana, ConfigMap (v1) has been added:
- 
+ # Source: grafana/templates/configmap.yaml
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+   name: grafana
+   namespace: grafana-prod
+   labels:
+     helm.sh/chart: grafana-8.4.5
+     app.kubernetes.io/name: grafana
+     app.kubernetes.io/instance: grafana
+     app.kubernetes.io/version: "11.1.4"
+     app.kubernetes.io/managed-by: Helm
+ data:
+   
+   grafana.ini: |
+     [analytics]
+     check_for_updates = true
(...)
Listing releases matching ^grafana$
grafana grafana-prod    1                             <release_creation_date>             deployed        grafana-8.4.5   11.1.4     


UPDATED RELEASES:
NAME      NAMESPACE      CHART             VERSION   DURATION
grafana   grafana-prod   grafana/grafana   8.4.5           2s