8
freesailarr
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.

freesailarr

Deploys a Wireguard-tunneled media automation stack as a single StatefulSet pod with ten containers: gluetun as VPN gateway plus qBittorrent, Prowlarr, FlareSolverr, Radarr, Recyclarr, Sonarr, Seerr, Bazarr and (off by default) MediathekArr. Every app container shares gluetun's network namespace, they talk to each other over 127.0.0.1, and their only route to the internet is the tunnel — gluetun's firewall is the kill switch.

Versions not listed on purpose — read the current pair off freesailarr/Chart.yaml, see Chart index. The app version is a hand-maintained marker and not the source of any image tag (see Ten upstreams, no app version)
Workloads one StatefulSet, 1 replica, 10 containers (9 by default) + one init container
Storage one RWO volumeClaimTemplate per enabled app (config, 1Gi each) + up to three pre-existing media claims
Routing two modes (Ingress, default, or Gateway API via httpRoute.enabled) × two shapes that mix (one host per app, default, or one shared ingress.host with sub-paths). There is no ingress.domain and therefore no httpRoute.hostnames — see Routing: Ingress or Gateway API
External dependencies a VPN account (Mullvad Wireguard by default), the media PVCs, optionally a Jellyfin for seerr
Secrets 1 × OnePasswordItem (gluetun credentials)
Install-test ci/freesailarr/ — the only chart in the repo with a fixtures.env.yaml and a real tunnel

Prerequisites

  • 1Password operator in the cluster and one item with the two Wireguard fields — see the fields below.
  • A VPN subscription whose Wireguard key and interface address are in that item. Defaults target Mullvad with an exit in Zurich.
  • /dev/net/tun on the node. gluetun mounts it as a hostPath CharDevice and runs as root with NET_ADMIN; a cluster policy that forbids either makes the pod unschedulable.
  • The media claims exist already. media.<category>.existingClaim is mounted, never created, and the claim's root directory must be writable for puid/pgid before the first start — see The claim root, not fsGroup.
  • The cluster pod and service CIDRs, for gluetun.vpn.firewallOutboundSubnets. Without them the kill switch also blocks cluster-internal egress, starting with DNS.
  • cert-manager with the ClusterIssuer named in ingress.clusterIssuer, and DNS for every host used, pointing at the ingress controller. In Gateway mode neither is needed from this release: the certificates sit on the Gateway's listener, and what is needed instead is the Gateway-API CRDs plus a Gateway whose listener admits routes from this namespace.

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

Minimal my-values.yaml — one shared domain, one library claim with three sub-directories:

secrets:
  gluetunSecretRef: op://vault/freesailarr-gluetun

gluetun:
  vpn:
    firewallOutboundSubnets: "10.42.0.0/16,10.43.0.0/16"   # k3s pod + service CIDRs

media:
  downloads: { existingClaim: media-library, subPath: downloads }
  movies:    { existingClaim: media-library, subPath: movies }
  tv:        { existingClaim: media-library, subPath: tv-shows }

ingress:
  host: media.example.com
  clusterIssuer: letsencrypt

The same installation in Gateway mode instead — the Gateway, its class and its certificates belong to the cluster, not to this release. ingress.enabled is this chart's master switch and defaults to true, so it has to be switched off explicitly or the render aborts:

ingress:
  enabled: false
  host: media.example.com   # still the shared hostname: both modes read it
  # clusterIssuer / className / annotations are inert in this mode
httpRoute:
  enabled: true
  parentRefs:
    - name: public
      namespace: gateway
      sectionName: https

Both shapes work unchanged, and the sub-path shape gets simpler: qBittorrent's prefix-stripping rewrite becomes a per-rule URLRewrite filter, so it no longer needs a routing object of its own. It is also the one place where the Gateway mode does something the Ingress mode cannot at all — it renders the redirect for the bare, slashless prefix itself. See Routing: Ingress or Gateway API and The trailing slash on qBittorrent's prefix.

helm install prints the chart's NOTES.txt, which renders the same information against the values actually set, including the computed startup budgets, the bazarr base-URL step and the warning when firewallOutboundSubnets is still empty. Read it — it is the per-release version of this page.

What the 1Password item must contain

The chart renders one OnePasswordItem CR; the operator turns it into a Kubernetes Secret whose keys are the field names of the 1Password item. A missing field does not fail the install: gluetun starts with the variable empty and never completes a handshake, and every app container then sits behind a kill switch that blocks everything.

Values key Kubernetes Secret Fields
secrets.gluetunSecretRef <release>-freesailarr-gluetun-secret wireguard-private-key (the Wireguard private key), wireguard-addresses (the interface address, e.g. 10.x.y.z/32)
kubectl -n <namespace> get onepassworditem <release>-freesailarr-gluetun-secret

Configurable values

Global

Key Type Default Description
scaleDown bool false Renders the StatefulSet with replicas: 0 without uninstalling. Takes precedence over replicas. PVCs, Secrets, Services and Ingresses stay.
replicas int 1 Replica count for the whole stack; 0 is allowed. See One pod, not a stack of pods before raising it.
puid int 1000 User the five linuxserver.io containers drop to after their s6 init (PUID), and runAsUser for seerr, recyclarr, mediathekarr and the qBittorrent init container.
pgid int 1000 The same as group (PGID / runAsGroup). Keep securityContext.pod.fsGroup in sync.
timezone string Europe/Zurich TZ in every container.
securityContext.pod object fsGroup: 1000, fsGroupChangePolicy: OnRootMismatch, seccompProfile: RuntimeDefault Merged onto those defaults and passed verbatim into the pod spec. Deliberately no pod-level runAsNonRoot/runAsUser: the pod mixes root containers (gluetun, linuxserver.io) with non-root ones; users are set per container. The seccomp profile sits here so every container, init containers included, inherits it.
storage.storageClass string null StorageClass for all config volumeClaimTemplates; null uses the cluster default. Not null-safe — an erased block yields an invalid claim template and fails loudly.
global object {} Helm's conventional cross-chart values. Unread by this chart, kept open so an umbrella install cannot break on it.

secrets

Key Type Default Description
secrets.gluetunSecretRef string null Required, always — gluetun renders unconditionally. op:// path of the Wireguard item.

media

Mounted, never created. Each claim is required as soon as an app that mounts it is enabled; a category no enabled app mounts is not rendered at all and demands nothing.

Key Type Default Description
media.downloads.existingClaim string null Required while qbittorrent, radarr, sonarr or mediathekarr is enabled. Mounted at /downloads in all four.
media.downloads.subPath string null Sub-directory of the claim to mount instead of its root.
media.movies.existingClaim string null Required while radarr or bazarr is enabled. Mounted at /movies.
media.movies.subPath string null As above.
media.tv.existingClaim string null Required while sonarr or bazarr is enabled. Mounted at /tv.
media.tv.subPath string null As above.

