Table of Contents
- rss-bridge
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing modes
- Why httpRoute.hostnames is its own value
- Nothing is lost in Gateway mode, and that is measured
- A value derived from ingress.enabled — screened, and clean
- Several hosts, several certificates
- The whitelist is a list, not a file
- The health action, and what it does not cover
- Why uid 33 and port 80
- Five writable paths, and the one that is invisible
- Numeric env values must be quoted
- The egress rule is the open internet on two ports, and it has to be
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
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
ClusterIssuernamed iningress.clusterIssuer, and DNS for every entry ofingress.hostspointing 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 inhttpRoute.parentRefs. The chart renders no Gateway. - A certificate on that Gateway's listener, for every hostname.
ingress.clusterIssueris unused and unnecessary here — the chart renders noCertificatein this mode, and none of thetls-*secrets of the Ingress mode exists. httpRoute.hostnamesset explicitly. Unlike most charts in this repo there is no fallback: this chart has noingress.domain, and it does not reuseingress.hosts— see WhyhttpRoute.hostnamesis its own value.- DNS for every name in
httpRoute.hostnamespointing 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:
- Fallback to
ingress.domain— most charts. An emptyhostnamesmeans[ingress.domain], so there is a single source of truth. - No
hostnameskey at all —freesailarrandhomeassistant. Neither has aningress.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 ahostnameskey outright. - A mandatory
hostnameswith 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-matchrender 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.05means - Renovate — date-tag versioning for this chart
- Lessons learned
- Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline