5
homeassistant
Justin Klein edited this page 2026-10-14 14:57:00 +00:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

homeassistant

Deploys Home Assistant as a single-replica StatefulSet whose /config directory is one RWO PVC, with three configuration files mounted read-only out of a 1Password item. Two things set this chart apart from the rest of the repository: its ingress block is the documented exception that keeps the upstream-style hosts/tls lists instead of the required domain/clusterIssuer pair, and the container must start as root because the image boots via s6-overlay.

Image ghcr.io/home-assistant/home-assistant
Versions not listed on purpose — read the current pair off homeassistant/Chart.yaml, see Chart index
Workloads StatefulSet (1 replica)
Storage one RWO PVC mounted at /config (subPath hass-config)
Routing two mutually exclusive modes, both off by default: Ingress (ingress.enabled) or Gateway API (httpRoute.enabled). Upstream-style hosts/tls lists, no required values — and because there is no ingress.domain, there is no httpRoute.hostnames either: ingress.hosts is read in both modes and Gateway mode renders one route per entry. See Routing
External dependencies none by default (recorder uses SQLite on the PVC unless configured otherwise)
Secrets 1 × OnePasswordItem (three fields)
Install-test ci/homeassistant/ (config-secret fixture, INSTALL_TIMEOUT=600)

Prerequisites

  • 1Password operator in the cluster, plus one item carrying the three fields below.
  • A storage class that provisions RWO volumes. The default value is the literal string default, which exists in few clusters — set core.storage.storageClass to yours.
  • cert-manager only if you ask for it. This chart adds no cert-manager annotation of its own; see The Ingress exception.
  • Someone ready to do the onboarding right after the first rollout — see The first visitor becomes the owner.

Installation

The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.

helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update

helm install hass jlab/homeassistant \
  --namespace homeassistant --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml — secrets.configSecretRef is the only value the chart insists on:

secrets:
  configSecretRef: op://vault/homeassistant-config

core:
  timezone: Europe/Berlin
  storage:
    storageClass: longhorn

ingress:
  enabled: true
  hosts:
    - host: hass.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: tls-hass-example-com
      hosts:
        - hass.example.com
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt

helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — including an explicit warning when ingress.tls names a Secret that nothing in the release creates. Read it; it is the per-release version of this page.

What the 1Password item must contain

The chart renders one OnePasswordItem; the operator turns it into a Kubernetes Secret whose keys are the field names of the item. This chart mounts exactly three of them, and the names are decided here — they appear nowhere in 1Password, so they have to match to the letter.

Values key Kubernetes Secret Field Mounted at
secrets.configSecretRef <release>-homeassistant-config-secret config /config/configuration.yaml
http-config /config/http-config.yaml
recorder-config /config/recorder-config.yaml

The three are separate files on purpose, and configuration.yaml has to pull the other two in itself — the chart only mounts them, it does not wire them together. A working minimum for the config field:

default_config:
http: !include http-config.yaml
recorder: !include recorder-config.yaml

A field spelled differently yields a Secret without that key. The pod then hangs in CreateContainerConfigError while the Secret itself exists, so it looks as if the operator had done its job. What it actually delivered:

kubectl -n <namespace> get secret <release>-homeassistant-config-secret -o jsonpath='{.data}'

All three are subPath mounts from a Secret volume, which makes them read-only inside the container. Home Assistant's own file editor and every UI page that offers to write configuration.yaml will fail on these three paths — and only on these three. Everything else under /config is on the PVC and writable as usual.

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders the StatefulSet with replicas: 0. Takes precedence over replicas. PVC, OnePasswordItem, Service and Ingress stay.
replicas int 1 Replica count; 0 is allowed. Top-level, not under a section — this chart keeps replicas, resources, the probes and securityContext at the root. See One replica, one volume.
global object {} Helm's cross-chart convention. Read by nothing here; kept open so an umbrella install cannot break on it.

secrets