The same existingClaim may back two or all three categories — that is the intended one-library layout. The pod then gets exactly one volume entry for that claim, owned by the first enabled category in the fixed order downloads, movies, tv, and the categories differ only in their subPath. See One claim, one volume entry and Hardlinks need one mount.

ingress

Defaults for all apps; clusterIssuer, className and annotations are overridable per app, where an explicitly set per-app value always wins (even an empty one). The shared object uses the global values only — it belongs to no single app.

Key Type Default Description
ingress.enabled bool true Master switch: false suppresses every app Ingress regardless of the per-app toggles. Mutually exclusive with httpRoute.enabled, and since it defaults to true it must be switched off explicitly to use the Gateway mode.
ingress.host string "" Empty = one host per app (shape 1). Set = shared domain with sub-paths (shape 2) for every app without a host of its own. Read by both routing modes — it names the public address of the stack and is not an Ingress switch. The same holds for the per-app <app>.ingress.host.
ingress.clusterIssuer string null Required for every Ingress object that renders and is not overridden by an <app>.ingress.clusterIssuer. cert-manager ClusterIssuer. Inert in Gateway mode.
ingress.className string nginx Ingress class. The shared-domain qBittorrent object relies on nginx's rewrite-target. Inert in Gateway mode — the class sits on the Gateway, and the rewrite becomes a filter.
ingress.annotations object {} Merged with the cert-manager annotation the chart always sets. No counterpart in Gateway mode, per-app overrides included: the Gateway API has no annotation channel. The chart needs nothing from it there, but an annotation you added is gone without a word — a body-size cap for qBittorrent's torrent-file uploads being the likely one. One annotation is worth adding here in Ingress mode and the chart cannot ship it: the configuration-snippet redirecting the bare /qbittorrent to /qbittorrent/ — see The trailing slash on qBittorrent's prefix. In Gateway mode it is neither needed nor possible, so unlike the body-size case nothing is lost on migration.

TLS secrets: tls-<release>-freesailarr-<app>-ingress per own-host object, tls-<release>-freesailarr-ingress for the shared one. In Gateway mode there are none — the certificates are the Gateway listener's.

httpRoute

The Gateway-API alternative to the Ingress, off by default. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's (style guide §4), with one documented deviation — see the note under the table.

Key Type Default Description
httpRoute.enabled bool false Renders HTTPRoutes instead of Ingresses. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values, and ingress.enabled defaults to true here.
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 hosts differ per app; the Gateway does not.

There is deliberately no httpRoute.hostnames here — this is the only chart without it. It has no ingress.domain either, for the same reason: ten independently exposed apps, so there is no single hostname a chart-global list could belong to. The hostnames stay in ingress.host and <app>.ingress.host, which is where this chart's single source of truth for them already is. A migration therefore runs by changing those values; in Gateway mode no Ingress renders that could disagree with them.

What the chart renders in this mode:

  • Shape 1 (own host): one HTTPRoute per app, <release>-freesailarr-<app>-httproute, hostname <app>.ingress.host, one rule at PathPrefix: / pointing at that app's Service.
  • Shape 2 (shared host): exactly one HTTPRoute, <release>-freesailarr-httproute, hostname ingress.host, one rule per app at its <app>.ingress.path — qBittorrent included, with its URLRewrite filter on that rule. qBittorrent is the one app contributing two rules: the prefix-stripping one, and an Exact match on the bare, slashless prefix carrying a RequestRedirect filter (301) to the slashed form. See The trailing slash on qBittorrent's prefix.
  • Mixed operation falls out as before: an app with a host of its own keeps its own route.

Every backendRefs entry carries the port number, where the Ingress addresses the same Service port by name (the app name). Both come from the same freesailarr.portApps helper the Services render from, so they cannot drift, and ci/run-httproute-match-test.sh holds the pair anyway.

Per-app keys

Nine app blocks (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr, mediathekarr) plus gluetun repeat the same shape. What differs per app is in the next table; gluetun's own extras and the two apps with additional keys have their own sections below.

Key Type Default Description
<app>.enabled bool true (mediathekarr: false) Renders this container, its config volumeClaimTemplate, its Service and its Ingress. gluetun has no such key — it always runs. Changing it after the first install needs one manual step.
<app>.image.repository string per app All ten images are pinned here, gluetun included; no tag comes from appVersion. This is also what puts them under Renovate's built-in helm-values manager.
<app>.image.tag string per app
<app>.ingress.enabled bool true (mediathekarr: false) Present only for apps that have an ingress at all.
<app>.ingress.host string null Required while this app's ingress is enabled AND the shared ingress.host is not set. Never demanded for a disabled ingress, never for an app served under the shared domain. Setting it always wins over the shared domain.
<app>.ingress.path string /<app> Sub-path in the shared-domain mode; ignored while this app has a host of its own. Null or empty falls back to /<app> — and to / for seerr and mediathekarr, which cannot be served anywhere else.
<app>.ingress.clusterIssuer string unset Overrides ingress.clusterIssuer for this app's own object.
<app>.ingress.className string unset Overrides ingress.className.
<app>.ingress.annotations object unset Replaces the global ingress.annotations for this app (it is an override, not a merge); the cert-manager annotation is always added.
<app>.storage.size string 1Gi Size of this app's config volumeClaimTemplate (RWO). Not null-safe: an erased size fails the render.
<app>.env list [] Extra environment variables appended to the ones the chart sets. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Additive, not overriding — see Environment variables.
<app>.trustedNetworks string "" Only for prowlarr, radarr and sonarr. Comma-separated CIDRs whose X-Forwarded-* headers the app honours, rendered as <APP>__SERVER__TRUSTEDNETWORKS and only when non-empty. Empty — the default — means no proxy is trusted; see Forwarded headers are not trusted by default.
<app>.livenessProbe object per app, see table Merged onto this container's own defaults; null restores them rather than dropping the probe.
<app>.readinessProbe object per app
<app>.startupProbe object per app Gates liveness and readiness.
<app>.resources.limitFactor number 3 Memory and ephemeral-storage limits are computed as factor × request.
<app>.resources.requests object per app cpu, memory, ephemeralStorage (camelCase is normalized to ephemeral-storage).
<app>.resources.limits object unset An explicit limit always wins. A CPU limit is only rendered when set here.
<app>.securityContext object per app Merged onto this container's hardening defaults. The defaults reproduce this chart's deliberate deviations — see Hardening is not uniform here.

Probes, resources and security contexts are the "silent class": erasing one of those blocks with key: (i.e. null) falls back to the chart's own per-container defaults instead of rendering a container without a probe, without hardening or without resource accounting.

What differs per app

