Kamal vs Kubernetes: An Honest Comparison for Teams Who Don’t Need 1,000 Services

Kamal vs Kubernetes: An Honest Comparison for Teams Who Don't Need 1,000 Services

The framing problem

Most “Kamal vs Kubernetes” articles are written by people who just discovered Kamal and are excited about it. They frame it as a simpler alternative that embarrasses Kubernetes. That framing is wrong and it will lead you to the wrong decision.

Kamal is not better than Kubernetes. Kubernetes is not overkill for everyone. They solve different problems at different scales, and picking the wrong one for your context is expensive either way.

This article gives you the honest version: what each tool actually is, where each one breaks, and the specific signals that should push you in one direction or the other.


What Kamal actually is

Kamal (formerly MRSK) is a deployment tool built by 37signals. It deploys Docker containers to servers via SSH, uses kamal-proxy as its default reverse proxy, and handles rolling deploys with zero downtime. That is the entire feature set.

kamal deploy

It connects to your servers over SSH, pulls the new image, starts the new container, waits for it to be healthy, then moves traffic through kamal-proxy and stops the old one. Traefik is still possible, but in Kamal 2 it sits in front of kamal-proxy as an optional accessory rather than being the standard path.

There is no control plane. No etcd. No API server. No scheduler. No concept of desired state reconciliation. Docker or systemd can restart a crashed container if you configure the right restart policy, but Kamal itself does not reschedule workloads to another node or provide cluster-level self-healing.

Kamal is a deployment tool. Kubernetes is an orchestration platform. These are not the same category.

That is the key distinction from tools like Nomad. I covered that angle in Nomad vs Kubernetes, as part of the same “lighter alternatives to Kubernetes” discussion. Nomad is a scheduler/orchestrator. Kamal is a deployment tool that leaves scheduling and node-level recovery mostly outside its scope.


What Kubernetes actually is

Kubernetes is a distributed system that continuously reconciles actual state against desired state. You declare what you want — N replicas of container X, with these resource limits, this health check, this rollout strategy — and the control plane makes it happen and keeps it that way.

When a node fails, your pods are rescheduled. When a container crashes, it is restarted. When you push a new image, the rollout respects your maxUnavailable and maxSurge settings. When traffic spikes, HPA can add replicas.

This machinery is powerful. It is also genuinely complex to operate.

A minimal production-grade Kubernetes cluster involves: a multi-node control plane for HA, a CNI plugin, an ingress controller (or Gateway API), cert-manager for TLS, a CSI driver for storage, a metrics server for HPA, RBAC policies, network policies, pod disruption budgets, and some form of secret management. Getting all of this right takes time and ongoing maintenance.

The question is whether the problems Kubernetes solves are problems you actually have.


The minimum config tells the story

Here is the same tiny web service expressed in both worlds. The exact fields vary by app, registry, ingress setup, and Kubernetes distribution, but the shape is representative.

<table> <thead> <tr> <th>Kamal <code>deploy.yml</code></th> <th>Kubernetes <code>Deployment</code> + <code>Service</code></th> </tr> </thead> <tbody> <tr> <td> <pre><code class=”language-yaml”>service: web image: ghcr.io/acme/web

servers: web: hosts: – 203.0.113.10

proxy: ssl: true host: app.example.com app_port: 3000

registry: username: acme password: – KAMAL_REGISTRY_PASSWORD

env: secret: – DATABASE_URL</code></pre> </td> <td> <pre><code class=”language-yaml”>apiVersion: apps/v1 kind: Deployment metadata: name: web spec: replicas: 2 selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: – name: web image: ghcr.io/acme/web ports: – containerPort: 3000 envFrom: – secretRef: name: web-secrets — apiVersion: v1 kind: Service metadata: name: web spec: selector: app: web ports: – port: 80 targetPort: 3000</code></pre> </td> </tr> </tbody> </table>

The Kubernetes example is still incomplete for internet traffic and TLS: you would normally add an Ingress or HTTPRoute, cert-manager, DNS, and secret provisioning. Kamal’s example is closer to “deploy this app to these servers.” Kubernetes is closer to “declare this workload inside a platform that already exists.”


The 37signals case

37signals (the company behind Basecamp and Hey) moved off cloud Kubernetes to bare-metal servers running Kamal in 2022-2023. Their own Ops team wrote about the “de-cloud and de-k8s” move. The story got a lot of attention.

What the hot takes missed: 37signals is not a startup. They have a dedicated ops team. They own and operate hardware. Their workload is well-understood and stable: a small number of mature applications serving a large, established user base.

Their decision was rational for their context. Kubernetes was costing them in cloud spend and operational complexity for problems their workload does not have. Moving to Kamal on owned hardware made economic and operational sense for them.

It does not follow that Kamal is the right choice for your early-stage startup, your company that runs on AWS because your team does not want to manage hardware, or your platform that needs to scale from 0 to 10x on short notice.


Where Kamal breaks

No cluster-level self-healing. If your container crashes, Docker or systemd can restart it with the right policy, but Kamal has no involvement. If your server fails, Kamal will not reschedule that workload elsewhere; your service is down until you intervene or until you have set up something else to handle it.

No built-in auto-scaling. Kamal has no equivalent of HPA or KEDA. If your traffic spikes, you add servers manually or script something yourself.

No multi-AZ HA by default. You can deploy to multiple servers across regions, but the coordination is yours to manage. There is no concept of spreading replicas across availability zones automatically.

Rolling deploys are basic. Kamal’s zero-downtime deploy is good for single-server or simple multi-server setups. It is not a substitute for Kubernetes rolling update with fine-grained health check integration across a fleet.

Secret management is manual. Kamal reads secrets from .kamal/secrets and has kamal secrets helpers for fetching and extracting values from password managers and cloud secret stores, but there is no Kubernetes-style projected secret lifecycle. Rotation and policy remain yours to manage.

No workload isolation. Everything on the same server shares the same kernel, same resources. Resource limits are Docker-level, not enforced by an orchestration layer with quotas and namespaces.


Where Kubernetes breaks

The complexity tax is real. Running Kubernetes yourself — not a managed service, but actual cluster operation — requires dedicated expertise. A team without a platform engineer will spend significant time debugging networking issues, understanding why pods are Pending, or figuring out why cert-manager is not issuing certificates.

Managed K8s is not free. EKS charges for the cluster control plane, with standard support priced around $0.10/hour per cluster; GKE and AKS have their own pricing rules and credits. In all cases, you still pay for nodes, load balancers, and persistent volumes. For a small application, this cost can exceed what bare-metal hosting would cost.

The YAML surface area scales with complexity. A simple web app on Kubernetes requires a Deployment, a Service, an Ingress (or HTTPRoute), a Certificate, potentially a HorizontalPodAutoscaler, a PodDisruptionBudget, and NetworkPolicies. That is a lot of infrastructure for a CRUD app.

Debugging is harder. Distributed systems are harder to debug than single-server setups. kubectl exec and kubectl logs are good tools but diagnosing why traffic is not reaching your pod — DNS, CNI, network policy, service selector, readiness probe — requires Kubernetes-specific knowledge that takes time to build.


The honest decision framework

Ask yourself these questions in order:

1. Do you have more than one team working on independent services with different scaling needs?

If yes: Kubernetes starts making sense. The isolation and per-service resource management are worth the overhead.

If no: Kamal is probably sufficient.

2. Do you need to survive individual server failures automatically, without manual intervention?

If yes: You need either Kubernetes with proper HA setup, or a managed container platform whose recovery and placement model fits your app. Cloud Run, Fly.io, Render, and Railway all help here, but their HA boundaries differ. Kamal alone does not give you this.

If no: A Docker or systemd restart policy covers the common crash case.

