6
rss bridge
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.

rss-bridge

Deploys RSS-Bridge, which turns websites without a feed into RSS/Atom, as a stateless Deployment behind an Ingress or a Gateway-API HTTPRoute. The enabled bridges come from a ConfigMap rendered as whitelist.txt; the feed cache is an emptyDir and is meant to be lost.

Two things about this chart are unlike any other in the repository, and both are worth knowing before reading on: it was the last chart of the Gateway-API sweep (Gitea #114), and its install-test had been failing on the Gitea runner since the move for a reason that lies in the runner and not in the chart (Gitea #51) — until it passed in the Gateway-API conversion PR, on a render byte-identical to main. See The install-test, which was red on purpose and then went green.

Image docker.io/rssbridge/rss-bridge
Versions not listed on purpose — read the current pair off rss-bridge/Chart.yaml, see Chart index
Workloads Deployment (1 replica)
Storage none — five emptyDirs, feed cache included
Routing two mutually exclusive modes. Ingress (default): ingress.hosts (a list) + ingress.clusterIssuer required while ingress.enabled. Gateway API: httpRoute.enabled with httpRoute.parentRefs and httpRoute.hostnames, both mandatory in that mode — see Routing modes
External dependencies none in the cluster; the bridges fetch the sites they scrape
Secrets none — no OnePasswordItem
Install-test ci/rss-bridge/ (test values only, no fixtures). Installs the Ingress mode; red on the runner from the move until 2026-10-13, see Gitea #51. The Gateway mode is covered by a render overlay at ci/httproute/rss-bridge.yaml

The image reference is written out with its registry on purpose: an unqualified Docker Hub reference puts rssbridge in the registry position, which says nothing about where the image comes from and is reported as an untrusted registry (AVD-KSV-0125).

Prerequisites

In Ingress mode (the default):

  • cert-manager with the ClusterIssuer named in ingress.clusterIssuer, and DNS for every entry of ingress.hosts pointing at the ingress controller.
  • Where existing certificates are being taken over, know their secret names before installing — see Several hosts, several certificates.

In Gateway mode (httpRoute.enabled: true):

  • The Gateway-API CRDs, standard channel. The chart does not and cannot detect them, so without them the install fails with no matches for kind HTTPRoute.
  • A Gateway whose listener accepts routes from this namespace (allowedRoutes), named in httpRoute.parentRefs. The chart renders no Gateway.
  • A certificate on that Gateway's listener, for every hostname. ingress.clusterIssuer is unused and unnecessary here — the chart renders no Certificate in this mode, and none of the tls-* secrets of the Ingress mode exists.
  • httpRoute.hostnames set explicitly. Unlike most charts in this repo there is no fallback: this chart has no ingress.domain, and it does not reuse ingress.hosts — see Why httpRoute.hostnames is its own value.
  • DNS for every name in httpRoute.hostnames pointing at the Gateway.

Either way: nothing else. No database, no cache service, no 1Password item, no volume.

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 rss-bridge jlab/rss-bridge \
  --namespace rss-bridge --create-namespace \
  -f my-values.yaml

Minimal my-values.yaml:

ingress:
  clusterIssuer: letsencrypt
  hosts:
    - host: rss-bridge.example.com

The same installation in Gateway mode:

ingress:
  enabled: false        # not optional - the default is true and the modes are exclusive
httpRoute:
  enabled: true
  parentRefs:
    - name: public
      namespace: gateway
      sectionName: https
  hostnames:            # mandatory: no fallback in this chart
    - rss-bridge.example.com

helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — which mode is active, the hostnames, the TLS secrets they were grouped into (Ingress mode) or what the platform owes the release (Gateway mode), and the enabled bridges. Read it — it is the per-release version of this page.

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders every workload with replicas: 0 without uninstalling. Takes precedence over rssBridge.replicas. Service and Ingress stay.
whitelist list of strings ["*"] Required (the chart default satisfies it; it may not be null or empty). Bridge names rendered into whitelist.txt, one per line. See The whitelist.
global object unset Helm's conventional cross-chart values. Unused by this chart, kept open in the schema so an umbrella install cannot break on it.

whitelist is top-level, not under rssBridge, and it is required regardless of ingress.enabled.

rssBridge

Key Type Default Description
rssBridge.replicas int 1 Replica count; 0 is allowed. The app is stateless (the cache is per-pod), so raising it is safe.
rssBridge.securityContext.pod object non-root 33, runAsGroup: 33, fsGroup: 33, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault, sysctls: net.ipv4.ip_unprivileged_port_start=0 Passed into the pod spec. uid/gid 33 is www-data and is not interchangeable — see Why uid 33 and port 80. The sysctl is what lets the non-root process bind port 80.
rssBridge.securityContext.container object readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation Passed into the container spec. Five emptyDirs make the read-only root filesystem possible.
rssBridge.livenessProbe object GET /?action=health:rss-bridge-port, period 10s, timeout 5s, 3 failures See The health action.
rssBridge.readinessProbe object GET /?action=health:rss-bridge-port, period 10s, timeout 5s, 3 failures
rssBridge.startupProbe object GET /?action=health:rss-bridge-port, period 5s, timeout 5s, 30 failures (= 150s) Gates liveness and readiness, so neither needs an initialDelaySeconds. The budget is generous: the container is ready in about two seconds — nginx plus php-fpm, no migrations, no warm-up.
rssBridge.env list [] The container's environment. The chart sets none of its own, so entries here are purely additive in effect. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. RSS-Bridge maps RSSBRIDGE_<section>_<key> onto its config, e.g. RSSBRIDGE_system_timezone: Europe/Berlin. Values must be strings — see Environment variables.
rssBridge.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request — but this chart sets both limits explicitly, so the factor only applies to a request the defaults do not cover.
rssBridge.resources.requests object cpu: 0.1, memory: 128Mi, ephemeralStorage: 64Mi Measured: ~21 MiB resident at startup, ~64 MiB after serving feeds (the 2025-06-03 image came out marginally higher at 24/71 MiB under the identical procedure). The only path that grows is /app/cache: 17 entries and 2.2 MB after fetching all five bridges, and the app purges it itself. 64Mi is roughly thirty times the measured footprint and frees 97% of the 2Gi the replaced manifest reserved.
rssBridge.resources.limits object memory: 512Mi, ephemeralStorage: 2Gi Set explicitly rather than computed. A warm container serving five concurrent feed fetches peaked at 119 MiB, so the replaced manifest's 100Mi cap was already below the real peak; php-fpm allows up to five workers at a 128M PHP memory_limit each. No CPU limit by default — CPU is compressible, so a limit only throttles.

Probes, security contexts and resources are null-safe: an erased block means "chart defaults apply". The resources fallback reproduces the explicit limits as well, because reproducing only the requests would let limitFactor compute a memory limit below the measured peak.

ingress

Key Type Default Description
ingress.enabled bool true Gates the whole Ingress template. Nothing outside it reads an ingress value — the container takes no host in its environment and nginx serves as default_server without a server_name — so switching it off frees every value below, hosts included. Mutually exclusive with httpRoute.enabled: with both true the render aborts naming both values. The default is true, so Gateway mode needs this set to false explicitly.
ingress.hosts list of objects null Required while ingress.enabled is true. The hosts this Ingress serves. The chart ships no default, because hostnames are installation data. Not read in Gateway mode — the names come from httpRoute.hostnames there. See Several hosts, several certificates.
ingress.hosts[].host string — Required per entry. The hostname.
ingress.hosts[].tlsSecretName string null Overrides the conventional name for this host. Omitted, the entry falls back to tls-<release>-rss-bridge-ingress. Set it only where an existing certificate must keep its secret name. Meaningless in Gateway mode, where the certificate sits on the listener — which is exactly why httpRoute.hostnames does not reuse this list.
ingress.clusterIssuer string null Required while ingress.enabled is true. cert-manager ClusterIssuer; it issues every secret in the grouping. Not read in Gateway mode — the chart renders no Certificate there.
ingress.className string nginx Ingress class. Not read in Gateway mode — the class is a property of the Gateway, which belongs to the platform.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode: the Gateway API has no annotation channel, so anything set here is gone in that mode. For this chart that costs nothing today — see Nothing is lost in Gateway mode.

httpRoute

The Gateway-API alternative, off by default. rss-bridge was the last chart to receive it (Gitea #114); the form is binding for all charts (style guide §4).

Key Type Default Description
httpRoute.enabled bool false Renders <release>-rss-bridge-httproute instead of the Ingress. Mutually exclusive with ingress.enabled, whose default is true.
httpRoute.parentRefs list [] The Gateway(s) the route 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 schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema.
httpRoute.hostnames list of strings [] The public names of this release. Required while httpRoute.enabled is true — the render aborts on an empty list, and there is no fallback. One HTTPRoute carries all of them. See Why httpRoute.hostnames is its own value.

Like every other block this chart dereferences, httpRoute is null-safe: an erased httpRoute: is YAML null and means "chart defaults apply", which collapses to "no HTTPRoute" rather than aborting with a nil pointer.

networkPolicy

Inbound 80 (the container's own port) only. Outbound DNS plus TCP 80 and 443 to any destination — fetching other people's websites is the application.

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.
networkPolicy.ingressController object kube-system / app.kubernetes.io/name: rke2-traefik The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed.
networkPolicy.egress.extraPorts list [] Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off.

Operating notes

Routing modes

Exactly one of ingress.enabled and httpRoute.enabled may be true. Both true aborts the render, and the message names both value paths.

Guard Why it is a hard stop
ingress.enabled and httpRoute.enabled cannot both be true two data paths onto one Service under the same hostnames. That is a conflict at DNS level already: one name, two addresses, and which one a client reaches is luck. A migration runs over a second hostname — give httpRoute.hostnames a name the Ingress does not serve — never in parallel.
httpRoute.parentRefs must not be empty a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer.
httpRoute.hostnames must not be empty an HTTPRoute with no hostnames does not render nothing — it matches every request that reaches its listener. On a shared Gateway this release would then answer for hostnames belonging to other releases.

One HTTPRoute carries all the hostnames, and that is a property of this chart rather than a convention. Nothing differs per host: every name serves the same Service at /, which was measured on the live Ingress (three host rules, byte-identical path and backend). spec.hostnames is a list and a route's rules apply to every hostname of the object, so one object with one rule is the complete answer. Compare the charts where that would be wrong: homeassistant renders one route per host because its paths differ per host, matrix-synapse renders two because homeserver and delegation want different paths and different backends, and the Keycloak realms of Gitea #122 would need one each because app-root differs per host.

What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, in cluster-config.

To check an attachment:

kubectl -n rss-bridge get httproute rss-bridge-rss-bridge-httproute -o yaml

Accepted: True and ResolvedRefs: True under status.parents[] is a working attachment. An empty status means no controller has looked at the route at all — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.

Why httpRoute.hostnames is its own value

Across the repository there are three shapes for the hostname in Gateway mode, and which one a chart gets follows from its values rather than from taste:

  1. Fallback to ingress.domain — most charts. An empty hostnames means [ingress.domain], so there is a single source of truth.
  2. No hostnames key at all — freesailarr and homeassistant. Neither has an ingress.domain, and a chart-global list of names could not say what it belonged to: one of ten bundled apps, or one host entry and therefore one set of paths. The names stay where they already live, and the schema rejects a hostnames key outright.
  3. A mandatory hostnames with no fallback — rss-bridge, and this is the only chart in that shape.

This chart has no ingress.domain either, so there is nothing to fall back to. But unlike the second group its names are unambiguous: one Service, one path, so a chart-global list says exactly one thing. What rules out simply reusing ingress.hosts is the other half of those entries — each carries an optional tlsSecretName, which exists only to keep three grown certificates on their existing names and which addresses nothing once TLS sits on the Gateway's listener. Reusing the list would carry a structure whose second field is meaningless into the new mode, and invite somebody to set it there.

So httpRoute.hostnames is a plain list of strings, and it is mandatory.

A corollary: rss-bridge.tlsGroups is inert in Gateway mode. That helper's entire purpose is to group hosts by TLS secret name; with the certificate on the listener there is nothing left to group. Both of its callers — the Ingress template and the Ingress branch of NOTES.txt — sit behind ingress.enabled, so this mode never invokes it. Worth stating rather than leaving to be noticed: it is the most intricate part of this chart's Ingress, and its absence from the Gateway path is a simplification, not an omission.

Nothing is lost in Gateway mode, and that is measured

ingress.annotations has no counterpart in the Gateway API, so for most charts of the sweep the switch costs something concrete — an upload limit, a buffer size, a CORS block, an auth annotation. Group 3 of the sweep screened the live Ingress objects rather than the charts, precisely because an operator's annotation can fill a gap the chart never sets, and found exactly such an annotation on four of its six charts.

That screen was run here too, read-only against the running object (2026-10-13), and it came back empty. The live Ingress carries cert-manager.io/cluster-issuer, the two meta.helm.sh/* keys, Fleet's objectset.rio.cattle.io/id and Rancher's generated field.cattle.io/publicEndpoints — and not one nginx.ingress.kubernetes.io annotation. No body-size limit, no buffer sizes, no CORS, no rewrite, no auth.

rss-bridge is therefore the only chart of the sweep whose Gateway branch has no loss to explain, and its NOTES.txt says so positively instead of carrying over a loss paragraph from another chart. Two things do become inert, and neither is a loss: ingress.annotations (empty here) and the TLS secret grouping described above.

The usual caveat still applies: that is an absence of evidence about this installation, not a guarantee about another. Anything a future installation puts in ingress.annotations disappears in Gateway mode exactly as it does elsewhere.

A value derived from ingress.enabled — screened, and clean

Gateway mode sets ingress.enabled: false by construction, so any value computed from that switch flips when the mode changes. Where such a value stood for "is there a TLS-terminating proxy in front" or "is this app published at all", the flipped value is wrong while nothing fails — a green pod serving a broken surface. The sweep found three of these: sure (RAILS_FORCE_SSL), freesailarr (<APP>__SERVER__URLBASE) and ytdlp-web-player (a silent authentication bypass).

Screened here over the whole chart directory, with a clean result. Outside the Ingress template the only readers of an ingress value are NOTES.txt, which knows its own mode, and the tlsGroups helper, which is called only from behind the switch. The Deployment, Service and ConfigMap read none of them. RSS-Bridge takes no base URL and no hostname in its environment — nginx serves as default_server without a server_name — so there is nothing that could come out wrong behind a Ready pod, and no rss-bridge.published helper is needed.

A clean result is worth recording: the screen's value is that it is run, not that it always finds something.

Several hosts, several certificates

Ingress mode only — in Gateway mode the certificate sits on the Gateway's listener and this whole mechanism is inert (see above).

This is a documented deviation from the repo's one-TLS-secret rule (style guide §4). Entries are grouped by tlsSecretName in first-appearance order: entries sharing a name become one tls entry and therefore one certificate with several SANs, entries with distinct names get one certificate each, and entries without a name fall into the conventional tls-<release>-rss-bridge-ingress group.

ingress:
  hosts:
    - host: rss-bridge.example.com          # -> tls-<release>-rss-bridge-ingress
    - host: feeds.example.com
      tlsSecretName: tls-legacy-feeds-ingress
    - host: bridge.example.com
      tlsSecretName: tls-legacy-feeds-ingress   # shares the certificate above

Why the secret name is configurable at all: the installation this chart took over had grown three separate secrets, one per host, from three years of kubectl apply. Renaming them would not be a cosmetic cleanup — cert-manager keys certificates by secret name, so three new names mean three fresh issuances, and the service is unreachable on each host until its certificate is issued. That is a self-inflicted outage plus three avoidable Let's Encrypt issuances for a deployment that had been running untouched for over a year. Making host and secret name per-entry values keeps the existing names in place and turns the eventual consolidation onto one certificate — or the retirement of the legacy hostnames — into a values change instead of a template change.

All hosts serve the same instance; there is no per-host routing.

The whitelist is a list, not a file

whitelist.txt is one bridge name per line, and the application trims and filters every line before use, so nothing is lost by modelling it as YAML — while a hardcoded file body in the template could not be changed without editing the chart.

The default ["*"] is RSS-Bridge's own wildcard and enables every bridge. It is the default because it is exactly what the image does when no whitelist.txt exists at all, so a default install behaves like stock RSS-Bridge instead of silently serving nothing. The schema enforces minItems: 1: an empty list would disable every bridge, which is a silent way to serve nothing.

A change to whitelist rolls the pod — the Deployment carries checksum/config over the rendered ConfigMap (style guide §9). That is not optional: whitelist.txt is a subPath mount, which the kubelet never refreshes, and RSS-Bridge reads the file once at request bootstrap.

The health action, and what it does not cover

All three probes hit /?action=health, which renders a fixed 49-byte JSON document without touching the file cache or any remote site. That keeps liveness free of external dependencies (style guide §12.3): a scraped website going down, or a cache directory that is not writable, must not restart the pod. / would be the wrong choice for a second reason — the front page enumerates every enabled bridge and answers with roughly 1.5 MB, which is a wasteful thing to fetch every ten seconds forever.

The price is one blind spot worth remembering: if /app/cache is not writable, /?action=health still answers 200 while every feed request throws. A Ready pod is not proof that feeds are being served.

Why uid 33 and port 80

The image ships no USER and runs everything as root by default, but it runs fully hardened once its writable paths are provided. uid/gid 33 is www-data and is not interchangeable: /app and every file in it is owned by www-data, nginx.conf declares user www-data and the php-fpm pool declares user = www-data. Both emit a harmless notice that those directives are ignored when the master process is not root, and then serve normally as uid 33.

nginx is configured for port 80 inside the image, and the only lever the entrypoint offers is HTTP_PORT, which runs a sed against /etc/nginx/sites-enabled/default — impossible with a read-only root filesystem. So the choice is port 80 or readOnlyRootFilesystem, and port 80 is the cheaper of the two. Kubernetes sets net.ipv4.ip_unprivileged_port_start=0 in the pod network namespace anyway, but that is a runtime default and not a guarantee, so the chart pins it instead of inheriting it silently. Measured in k3d (k3s v1.35.5), same pod spec, only that value changed:

net.ipv4.ip_unprivileged_port_start Capabilities Result
1024 drop: [ALL] nginx: bind() to 0.0.0.0:80 failed (13: Permission denied)
1024 plus NET_BIND_SERVICE Identical failure — for a non-root process the capability lands in the permitted set but not the effective/ambient one, so it buys nothing. That is why the chart does not add it.
0 (this default) drop: [ALL] Binds, HTTP 200.

Note how that failure looks without probes: the entrypoint starts nginx in the background and then execs php-fpm, so a failed bind leaves a container reporting Running with no web server at all. The probes turn that silent failure into a pod that never becomes Ready.

The container port name rss-bridge-port is load-bearing: the Service selects it by name (targetPort: rss-bridge-port) and so do all three probes.

Five writable paths, and the one that is invisible

readOnlyRootFilesystem: true is possible because of five emptyDirs. They are image facts rather than user configuration, so none of them is a value, and they were found empirically: the image was run with a writable root filesystem, exercised across every whitelisted bridge, and its diff layer read afterwards.

Path What writes there
/app/cache RSS-Bridge's file cache. Not optional: the app checks it with is_writable() and throws on every feed request if it is not.
/tmp PHP's opcache lock file and its default upload temp dir.
/run nginx.pid
/run/php php-fpm's pid file and fastcgi socket. Nested inside /run on purpose — the emptyDir above does not contain the php subdirectory the pool config expects, and php-fpm does not create it.
/var/lib/nginx nginx's body/fastcgi/proxy/scgi/uwsgi temp directories, created at startup.

/tmp did not show up in the filesystem diff — the opcache lock file is created and immediately unlinked — and only surfaced when the hardened container died at startup with Unable to create lock file: Read-only file system. Stopping at the first writable path found would have shipped a chart that cannot start.

The cache is deliberately lost on every restart. RSS-Bridge refills it on demand; it holds only fetched feeds, no configuration and no user data.

Numeric env values must be quoted

The template quotes every value because a value like "5" or "true" renders unquoted as a YAML number or bool, and the API server then rejects the whole manifest with cannot unmarshal number into Go struct field EnvVar.value of type string. RSS-Bridge takes numeric settings this way (RSSBRIDGE_http_timeout), so this is reachable with the very first env entry someone adds. The schema accepts strings only.

The egress rule is the open internet on two ports, and it has to be

A feed bridge that cannot reach the sites it bridges does nothing, and A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination. So "only the configured bridges" cannot be written at all, and the rule permits 80 and 443 to any address. 80 as well as 443, because plenty of bridged sources are still plain HTTP.

Said plainly so the object does not read stricter than it is. What the cap still buys is narrow but real: this pod can open no other outbound port — no database, no SMTP, no SSH anywhere in the cluster.

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

Nothing to back up: no database, no volumes, no user data. The feed cache is disposable by design, so helm rollback and app-version bumps carry no data risk.

App versions are date tags (YYYY-MM-DD). Renovate tracks them with a regex versioning of ^(?<major>\d{4})-(?<minor>\d{2})-(?<patch>\d{2})$, and the bump script normalizes the dashes to dots for the +up suffix — so an appVersion: "2026-09-30" pairs with a chart version ending +up2026.09.30. Read the current pair off rss-bridge/Chart.yaml. See Versioning and Renovate.

Note that the image also publishes latest, stable and sha-* tags; only the date tags are tracked, which is why a newer sha-* tag is not an outstanding update. Note as well that app updates to this chart went through while its install-test was red — Renovate itself will not merge a red PR, so those arrived by hand (Gitea #51, PR #62).

CI

ci/rss-bridge/test-values.yaml sets the required ingress values with three hosts across two TLS secret names — two sharing one, a third with its own — so the install-test renders the secret-grouping helper as well. That helper is what keeps the three existing certificates of the takeover installation alive, and it is not exercised by the chart defaults; an install-test that never rendered it would not notice if it broke. whitelist is set to a concrete list (Youtube, Heise) rather than the default *, so the ConfigMap render and the subPath mount are covered by something the app has to parse. There is no fixtures.yaml — nothing to stand up. The install-test runs on every PR that touches the chart — see CI/CD.

The Gateway mode is covered by a render overlay, not by the install-test. ci/httproute/rss-bridge.yaml switches the chart into Gateway mode for ci/run-httproute-match-test.sh, which holds every backendRefs entry against the Services of the same render and refuses an empty parentRefs. An overlay rather than a change to the fixture, and here the reason is sharper than for its siblings: the fixture's Ingress is the only thing that exercises tlsGroups, which is inert in Gateway mode, so a fixture switched over would stop covering it entirely. The overlay is also the second in the repository that has to supply hostnames (homeassistant's was the first), because this chart has no fallback.

Since the install-test installs the Ingress mode, the overlay is the only automated coverage the Gateway mode of this chart has — the ordinary situation across the sweep, not a special weakness of this chart.

The install-test, which was red on purpose and then went green

Current state: green. On 2026-10-13, in the Gateway-API conversion PR, this chart's install-test passed for the first time since the move to Gitea — k3d cluster up, helm install in Ingress mode, pod 1/1 Ready in 22 s with 0 restarts, and no permission error anywhere in the job.

The chart is ruled out as the cause, and that is measured rather than argued. That PR's rendered output for the fixture values is byte-identical to origin/main, so the pod spec that installed is exactly the one main produces — the same spec that had been failing. Whatever changed is therefore not in the chart, which is precisely where Gitea #51 had already placed the burden of proof.

Gitea #51 was closed on that round, immediately after the conversion PR merged. One caution belongs with the result and did not go away with the issue:

The cause was never identified. The green reproduced on every run of the conversion branch after the first, across several commits, so it is not a fluke — but a green test does not explain itself, and the check programme in #51's body (docker info on the runner for userns and the dind configuration, the same image in a local k3d without remapping) was not executed. Nobody established what changed on the runner.

So if this test goes red again, that is a fresh finding and not a known quirk. With the issue closed, that sentence has nowhere left to live in the tracker — which is why it is here, and in the comment at the top of ci/httproute/rss-bridge.yaml, where whoever sees the red will actually be reading.

The history below is kept for the same reason: it is the evidence that localised the defect, and a recurrence would be read against it.

The failure mode was: php-fpm runs as uid 33 and cannot read /app/index.php; the startup probe gets a 403 through its whole 150 s budget and the pod never becomes Ready.

What has been measured, and what each measurement rules out:

Measurement What it settles
The running instance is healthy — Ready=True, 0 restarts, on the same image the chart and the image are not the cause; production is unaffected
The runner pulls the same digest that Docker Hub serves for the tag the "the tag was re-pushed" theory is dead
/app/index.php is mode 0644 inside a 0755 directory — world-readable ordinary file ownership cannot explain it, which also killed the original userns-remap hypothesis
Reproduced on two images four weeks apart (2025-08-05 and 2026-09-30) an image change will not fix it
23 of 24 charts come up on the same runner, nearly all non-root with readOnlyRootFilesystem weakens any explanation that is purely about the runner denying non-root access to image files

That last row is the unresolved contradiction: it looks like a general filesystem effect, yet it hits exactly one chart. The candidates left are a FUSE/overlay mount the process cannot reach, an LSM denial (AppArmor, SELinux), or something php-fpm-specific — it emits this same text when the SCRIPT_FILENAME nginx hands it is not resolvable for the worker, not only when read permission is missing.

The chart was deliberately not listed in ci/excluded-charts.txt, and that decision paid off. The alternative was on the table — an entry with this issue as its reason, pipeline honestly green instead of red-and-ignored — and it was rejected, because an exclusion silences the signal. The argument made at the time was that on the day the runner stopped causing this, the test would go green by itself and that is how anyone would find out. That is exactly what happened, and an excluded chart would have passed the whole time and told nobody.

The red install-test was therefore the status indicator for Gitea #51, which is why it mattered that it was red and why it matters that it is now green.

Two rules for anyone working on this chart, both still standing:

  • Do not weaken the chart to make the test pass. Not the probes, not the budgets, not the security context, not runAsUser. The chart runs in production exactly as it is, and the green above was obtained without changing any of them — by construction, since the render was byte-identical.
  • If it goes red again, that is a finding, not a known quirk. It would also be the control case the single green run is missing.

A consequence for the Gateway mode in particular: the install-test installs the Ingress mode, because that is what the fixture sets. So the Gateway mode of this chart is covered at render level and nowhere else — helm lint --strict, the httproute-match assertions and the schema. That is the same position as all 21 other charts of the sweep, no better and no longer worse.

See also

  • Chart style guide — the rules this chart implements, the Gateway-API form, and the TLS-secret deviation it documents
  • CI/CD — the httproute-match render test, the Gateway-API CRD fixture, and why this chart's install-test is red
  • Versioning — what a bump of 1.0.2+up2025.08.05 means
  • Renovate — date-tag versioning for this chart
  • Lessons learned
  • Chart index