# freesailarr runs gluetun plus the whole media automation stack in a single # StatefulSet pod: every app container shares gluetun's network namespace, the # containers talk to each other over localhost and all traffic leaves through # the Wireguard tunnel. # # Image pinning (documented deviation from the Chart.AppVersion standard): # NO image tag comes from appVersion. All ten images, gluetun included, are # pinned here via .image.repository / .image.tag, because the stack # combines ten independently versioned upstream projects and none of them is # "the" one app version. mediathekarr additionally carries an image.digest - # see the reasoning in its own block at the end of this file. # # That is also what puts every image under Renovate: the built-in helm-values # manager reads image.repository/image.tag pairs out of this file and proposes # one update per image, and ci/renovate-bump-chart.sh bumps the chart version # along with it. A chart whose main image came from appVersion would instead # need a hard-coded block in renovate.json5 - freesailarr deliberately has none. # # Chart.yaml's appVersion is therefore the chart's own version marker, freely # chosen and tied to no upstream - not the source of any tag here. Nothing # rewrites it automatically; raise it by hand when you want to mark a new # edition of the chart as a whole. # Scale the whole chart down without uninstalling it: scaleDown: true renders # every Deployment/StatefulSet with replicas: 0 (and suspends CronJobs) and # takes precedence over any per-workload replicas value. PVCs, Secrets and # Services stay in place; scaleDown: false brings the workloads back up. scaleDown: false # Replica count for the whole stack (one pod contains all containers); 0 is # allowed and scales it down. The chart-global scaleDown flag takes precedence. # More than 1 replica is not meaningful: every replica would run its own # isolated stack on its own config volumes. replicas: 1 secrets: # op:// item path for the gluetun VPN credentials. The 1Password item must # contain the fields "wireguard-private-key" (the Wireguard private key) and # "wireguard-addresses" (the Wireguard interface address, e.g. 10.x.y.z/32). gluetunSecretRef: null # User/group the linuxserver.io containers drop to after init (their PUID/PGID # mechanism) and the runAsUser/runAsGroup for the seerr and recyclarr # containers. Media and config volumes must be writable for this user. puid: 1000 pgid: 1000 # Timezone for all containers. timezone: Europe/Zurich securityContext: # Pod-level security context. No pod-level runAsNonRoot/runAsUser: the pod # mixes root containers (gluetun, linuxserver.io images) with non-root ones # (seerr, recyclarr, flaresolverr); users are set per container instead. # fsGroup (keep in sync with pgid) makes freshly provisioned config volumes # group-writable for the non-root containers; fsGroupChangePolicy # OnRootMismatch avoids recursively re-chowning the (large, shared) media # claims on every pod start. # How far that reaches is the CSI driver's decision, not this chart's, and the # media claims are the case where it stops short: a driver whose fsGroupPolicy # is ReadWriteOnceWithFSType - Longhorn's setting - applies fsGroup to RWO # volumes only, so on an RWX media claim fsGroup is ignored outright and # OnRootMismatch never even gets a say. The config claims are RWO and are # covered normally. Consequence for the media claims: their root directory has # to be writable for PUID/PGID by itself, see the media block below (#164). # seccompProfile RuntimeDefault applies the container runtime's default # syscall filter to every container in the pod, init containers included, and # is set at pod level so a container added later inherits it without the # value being repeated. It is worth naming what it has to survive here: # gluetun runs as root with NET_ADMIN, creates a tun device and programs # iptables, and the s6-overlay init of the five linuxserver.io images starts # as root and drops to PUID/PGID via CHOWN/SETUID/SETGID. Both were verified # against a real tunnel under this filter - see the chart's PR. # A container that genuinely needs a different filter can still override it # under .securityContext, which wins over the pod value. pod: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault storage: # StorageClass for all config volumeClaimTemplates (null = cluster default). storageClass: null # Media libraries as pre-existing PVCs (shared with the separately deployed # Jellyfin) - the chart mounts but never creates them. Each claim is required # as soon as an app that mounts it is enabled: # downloads: qbittorrent, radarr, sonarr, mediathekarr # movies: radarr, bazarr # tv: sonarr, bazarr # # subPath mounts a sub-directory of the claim instead of its root (null = the # claim root, i.e. one claim per category). The same existingClaim may back two # or all three categories, which is what makes the one-library-volume layout # possible - one claim, one sub-directory per category: # # media: # downloads: { existingClaim: media-library, subPath: downloads } # movies: { existingClaim: media-library, subPath: movies } # tv: { existingClaim: media-library, subPath: tv-shows } # # Categories sharing a claim get exactly ONE pod volume entry and differ only in # the subPath of their mounts (#164). That is not cosmetic: a pod that lists the # same claim twice never starts - it stays in Init:0/1 forever without a single # kubelet event (measured on RKE2/Longhorn, with RWO as well as RWX). The entry # is named after the first ENABLED category in the fixed order downloads, # movies, tv that carries the claim, so the name does not wander when an # unrelated app flag changes (see the mediaVolumeName helper). # # The subPath directories do NOT have to exist beforehand - the kubelet creates # a missing one. What it does not do is give it usable permissions: the created # directory belongs to root and inherits the mode of the CLAIM ROOT. So the one # thing that has to hold is: # # the root directory of the claim must be writable for PUID/PGID. # # Do not count on fsGroup for that. Whether the kubelet applies fsGroup to a # volume at all is the CSI driver's decision (its fsGroupPolicy), not the pod's: # Longhorn ships ReadWriteOnceWithFSType, so fsGroup applies to its RWO volumes # and is silently ignored on an RWX claim - measured by the reporter of #164 on # the target cluster (kubectl get csidriver driver.longhorn.io -o # jsonpath='{.spec.fsGroupPolicy}'). # # In practice this works out on Longhorn because Longhorn hands out its volume # root as mode 777, and a subPath directory created under it inherits those 777 # - root-owned, but writable for everyone. That is a property of the driver, not # of Kubernetes. Measured in k3d against a claim whose root is 0755 and 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 does not chown anything: a multi-TB library # must not be walked on every pod start. # # What this buys and what it does NOT buy - measured in a k3d cluster against # this chart, because the usual argument for a shared volume is hardlinking: # - It buys the layout: one claim to provision, back up and share with # Jellyfin, with the categories as sibling directories instead of three # separate volumes. # - It does NOT buy hardlinked imports. Radarr and Sonarr import by moving, # and a move within one filesystem can be a hardlink or a rename instead of # a copy - but Kubernetes turns every volumeMount into its own bind mount, # and link()/rename() across two mount points fails with EXDEV even when # both sides are the same claim on the same device. Measured: /downloads and # /movies in the radarr container both report device 65025 and resolve to # the same PV directory, yet `ln /downloads/f /movies/f` fails with # "Cross-device link", while the identical link succeeds when the claim root # is mounted once and both categories are sub-directories under it. # This is not a property of subPath: two plain mounts of the same claim # without any subPath fail exactly the same way, so the chart has never # supported cross-category hardlinks. # - Consequence today: an import still copies, so download and library each # hold a full copy until the torrent is removed. # # HARDLINKS NEED ONE MOUNT, NOT ONE CLAIM. This is the conclusion people draw # from the paragraph above and get wrong, so it is spelled out: adding downloads # as a third subPath on the shared claim changes NOTHING about hardlinks. The # kernel compares the VFSMOUNT in do_linkat, not the superblock, and two bind # mounts of the same filesystem are two vfsmounts - hence EXDEV. Measured again # 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 a single mount succeeds with link count 2. # The device number is the trap. "stat" reports the same device on both sides # and thereby suggests the exact opposite of what happens - which is why this # reads like an application bug instead of a property of the mount namespace. # The layout that WOULD hardlink is one claim with exactly ONE mount and the # categories as sub-directories inside it (the "one /data mount" layout the *arr # guides recommend). The chart cannot express that today - it mounts each # category separately. Named as a possibility, not as instructions: there are no # values for it, and providing them would be a change to the mount layout. media: downloads: existingClaim: null subPath: null movies: existingClaim: null subPath: null tv: existingClaim: null subPath: null # Ingress defaults. There are two shapes, and they mix freely: # # 1. One host per app (the default). Every app carries its own # .ingress.host, so the hosts are independent of each other and may # live in completely different domains. Each app gets its own Ingress # object and its own certificate. # 2. One shared domain with sub-paths: set ingress.host and leave the per-app # hosts empty. Every app then moves into ONE Ingress object as a sub-path # (.ingress.path, default /), which means one DNS name and one # certificate for the whole stack instead of six. # # A per-app host always wins: an app that has one keeps its own object even # while ingress.host is set, so a mixed setup needs no extra values. # # clusterIssuer, className and annotations are the defaults for all apps and # can be overridden per app in the .ingress block (an explicitly set # per-app value always wins). The shared object uses the global values only - # it belongs to no single app. # # What the sub-path mode costs, per app, measured against the upstream # documentation and not assumed: # # - radarr, sonarr, prowlarr: nothing. The chart sets their UrlBase itself # (RADARR__SERVER__URLBASE and friends) as soon as they render under a # path other than "/" - unless that app's own env block already contains # the variable, in which case yours wins and the chart stays out. # One measured detail, in case a probe ever looks odd: with a UrlBase set, # these apps answer the bare /ping with "307 Temporary Redirect, # Location: //ping" instead of 200. The probes keep passing - # the kubelet counts every status from 200 to 399 as success, and the # containers come up Ready with 0 restarts (verified in k3d) - so the # probe paths are deliberately left at /ping and stay valid in both # ingress shapes. Worth knowing before reading anything into that 307. # - bazarr: ONE manual step. Bazarr reads its base path only from its own # configuration, it has no environment variable for it, so after the first # start set Settings -> General -> Base URL to the same value as # bazarr.ingress.path and restart the pod. Until then the sub-path yields # a broken UI - and it does NOT announce itself with a 404: bazarr answers # every unknown path with its index page, so /bazarr/ AND /bazarr/ # both return "200 text/html" of identical length (measured in k3d). The # page loads, its assets silently come back as HTML, and nothing looks # like an error. Check the content type, not the status code. Deliberately not # automated: seeding it would mean an init container writing into bazarr's # config database, which is a much bigger promise than this chart wants to # make. Note that bazarr's probes address "/" - if it does not answer # there once a base URL is set, adjust bazarr.livenessProbe/readinessProbe/ # startupProbe accordingly (they are values, precisely for cases like this). # - qbittorrent: it has no base path at all, so the chart gives it its own # Ingress object on the same host with an nginx rewrite that strips the # prefix. Two traps from the upstream recipe, both of which look like a # login failure and therefore point in the wrong direction: # * qBittorrent marks its session cookie "Secure". Reaching the UI over # plain HTTP means the browser never sends the cookie back and every # click bounces to the login form. Use https. # * qBittorrent bans the client IP for a while after a few failed login # attempts. Once that has triggered, even correct credentials fail - # the ban has to expire (or the pod be restarted) first. # If it does not work behind the stripped prefix, the fallback costs no # chart change: set qbittorrent.ingress.host and qBittorrent is back on a # host of its own while everything else stays on the shared domain. # - seerr: it has no base-path support either and, unlike qBittorrent, # cannot be fixed by a rewrite (the upstream request has been open since # May 2022, https://github.com/Fallenbagel/jellyseerr/issues/97). It # therefore sits at "/" and the chart refuses to render it anywhere else. # If seerr is switched off, nothing serves the root and the shared domain # answers 404 there. That is correct and left alone on purpose. ingress: # Master switch: false suppresses every app ingress regardless of the # per-app toggles. enabled: true # The shared domain for the sub-path mode, e.g. "media.example.com". Empty # means the default: each app on a host of its own. host: "" clusterIssuer: null className: nginx # Extra annotations for the Ingress objects; merged with the cert-manager # cluster-issuer annotation the chart always sets. annotations: {} gluetun: # Pinned here like every other image in this chart, so Renovate's helm-values # manager proposes gluetun updates too. Docker Hub tags carry a v prefix. image: repository: docker.io/qmcgaw/gluetun tag: v3.41.3 vpn: serviceProvider: mullvad type: wireguard serverCities: Zurich serverCountries: Switzerland dnsUpstreamResolvers: quad9 # Comma-separated CIDRs the gluetun firewall allows outbound traffic to # outside the VPN tunnel. Set this to the cluster pod and service CIDRs # (e.g. "10.42.0.0/16,10.43.0.0/16" on k3s) - without it the firewall # blocks cluster-internal egress such as DNS lookups against cluster DNS # and seerr talking to the in-cluster Jellyfin. firewallOutboundSubnets: null storage: # Size of the gluetun config volume (server list cache under /gluetun). size: 1Gi # Extra environment variables, appended to the ones the chart always sets. # Values are rendered through tpl; value, secretKeyRef and configMapKeyRef # forms are supported. (Same for every .env block below.) env: [] # gluetun's built-in HTTP health server; the chart binds it to :9999 (env # HEALTH_SERVER_ADDRESS) so the kubelet probes, which connect to the pod IP, # can reach it. It returns 200 only while the VPN tunnel is up and working. # Liveness is more tolerant (60s) than readiness (30s): during a short # tunnel re-negotiation gluetun recovers on its own and a kubelet restart # would only prolong the outage; the kill switch keeps all egress blocked # the whole time, so nothing runs unprotected. livenessProbe: httpGet: path: / port: 9999 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 6 readinessProbe: httpGet: path: / port: 9999 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. Budget for image start plus # Wireguard handshake: 5s * 30 = 150s. startupProbe: httpGet: path: / port: 9999 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: # Limits are optional: memory and ephemeral-storage limits default to # limitFactor x the request, an explicitly set limit always wins, and a # CPU limit is only set when configured explicitly under "limits". # (Same for every .resources block below.) limitFactor: 3 requests: cpu: 0.1 memory: 128Mi ephemeralStorage: 50Mi # Deviations from the hardening baseline, forced by gluetun's role as VPN # gateway: # - runs as root (no runAsNonRoot): required to create the tun device and # program the firewall. # - NET_ADMIN capability: creates the Wireguard tunnel and the iptables # rules (firewall, kill switch). # - readOnlyRootFilesystem: false: gluetun rewrites /etc/resolv.conf and # other files on its root filesystem at runtime. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - NET_ADMIN qbittorrent: enabled: true image: repository: docker.io/linuxserver/qbittorrent tag: 5.2.3 # Every app gets its own Ingress object (named ---ingress) # with its own TLS secret tls----ingress, so the apps can # be exposed independently and under unrelated hosts. # host is required while enabled is true and has no default - an app whose # ingress is switched off never demands one, and neither does an app served # under the shared ingress.host. clusterIssuer, className and annotations may # be set here to override the global ingress block. # path is only read in the shared-domain mode (see the ingress block above); # it defaults to / and is ignored while this app has a host of its own. # (Same block for prowlarr, radarr, sonarr, seerr and bazarr below.) ingress: enabled: true host: null path: /qbittorrent # clusterIssuer: null # className: null # annotations: {} storage: # The CONFIG volume, and it is meant to stay small: it holds # qBittorrent.conf, the BT_backup state and nothing else. Downloads must # never land here - see savePath below for what happens when they do. size: 1Gi # Default save path for new torrents, seeded into the persisted config by the # init container below. Without it qBittorrent falls back to its built-in # default /config/Downloads, which is this 1Gi config volume: it fills within # minutes, every torrent goes to "errored", and qBittorrent can no longer # even 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. Measured on a real cluster, not assumed. # /downloads is the media claim (media.downloads), mounted into this # container. # Unlike networkInterface below this value is seeded ONLY when the key is # absent from the config, never replaced: the save path is a legitimate # operator decision, and someone who changes it in the WebUI must not be # overwritten on every pod restart. Set it to null or "" to opt out entirely # and leave the path to qBittorrent's built-in default (not recommended, see # above). savePath: /downloads # Network interface qBittorrent binds its BitTorrent session to - gluetun's # Wireguard device. Without this binding libtorrent listens and announces on # every interface in the shared pod network namespace, including eth0 (the # pod IP) and loopback. Those announces bypass the tunnel, so gluetun's kill # switch (OUTPUT DROP except tun0 and the allowed cluster subnets) drops # them: trackers report "skipping tracker announce (unreachable)", no peers # are found and torrents stall at 0 bytes in stalledDL. Binding to tun0 is # therefore both the functional fix and a leak fix - it stops qBittorrent # from listening on the pod IP past the tunnel. The value is written into # /config/qBittorrent/qBittorrent.conf by the config-seed init container # below, and it is ENFORCED on every start (unlike savePath), because a # deviating value would be a leak; setting it to null or "" drops the binding # and leaves it to the user (not recommended). networkInterface: tun0 # The init container that seeds qbittorrent.networkInterface and # qbittorrent.savePath into the persisted qBittorrent config before the app # starts. Runs as puid/pgid so the config file keeps the ownership the app # expects. Setting both networkInterface and savePath to null removes the # init container entirely. configInit: # Auxiliary busybox image for the init container, pinned here like every # other image in this chart and proposed by Renovate the same way. image: repository: docker.io/busybox tag: "1.38.0" resources: limitFactor: 3 requests: cpu: 10m memory: 16Mi ephemeralStorage: 1Mi securityContext: runAsNonRoot: true allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL env: [] livenessProbe: httpGet: path: / port: 8080 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: / port: 8080 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. startupProbe: httpGet: path: / port: 8080 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.2 memory: 512Mi ephemeralStorage: 100Mi # Deviations from the hardening baseline, forced by the linuxserver.io # images (s6-overlay init that starts as root and drops to PUID/PGID): # - no runAsNonRoot: s6-overlay must start as root to apply PUID/PGID and # chown /config. # - readOnlyRootFilesystem: false: the s6 init applies PUID/PGID by # rewriting /etc/passwd and /etc/group and stages runtime state on the # root filesystem. With a read-only root filesystem the containers still # start, but silently keep the image's built-in uid/gid (911) instead of # PUID/PGID (verified at runtime) - unacceptable for the shared media # volumes. # - capabilities: ALL dropped except what the s6-overlay init needs: # CHOWN/FOWNER/DAC_OVERRIDE (taking ownership of /config and /run) and # SETUID/SETGID (dropping from root to the PUID/PGID user). # (Same block for all linuxserver.io containers: qbittorrent, prowlarr, # radarr, sonarr, bazarr.) securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID prowlarr: enabled: true image: repository: docker.io/linuxserver/prowlarr tag: 2.5.2 # See the qbittorrent ingress comment. ingress: enabled: true host: null path: /prowlarr # clusterIssuer: null # className: null # annotations: {} storage: size: 1Gi env: [] # The *arr apps expose /ping as an unauthenticated health endpoint. livenessProbe: httpGet: path: /ping port: 9696 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /ping port: 9696 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. startupProbe: httpGet: path: /ping port: 9696 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID flaresolverr: enabled: true image: repository: ghcr.io/flaresolverr/flaresolverr tag: v3.5.0 # No ingress and no config volume: flaresolverr is stateless and only # consumed by prowlarr over localhost. env: [] # /health is flaresolverr's dedicated lightweight health endpoint. livenessProbe: httpGet: path: /health port: 8191 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 8191 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. startupProbe: httpGet: path: /health port: 8191 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.2 memory: 512Mi ephemeralStorage: 200Mi # Deviations from the hardening baseline, forced by the flaresolverr image: # - readOnlyRootFilesystem: false: on startup flaresolverr copies and # patches the chromedriver binary to the hardcoded path /app/chromedriver # and writes chromedriver caches under /app (its home directory). # - runAsUser/runAsGroup pin the image's "flaresolverr" user numerically # (uid/gid 1000; the image declares it by name only, which the kubelet's # runAsNonRoot check cannot verify). # Chromium's scratch paths (/tmp, /dev/shm) are emptyDirs. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 capabilities: drop: - ALL radarr: enabled: true image: repository: docker.io/linuxserver/radarr tag: 6.4.4 # See the qbittorrent ingress comment. ingress: enabled: true host: null path: /radarr # clusterIssuer: null # className: null # annotations: {} storage: size: 1Gi env: [] livenessProbe: httpGet: path: /ping port: 7878 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /ping port: 7878 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. startupProbe: httpGet: path: /ping port: 7878 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID recyclarr: enabled: true image: repository: ghcr.io/recyclarr/recyclarr tag: 8.7.1 # No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and # sonarr on a schedule (daemon mode). storage: size: 1Gi env: [] # recyclarr serves no HTTP port in daemon mode, so all probes exec the CLI. livenessProbe: exec: command: - recyclarr - --version periodSeconds: 30 timeoutSeconds: 10 failureThreshold: 3 readinessProbe: exec: command: - recyclarr - --version periodSeconds: 30 timeoutSeconds: 10 failureThreshold: 3 # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. startupProbe: exec: command: - recyclarr - --version periodSeconds: 5 timeoutSeconds: 10 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.05 memory: 128Mi ephemeralStorage: 50Mi # The image supports running as an arbitrary non-root user; runAsUser and # runAsGroup are set from the global puid/pgid values in the template - do # not set them here. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL sonarr: enabled: true image: repository: docker.io/linuxserver/sonarr tag: 4.0.19 # See the qbittorrent ingress comment. ingress: enabled: true host: null path: /sonarr # clusterIssuer: null # className: null # annotations: {} storage: size: 1Gi env: [] livenessProbe: httpGet: path: /ping port: 8989 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /ping port: 8989 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. startupProbe: httpGet: path: /ping port: 8989 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID seerr: enabled: true image: repository: ghcr.io/seerr-team/seerr tag: v3.4.1 # See the qbittorrent ingress comment. ingress: enabled: true host: null path: / # clusterIssuer: null # className: null # annotations: {} storage: size: 1Gi env: [] # /api/v1/settings/public answers unauthenticated once the app is up. livenessProbe: httpGet: path: /api/v1/settings/public port: 5055 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /api/v1/settings/public port: 5055 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. First boot runs database # migrations: budget 10s * 30 = 300s. startupProbe: httpGet: path: /api/v1/settings/public port: 5055 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi # The image runs fine as an arbitrary non-root user; runAsUser and # runAsGroup are set from the global puid/pgid values in the template - do # not set them here. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL bazarr: enabled: true image: repository: docker.io/linuxserver/bazarr tag: 1.6.0 # See the qbittorrent ingress comment. ingress: enabled: true host: null path: /bazarr # clusterIssuer: null # className: null # annotations: {} storage: size: 1Gi env: [] # bazarr has no unauthenticated health endpoint; / serves the UI with 200. livenessProbe: httpGet: path: / port: 6767 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: / port: 6767 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s. startupProbe: httpGet: path: / port: 6767 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 30 resources: limitFactor: 3 requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi # linuxserver.io image - see the qbittorrent securityContext comment. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: - ALL add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID # MediathekArr: the tenth container, and the only one that is OFF by default. It fakes a Usenet indexer towards Prowlarr and a SABnzbd downloader # towards Sonarr/Radarr, and fetches the content over plain HTTP from the ARD # and ZDF media libraries. Both consumers already sit in this pod and share # gluetun's network namespace, so they reach it over 127.0.0.1 - no service, no # DNS, no ingress detour. # # Off by default because it is additive and nobody's existing release should # grow a container on an upgrade: enabling it creates a new # volumeClaimTemplate, which a StatefulSet cannot take without being recreated # (see the NOTES and the volumeClaimTemplates comment in the StatefulSet). # # ---------------------------------------------------------------------------- # THE GEO CAVEAT, AND HOW TO CHECK IT YOURSELF # ---------------------------------------------------------------------------- # The tunnel leaves through Zurich by this chart's default, and ARD and ZDF # block parts of their catalogue outside Germany. Every container hangs in # gluetun's namespace; there is no bypass for a single container. # # Measured, 480 URLs across 12 channels: 104 of 473 loadable URLs were blocked # through Zurich (22.0%), none of them on the German side. The spread is very # uneven - ONE 97.5%, ZDFneo 47.5%, ZDFinfo 37.5%, Das Erste 20.5%, ZDF 10.3%, # BR 0% - so a single number tells you little about the show you actually want. # # THE PROCEDURE, NOT A TABLE OF SERIES. A list of titles is stale in half a # year; the marker in the CDN path is not. Over two measurement rounds it # predicted the outcome perfectly - 584 of 584 cases in the full survey, not a # single misclassification. Look at the media URL of an entry and read the # path: # # /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 obvious rule of thumb "own production = available worldwide" is MEASURABLY # WRONG and is named here so nobody re-invents it: an ARD own production was # blocked, a licensed series was open. Only the marker counts. # # THE ESCAPE ROUTE AND ITS PRICE. The exit is a deployment decision, not a chart # property: set gluetun.vpn.serverCountries/serverCities to a German exit and # the blocks disappear. The price is that this moves the WHOLE stack, all ten # containers - qBittorrent included - into that jurisdiction and onto those # servers. There is no per-container exit, and this chart deliberately does not # pretend otherwise. # # ---------------------------------------------------------------------------- # THE fail_message: null TRAP - READ THIS BEFORE DEBUGGING A FAILED DOWNLOAD # ---------------------------------------------------------------------------- # MediathekArr never checks the HTTP status code. Measured A/B with an identical # NZB: direct (DE) -> status Completed, 94,235,525 bytes, fail_message null; # through Zurich -> status 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 (exit code 2) # and flips the status to Failed. Two consequences: # # 1. Sonarr is told "failed" without a reason. A geo block looks exactly like # a broken server, an expired URL or a full disk - and the container log # does not mention the 403 either. # 2. That protection is an accident, not error handling. It rests entirely on # mkvmerge rejecting an HTML page. Any geo response mkvmerge would accept # is reported as Completed, and then a broken file walks into the library. # It did not happen in either measurement round (Akamai consistently # returns HTML), but that is not something to rely on. # # ---------------------------------------------------------------------------- # OPEN QUESTION, EXPLICITLY UNMEASURED: HLS # ---------------------------------------------------------------------------- # Part of the catalogue is served as an HLS playlist (application/x-mpegURL) # instead of a progressive MP4. The image carries an ffmpeg-based M3U8 path # (log lines "Starting M3U8 download" / "M3U8 download completed", and ffmpeg is # present in the image), so the code is there - but whether such an entry # actually completes end to end has NOT been measured. If it does not, those # entries fail regardless of any geo block. Stated as an open question on # purpose: no assumption in either direction. mediathekarr: enabled: false # Pinned BY DIGEST, unlike every other image in this chart, because the tags # are unusable: "latest" is from 2024-11-12 (= v0.2.5) and the upstream README # points at "beta", which is a moving tag. tag + digest together keep the # human-readable line and the immutable reference. # # The image is SINGLE-ARCH, linux/amd64 only - there is no arm64 variant. The # target cluster is amd64 throughout, so this is a note, not a problem; on a # mixed cluster the pod would need a nodeSelector this chart does not offer. # # Renovate: the built-in helm-values manager cannot follow this pair (the tag # carries no version), so renovate.json5 has a customManagers block that # matches exactly these three lines and proposes digest updates for the beta # tag. Renaming or reordering the keys silently switches that off - the rule # would still be valid, it just would not match any more (#158). image: repository: docker.io/pcjones/mediathekarr tag: beta digest: sha256:3ed7e1487810e1519301bd56a0c420e260e428295b029a47806c0397e4a52a33 # TWO ports, not one, and the upstream documentation only mentions the first: # 5007 MediathekArrDownloader - the SABnzbd role and the web UI # 5008 MediathekArrServer - the Newznab role Prowlarr talks to # The SABnzbd API lives under /download/api, NOT under /api: a request to # /api on 5007 answers with a Newznab validation error and sends you looking # in the wrong place. Both ports are declared to gluetun's firewall # automatically (see the firewallInputPorts helper). # # No ingress by default: MediathekArr's UI links its assets with root-absolute # paths (/download/css/..., measured), so it can only be served at the ROOT of # a host - a sub-path is broken even behind a prefix-stripping rewrite, # because the browser then requests the assets at the host root. On the shared # domain the root belongs to seerr, so enabling this ingress there needs # either seerr switched off or a host of its own here. Both consumers reach # MediathekArr over localhost and need no ingress at all; the UI is only # needed to run the setup wizard, for which a port-forward is enough. ingress: enabled: false host: null # Read only in the shared-domain mode. "/" is the only value that works; # anything else fails the render rather than producing a broken UI. path: / # clusterIssuer: null # className: null # annotations: {} storage: # Size of the MediathekArr config volume (/app/config, a small JSON). size: 1Gi # THE START GATE - the sharpest point of this container, and the one whose # failure mode looks green. # # If MediathekArr starts before the tunnel is up, its ruleset fetch fails once # and is never retried: the instance then answered every Newznab search with 0 # results for over eleven minutes, while the container was up, Ready and # logged nothing wrong. A green pod proves nothing here. With the start # delayed until gluetun reports healthy, the ruleset loads and the search # matches an instance running with direct egress (the search itself is not # geo-blocked - 56 hits through either exit). # # 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 this container's command - it polls gluetun's health server on # 127.0.0.1:9999 (the same endpoint gluetun's own probes use, 200 only while # the tunnel works) and only then execs the image entrypoint. # # On timeout the container EXITS instead of starting anyway. That is # deliberate: a crash-looping container is visible, a silently empty indexer # is not. # # Keep startupProbe budget > timeoutSeconds + app start, or the kubelet kills # the container while the gate is still waiting. Defaults: 180s gate, # 5s * 60 = 300s startup budget. startGate: enabled: true # Poll interval and total budget for gluetun becoming healthy. gluetun's own # startup budget is 150s, so 180s covers a normal handshake with room. intervalSeconds: 3 timeoutSeconds: 180 # Extra pause after gluetun reports healthy, before the app starts. NOT a # measured requirement - gluetun's 200 is the actual condition - but a cheap # buffer for DNS and routing to settle. 0 disables it. settleSeconds: 10 # WHERE THE DOWNLOADS GO. Both directories live on the SHARED downloads claim # (media.downloads), mounted at /downloads exactly like sonarr, radarr and # qbittorrent mount it, under this sub-directory: # # /downloads//incomplete work in progress # /downloads//complete finished, plus one sub-dir per category # # Two reasons, both of them the kind that fails silently: # # 1. complete/ MUST be visible to Sonarr/Radarr under the SAME absolute # path. The chart hands that path to MediathekArr # (DOWNLOAD_COMPLETE_PATH), which reports it to Sonarr as the SABnzbd # complete_dir. Every container here mounts the claim at /downloads, so # the paths are identical by construction - a deviation imports nothing, # without an error. # 2. incomplete/ must NOT be an emptyDir. An emptyDir counts against the # container's ephemeral-storage limit, and one large download makes the # kubelet evict the WHOLE pod - all ten containers. Same trap as the one # documented in ytdlp-web-player. # # Do not set DOWNLOAD_COMPLETE_PATH or DOWNLOAD_INCOMPLETE_PATH in the env # block below: the chart always sets both, and a second entry of the same name # is not a defined state (see README, "Environment variables"). downloads: directory: mediathekarr # Extra environment variables, appended to the ones the chart always sets. # Useful ones the image reads: CATEGORIES, MAX_PARALLEL_DOWNLOADS, # MEDIATHEKARR_API_BASE_URL. env: [] # Probes on measured 200 endpoints - MediathekArr has NO health endpoint at # all (/health, /healthz, /api/health, /download/health, /status and /ping all # answer 404). # # Liveness is an exec on purpose: one container runs TWO processes, and the # kubelet allows one probe of each kind. The Newznab half can wedge while the # downloader half still answers, and that is exactly the failure that makes # searches come back empty. The command checks both ports and restarts the # container when either stops answering. (The entrypoint already exits when # one of the two processes CRASHES - the exec probe covers the case where it # is alive but not serving.) # # Readiness and startup use the downloader port: that is the one the Service # and any ingress point at, and /download/ answers 200 without a query string. livenessProbe: exec: command: - /bin/bash - -c - >- wget -q -T 5 -O /dev/null "http://127.0.0.1:5008/api?t=caps" && wget -q -T 5 -O /dev/null "http://127.0.0.1:5007/download/api?mode=version&output=json&apikey=x" periodSeconds: 30 timeoutSeconds: 15 failureThreshold: 3 readinessProbe: httpGet: path: /download/ port: 5007 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 # The startup probe gates liveness/readiness AND has to outlast the start gate # (see startGate above): 5s * 60 = 300s against a 180s gate. startupProbe: httpGet: path: /download/ port: 5007 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 60 resources: limitFactor: 3 # ephemeral-storage stays small on purpose: downloads land on the media # claim, not on the container filesystem (see downloads above). requests: cpu: 0.1 memory: 256Mi ephemeralStorage: 100Mi # Measured, including a real download and the mkvmerge remux: the container # runs fully hardened - non-root, uid/gid from puid/pgid, # readOnlyRootFilesystem: true - as long as its writable paths are volumes. # Those are /app/config (the config claim) and the two download directories on # the media claim; the image's built-in default /data/mediathek is never # touched, because the chart overrides both paths by environment (measured: # read-only root, no /data/mediathek mount, no error). # PUID/PGID are NOT needed (the image's gosu path is only taken when they are # set), and the # commented-out non-root block in its Dockerfile is outdated for the beta. # runAsUser/runAsGroup come from the global puid/pgid values in the template - # do not set them here. securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true runAsNonRoot: true capabilities: drop: - ALL