3. Does your traffic profile require automatic scaling?

If yes: Kubernetes with HPA or KEDA, or a managed platform with the scaling behavior you actually need. Cloud Run scales request-driven services to and from zero; Fly.io supports autostart/autostop and machine scaling; Render and Railway support scaling, but with provider-specific limits and billing. Kamal has no answer here without custom tooling.

If no: Fixed capacity is fine. Kamal works.

4. Do you have a platform team, or is “ops” a shared responsibility among product engineers?

If platform team exists: Kubernetes operational overhead is distributed and manageable.

If ops is everyone’s side job: Kamal’s simplicity is a genuine advantage. Less to go wrong, less to learn, faster to debug.

5. Are you on cloud infrastructure you do not own, and cloud costs matter?

If yes: Run the numbers on managed K8s vs a few VPS instances. For small workloads, VPS + Kamal is significantly cheaper.

If no: You have hardware already. Kamal is almost certainly the right choice.


Side-by-side

KamalManaged container platformsKubernetes
ExamplesKamal on VPS/bare metalCloud Run, Fly.io, Render, RailwayEKS, GKE, AKS, self-managed
Deployment modelSSH + DockerPlatform API / Git deploy / container deployAPI-driven, reconciliation loop
Self-healingDocker/systemd restart policy onlyPlatform-managed restart and placementPod rescheduling, node failure recovery
Auto-scalingManualProvider-specific; not all scale the same wayHPA, VPA, KEDA
HAManual (multiple servers)Provider-managed, with platform limitsAvailable with a well-configured multi-node control plane (not free)
TLSkamal-proxy with automatic HTTPS; Traefik optionalUsually built incert-manager + ingress controller
Secret management.kamal/secrets + helper commandsPlatform secretsSecrets API, external-secrets, Vault
ObservabilityStandard Docker loggingProvider logs/metrics, export variesRich ecosystem (Prometheus, OTel, etc.)
Rollout controlBasic rolling deploySimple rollouts, provider-specificFine-grained (maxUnavailable, canary, etc.)
Learning curveLowLow to mediumHigh
Ops overheadLowLowMedium to high
Right scale1-20 services, stable loadSmall to medium teams that want managed recovery/scaling10+ services, variable load, or regulated

What the threshold actually looks like

Kamal is the right default if you are:

  • A team of 2–10 engineers shipping a web application
  • Running stable, predictable workloads
  • On a budget where managed K8s costs matter
  • Without a dedicated platform engineer

Kubernetes is the right choice when:

  • You have 10+ services with independent deployment cycles
  • You need multi-zone HA and automatic failover
  • Traffic is variable enough that auto-scaling saves real money or prevents real incidents
  • You have a platform team that can absorb the operational complexity
  • You are in a regulated environment that benefits from K8s’s audit trails and RBAC

There is a middle tier worth mentioning: managed container platforms like Fly.io, Railway, Render, and Google Cloud Run. These give you some of Kubernetes’s operational benefits with closer to Kamal’s operational simplicity, but the details matter: HA, auto-scaling, scale-to-zero, regions, and billing differ by provider. For teams that need more than Kamal but do not want to operate K8s, this tier is often the right answer and gets underrepresented in the debate.


The real lesson from 37signals

The story is not “Kamal beat Kubernetes.” The story is: match your infrastructure to your actual operational profile, not to what is fashionable or what scales to Google’s size.

37signals evaluated what problems they actually had and picked the tool that solved those problems at the lowest operational cost. That is the right framework.

For most teams reading this, Kubernetes is probably the right answer eventually — when your system’s complexity justifies it. The mistake is adopting it before you are there, burning engineering time on infrastructure problems instead of product problems.

Kamal is a good tool for a specific stage and scale. Use it until you outgrow it. When you outgrow it, you will know — because the things Kubernetes solves will be actual problems you have, not hypothetical ones.


What to do next