Key Type Default Description
secrets.configSecretRef string null Required — the only required value in this chart. op:// path of the item holding config, http-config and recorder-config. Changing the path restarts the pod (checksum/secret-ref); changing the item's content does not.

service

Key Type Default Description
service.type string ClusterIP Service type.
service.port int 80 Service port; the target is always the container's 8123 (port name http).

ingress

Documented exception to style guide §4: this chart keeps the upstream-style flexible block. There is no ingress.domain, no ingress.clusterIssuer, and no required value here at all — only className follows the repo standard. The exception extends to the Gateway mode, which is built on top of this block rather than beside it — see Routing.

Key Type Default Description
ingress.enabled bool false Off by default. Mutually exclusive with httpRoute.enabled.
ingress.className string nginx Set to "" to omit ingressClassName from the rendered Ingress entirely. Not read in Gateway mode — the class is a property of the Gateway there.
ingress.annotations object {} Rendered verbatim. This is where the cert-manager annotation has to go — the chart adds none. Not read in Gateway mode: the Gateway API has no annotation channel, so anything here is simply gone.
ingress.hosts list [{host: chart-example.local, paths: [{path: /, pathType: ImplementationSpecific}]}] The public names of this release, and read in BOTH modes. Host/path list, passed verbatim into the Ingress rules; in Gateway mode the chart renders one HTTPRoute per entry with one rule per path of that entry. The default is the chart scaffold's placeholder — see below. pathType does not map cleanly; see Routing.
ingress.tls list [] TLS list (secretName + hosts), passed verbatim. Empty means the Ingress carries no TLS section at all and the URLs are plain http. Not read in Gateway mode: the certificate sits on the Gateway's listener.

httpRoute