App Port(s) Config volume Ingress Default path Probes (liveness / readiness / startup) Requests (cpu / mem / eph) Hardening
gluetun 9999 (health, not a Service) yes, always no — GET /:9999 ×3; liveness failureThreshold: 6, readiness 3, startup 5s × 30 = 150s 0.1 / 128Mi / 50Mi root, NET_ADMIN, writable root fs
qbittorrent 8080 yes yes, on /qbittorrent GET /:8080; startup 5s × 30 = 150s 0.2 / 512Mi / 100Mi linuxserver.io set
prowlarr 9696 yes yes, on /prowlarr GET /ping:9696; startup 150s 0.1 / 256Mi / 100Mi linuxserver.io set
flaresolverr 8191 no no ingress block at all — consumed by prowlarr over localhost — GET /health:8191; startup 150s 0.2 / 512Mi / 200Mi non-root uid/gid 1000, writable root fs
radarr 7878 yes yes, on /radarr GET /ping:7878; startup 150s 0.1 / 256Mi / 100Mi linuxserver.io set
recyclarr none (daemon mode) → no Service yes no ingress block — exec recyclarr --version, period 30s / 30s / 5s, timeout 10s; startup 150s 0.05 / 128Mi / 50Mi non-root from puid/pgid, read-only root fs
sonarr 8989 yes yes, on /sonarr GET /ping:8989; startup 150s 0.1 / 256Mi / 100Mi linuxserver.io set
seerr 5055 yes yes, on / — pinned, any other path fails the render GET /api/v1/settings/public:5055; startup 10s × 30 = 300s (first-boot migrations) 0.1 / 256Mi / 100Mi non-root from puid/pgid, read-only root fs
bazarr 6767 yes yes, on /bazarr GET /:6767 (no unauthenticated health endpoint); startup 150s 0.1 / 256Mi / 100Mi linuxserver.io set
mediathekarr 5007 (primary, SABnzbd + UI) and 5008 (Newznab) on one Service yes yes, off by default / — pinned, see above liveness exec checking both ports, period 30s, timeout 15s; readiness/startup GET /download/:5007; startup 5s × 60 = 300s 0.1 / 256Mi / 100Mi non-root from puid/pgid, read-only root fs

"linuxserver.io set" = allowPrivilegeEscalation: false, readOnlyRootFilesystem: false, all capabilities dropped except CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID.

gluetun extras

Key Type Default Description
gluetun.vpn.serviceProvider string mullvad VPN_SERVICE_PROVIDER.
gluetun.vpn.type string wireguard VPN_TYPE.
gluetun.vpn.serverCities string Zurich SERVER_CITIES. Rendered only when non-empty. Also decides the geo answer for mediathekarr — see The geo caveat.
gluetun.vpn.serverCountries string Switzerland SERVER_COUNTRIES.
gluetun.vpn.dnsUpstreamResolvers string quad9 DNS_UPSTREAM_RESOLVERS.
gluetun.vpn.firewallOutboundSubnets string null Comma-separated CIDRs the firewall allows outside the tunnel. Not enforced by the chart, but effectively required in-cluster: without it cluster DNS lookups and seerr→Jellyfin traffic are dropped, looking like an application bug. NOTES.txt warns while it is empty.