Before you choose, write down your actual requirements in one page: number of services, expected traffic variance, failure tolerance, who owns ops, whether you own hardware, and what compliance needs are real today. Then run a small proof of concept with the simplest tool that satisfies those requirements. If Kamal plus Docker restart policies covers the failure modes you actually accept, start there. If you need automatic placement, autoscaling, and multi-zone recovery now, compare managed container platforms first, then Kubernetes.


FAQ

Can Kamal and Kubernetes coexist?

Yes. Some teams use Kamal for simple auxiliary services (internal tools, cron jobs, simple APIs) while running their core platform on Kubernetes. The tools are not mutually exclusive.

Is Kamal production-ready?

Yes. 37signals runs Basecamp and Hey on it. It handles zero-downtime deploys, TLS, and multi-server deployments reliably. The limitation is not reliability — it is feature scope.

What about Docker Swarm? Is it still relevant?

Docker Swarm fills a similar niche — simpler than Kubernetes, multi-host orchestration. Swarm mode is still documented and supported as part of Docker Engine for teams that want it as a production runtime, but its ecosystem is much smaller than Kubernetes and less active in the market. I would not pick it as the default for a new project unless the team already knows and wants Swarm.

Does Kamal work with any cloud provider?

Yes. Kamal only requires SSH access to a server and a container registry. It works with any VPS provider (Hetzner, DigitalOcean, OVH, AWS EC2, etc.) and any registry (Docker Hub, GitHub Container Registry, ECR, Harbor).

Is Kubernetes worth learning even if you use Kamal today?

Yes. Kubernetes is the dominant orchestration platform. Understanding it — even without operating it — makes you a better platform engineer and opens more career options. Learning Kamal does not preclude learning Kubernetes.

Helm Subcharts Explained: Values, Globals, import-values and Multiple Instances with Alias

Helm Subcharts Explained: Values, Globals, import-values and Multiple Instances with Alias

A Helm subchart is simply a chart that lives inside another chart. That one sentence hides most of the questions people actually have when they start using them: where the subchart comes from, how its values get set, what it can and cannot see from the parent, how to turn it off, and how to deploy the same subchart twice with different configuration.

This guide covers all of it, starting from the basics and ending with the pattern this post was originally written about: running multiple instances of the same subchart with alias. Everything here applies to charts with apiVersion: v2, which is what Helm 3 and Helm 4 use. For the wider picture of how charts, repositories and releases fit together, start with the complete Helm charts guide.

What Is a Helm Subchart?

A subchart is a complete, standalone chart that another chart (the parent, sometimes called an umbrella chart) includes and renders as part of the same release. When you run helm install on the parent, Helm renders the parent’s templates and every enabled subchart’s templates together, and all resulting objects belong to a single release with a single revision history.

There are two ways a chart can become a subchart.

Declared in Chart.yaml (the normal way)

You list it in the dependencies section of the parent’s Chart.yaml, and Helm downloads it into the charts/ directory for you:

# Chart.yaml
apiVersion: v2
name: shop
version: 1.4.0
dependencies:
  - name: postgresql
    version: "~16.2.0"
    repository: "https://charts.bitnami.com/bitnami"
  - name: redis
    version: "~20.1.0"
    repository: "oci://registry-1.docker.io/bitnamicharts"

Dropped manually into charts/

Any chart placed in the parent’s charts/ directory, either unpacked as a folder or as a .tgz archive, is treated as a dependency even if Chart.yaml does not mention it. Directory and file names starting with _ or . are ignored by the loader. This is handy for vendoring a chart you forked, but you lose version constraints and the lock file, so it is best reserved for special cases.

Subchart vs dependency: is there a difference?

In practice, no. “Dependency” is the declaration in Chart.yaml, “subchart” is the chart that ends up in charts/ and gets rendered. Every dependency becomes a subchart once it is fetched, and every subchart is a dependency of its parent. The distinction people usually mean when they search for “helm subchart vs dependency” is actually declared dependency vs manually vendored chart, which is the comparison above. If you want the full reference of every field in the dependencies block and how version ranges are resolved, see the Helm dependencies guide and the post on Helm version constraints.