The Gateway-API alternative to the Ingress, off by default. Added with group 3 of the Gateway-API sweep (Gitea #114) — and this is the chart where the convention had to bend rather than the chart, so it has two keys, not three.

Key Type Default Description
httpRoute.enabled bool false Renders one HTTPRoute per entry of ingress.hosts instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values.
httpRoute.parentRefs list [] The Gateway(s) every route of this release attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The same list is used for every route; the hosts differ, the Gateway does not. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases.

There is deliberately no httpRoute.hostnames, and the schema rejects it outright so the absence is enforced rather than merely intended. This chart has no ingress.domain to fall back to, and a chart-global hostname list could not say which host entry — and therefore which set of paths — it belonged to. freesailarr is the other chart without that key, for the same reason.

securityContext

Key Type Default Description
securityContext.pod object {} Deliberately empty. The official image boots via s6-overlay, which performs its setup as uid 0 and supports no non-root user, so runAsNonRoot/runAsUser are not set. Documented deviation from §5.
securityContext.container object allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, caps drop: [ALL], add: [NET_RAW] Everything else is hardened. NET_RAW is added back because device discovery (the DHCP watcher, the ping and arp integrations) uses raw sockets — see Dropping NET_RAW is silent.

resources

Key Type Default Description
resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request.
resources.requests object cpu: 0.2, memory: 512Mi, ephemeralStorage: 128Mi
resources.limits object unset An explicit limit always wins. A CPU limit is only rendered when set here.

Probes

Key Type Default Description
livenessProbe object GET / on port http, period 10s, timeout 5s, 3 failures
readinessProbe object GET / on port http, period 10s, timeout 5s, 3 failures
startupProbe object GET / on port http, period 10s, 30 failures (= 300s) Gates liveness and readiness. Deliberately generous: a cold start installs and initializes integrations.

env

Key Type Default Description
env list [] Appended after TZ, the only variable the chart sets itself. Rendered through tpl; value, secretKeyRef, configMapKeyRef. Additive, not overriding (§10) — and apart from TZ, Home Assistant reads no configuration from the environment: an entry reaches the application only if the YAML in the 1Password item picks it up with !env_var.

core

Key Type Default Description
core.timezone string UTC Rendered as TZ. Not Home Assistant's own time zone — see Two time zones.
core.storage.size string 5Gi Size of the /config PVC. Not null-safe on purpose: an erased block yields an invalid PVC instead of a degraded workload.
core.storage.storageClass string default Storage class of the PVC. The literal string default is not the cluster default — it is a class name that must exist.

Values the chart accepts but does not render

These five are declared in values.yaml and in values.schema.json (the schema even describes them as "rendered verbatim into the pod spec"), but no template reads them. Setting them passes validation and changes nothing:

Key Type Default Description
volumes list [] Not rendered.
volumeMounts list [] Not rendered.
nodeSelector object {} Not rendered — the StatefulSet template still carries a # TODO: bind to local nodes.
tolerations list [] Not rendered.
affinity object {} Not rendered.

networkPolicy

Inbound 8123 only — anything else this image or an add-on binds is not reachable. There is no egress section at all, and that is the decision in this chart rather than an omission; see the operating note. There is no ingressController value either, because this chart keeps its flexible upstream ingress style and renders no Ingress of its own.

Value Type Default Meaning
networkPolicy.enabled bool true Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured.
networkPolicy.allowedNamespaces list [] Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it.
networkPolicy.probeSources list [] CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready.

Operating notes

The Ingress exception: nothing here requests a certificate

Unlike every other chart in this repository, this one builds no cert-manager.io/cluster-issuer annotation from a clusterIssuer value, and it does not name the TLS Secret either — you do, in ingress.tls. Three outcomes follow:

  • ingress.tls empty → the Ingress carries no TLS section, the URLs are plain http, nothing requests a certificate.

  • ingress.tls set, no cert-manager annotation under ingress.annotations → the Ingress references a Secret that nothing creates. ingress-nginx answers with its own default certificate and every browser shows a name mismatch. NOTES.txt calls this out in capitals.

  • ingress.tls set and the annotation present → cert-manager's ingress-shim names the Certificate after your secretName, so that is the object to watch:

    kubectl -n <namespace> get certificate <your ingress.tls secretName>
    

Routing: Ingress or Gateway API

Two mutually exclusive modes, and both ship off — this chart publishes nothing until one is switched on. The Gateway support is built on top of the flexible ingress block rather than beside it, which has three consequences.

1. No httpRoute.hostnames, and one route per host entry. The hostnames and the paths come from ingress.hosts, so that list is read in both modes and is not an Ingress-only value. Gateway mode renders one HTTPRoute per entry, with one rule per path of the entry. One object carrying several hostnames would be wrong rather than merely different: an HTTPRoute's rules apply to every hostname of the object, and per-host paths are this chart's whole point.

Route names carry the entry's index, not its hostname — <release>-homeassistant-httproute for the first, …-httproute-1, …-httproute-2 for the rest. A hostname is not a dependable object-name fragment (the 63-character label limit plus the release prefix, and a sanitised hostname can collide). Reordering ingress.hosts therefore renames routes, which deletes and recreates them on the next upgrade instead of editing them. Harmless — a route holds no state and no certificate — but worth knowing before it looks like a bug.

2. pathType has no clean counterpart, and this chart's default is the awkward value. Ingress offers Exact, Prefix and ImplementationSpecific; the Gateway API offers Exact, PathPrefix and RegularExpression.

pathType Rendered as
Exact Exact
Prefix PathPrefix
ImplementationSpecific on path / PathPrefix — every controller treats a root ImplementationSpecific path as a prefix match, so the two coincide and the chart's own default works in Gateway mode unchanged
ImplementationSpecific on any other path the render aborts, naming the value

The abort is the point rather than an inconvenience: in ingress-nginx — the class this chart defaults to — an ImplementationSpecific sub-path is a regex, and PathPrefix is not a regex. Mapping it silently would change how the path matches while the render stayed green, which is precisely the class of defect #118 was. Say which you mean: pathType: Prefix or pathType: Exact. RegularExpression is deliberately not offered as an escape hatch — its support level is Implementation-specific, which would bind this chart to one Gateway implementation.

3. Two render aborts guard the list itself. ingress.hosts empty while httpRoute.enabled is true aborts (there would be no hostname and no route at all, silently); an entry without a host aborts (an HTTPRoute with an empty hostname matches every request reaching its listener, which on a shared Gateway means this release answers for hostnames belonging to other releases); and an entry without paths aborts (a route with no rules matches nothing while being perfectly valid and Accepted).

Plus the two rules every chart has: the modes are mutually exclusive with a fail naming both value paths — and for Home Assistant there is a second edge, since it refuses requests whose Host header is not in its own configuration and builds absolute URLs from it, so a client arriving over the other path can be rejected by the application rather than by the proxy — and httpRoute.parentRefs must not be empty, because a route without a parent attaches to no Gateway and routes nothing with no event and no condition to say so.

Nothing was measured on a live object for this chart

Unlike its five siblings in group 3, four of which carried an operator-supplied annotation that Gateway mode drops, this chart had nothing to measure: homeassistant is not deployed in jk-cluster (checked 2026-10-12 — 23 Ingress objects cluster-wide, none of them this chart's, and no workload of it in any namespace).

That is an absence of evidence about that installation, not a property of the chart. ingress.annotations defaults to {} and the chart sets nothing itself, so whatever a future installation puts there would be dropped in Gateway mode exactly as elsewhere — and for this chart that includes the cert-manager annotation, which is the only way a certificate is ever requested in Ingress mode (the Ingress exception).

The placeholder host

ingress.hosts defaults to the scaffold entry chart-example.local. With either mode switched on and that default untouched, the routing object exists, the controller or Gateway accepts it, and no request ever arrives — the name resolves nowhere. Set the host you actually serve before looking for a deeper reason. ingress.hosts is read in both modes, so this applies to Gateway mode too.

Behind a proxy, the http block decides between 400 and wrong client IPs

Every request through ingress-nginx arrives from the controller's pod IP, not from the browser — and in Gateway mode from the Gateway's data-plane pod IP instead. The http-config field is where that is dealt with, and both ways of getting it wrong are quiet in their own way:

  • use_x_forwarded_for: true without the proxy's address in trusted_proxies makes Home Assistant reject the request outright — the browser gets 400 Bad Request and the log names the untrusted address it saw.
  • Neither setting at all means every client looks like the ingress controller. Nothing fails; you get one address in the logbook for everyone, and the login-attempt ban then locks out all users at once because they share it.

The value belongs to the proxy's pod network, not to the nodes and not to the clients:

use_x_forwarded_for: true
trusted_proxies:
  - 10.42.0.0/16      # the cluster's pod CIDR — check yours

Switching from an Ingress to a Gateway changes that network, because a Gateway's data plane normally runs in a namespace of its own. A trusted_proxies list carried over unchanged turns every request into a 400 if the CIDR differs — or, if it does not, silently keeps attributing every client to one address. This is the half of a mode switch that lives in the application's own configuration, which arrives from 1Password and which no chart value and no render test can see.

Read the address Home Assistant actually sees out of the log rather than guessing:

kubectl -n <namespace> logs <release>-homeassistant-sfs-0 | grep -i forwarded

Changing the item's content does not restart the pod

The StatefulSet carries a checksum/secret-ref annotation over the rendered OnePasswordItem, so pointing secrets.configSecretRef at a different vault item does roll the pod — the deliberately weaker promise of §9: "the config source changed", not "the config changed".

Changing the content of the same item does not, and this is the sharp edge. Helm never sees that content, and a subPath mount is never refreshed by the kubelet even after the operator has updated the Secret. The running pod keeps the old files until something else restarts it — possibly months later — so a broken value surfaces far away from the edit that caused it. That is especially true for a release parked at scaleDown: true: whoever brings it back up gets whatever the vault holds at that moment, on whatever app version the chart carries by then.

The operator's operator.1password.io/auto-restart annotation is not the fix and is deliberately not set: it only ever restarts Deployments, never StatefulSets (verified against onepassword-operator 1.12.0). Setting it here would look like a fix and do nothing. After every content change:

kubectl -n <namespace> rollout restart statefulset/<release>-homeassistant-sfs

The container starts as root

The pod security context is empty on purpose. s6-overlay performs its setup as uid 0 and the image supports no non-root user, so this is one of the documented image-forced deviations in §5 — justified with a comment in values.yaml, as the rule requires. Everything else holds: automountServiceAccountToken: false, no privilege escalation, all capabilities dropped (bar NET_RAW), read-only root filesystem, /tmp and /run as emptyDirs.

Dropping NET_RAW is silent

The chart's default adds NET_RAW back after dropping ALL. Overriding securityContext.container without it does not fail loudly: the DHCP watcher and the ping and arp integrations log Operation not permitted and simply find nothing, so devices that used to appear on their own stop appearing. NOTES.txt prints a warning when the effective context has lost the capability.

The first visitor becomes the owner

Against an empty /config Home Assistant has no users and sends every visitor into onboarding, where the first account created is the owner. There is no password to look up anywhere and no Secret holding one — whoever reaches the URL first owns the instance. Do the onboarding right after the first rollout, not the next morning.

Onboarding also decides the instance's location, unit system and time zone and stores them under /config/.storage. That is state on the PVC, not configuration in the 1Password item: it survives a pod restart, and it is gone with the volume.

TZ and Home Assistant's own time zone are two settings

core.timezone is passed to the container as TZ and is the only environment variable the chart sets by itself. Home Assistant's own time zone comes from onboarding or from configuration.yaml and is not derived from it. When the two disagree, log timestamps and time-based automations disagree with them, and nothing reports a problem.

What a Ready pod proves — and what it does not

All three probes fetch / on the container port, served by the HTTP integration early in the bootstrap:

  • A configuration.yaml that cannot be loaded does not necessarily keep the pod out of Ready. Home Assistant falls back to safe mode, serves the frontend with none of your configuration in effect, and the rollout goes green. An instance that comes back "empty" after a config change is this, and the reason is only in the log.
  • An integration that fails to set up, a recorder that cannot reach its database, a full volume — none of them are in the probe's answer.

So check the log after every config change, not the rollout status. If a start outruns the 300s startup budget, the kubelet kills the container mid-setup and the next attempt begins again, which looks like CrashLoopBackOff with no error in the log at all.

/tmp and /run count against ephemeral-storage

The root filesystem is read-only, so the two paths the image must write to — /run for s6-overlay's state and /tmp for scratch files — are emptyDirs, and an emptyDir counts against the container's ephemeral-storage limit. Going over it does not fail a request: the kubelet evicts the whole pod, and the reason is on the pod, not in Home Assistant's log. Where integrations pull large downloads through /tmp, raise resources.requests.ephemeralStorage; the limit follows through resources.limitFactor.

One replica, one volume

Every pod of the StatefulSet mounts the same RWO claim at /config — there is no volumeClaimTemplate and no per-pod volume. Additional pods stay stuck before start unless the storage class hands the claim to several nodes; where they do start, two Home Assistant processes write the same state directory and the same recorder database at once. Home Assistant does not run active-active.

The Helm test is inert

This is the only chart in the repository with a templates/tests/ directory, but its test-connection.yaml is entirely commented out — the whole manifest sits inside {{/* … */}}. Nothing is rendered, no helm.sh/hook: test object exists, and helm test <release> finds nothing to run. The file is a placeholder from the upstream scaffold, not a working smoke test.

Why there is no egress restriction

Home Assistant's outbound traffic is the union of every integration an operator enables: mDNS and SSDP discovery on 5353/UDP and 1900/UDP, MQTT on 1883 or 8883, a Zigbee or Z-Wave bridge on whatever port its firmware chose, ESPHome on 6053, printers, speakers, TVs, inverters, cloud APIs on 443, an NTP client on 123/UDP. There is no version of that list which stays correct.

A cap would also protect very little — the devices are on the same LAN as the node — and it would break integrations one at a time, months apart, each failure looking like a flaky device rather than like this object.

So the rule valkey established applies in the other direction: policyTypes names only what there is something to restrict, because an egress rule that has to be widened until it permits everything makes the object look stricter than it is while costing real outages to get there. An installation that wants a cap should write one at release level, against its own integration list.

The probed port is open inside the cluster by default

networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.

It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.

Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.

The policy is only a boundary where the CNI enforces one

The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.

Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.

Upgrades and backup

  • /config is the whole of the state. The owner account, every long-lived token, all UI-created automations and dashboards, every integration's stored credentials and the recorder database live there — on <release>-homeassistant-pvc, inside its subPath hass-config. Only the three files from the vault are reproducible from the chart's values; nothing else is.
  • helm uninstall deletes that PVC. It is an ordinary claim in the release, not a volumeClaimTemplate, and with a Delete reclaim policy the instance is then gone for good. A resource-policy annotation added to the live PVC afterwards does not help — Helm reads it from the release manifest.
  • The recorder is what makes the volume grow, and its retention (purge_keep_days) is a field of the 1Password item, not a chart value. Growing core.storage.size only takes effect if the storage class allows volume expansion; shrinking it is refused by Kubernetes and fails the next upgrade on the PVC instead of touching the data.
  • Home Assistant releases monthly and deprecates integrations on a schedule. Read the upstream release notes before a bump; a rollback returns the old image to a /config the new one has already migrated.
  • The app version is calendar-versioned (YYYY.M.P), so the repo's "app major changed → chart major" rule fires once a year at the January release rather than on a breaking change. Judge the bump level by the upstream notes, not by the number — see Versioning.

CI

ci/homeassistant/ holds test-values.yaml (op:// ref against the stub CRD, plus core.storage.storageClass: local-path because the values default default does not exist in k3d), fixtures.yaml (the config Secret the operator would otherwise materialize, with the three keys and a trusted_proxies entry for the k3d pod CIDR) and settings.env (INSTALL_TIMEOUT=600, because the first boot installs and initializes integrations). Home Assistant starts as root, so the writability of the dynamically provisioned volume is not an issue here — unlike the charts that need hostPath fixtures. The install-test runs on every PR that touches the chart — see CI/CD.

Neither routing object is installed, and that follows from the chart rather than from the fixture: ingress.enabled ships false and the fixture does not switch it on, so the install-test has never exercised a routing object for this chart. Same situation as garage.

The Gateway mode is held at render level, by ci/httproute/homeassistant.yaml plus ci/run-httproute-match-test.sh, which runs over every chart on every PR (CI/CD) — so for this chart the overlay adds coverage that never existed rather than trading one object for another.

It is also the only overlay in the repository that has to supply hostnames, because this chart has no ingress.domain and the chart's default entry is the chart-example.local placeholder. Two entries, each earning its place:

  1. ha.ci.local with / and pathType: Prefix — the ordinary case and the clean Prefix → PathPrefix mapping.
  2. ha-alt.ci.local with two paths, one Prefix and one Exact — the part that cannot be expressed in a single HTTPRoute together with entry 1, because an HTTPRoute's rules apply to every hostname of the object. It is therefore the test that the chart really renders one object per host entry rather than one object with several hostnames, and it exercises the Exact mapping and the multi-rule path at once.

5 assertions over 2 routes and 3 rules — a parentRefs check per route plus a backendRefs check per rule, each held against the Service of the same render by port number, because backendRefs takes a PortNumber and cannot name the port http the way the Ingress does.

What that does not cover: that either hostname answers (no Gateway controller runs in CI, so no route reaches Accepted: True); the pathType: ImplementationSpecific abort on a non-root path, which is a render-failure path while this test only runs renders it expects to succeed — it was exercised by hand instead; and anything about whether Home Assistant accepts the Host header or reads X-Forwarded-For, both of which live in the application's own configuration and change with the proxy's address. Nothing in CI reads NOTES.txt either: helm template does not emit it and no script under ci/ reads it, so its text carries review-only assurance.

See also