Feedback

Chat Icon

Helm in Practice

Designing, Deploying, and Operating Kubernetes Applications at Scale

A Practical Guide to Building Multi-Service Applications with Helm
71%

Creating the Helm Chart

Scaffolding the Chart

Create the Helm chart scaffold (helm create), which generates a standard chart structure:

cd $HOME
helm create todowithdb-chart

The helm create command generates several template files we won't need for this application. Remove the unused templates to keep the chart clean and simple. Our focus here is on deploying the application and its PostgreSQL dependency, so we can discard templates related to Horizontal Pod Autoscalers (HPAs), Ingress, HTTPRoutes, NOTES, etc.

# Remove unused template files
rm -f $HOME/todowithdb-chart/templates/hpa.yaml
rm -f $HOME/todowithdb-chart/templates/ingress.yaml
rm -f $HOME/todowithdb-chart/templates/httproute.yaml
rm -f $HOME/todowithdb-chart/templates/NOTES.txt

This leaves us with the standard chart structure:

todowithdb-chart/
├── Chart.yaml # Chart metadata and dependencies
├── charts # Directory for chart dependencies
├── templates # Kubernetes manifest templates
│   ├── _helpers.tpl # Helper template functions
│   ├── deployment.yaml # Deployment manifest
│   ├── service.yaml # Service manifest
│   ├── serviceaccount.yaml # ServiceAccount manifest
│   └── tests # Test templates
│       └── test-connection.yaml # Test pod manifest
└── values.yaml # Default configuration values

Adding Dependencies (Subcharts)

The easiest way to add a database to our application is to use an existing Helm chart as a dependency. We could create one from scratch, but using a well-maintained, community-supported chart saves time and effort. In a production scenario, you would evaluate different charts based on your requirements. You may choose between CloudNative PG, Zalando Postgres Operator, KubeBlocks, and others, but for this example, we'll use a popular choice: the Bitnami PostgreSQL chart.

First, we need to choose a version of the chart that is compatible with our application. Then, we will declare it as a dependency in our Chart.yaml. This is how our Chart.yaml looks after adding the PostgreSQL dependency:

apiVersion: v2
name: todowithdb-chart
description: A Helm chart for a Todo application with PostgreSQL backend
type: application
version: 0.1.0
appVersion: "1.0.0"

# Chart dependencies
# These charts will be downloaded and installed alongside our chart
dependencies:
  - name: postgresql
    # Choose a specific version compatible with your needs
    version: "16.4.3"
    repository: "https://charts.bitnami.com/bitnami"

By configuring this dependency, each time a user installs our todowithdb-chart, Helm will automatically download and install the specified version of the Bitnami PostgreSQL chart from the Bitnami repository.

The problem is that, in some cases, you don't need the database to be installed. For example, if you want to connect to an existing external database. To handle this scenario, we can add a condition field to the dependency declaration.

cat << 'EOF' > $HOME/todowithdb-chart/Chart.yaml
apiVersion: v2
name: todowithdb-chart
description: A Helm chart for a Todo application with PostgreSQL backend
type: application
version: 0.1.0
appVersion: "1.0.0"

# Chart dependencies
# These charts will be downloaded and installed alongside our chart
dependencies:
  - name: postgresql
    # Choose a specific version compatible with your needs
    version: "16.4.3"
    repository: "https://charts.bitnami.com/bitnami"
    # The 'condition' key allows users to disable this dependency
    condition: postgresql.enabled
EOF

This allows users to enable or disable the PostgreSQL installation via a value in values.yaml. In practice, the end user should have a values file like this:

# The custom values.yaml file of the user
# at the installation time
[...]

postgresql:
  enabled: false
[...]

Then, when they install the chart using helm install, PostgreSQL will not be installed.

To summarize, here are the fields we used in the dependency declaration:

FieldDescription
nameThe chart name in the repository
versionThe exact version to use (use helm search repo bitnami/postgresql --versions to find versions)
repositoryThe Helm repository URL
conditionA values path that enables/disables this dependency

Default Values, Design Decisions, and Global Variables