helm dependency update, build and Chart.lock

Declaring a dependency does nothing on its own. You have to fetch it:

helm dependency update ./shop

update resolves each version range against the repository, downloads the matching archives into charts/, and writes Chart.lock with the exact versions and a digest of the dependency list. Run it whenever you change dependencies in Chart.yaml.

helm dependency build ./shop

build does the reproducible thing: it rebuilds charts/ from the versions pinned in Chart.lock without re-resolving ranges. This is what you want in CI.

Two errors you will see sooner or later:

  • found in Chart.yaml, but missing in charts/ directory means you declared a dependency and never fetched it. Run helm dependency build (or update).
  • the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml) means someone edited Chart.yaml without updating the lock. Run helm dependency update and commit the new Chart.lock.

Commit Chart.lock. Whether to commit the charts/*.tgz files is a team choice: committing them makes builds hermetic, not committing them keeps the repository small and relies on helm dependency build in the pipeline.

Passing Values to a Subchart

This is the part that confuses people the most, and it follows one rule: values for a subchart go under a top-level key named after the subchart (or after its alias, as we will see later).

# shop/values.yaml
replicaCount: 3            # parent value

postgresql:                # everything under this key goes to the postgresql subchart
  auth:
    database: shop
    username: shop
  primary:
    persistence:
      size: 20Gi

redis:
  architecture: standalone

Inside the postgresql subchart, templates see .Values.auth.database, not .Values.postgresql.auth.database. Helm strips the prefix and hands the subchart only its own slice. Values you set in the parent under that key are merged on top of the subchart’s own values.yaml defaults, so you only need to write what you want to change.

The same works on the command line:

helm install shop ./shop \
  --set postgresql.primary.persistence.size=50Gi \
  --set redis.architecture=replication

To remove a default the subchart ships with, rather than overriding it, set it to null in the parent. The details and the edge cases of that are covered in Helm null values.

The parent can read subchart values, not the other way around

The parent’s templates can reference .Values.postgresql.auth.database because that key exists in the parent’s value tree. The subchart cannot reference replicaCount from the parent: it only receives its own section plus global. This is intentional. A subchart must render the same way whether it is installed alone or embedded, so it cannot depend on anything its parent happens to define.

Global Values

global is the one channel that reaches every chart in the tree:

# shop/values.yaml
global:
  imageRegistry: registry.example.com
  storageClass: fast-ssd

Every chart, parent and subcharts, can read .Values.global.imageRegistry. Globals flow downwards and parent globals take precedence over any global a subchart defines in its own values.yaml.

Two practical warnings. First, a global only does something if the subchart’s templates actually read it. Bitnami charts, for example, honour global.imageRegistry and global.storageClass because their templates were written to; a random chart may ignore both. Check the subchart’s templates or README before relying on a global. Second, do not use global as a general-purpose way to share configuration between your own charts. It works, but it creates an invisible contract between charts that is hard to trace months later. Explicit per-subchart values are easier to review.

Enabling and Disabling Subcharts: condition and tags

Every declared dependency is rendered by default. To make one optional you add a condition, a tags list, or both.

# Chart.yaml
dependencies:
  - name: postgresql
    version: "~16.2.0"
    repository: "https://charts.bitnami.com/bitnami"
    condition: postgresql.enabled
  - name: prometheus-exporter
    version: "1.x.x"
    repository: "https://example.com/charts"
    tags:
      - monitoring
  - name: grafana-dashboards
    version: "1.x.x"
    repository: "https://example.com/charts"
    tags:
      - monitoring
# values.yaml
postgresql:
  enabled: true

tags:
  monitoring: false

The resolution rules, from the Helm documentation:

  • A condition is a YAML path into the parent’s values that must resolve to a boolean. You can list several comma-separated paths; the first one that exists wins and the rest are ignored.
  • tags enable a chart if any of its tags is true.
  • When both are present and the condition path is set in values, the condition always overrides tags.
  • If neither resolves, the chart is enabled.

The usual pattern is a condition per subchart for fine control and a shared tag for groups that should switch on and off together, such as everything related to monitoring:

helm install shop ./shop --set tags.monitoring=true --set postgresql.enabled=false

A common mistake is to write condition: enabled instead of condition: postgresql.enabled. The path is resolved from the parent’s root, not from inside the subchart’s section.

import-values: Pulling Subchart Values Up to the Parent

Values normally flow down. import-values lets the parent pull specific values up from a subchart, which is useful when the subchart knows something the parent’s templates need, such as a port or a service name.

The exports format

The subchart publishes values under a top-level exports key:

# charts/api/values.yaml
exports:
  data:
    servicePort: 8080

The parent imports them by key:

# Chart.yaml (parent)
dependencies:
  - name: api
    version: "2.x.x"
    repository: "https://example.com/charts"
    import-values:
      - data

After rendering, the parent sees .Values.servicePort at its root. The contents of exports.data are merged into the parent’s root values, not under a data key.

The child-parent format

When the subchart does not export anything, you can map any path explicitly:

dependencies:
  - name: api
    version: "2.x.x"
    repository: "https://example.com/charts"
    import-values:
      - child: service
        parent: apiService

Now .Values.apiService.port in the parent holds whatever the subchart had in service.port. Imported values override defaults in the parent’s values.yaml, but values passed at install time with -f or --set still win.

Use import-values sparingly. If the parent needs a value that the subchart also needs, it is often clearer to define it once in the parent and pass it down, or put it in global.

What a Subchart Cannot Do

Knowing the limits saves a lot of debugging time:

  • It cannot see parent values. Only its own section and global. There is no .Parent.Values.
  • It cannot override parent values. Values flow down; import-values is the only way up, and the parent opts into it.
  • Named templates are global. Everything defined with {{ define }} across the parent and all subcharts shares one namespace. If two charts define mychart.labels, one silently wins. That is why well-written charts prefix every template with the chart name, and why helm create generates names like {{ include "shop.fullname" . }}.
  • It is part of the same release. You cannot upgrade, roll back or uninstall a subchart independently. A rollback of the parent rolls back every subchart.
  • Hooks run in the same release. Subchart hooks execute alongside the parent’s, ordered by weight, which can surprise you if two charts both ship a pre-install migration job. See the Helm hooks guide for ordering rules.

Multiple Instances of the Same Subchart with alias

Now to the less common but very useful case: you want the same subchart twice, each copy with its own configuration.

When you need it

The scenario that led me to this pattern was a set of microservices that belong to one application and share the same technology base. All of them could use the same generic chart (in my case a TIBCO BusinessWorks Container Edition chart; it could just as well be a generic go-microservice or spring-boot chart), but each one needs:

  • its own image,
  • its own configuration and environment variables,
  • its own endpoints, often talking to different databases or external systems.

Without subcharts you end up copying the chart once per service, and every fix to the base chart has to be applied N times. With one subchart instantiated N times, the base chart is versioned once and each service is just a block of values.

Other real cases: two Redis instances (one cache, one queue) with different persistence settings, or a primary and a reporting database from the same PostgreSQL chart.

Declaring the instances

Start from a parent that includes the generic chart once:

# Chart.yaml
apiVersion: v2
name: orders-platform
description: Orders application deployed from a shared service chart
version: 0.2.0
appVersion: "2.7.2"
dependencies:
  - name: service
    version: "~0.2.0"
    repository: "https://charts.example.com"

To have two instances, declare the same chart twice and give each an alias:

# Chart.yaml
apiVersion: v2
name: orders-platform
description: Orders application deployed from a shared service chart
version: 0.3.0
appVersion: "2.7.2"
dependencies:
  - name: service
    alias: order-api
    version: "~0.2.0"
    repository: "https://charts.example.com"
    condition: order-api.enabled
  - name: service
    alias: order-worker
    version: "~0.2.0"
    repository: "https://charts.example.com"
    condition: order-worker.enabled

name stays the same, so helm dependency update downloads the chart once. The alias is what makes each copy distinct: it becomes the values key for that instance and it becomes .Chart.Name inside the instance’s templates.

Configuring each instance

Each alias gets its own section in values.yaml:

# values.yaml
order-api:
  enabled: true
  image:
    repository: registry.example.com/orders/order-api
    tag: "2.5.2"
    pullPolicy: IfNotPresent
  service:
    port: 8080
  env:
    DB_HOST: orders-db.internal

order-worker:
  enabled: true
  replicaCount: 2
  image:
    repository: registry.example.com/orders/order-worker
    tag: "2.5.2"
  env:
    QUEUE_URL: amqp://queue.internal

Anything you do not set falls back to the shared chart’s defaults, so each block contains only what makes that service different.

Three things that bite with aliases

Use lowercase, DNS-safe aliases. Because the alias replaces .Chart.Name, the standard fullname helper builds resource names from it. An alias like serviceA (which the first version of this post used) produces names such as myrelease-serviceA, which Kubernetes rejects because object names must be lowercase. Stick to order-api style names.

Hyphenated aliases need index in parent templates. If the parent’s own templates need to read an instance’s values, .Values.order-api.service.port is a template parse error. Use {{ index .Values "order-api" "service" "port" }} instead. Templates inside the subchart are unaffected, because they see their own slice without the prefix.

The shared chart must use its chart name in resource names. If the base chart hard-codes name: service in its Deployment, both instances render an object with the same name and the install fails. A chart designed for reuse builds names from include "service.fullname", which derives from .Chart.Name and therefore from the alias.

Library Charts vs Subcharts

A library chart (type: library in Chart.yaml) is also declared as a dependency, but it renders nothing by itself. It only provides named templates the parent can include.

Subchart (application)Library chart
Renders Kubernetes objectsYesNo
Has its own values.yaml sectionYesNo, uses the caller’s context
Can be installed aloneYesNo
Typical useBundling a database, cache or serviceShared labels, Deployment skeletons, helpers
Multiple instancesWith aliasNot needed: call the template N times

For the multi-microservice case above there is a real alternative: a library chart that defines a full Deployment/Service template, and a parent that calls it once per service from a range over values. That keeps every service in the parent’s own templates and avoids alias quirks, at the cost of writing a bit more template code. Aliased subcharts are simpler when the shared chart already exists as an installable chart; library charts are cleaner when you design the abstraction from scratch.

helm upgrade with Subcharts

Upgrading a release that contains subcharts is the same helm upgrade, with two details worth knowing.

If you bump a dependency version, you must refresh charts/ before upgrading, either explicitly or with the flag:

helm dependency update ./shop
helm upgrade shop ./shop -f values-prod.yaml

# or in one step: fetches dependencies if they are missing
helm upgrade shop ./shop -f values-prod.yaml --dependency-update

Note that --dependency-update only fetches dependencies that are missing; it does not replace an older archive that is already sitting in charts/. After changing a version range, run helm dependency update explicitly.

Values behave as in any upgrade. Without --reuse-values, Helm starts from the chart’s defaults plus whatever you pass on this command; that is usually what you want, because a new subchart version may have renamed or added keys. --reuse-values merges your overrides on top of the previous release’s values and can hide new subchart defaults. --reset-then-reuse-values is the middle ground: chart defaults first, then the last release’s values, then your overrides. Before a subchart major version bump, render both versions with helm template and diff them. The advanced Helm commands post has more on inspecting a release before touching it.

Umbrella Charts: When to Use Them and When to Avoid Them

An umbrella chart is a parent whose main job is to bundle several subcharts into one deployable unit. Subcharts make them easy to build, which is exactly why they get overused.

They work well when:

  • the components share a lifecycle and are always released together, such as an application and its dedicated cache,
  • you want one versioned artifact per environment that says exactly what is deployed,
  • the same generic chart is instantiated several times, as in the alias example.

They hurt when:

  • components have different release cadences, because every change to one service creates a new revision of all of them and a rollback reverts everything,
  • different teams own different components and step on each other’s values,
  • the release grows so large that a single failed hook or a single invalid object blocks the whole upgrade.

If you are deciding how to organise charts across many services, the trade-offs between one central chart and per-service charts are covered in detail in Helm chart management: centralized vs per-service. Whatever structure you choose, test the charts with the subcharts enabled, and validate the values each instance receives with a values schema.

Frequently Asked Questions

What is a subchart in Helm?

A subchart is a complete Helm chart included inside another chart, either declared in the parent’s Chart.yaml under dependencies and fetched into charts/, or placed there manually. It is rendered as part of the parent’s release, receives its values from a key named after it in the parent’s values, and cannot see the parent’s own values except for global.

What is the difference between a Helm subchart and a dependency?

They are two views of the same thing. A dependency is the entry in Chart.yaml that says which chart and version to use; the subchart is that chart once it has been downloaded into charts/ and rendered. The only practical distinction is between declared dependencies, which have version ranges and a Chart.lock, and charts copied manually into charts/, which have neither.

How do I pass values to a Helm subchart?

Put them under a top-level key with the subchart’s name, or its alias if it has one, in the parent’s values.yaml, or use --set subchartname.key=value. Helm strips that prefix and merges the section over the subchart’s own defaults, so inside the subchart the values appear at .Values.key. Values that every chart should see go under global.

Can a subchart access the parent chart’s values?

No. A subchart only receives its own section of values and the global section. This keeps subcharts self-contained so they render identically whether installed alone or embedded. If a subchart needs something from the parent, pass it explicitly under the subchart’s key or place it in global.

How do I deploy the same Helm chart multiple times as subcharts?

Declare the same chart several times in dependencies with the same name and a different alias for each entry. Each alias becomes its own values key and its own .Chart.Name, so every instance gets separate configuration and separate resource names. Use lowercase, DNS-safe aliases, and make sure the shared chart builds resource names from its fullname helper rather than hard-coding them.

How do I disable a subchart?

Add condition: <subchart>.enabled to the dependency in Chart.yaml and set <subchart>.enabled: false in values or with --set. Alternatively assign tags and set tags.<tag>: false. If both are defined, a condition that resolves in values always overrides tags.

Do I need to run helm dependency update before helm upgrade?

Yes, whenever you change a dependency version in Chart.yaml. helm upgrade --dependency-update fetches dependencies that are missing from charts/, but it does not replace an archive that is already there, so after bumping a version run helm dependency update and commit the new Chart.lock. In CI, helm dependency build recreates charts/ from the lock file.

What is the difference between a library chart and a subchart?

A subchart is an application chart that renders its own Kubernetes objects and has its own values section. A library chart, marked with type: library, renders nothing and only provides named templates for the parent to include. Use subcharts to bundle components, and library charts to share template code across many charts.

Conclusion

Subcharts are Helm’s composition mechanism, and most of their behaviour follows from two ideas: values flow down under a key named after the subchart, and everything renders into one release. Once that is clear, global, condition, tags and import-values are just controlled exceptions to the flow.

The multi-instance pattern with alias builds on the same rules. It lets you keep one well-tested generic chart and instantiate it for every service that shares the same base, each with its own values, instead of maintaining a copy per service. Use it when the instances genuinely share a lifecycle, keep aliases lowercase, and reach for per-service releases or a library chart when they do not. That keeps the benefits of reuse without turning your umbrella chart into the monolith containers were supposed to get rid of.