Use Terraform to deploy Helm charts
Archie ToWe’ve talked about Terraform and Helm. Those surely are cool technologies. But you know what’s even cooler? When you combine both of those.
This article will show you how to use Terraform to deploy Helm charts, a very popular practice for our infrastructure in ARCsoft. This article requires some prerequisites: readers must read about Terraform and Helm first. So if you haven’t, go read those, I can wait.
Why use Terraform to deploy Helm charts?
You might ask: “Why don’t just use helm install
? It gets the job done.” You’re not wrong, but below are the reasons why our team uses Terraform for the job.
Multiple chart deployments
If you have multiple charts describing different services, for example, a webserver, database and object store, deploying each one with helm install
might take a while. For each one of those, you’d have to change directory, provide a separate values.yaml
file and run helm install
multiple times. Helm has a subchart feature, but this approach has similar shortcomings: we’d have to explicitly run extra commands for each subchart, for example.
Terraform allows you to deploy multiple charts with just one tfvars
variable file and a single terraform apply
command.
Deployments with other resources
Frequently, Helm charts are not deployed on their own. They need further infrastructure to support their existence. For example, a persistent volume or a database, a virtual machine, some networking for external access, service accounts, etc. These resources cannot be deployed using Helm alone. You could still deploy manually or write a script that does these things. However Terraform allows you to automate all these things together.
So why not just use Terraform to deploy your Helm charts anyway? One less technology to think about. Plus, you’ll get to keep all the variables in one .tfvars
file, run exactly one command to deploy everything and manage all resources using only terraform
CLI. You can even have the Helm chart deployed only after all its required resources are deployed to avoid messing up your workflow.
Automation
Automation is certainly a big reason why Terraform is used. Terraform is efficient and reliable in automated environments such as CI/CD and processes in your web apps (like ones in Strapper). Its capabilities in automation are supported by features such as remote state management and decomposition through modules.
Let’s try it out
In this example, we will use a Helm chart that we created in the Introduction to Helm article. To recap, here are the main components of the Helm chart:
Chart.yaml
:
apiVersion: v2
name: my-web-app
description: A Helm chart for deploying a simple web application
version: 1.0.0
appVersion: 1.0.0
values.yaml
:
replicaCount: 2
image:
repository: my-web-app
tag: "1.0.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 100m
memory: 128Mi
templates/deployment.yaml
:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: 80
resources:
limits:
cpu: {{ .Values.resources.limits.cpu }}
memory: {{ .Values.resources.limits.memory }}
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
templates/service.yaml
:
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
selector:
app: {{ .Chart.Name }}
(This example assumes the chart is stored at ./helm
.)
Specify the provider
First, we have specify which Terraform provider we want so Terraform can download that provider during terraform init
. In this case, we want the hashicorp/helm provider. Create a main.tf
file in the current directory:
terraform {
required_providers {
helm = {
source = "hashicorp/helm"
version = "~> 2.13.1"
}
}
}
Configure the provider
Let’s provide some necessary configuration for the Helm provider. In here, we will tell the provider which Kubernetes context we’d like Helm to work with. Add the following to main.tf
:
provider "helm" {
kubernetes {
config_path = "~/.kube/config" # Assuming you have a kubeconfig file at `~/.kube/config`
config_context = "my_context" # Specify which context you'd like to use in the kubeconfig file
}
}
Configure helm_release resource
hashicorp/helm
provider provides a helm_release resource. Running terraform apply
for this resource is the equivalent of running helm install
for our Helm chart. In main.tf
, add:
# Define custom values
# Specify 3 replicas and set the image tag to 2.0.0
locals {
helm_values = yamlencode({
replicaCount = 3
image = {
tag = "2.0.0"
}
})
}
resource "helm_release" "app" {
name = "myapp"
chart = "./helm"
version = "1.0.0"
namespace = "myapp-namespace"
create_namespace = true
timeout = 180
values = [locals.helm_values]
}
If you want to avoid specifying sensitive Helm values in main.tf
, you can also provide the values as follows:
resource "helm_release" "app" {
...
values = [
"${file("myapp_values.yaml")}",
]
}
In myapp_values.yaml
:
replicaCount: 3
image:
tag: 2.0.0
And make sure you don’t track this file in Git 😉.
Deploy Helm chart with a database
Looking at the example above, you might ask: “Why did I have to go through all that just to deploy a Helm chart? Could I just run helm install
?”
Yes, you could. But here is an example why Terraform should be used. Most apps need a database to do its job, right? Let’s say you want to deploy a PostgreSQL database for myapp
. Here is where Terraform shines. You can deploy the database and your app at once. Better yet, you can modify the deployment so that your app is only deployed after the database.
Note: This example assumes that you’ve made necessary changes in your Helm chart to configure a database. Here is the updated values.yaml
:
replicaCount: 2
image:
repository: my-web-app
tag: "1.0.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 100m
memory: 128Mi
database:
host: myhost.example.org
user: postgres
database: mydb
password: passw0rd
port: 5432
sslmode: none
Here is the updated version of main.tf
to create the database and connect that database to myapp
:
terraform {
required_providers {
helm = {
source = "hashicorp/helm"
version = "~> 2.13.1"
},
# Add postgres provider
postgresql = {
source = "cyrilgdn/postgresql"
version = "1.22.0"
}
}
}
# Define a variable to configure Postgres server
variable "postgres" {
description = "Postgres server definition"
type = object({
host = string
port = optional(number, 5432)
database = string
username = string
password = string
})
nullable = false
}
# Define a variable to specify Postgres user credentials for "myapp"
variable "myapp" {
description = "myapp deployment specifications"
type = object({
dbname = optional(string, "myapp")
dbuser = optional(string, "myapp")
dbpass = string
})
nullable = false
}
# Configure postgres provider
provider "postgresql" {
host = var.postgres.host
port = var.postgres.port
database = var.postgres.database
username = var.postgres.username
password = var.postgres.password
sslmode = "require"
}
# Provision Postgres roles and database
resource "postgresql_role" "myapp" {
name = var.myapp.dbuser
password = var.myapp.dbpass
login = true
}
# Only create the database after creating the role
resource "postgresql_database" "myapp" {
name = var.myapp.dbname
owner = postgresql_role.myapp.name
}
provider "helm" {
kubernetes {
config_path = "~/.kube/config" # Assuming you have a kubeconfig file at `~/.kube/config`
config_context = "my_context" # Specify which context you'd like to use in the kubeconfig file
}
}
# These values are only fully specified after the database is created
locals {
myapp_yaml_overrides = {
"database" = {
host = var.postgres.host
port = var.postgres.port
database = postgresql_database.myapp.name
user = postgresql_database.myapp.owner
password = var.myapp.dbpass
}
}
}
# This Helm release is only deployed after the database is created
resource "helm_release" "myapp" {
name = "myapp"
chart = "./helm"
version = "1.0.0"
namespace = "myapp-namespace"
create_namespace = true
timeout = 180
values = [
"${file("myapp_values.yaml")}",
yamlencode(local.myapp_yaml_overrides)
]
}
Create a terraform.tfvars
in the same directory:
postgres = {
host = "database_host.postgres.example.org"
database = "postgres"
username = "superuser"
password = "superuserpassword"
}
myapp = {
dbpass = "anypasswordyouwant"
}
One great thing is that the configuration above will deploy the database first, then the app: because Terraform does a decent job with dependencies, and the app depends on the local variables which depend on information about the deployed database.
Simply run terraform apply
, you will have a database created that is dedicated to myapp
, and the Helm chart will have the database credentials to hook them up with your deployment.
Conclusion
Terraform provides extensive automation to your workflow with Helm. It’s best used when you need to deploy multiple Helm charts or deploy Helm charts with other resources.