To add a default configuration for our chart, we can start from the generated values.yaml and modify it to suit our application and PostgreSQL dependency. These are our default values:

cat << 'EOF' > $HOME/todowithdb-chart/values.yaml
# -----------------------------------------------------------------------------
# Global variables that can be accessed by the chart and its subcharts
# -----------------------------------------------------------------------------
global:
  postgresql:
    auth:
      postgresPassword: "postgres_password"
      username: "todo_user"
      password: "todo_password"
      database: "todo_db"
      existingSecret: "todowithdb-postgresql"
      secretKeys:
        adminPasswordKey: "admin-password"
        userPasswordKey: "user-password"
        replicationPasswordKey: "replication-password"
  security:
    allowInsecureImages: true
# -----------------------------------------------------------------------------
# Application Deployment Configuration
# -----------------------------------------------------------------------------
replicaCount: 1

image:
  # The container image repository
  # Override this with your actual image location
  repository: todowithdb-app
  # Image tag - defaults to Chart.appVersion if not specified
  tag: ""
  # Pull policy: Always, IfNotPresent, or Never
  pullPolicy: IfNotPresent

# Reference to pre-existing image pull secrets
# Example:
#   imagePullSecrets:
#     - name: my-registry-secret
imagePullSecrets: []

# Override the chart name used in resource names
nameOverride: ""
fullnameOverride: ""

# -----------------------------------------------------------------------------
# Service Account Configuration
# -----------------------------------------------------------------------------
serviceAccount:
  # Specifies whether a ServiceAccount should be created
  create: true
  # Automatically mount the service account token
  automount: true
  # Annotations to add to the service account
  annotations: {}
  # The name of the service account to use
  # If not set and create is true, a name is generated using the fullname template
  name: ""

# -----------------------------------------------------------------------------
# Registry Credentials (for private registries)
# -----------------------------------------------------------------------------
imageCredentials:
  # Set to true to create an image pull secret
  create: false
  # registry: "my-registry.example.com"
  # username: ""
  # password: ""
  # secretName: "my-registry-secret"

# -----------------------------------------------------------------------------
# Pod Configuration
# -----------------------------------------------------------------------------
podAnnotations: {}
podLabels: {}

# Pod-level security context
podSecurityContext: {}
  # fsGroup: 2000

# Container-level security context
securityContext: {}
  # runAsNonRoot: true
  # runAsUser: 1000
  # allowPrivilegeEscalation: false
  # capabilities:
  #   drop:
  #     - ALL

# -----------------------------------------------------------------------------
# Service Configuration
# -----------------------------------------------------------------------------
service:
  type: ClusterIP
  # The port the Service exposes
  port: 5000

# -----------------------------------------------------------------------------
# Ingress Configuration
# -----------------------------------------------------------------------------
ingress:
  enabled: false
  className: ""
  annotations: {}
  hosts:
    - host: todowithdb.local
      paths:
        - path: /
          pathType: Prefix
  tls: []

# -----------------------------------------------------------------------------
# Resource Limits
# -----------------------------------------------------------------------------
resources: {}
  # requests:
  #   cpu: 100m
  #   memory: 128Mi
  # limits:
  #   cpu: 500m
  #   memory: 256Mi

# -----------------------------------------------------------------------------
# Health Probes
# -----------------------------------------------------------------------------
livenessProbe:
  httpGet:
    path: /health
    port: http

readinessProbe:
  httpGet:
    path: /health
    port: http

# -----------------------------------------------------------------------------
# Autoscaling Configuration
# -----------------------------------------------------------------------------
autoscaling:
  enabled: false

# -----------------------------------------------------------------------------
# Scheduling Configuration
# -----------------------------------------------------------------------------
nodeSelector: {}
tolerations: []
affinity: {}