The chart additionally sets, always: TZ, WIREGUARD_PRIVATE_KEY / WIREGUARD_ADDRESSES from the Secret, FIREWALL_INPUT_PORTS (the enabled apps' ports plus 9999), and HEALTH_SERVER_ADDRESS=:9999 so kubelet probes, which connect to the pod IP, reach the health server. gluetun's command first removes a stale ip rule/tun0 left behind by an unclean exit, then execs the entrypoint.

qbittorrent extras

Key Type Default Description
qbittorrent.networkInterface string tun0 Interface the BitTorrent session binds to. Enforced on every start (an existing value is replaced), because a deviating value is a leak: without the binding libtorrent announces on every interface of the shared namespace, gluetun's kill switch drops those packets and torrents stall at 0 bytes in stalledDL. null/"" drops the binding.
qbittorrent.savePath string /downloads Default save path, seeded only when the key is absent, never replaced — a path changed in the WebUI stays. null/"" leaves qBittorrent on its built-in /config/Downloads, i.e. the 1Gi config volume; see The save path trap.
qbittorrent.configInit.image.repository string docker.io/busybox Init container that seeds those two keys into the persisted config.
qbittorrent.configInit.image.tag string 1.38.0
qbittorrent.configInit.resources.limitFactor number 3
qbittorrent.configInit.resources.requests object cpu: 10m, memory: 16Mi, ephemeralStorage: 1Mi
qbittorrent.configInit.resources.limits object unset
qbittorrent.configInit.securityContext object non-root from puid/pgid, readOnlyRootFilesystem: true, all caps dropped, no privilege escalation

Setting both networkInterface and savePath to null removes the init container entirely.

mediathekarr extras

Key Type Default Description
mediathekarr.image.digest string sha256:3ed7e148… Required while mediathekarr is enabled — the template refuses to render without it. The only digest-pinned image in the repo: latest is a 2024 build (v0.2.5) and beta moves, so tag + digest keep both a readable label and an immutable reference. The image is single-arch linux/amd64.
mediathekarr.startGate.enabled bool true Delays the container until gluetun reports healthy. See The start gate. Not null-safe on purpose.
mediathekarr.startGate.intervalSeconds int 3 Poll interval against 127.0.0.1:9999.
mediathekarr.startGate.timeoutSeconds int 180 Total wait; on timeout the container exits instead of starting. Keep the startup budget (periodSeconds × failureThreshold, 300s by default) above it.
mediathekarr.startGate.settleSeconds int 10 Extra pause after gluetun is healthy. Not a measured requirement, a cheap buffer; 0 disables it.
mediathekarr.downloads.directory string mediathekarr Sub-directory on the shared downloads claim. The chart derives DOWNLOAD_INCOMPLETE_PATH=/downloads/<dir>/incomplete and DOWNLOAD_COMPLETE_PATH=/downloads/<dir>/complete from it. Do not set either variable under env — the chart always sets both.

networkPolicy

Ten containers in one pod, behind a VPN — and the first thing to know is that they talk to each other over localhost. They share a network namespace, so none of that traffic touches an interface and no NetworkPolicy sees any of it. This object governs only what enters or leaves the pod.

  • Inbound: the nine probed ports as one rule — gluetun's health server (9999) plus qBittorrent (8080), Prowlarr (9696), FlareSolverr (8191), Radarr (7878), Sonarr (8989), Seerr (5055), Bazarr (6767) and Mediathekarr (5007) — and mediathekarr's newznab port (5008) restricted by source, because nothing probes it and its only consumer is Prowlarr in this same pod.
  • Outbound: DNS, UDP 51820 (the WireGuard tunnel) and TCP 443 (gluetun's server-list API, which cannot go through the tunnel because it runs before the tunnel exists). Indexer and tracker traffic needs no rule of its own: it is already inside the tunnel by the time it leaves the pod.
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

Nothing is Ready until the tunnel is up

gluetun's readiness probe is its health server, which answers 200 only while the Wireguard tunnel actually works. "Pod not Ready" therefore almost always means "no tunnel", not "app broken", and the app containers behind the kill switch reach nothing in the meantime — they are up and simply have no egress.

kubectl -n <namespace> logs <release>-freesailarr-sfs-0 -c gluetun -f

Liveness is more tolerant (60s) than readiness (30s) on purpose: during a short re-negotiation gluetun recovers by itself and a kubelet restart would only prolong the outage, while the kill switch keeps blocking all egress the whole time.

Routing: Ingress or Gateway API

Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false. Both shapes below work in both modes; the per-app <app>.ingress.enabled toggle selects which apps are published either way.

Rule Why it is a hard failure and not a default
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths two data paths onto the same Services under the same hostnames is a conflict at DNS level already. The abort message names the true default explicitly, because setting httpRoute.enabled alone is the likely mistake here. A migration runs by changing ingress.host or <app>.ingress.host, never in parallel.
httpRoute.parentRefs must not be empty — the render aborts 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.
the mode is a value, never auto-detected .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the CRDs fails at install time.

What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object. Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio. ingress.clusterIssuer, ingress.className, ingress.annotations and their per-app overrides are all inert there.

Here the Gateway API is less machinery than what it replaces, and that is the reason this chart was worth converting. rewrite-target applies per Ingress object, and qBittorrent's neighbours must not be rewritten — their UrlBase contains the prefix, so they expect the full path. That forced a second Ingress object for qBittorrent on the shared host, which then had to repeat the main object's TLS secret name, omit the cert-manager annotation so only one certificate is requested (conditionally, for the case where no main object renders at all), and match an ImplementationSpecific regex path whose capture group the rewrite refers to. A URLRewrite filter applies per rule: one route, one rule per app, the filter on qBittorrent's rule, a plain PathPrefix. All four of those crutches disappear.

And in one respect the Gateway mode is not less machinery but more capability: it renders the 301 for the bare, slashless /qbittorrent that the Ingress mode cannot render at all, because nginx offers only a snippet annotation for it and snippets are off on a default controller. That is the single behavioural difference between the two modes in this chart — The trailing slash on qBittorrent's prefix has the whole of it.

What does not get better is anything that was an annotation and is not a filter. This chart sets no body-size annotation of its own, so nothing it needs is lost — but ingress.annotations is the only lever an operator has for one, and qBittorrent takes torrent-file uploads. In Gateway mode that lever does not exist in the chart at all; the limit belongs to the Gateway or to its implementation's own policy object.

The sub-path environment variables follow the shape, not the mode — and that took a fix. <APP>__SERVER__URLBASE for radarr, sonarr and prowlarr is derived from the same validated list the routing objects use, and that list was gated on ingress.enabled. In Gateway mode that value is false by construction, so the list would have come out empty while the routes served those apps under /radarr, /sonarr and /prowlarr: no UrlBase, assets and API calls linked at the host root, a broken UI behind a Ready pod and nothing in any log. The two guards in that helper — seerr and mediathekarr only at /, no duplicate paths on the shared host — would have gone unenforced in that mode for the same reason. The list now asks ingress.enabled OR httpRoute.enabled, which is the general form in Lessons learned.

Whether a route attached at all is only visible in its status, because no Kubernetes object reports a route that attaches to nothing:

kubectl -n <namespace> get 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 — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.

The Gateway mode is in production

Every PR of the Gateway-API sweep (Gitea #114) says in as many words that "it routes" is not demonstrable in CI: no Gateway controller runs there, no HTTPRoute ever reaches Accepted: True, and what is proven is a schema-valid object plus the render assertions. freesailarr is where that changed. On 2026-10-13 freesailarr.jk-dev.de was switched to Gateway mode, and these are the first measurements of this repository's Gateway mode against a running system.

Measured (read-only, no redirects followed, 2026-10-13):

Where the release runs namespace jellyfin — freesailarr-freesailarr-sfs-0, 10/10 Running, 0 restarts, on jk-worker-2. The StatefulSet's Helm annotations name release freesailarr in namespace jellyfin. The namespace is named after the neighbouring service, not after the chart — see the note below
freesailarr Ingress objects none, and the measurement that matters is in jellyfin itself: kubectl get ingress -n jellyfin returns no resources at all — not this chart's, and not jellyfin's own. Cross-checked cluster-wide: the same identity lists 10 Ingress objects across six other namespaces, so this is a real absence and not a visibility gap
RADARR__SERVER__URLBASE in the running pod /radarr — and PROWLARR__SERVER__URLBASE=/prowlarr, SONARR__SERVER__URLBASE=/sonarr. Read off the pod spec, not inferred
GET /radarr 302, server: Kestrel, location: https://freesailarr.jk-dev.de/radarr/login?returnUrl=%2Fradarr
GET /qbittorrent 301, location: …/qbittorrent/, 17-byte body, no content-type, no HSTS, no server
GET /qbittorrent?foo=bar 301, location: …/qbittorrent/?foo=bar — the query string survives

What each one settles.

freesailarr.published is proven directly, from the cluster rather than from a redirect. RADARR__SERVER__URLBASE=/radarr is set in the running container while no Ingress object exists in that namespace — which is precisely the combination the helper exists for. Read from ingress.enabled alone that variable would be empty in Gateway mode, because that value is false there by construction, and the UI would be broken behind a Ready pod with assets and API calls linked at the host root. The pod spec is the strongest form this evidence can take: it is the chart's output, read where it landed, with nothing in between.

The /radarr answer is the application agreeing: returnUrl=%2Fradarr is radarr itself reporting the base path it was given.

The two qBittorrent answers show the chart's own redirect rule working, and the query string surviving it. The specification still does not promise the latter — see The trailing slash on qBittorrent's prefix.

A consequence for the operator: the nginx snippet in jk-cluster-config is now without effect and can be removed. Removing it earlier would have been wrong — in Ingress mode it was the only thing performing the redirect. It lost its effect not through a change in the chart but because the mode drops ingress.annotations.

Look for the release, not for a namespace named after it. This release lives in jellyfin, next to the service that namespace is named for. A search over namespace names — for freesailarr, or for anything containing "sail" or "arr" — finds nothing and invites the conclusion that the workload is not in this cluster at all. It is, it is healthy, and all ten of its containers are readable. The question "is this running" is not answered by a query over names; it is answered by looking for the release, e.g. kubectl get pods -A | grep freesailarr or the Helm release annotations. Recorded in Lessons learned because the same mistake is available on every chart here.

The limits of these measurements, and they are narrower than the ones first written here. Two things remain genuinely out of reach:

  • The implementation cannot be named. httproutes and gatewayclasses are RBAC-forbidden to the read-only identity (system:serviceaccount:coder-workspaces:claude-readonly), so neither the route nor its controller can be read. That the 301 comes from the chart's rule is inferred from the carrier's disappearance plus the changed response signature, not read off an object.
  • Every HTTP measurement passed through a front proxy. freesailarr.jk-dev.de — like the other hosts of this cluster — resolves to 152.53.207.153, proxy.jk-dev.de, which is not the ingress/gateway load balancer. So the response signatures above describe what that proxy returned, one hop out from whatever the Gateway did. This limit applies to the four HTTP rows and not to the three cluster rows, which were read from the API server directly.

What is therefore established: the release runs in jellyfin, healthy; no Ingress object exists in that namespace; the sub-path applications have been given their base paths, read from the pod spec; the host is served over HTTPS; and the bare-prefix redirect happens with the query string intact. What is not established: which Gateway implementation serves it, or that a particular HTTPRoute of this chart is the object doing so. The first proof that this repository's Gateway mode routes at all is a proof about the form — but the chart's own half of it, the published-helper output, is now read at the source rather than deduced.

Two routing shapes, and they mix

  1. One host per app (default): each app with <app>.ingress.enabled gets its own object — <release>-freesailarr-<app>-ingress in Ingress mode, <release>-freesailarr-<app>-httproute in Gateway mode — its own host and, in Ingress mode, its own certificate. The hosts need not share a domain.
  2. One shared domain: set ingress.host, leave the per-app hosts empty. Every such app moves into one object as a sub-path — one DNS name, one certificate.

A per-app host always wins, so a mixed setup needs no extra values. The cost per app in sub-path mode, measured rather than assumed:

  • radarr, sonarr, prowlarr — nothing. The chart sets <APP>__SERVER__URLBASE itself as soon as the app renders under a path other than /, unless that app's own env block already carries the variable (then yours wins and the chart stays out). Side effect worth knowing: with a UrlBase set, these apps answer the bare /ping with 307 and a Location under the base. The probes keep passing — the kubelet counts 200–399 as success — so the probe paths stay /ping in both shapes.
  • bazarr — one manual step. It reads its base path only from its own configuration; there is no environment variable. After the first start set Settings → General → Base URL to the same value as bazarr.ingress.path and restart the pod. Until then the UI is broken without announcing it: bazarr answers every unknown path with its index page, so /bazarr/ and /bazarr/<asset> both return 200 text/html of identical length. Check the content type, not the status code. Deliberately not automated — seeding it would mean writing into bazarr's config database.
  • qbittorrent — the prefix has to be stripped, and what that costs depends on the mode. In Ingress mode it gets its own object with an nginx rewrite-target, on the same host and sharing the main object's TLS secret (the cert-manager annotation sits on the main object, or on this one when it is the only app on the shared host). In Gateway mode it is one rule of the shared route carrying a URLRewrite filter, because a filter is per rule — no second object, no shared secret, no conditional annotation, no regex path — plus a second rule for the redirect below. Call the path with a trailing slash in Ingress mode, and read The trailing slash on qBittorrent's prefix before deciding you do not need to. Two upstream traps apply in both modes, and both look like a login failure: the session cookie is marked Secure, so over plain HTTP the browser never sends it back and every click bounces to the login form; and qBittorrent bans the client IP for a while after a few failed attempts, after which even correct credentials fail until the ban expires. Fallback without a chart change: set qbittorrent.ingress.host.
  • seerr and mediathekarr — pinned to /. seerr has no base-path support (upstream issue open since May 2022); mediathekarr links its UI assets with root-absolute paths, which a prefix-stripping rewrite cannot fix. Any other path fails the render rather than producing a broken UI, and two apps claiming the same path on the shared host fail the render too — one of them then needs its own host. With seerr off the shared domain's root answers 404; that is correct and left alone.

The trailing slash on qBittorrent's prefix

qBittorrent has no base-path setting, so in the shared-domain shape the prefix is stripped before the request reaches it. That is correct for the slashed form and wrong for the bare one, and the wrongness is silent at every level.

A request for https://<host>/qbittorrent — no trailing slash — is stripped to /. qBittorrent answers it 200, with its login page. But the browser's document URL stays /qbittorrent, so every relative link in that page resolves against the parent of that path, which is the host root — and / on the shared host is seerr. Assets, XHRs and form posts all go to seerr. What you see is an unstyled login form that does nothing, while another application answers its requests. Nothing logs an error, nothing fails a probe, and seerr appearing in a qBittorrent request's path is not a clue anyone follows quickly.

https://<host>/qbittorrent/ works. The same applies to any custom qbittorrent.ingress.path — it is a property of prefix stripping, not of the default value.

In Gateway mode the chart renders the redirect (Gitea #118). The shared HTTPRoute carries a second rule for qBittorrent: an Exact match on the path with a RequestRedirect filter, path.type: ReplaceFullPath to the slashed form, statusCode: 301. Three things about it are worth knowing because they are decisions, not details:

  • Precedence beats position. The Gateway API ranks Exact path match above Prefix path match with largest number of characters, and consults list order only when every earlier criterion ties. The rule therefore wins over qBittorrent's own PathPrefix rule and over seerr's PathPrefix / wherever it is emitted. The rules' length-descending order in the rendered route stays what it always was: a reviewable rendering convention, not matching behaviour.
  • 301, not 308. Only 301 and 302 are Core in that API; everything else is Extended, and an implementation that does not support the value must set Accepted: False with reason UnsupportedValue on the whole route. On the shared host that is one route carrying every app, so an Extended status code one implementation declines takes prowlarr, radarr, sonarr and seerr down with qBittorrent — schema-valid, in a status field nothing in CI writes. It would also break the implementation-neutral subset rule of the Gateway-API convention. 308's method preservation is not needed here: the rule fires only on the prefixless UI entry path, which a browser reaches with GET, while the API lives under <path>/api/… and is served by the PathPrefix rule. statusCode is written out in the template because it defaults to 302.
  • The query string is the one asymmetry — and it has since been measured, though not promised. HTTPRequestRedirectFilter has exactly five fields (scheme, hostname, path, port, statusCode) and says nothing about the query; ReplaceFullPath replaces the full path. The nginx remedy below carries the query over explicitly with $is_args$args. The specification is still silent, so this chart still cannot promise the query survives. What exists now is a data point: measured against the live host on 2026-10-13, GET /qbittorrent?foo=bar answered 301 with location: https://freesailarr.jk-dev.de/qbittorrent/?foo=bar — the query string was preserved. That settles the question for this implementation on that day and nothing more; it is not a guarantee, and a different Gateway implementation may legitimately drop it. It costs nothing on the UI entry path, which takes no query parameters, so the asymmetry remains real on paper and has so far not been observable in practice.

In Ingress mode the chart renders no redirect, and the remedy is the operator's to install. It is an nginx snippet, supplied through the global ingress.annotations (which are merged into both of the chart's objects on the shared host, which is what makes it work from there):

ingress:
  annotations:
    nginx.ingress.kubernetes.io/configuration-snippet: |
      if ($uri = "/qbittorrent") { return 301 $scheme://$host/qbittorrent/$is_args$args; }

Its price, and why the chart does not ship it: snippet annotations are honoured only on a controller started with allow-snippet-annotations=true, which has been off by default since ingress-nginx 1.9, and switching it on also means accepting annotations risk level Critical — a configuration snippet is arbitrary nginx configuration injected from a namespaced object. A chart that shipped this annotation would render a silent no-op on every cluster keeping the default, which is worse than naming the gap. The decision belongs to whoever runs the ingress controller.

A third Ingress object with pathType: Exact is not a substitute, which is the obvious-looking alternative and does not work on this hostname. In ingress-nginx controller-v1.14.5, internal/ingress/controller/template/template.go, buildLocation() returns a plain regex location (~* "^<path>") in its enforceRegex branch and returns there — before ever reaching the PathTypeExact branch that would emit the = "<path>" location nginx gives precedence to. And enforceRegex is computed once per nginx server, not per location: rootfs/etc/nginx/template/nginx.tmpl evaluates enforceRegexModifier over every location of the hostname and it returns true as soon as one of them needs a rewrite. qBittorrent's own object carries rewrite-target on that very hostname, so enforceRegex is true for the whole server and an Exact path renders as an ordinary regex location. The exact-match precedence the idea depends on never comes into being.

That short-circuit alone settles it. What an unanchored regex location would then do to ordering — shadow the app, or loop the redirect, depending on emission order — is inferred from the same sources and has not been observed on a running nginx; it is not needed for the conclusion.

What CI can and cannot prove about this. ci/run-httproute-match-test.sh asserts, per HTTPRoute object, that a rule stripping a prefix with ReplacePrefixMatch is accompanied by a rule matching that path Exactly with a RequestRedirect filter — see CI/CD. That proves the rendered object carries the redirect. It does not prove a 301 is ever served: no Gateway controller runs in CI, no route reaches Accepted: True, and nothing there evaluates matching precedence.

And what production now shows, which CI could not. Measured against the live host on 2026-10-13, no redirects followed:

GET https://freesailarr.jk-dev.de/qbittorrent
→ 301, location: https://freesailarr.jk-dev.de/qbittorrent/
  content-length: 17, no content-type, no strict-transport-security, no server header

A 301 is served, and the rule fires. That the 301 comes from this chart's rule rather than from the operator's nginx snippet is inferred, not read: the snippet's carrier is gone (ingress.annotations are dropped in Gateway mode, and there are no freesailarr Ingress objects left), and the response signature changed with it — before: nginx, content-type: text/html, 162 bytes, HSTS; now: 17 bytes, none of those three headers. The implementation itself cannot be named from here, because httproutes and gatewayclasses are RBAC-forbidden to the read-only identity. See The Gateway mode is in production for what else that measurement round established, and for its limits.

Forwarded headers are not trusted by default

prowlarr, radarr and sonarr trust no proxy out of the box. Their trustedNetworks value is empty by default, and while it is empty the app registers not a single trusted network — upstream's ForwardedHeadersConfigurator.Configure returns early.

The chart does not restore the behaviour these apps used to have. Up to prowlarr 2.5.2 and radarr 6.3.0 they hard-coded 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 and fe80::/10 as trusted proxy networks; prowlarr 2.6.3 and radarr 6.4.3 removed all five in favour of a setting that defaults to empty. This chart follows that default deliberately, because trusting X-Forwarded-For from any private network is a spoofing vector. sonarr has been in this state since before 4.0.19 — it arrived unnoticed in a plain app-version bump, which is its own lesson.

What it costs while the value is empty and the app is published through an Ingress or an HTTPRoute:

  • the client address the app logs and shows is your ingress controller's pod IP, not the real client;
  • the app sees the scheme as http even where TLS is terminated in front of it;
  • with authentication set to Disabled for Local Addresses, a proxied request no longer counts as local and is asked to authenticate.

NOTES.txt prints a warning in exactly that combination — published and empty — and stays quiet otherwise, so it is not a banner to learn to ignore.

To honour the headers, set the network your ingress controller's pods sit in. The chart cannot know it: it is a property of the cluster, not of the release.

prowlarr:
  trustedNetworks: "10.42.0.0/16"   # e.g. the k3s pod CIDR
radarr:
  trustedNetworks: "10.42.0.0/16"
sonarr:
  trustedNetworks: "10.42.0.0/16"

Narrowing it to the controller's own pod IP is stricter and valid; a pod IP is not stable across reschedules, so a CIDR is the practical choice. Leaving it empty is a legitimate decision — it is only no longer a silent one.

AllowedHosts is deliberately not a value here. Upstream added it in the same releases, its default is empty as well, and while it is empty the app accepts requests for any host (it logs "Allowed Hosts is not configured, accepting requests for any host"), so there is nothing to repair. One trap if you ever set it through <app>.env anyway: the app appends only localhost, 127.0.0.1, [::1] and its own hostname to the list, so neither the pod IP nor the in-cluster Service DNS name is covered — and the kubelet addresses the probes to the pod IP, which an incomplete list answers with HTTP 400. Readiness then fails on an app that is working.

Every Service points at the same pod IP

One ClusterIP Service per enabled app that listens on a port (recyclarr has none). Because all containers share gluetun's namespace, the Services differ only in their port: they give stable per-app DNS names, not network separation. mediathekarr's Service carries both its ports; only the primary (5007) is ever addressed by a routing object.

One pod, not a stack of pods

replicas > 1 is not meaningful: every replica would run its own isolated stack on its own config volumes, all of them fighting over the same media claims and the same VPN credentials. NOTES.txt says so at render time.

Toggling an app on or off later

The config volumes come from volumeClaimTemplates, and those are immutable. Changing any <app>.enabled after the first install makes the upgrade fail until the StatefulSet is recreated — the data survives:

kubectl -n <namespace> delete statefulset <release>-freesailarr-sfs --cascade=orphan

This is exactly why mediathekarr is off by default: enabling it adds a claim template, and nobody's existing release should grow one on an upgrade.

One claim, one volume entry

Several categories may point at the same existingClaim. A pod may reference such a claim only once: two volume entries with the same claimName leave the pod in Init:0/1 forever, without a single kubelet event (measured on RKE2/Longhorn, RWO as well as RWX). The chart therefore renders one entry, owned by the first enabled category in the fixed order downloads, movies, tv, and the other categories mount it with their own subPath. The order is fixed rather than derived from the values, because a volume name that moves when an unrelated flag changes is a pod restart.

Radarr and Sonarr do not hardlink between categories, not even on a single claim. Kubernetes makes every volumeMount its own bind mount, and link() across two mount points fails with EXDEV regardless of subPath: measured on Longhorn/NFS, both mount points report st_dev 0:56, the mount table shows two mount IDs on that one device, a cross-mount link() fails and a link() inside one mount succeeds with link count 2. The device number is the trap — stat suggests the opposite of what happens, which makes this look like an application bug.

Consequence: an import copies, so download and library each hold a full copy until the torrent is removed. The layout that would hardlink is one claim with exactly one mount and the categories as sub-directories inside it (the "single /data mount" the *arr guides recommend). This chart cannot express that today — named as a possibility, not as instructions.

The claim root, not fsGroup

A subPath directory does not have to exist — the kubelet creates it — but it is created root-owned and with the mode of the claim root. So the requirement is: the root directory of the claim must be writable for puid/pgid.

Do not count on fsGroup for that. Whether the kubelet applies it at all is the CSI driver's fsGroupPolicy, not the pod's: Longhorn ships ReadWriteOnceWithFSType, so fsGroup applies to RWO volumes and is silently ignored on an RWX media claim. It works out on Longhorn only because Longhorn hands out its volume root as mode 777. Measured in k3d against a 0755 claim root with no fsGroup in effect, the kubelet creates /movies as root:root 0755 and the container's touch fails with EPERM. On such a driver, prepare the claim root once (chown 1000:1000, or chmod g+w with the right group) before the first start — the chart deliberately chowns nothing, because a multi-TB library must not be walked on every pod start. fsGroupChangePolicy: OnRootMismatch exists for the same reason.

kubectl get csidriver driver.longhorn.io -o jsonpath='{.spec.fsGroupPolicy}'

The save path trap

Without Session\DefaultSavePath, qBittorrent falls back to /config/Downloads — the 1Gi config volume. It fills within minutes, every torrent goes to "errored", and qBittorrent can no longer write its own metadata to BT_backup, so a restart in that state costs the torrent list. The failure looks like a network, tracker or VPN problem and needs a df to recognise. The init container seeds the value only when the key is absent, so check which one is actually in effect:

kubectl -n <namespace> exec <release>-freesailarr-sfs-0 -c qbittorrent -- \
  grep SavePath /config/qBittorrent/qBittorrent.conf

If that shows a path under /config, fix it under Options → Downloads → Default Save Path — the running app owns the file, and editing it underneath the app is overwritten on exit.

Related, and also looking like a login failure: qBittorrent's WebUI rejects a Host header whose port differs from its own (8080) with a plain 401. A port-forward on a different local port therefore fails — forward 8080:8080, or go through the ingress.

Hardening is not uniform here

Each container carries its own baseline, and the deviations are image-forced and documented in values.yaml:

  • gluetun runs as root with NET_ADMIN and a writable root filesystem — it creates the tun device, programs iptables and rewrites /etc/resolv.conf. Its /tmp/gluetun is an emptyDir because it creates that directory mode 0644 and, with DAC_OVERRIDE dropped, could not enter it. It still logs a chown … operation not permitted line per VPN restart; the file is written, and granting CHOWN just to silence a log line would widen the container for nothing.
  • The five linuxserver.io containers keep a writable root filesystem and no runAsNonRoot: their s6-overlay init must start as root to apply PUID/PGID. With a read-only root filesystem they still start but silently keep the image's built-in uid 911 — which makes the shared media volumes inaccessible.
  • flaresolverr pins uid/gid 1000 numerically (the image declares the user by name only, which the kubelet's runAsNonRoot check cannot verify) and needs a writable root filesystem for its chromedriver patching; /tmp and a memory-backed /dev/shm (512Mi) are emptyDirs.
  • recyclarr, seerr, mediathekarr run fully hardened: non-root from puid/pgid, read-only root filesystem, all capabilities dropped.

The pod-level seccompProfile: RuntimeDefault covers all of it, gluetun's tunnel and iptables included — verified against a real tunnel.

The start gate, and why a green pod proves nothing

MediathekArr fetches its ruleset once at start and never retries. Started before the tunnel, it answered every Newznab search with 0 results for over eleven minutes — container up, Ready, nothing in the log. Kubernetes cannot express "start after that container" (gluetun is a regular container, and an initContainer waiting for it would deadlock), so the gate lives in mediathekarr's command: poll gluetun's health server on 127.0.0.1:9999, then exec the image entrypoint. On timeout the container exits instead of starting anyway — a crash-looping container is visible, a silently empty indexer is not.

If you lower startGate.timeoutSeconds or raise it, keep startupProbe.periodSeconds × failureThreshold above it, or the kubelet kills the container while the gate is still waiting.

MediathekArr: two ports, and the SABnzbd API is not at /api

Consumer Role Endpoint
Prowlarr Newznab indexer http://127.0.0.1:5008
Sonarr / Radarr SABnzbd download client host 127.0.0.1, port 5007, API path /download/api

A request to /api on 5007 answers with a Newznab validation error and sends you looking at the wrong half of the app; upstream documentation mentions only port 5007 at all. Sonarr and Radarr must import from /downloads/<directory>/complete — the identical absolute path the chart hands MediathekArr as the SABnzbd complete_dir. Any deviation (a remote path mapping, a different mount) imports nothing, without an error. incomplete/ lives on the media claim rather than an emptyDir on purpose: an emptyDir counts against the container's ephemeral-storage limit, and one large download would make the kubelet evict the whole pod — all ten containers.

The UI is only needed for the setup wizard; a port-forward is enough:

kubectl -n <namespace> port-forward svc/<release>-freesailarr-mediathekarr-service 5007:5007

A failed MediathekArr download never says why

MediathekArr does not check the HTTP status code. Measured A/B with one identical NZB: direct (DE) → Completed, 94,235,525 bytes, fail_message: null; through Zurich → Failed, 0 bytes, fail_message also null. On a geo-blocked fetch it writes the ~476-byte HTML error page out as .mp4 and logs "Download completed"; only mkvmerge then refuses the file and flips the status. Two consequences: Sonarr is told "failed" without a reason, so a geo block is indistinguishable from a broken server, an expired URL or a full disk; and that protection is an accident — anything a geo endpoint returns that mkvmerge accepts is reported as Completed and walks into the library.

Explicitly unmeasured: part of the catalogue is served as an HLS playlist instead of a progressive MP4. The image carries an ffmpeg-based M3U8 path, but whether such an entry completes end to end has not been tested. If it does not, those entries fail regardless of any geo block — and, per the above, without a reason.

The geo caveat, and how to check it yourself

The tunnel exits in Zurich by default, and ARD and ZDF block parts of their catalogue outside Germany. Every container hangs in gluetun's namespace; there is no per-container exit. Measured over 480 URLs across 12 channels: 104 of 473 loadable URLs blocked through Zurich (22.0%), none on the German side — but the spread is very uneven (ONE 97.5%, ZDFneo 47.5%, ZDFinfo 37.5%, Das Erste 20.5%, ZDF 10.3%, BR 0%), so one number says little about the show you want.

A list of titles goes stale; the marker in the media URL does not — it predicted the outcome in 584 of 584 measured cases:

Marker in the media URL Outcome
/de/, /geo/de/, /deutschlandweit/, geoavailability_D blocked outside DE
/weltweit/ open
/none/ (ZDF) open
/dach/, deChAt (WDR variant) open — Switzerland is part of DACH

The rule of thumb "own production = available worldwide" is measurably wrong (an ARD own production was blocked, a licensed series was open). Only the marker counts. The escape route is one value — gluetun.vpn.serverCountries/serverCities set to a German exit — and its price is that it moves the whole stack, qBittorrent included, into that jurisdiction and onto those servers.

Rotating the VPN credentials

The pod template carries checksum/secret-ref, a hash of the rendered OnePasswordItem — not of the secret's content, which Helm never sees (style guide §9). Pointing secrets.gluetunSecretRef at a different item therefore rolls the pod; editing a value inside the same item does not:

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

FIREWALL_INPUT_PORTS is a statement of intent, not what opens the ports

The chart declares the enabled apps' ports (plus 9999) to gluetun's firewall, but inside a pod network those rules never match: gluetun detects the pod's own subnet and inserts an ACCEPT rule ahead of them, and the packet counters on the --dport rules stayed at 0. The declaration is kept because that subnet detection is gluetun's behaviour, not a guarantee of the chart's — a CNI handing out a /32, or a future gluetun dropping the rule, would make these the only thing keeping probes and ingress alive.

If the pod will not become Ready, suspect the tunnel rule

gluetun's health server answers 200 only while the tunnel is up, and all three of its probes address it — so a blocked UDP 51820 shows up as a pod that never goes Ready rather than as a network error. That also makes it the best-covered rule in the chart: the install-test brings up a real tunnel, so getting this wrong goes red in CI.

51820 is WireGuard's standard port and Mullvad's. Mullvad also offers 53, 123, 500 and 4500 for obfuscation, and a release on one of those has to widen the rule in the template — networkPolicy.egress.extraPorts is TCP only. That is a limit of the shared values shape rather than an oversight, and it is said plainly here because nothing else would say it.

If Seerr cannot reach an in-cluster service, two things must be true

Its port in networkPolicy.egress.extraPorts, and gluetun.vpn.firewallOutboundSubnets set — gluetun's own firewall blocks cluster-internal egress before this policy is ever consulted. Jellyfin on 8096 is the usual case.

Because the namespace belongs to gluetun, the egress cap is unusually meaningful

Everything the *arr containers send is encapsulated before it leaves, so three narrow rules really do describe the pod's whole outbound surface — which is rare in this repository, where most egress rules are a guess at a standard port.

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.

Ten upstreams, no app version

A documented exception to two repo-wide rules (Versioning, Chart style guide):

  • No image tag comes from Chart.AppVersion. All ten images are pinned in values.yaml as <app>.image.repository/tag, because ten independently versioned projects have no single "the" app version. appVersion is the chart's own marker, raised by hand when a new edition of the chart is marked as a whole; the chart is deliberately not in the change-app-version.yml choice list and has no app-version block in renovate.json5.
  • No icon: in Chart.yaml. The rule everywhere else is "the icon is this chart's one upstream's logo", and picking one of ten would imply a main app the stack does not have. It costs an INFO line in helm lint --strict, which still exits 0. This applies to no other chart — a single-upstream chart without an icon is a missing icon, not an exception.
  • No ConfigMap, so no checksum/config; the pod template carries checksum/secret-ref instead, which promises only "the config source changed".

Renovate reaches every image through the built-in helm-values manager, which reads exactly the repository/tag pairs. mediathekarr is the exception on top: pinned by digest, matched by its own customManagers block anchored on the three keys in their values.yaml order — renaming or reordering them does not fail anything, it silently stops matching. linuxserver/qbittorrent additionally excludes the legacy tags 14.3.9 and 20.04.1, which docker versioning sorts above every real release. See Renovate.

Upgrades and backup

  • Back up the config volumes before an app-version bump. Each app's database lives on its own RWO claim; helm uninstall leaves volumeClaimTemplates-created PVCs behind, but a StatefulSet recreation plus a mistaken PVC delete does not.
  • The media claims are yours, the config volumes are the chart's. Nothing in the chart creates, chowns or deletes a media claim.
  • Update images one at a time via Renovate PRs, not by hand-editing several tags at once: the install-test is the only place where a real tunnel and ten containers come up together, and a single failing container is much easier to attribute.
  • After enabling or disabling an app, expect the --cascade=orphan step above. Plan it — the upgrade fails first and the reason is in the StatefulSet's error, not in Helm's.
  • seerr's first boot runs migrations (300s startup budget). Raise seerr.startupProbe.failureThreshold before a large import, not after the crash loop has started.

CI

ci/freesailarr/ holds:

  • test-values.yaml — release name ci, the three media claims, firewallOutboundSubnets for k3s, shared-domain ingress on freesailarr.example.com, and mediathekarr enabled so the install-test actually covers the tenth container, its digest-pinned image, its start gate and its two-port probes (its ingress stays off — the shared root belongs to seerr).
  • fixtures.yaml — the three media PVCs on the default StorageClass (k3d's local-path, mode 0777, so the claim roots are writable for PUID/PGID without a chown job).
  • fixtures.env.yaml — the only one in the repo: a Secret template with variable names only, rendered with envsubst and piped into kubectl. gluetun needs a real Wireguard key, or no app container behind its kill switch ever becomes Ready.
  • settings.env — INSTALL_TIMEOUT=1200 (ten containers, several GB of images, startup budgets that only start once the tunnel is up) and REQUIRED_ENV for the two credential variables, whose absence fails the job loudly instead of skipping it.

The key in 1Password is a CI-only Mullvad key, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would let every PR pull the tunnel out from under the running stack. The install-test runs on every PR that touches the chart and takes ~225 s alone — see CI/CD.

The install-test stays in Ingress mode; the routes are held at render level. The install runs exactly one configuration per chart, and this chart's is the expensive one — a real tunnel so that ten containers can become Ready. Switching it would trade that for a CRD schema cyberchef already exercises against the real Gateway-API CRDs. Instead ci/httproute/freesailarr.yaml is a render-only overlay for ci/run-httproute-match-test.sh, and for a multi-app chart that overlay has a second job: it must enter every shape, or the branch nobody exercised is the one that breaks. It therefore moves bazarr onto a host of its own on top of the fixture's shared ingress.host, so the shared-host route and a per-app route both render — eight assertions from this chart alone, including qBittorrent's rule with its URLRewrite filter and mediathekarr's ingress staying off, which exercises the per-app selector in the negative direction. Neither half proves that traffic flows: no Gateway controller runs in CI.

See also

  • Chart style guide — the rules this chart implements, and the four it is a documented exception to
  • Versioning — why 3.1.0+up1.0.0 has a hand-set suffix here
  • Renovate — the helm-values manager, the digest block, the qBittorrent tag exclusions
  • Lessons learned — the EXDEV hardlink measurement, the duplicate-claim hang, the fail_message: null trap
  • Chart index