# =============================================================================
# PostgreSQL Subchart Configuration (Bitnami)
# =============================================================================
# Full documentation: https://github.com/bitnami/charts/tree/main/bitnami/postgresql
# These values are passed directly to the postgresql subchart.
# -----------------------------------------------------------------------------
postgresql:
  # Set to false to use an external PostgreSQL database
  enabled: true

  # Image configuration
  # Bitnami uses versioned tags in the format: -debian--r
  # Find available tags at: https://hub.docker.com/r/bitnami/postgresql/tags
  image:
    registry: docker.io
    repository: bitnamilegacy/postgresql
    tag: "17.6.0-debian-12-r4"

  # Primary instance configuration
  primary:
    # Persistence configuration
    persistence:
      enabled: true
      size: 1Gi
      # storageClass: ""  # Use default storage class

    # Resource limits for the PostgreSQL container
    resources: {}
      # requests:
      #   cpu: 250m
      #   memory: 256Mi
      # limits:
      #   cpu: 500m
      #   memory: 512Mi

  # Disable read replicas for simplicity
  # In production, consider enabling for high availability
  readReplicas:
    replicaCount: 0

  architecture: standalone
EOF

To write good values files, it's essential to understand how the subchart (Bitnami PostgreSQL) works. The Bitnami chart has many configuration options. We could have included all of them, but depending on your needs, you can choose to include the relevant ones. All other options will use the subchart's defaults. In all cases, using a subchart means you should understand its internals, configuration options, and best practices. Fortunately, this chart is well-documented.

Before moving on, let's highlight an important design decision regarding database connection configuration. Our application needs to connect to PostgreSQL using specific credentials. As we have seen, these credentials should be defined in the environment variables DB_NAME, DB_USER, and DB_PASSWORD. To ensure consistency, we can source these values directly from the global.postgresql.auth section of our values.yaml. This approach offers several benefits:

  • Makes it easy for the main chart and subchart to share the same database credentials.
  • Defines a single source of truth for database credentials.
  • Reduces the risk of misconfiguration.
  • Simplifies maintenance and updates.
  • Enhances security by centralizing sensitive information.

Our PostgreSQL chart automatically accepts these global variables, so we don't need to do anything extra to pass them to it. Here is the code snippet from the subchart's values that shows how it uses these global variables:

global:
  [...]
  security:
    ## @param global.security.allowInsecureImages Allows skipping image verification
    allowInsecureImages: false
  postgresql:
    ## @param global.postgresql.auth.postgresPassword Password for the "postgres" admin user (overrides `auth.postgresPassword`)
    ## @param global.postgresql.auth.username Name for a custom user to create (overrides `auth.username`)
    ## @param global.postgresql.auth.password Password for the custom user to create (overrides `auth.password`)
    ## @param global.postgresql.auth.database Name for a custom database to create (overrides `auth.database`)
    ## @param global.postgresql.auth.existingSecret Name of existing secret to use for PostgreSQL credentials (overrides `auth.existingSecret`).
    ## @param global.postgresql.auth.secretKeys.adminPasswordKey Name of key in existing secret to use for PostgreSQL credentials (overrides `auth.secretKeys.adminPasswordKey`). Only used when `global.postgresql.auth.existingSecret` is set.
    ## @param global.postgresql.auth.secretKeys.userPasswordKey Name of key in existing secret to use for PostgreSQL credentials (overrides `auth.secretKeys.userPasswordKey`). Only used when `global.postgresql.auth.existingSecret` is set.
    ## @param global.postgresql.auth.secretKeys.replicationPasswordKey Name of key in existing secret to use for PostgreSQL credentials (overrides `auth.secretKeys.replicationPasswordKey`). Only used when `global.postgresql.auth.existingSecret` is set.
    ##
    auth:
      postgresPassword: ""
      username: ""
      password: ""
      database: ""
      existingSecret: ""
      secretKeys:
        adminPasswordKey: ""
        userPasswordKey: ""
        replicationPasswordKey: ""

The case where the user chooses an external database (by setting postgresql.enabled: false) is not handled in our example for brevity, but it can also be done by using the same global variables approach.

We also added global.security.allowInsecureImages to allow skipping image verification. For more information, refer to issue #30850 on Bitnami's GitHub repository. In brief, Bitnami introduced a security mechanism to detect non-standard container images (i.e., not provided by Bitnami) being used with their charts. This mechanism may lead to installation errors if you replace the original image with a different one. We are using bitnamilegacy/postgresql as the PostgreSQL image, which is not considered a standard Bitnami image (after they introduced some changes). Therefore, we set global.security.allowInsecureImages: true to avoid installation issues. The word "insecure" is misleading here; it simply means we are allowing non-official Bitnami images (even though bitnamilegacy/postgresql was originally provided by Bitnami, but they no longer maintain it).

Deployment Template and Environment Variables

Since global variables are now defined, we can proceed to update our Deployment template to set these variables for our application container. We will source the database connection details from the global.postgresql.auth section of our values.yaml.

env:
  - name: DB_HOST
    value: [..we will complete this part below..]
  - name: DB_PORT
    # Let's keep this static as PostgreSQL's default port
    # we can make it configurable later if needed
    value: "5432"
    # Read environment variables from global.postgresql.auth
  - name: DB_NAME
    value: {{ .Values.global.postgresql.auth.database | quote }}
  - name: DB_USER
    value: {{ .Values.global.postgresql.auth.username | quote }}
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        # The name of the secret containing the DB password
        # We use the existingSecret defined in global variables
        name: {{ .Values.global.postgresql.auth.existingSecret }}
        # The key in the secret that holds the password
        key: {{ .Values.global.postgresql.auth.secretKeys.userPasswordKey }}

As a reminder, DB_PASSWORD is sourced from a Kubernetes Secret created by the PostgreSQL subchart that uses:

  • A Secret name defined in global.postgresql.auth.existingSecret
  • A key inside this Secret defined in global.postgresql.auth.secretKeys.userPasswordKey

For DB_HOST, which our application also requires, we will use a conditional statement to determine whether to use the internal PostgreSQL service (when postgresql.enabled: true) or an external database host (when postgresql.enabled: false).

env:
  - name: DB_HOST
    {{- if .Values.postgresql.enabled }}
    # Use Bitnami's helper template to get the correct service name
    value: [The service name created by the Bitnami PostgreSQL chart]
    {{- else }}
    # External database - user must provide the host
    value: [The value from .Values.database.host]
    {{- end }}

When we enable the chart, it's important to note that the hostname of the database (the Kubernetes service created by the Bitnami PostgreSQL chart) follows the internal naming logic and convention of the PostgreSQL chart. To get the correct service name, we can use Bitnami's helper template postgresql.v1.primary.fullname.

This is the full code of this helper template:

{{- define "postgresql.v1.primary.fullname" -}}
{{- if eq .Values.architecture "replication" -}}
    {{- printf "%s-%s" (include "common.names.fullname" .) .Values.primary.name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
    {{- include "common.names.fullname" . -}}
{{- end -}}
{{- end -}}

We will base our logic on this helper, which defines the full name of the primary PostgreSQL service.

This is the logic (the explanation of the code above):

  • If architecture == "replication":
  • It uses printf "%s-%s" $fullname .Values.primary.name, then truncates it to 63 chars and strips -.
  • By default, .Values.primary.name is "primary", so you get something like: todowithdb-postgresql-primary

  • If architecture != "replication", it just uses $fullname, e.g.: todowithdb-postgresql

The helper calls include "common.names.fullname" to get the base fullname, which is defined in the common chart helpers (a chart that Bitnami charts depend on). This uses the release name and chart name to generate the full name:

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "common.names.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- $releaseName := regexReplaceAll "(-?[^a-z\\d\\-])+-?" (lower .Release.Name) "-" -}}
{{- if contains $name $releaseName -}}
{{- $releaseName | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" $releaseName $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{

Helm in Practice

Designing, Deploying, and Operating Kubernetes Applications at Scale

Enroll now to unlock current content and receive all future updates for free. Your purchase supports the author and fuels the creation of more exciting content. Act fast, as the price will rise as the course nears completion!

Unlock now  $15.99$11.99

Hurry! This limited time offer ends in:

To redeem this offer, copy the coupon code below and apply it at checkout:

Learn More