# 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 nine images, gluetun included, are # pinned here via .image.repository / .image.tag, because the stack # combines nine independently versioned upstream projects and none of them is # "the" one app version. # # 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 a hand-maintained marker for the gluetun # line this chart is built around, not the source of any tag. Keep it in step # with gluetun.image.tag below when you accept a gluetun update, otherwise the # +up suffix of the chart version drifts away from what is actually deployed. # 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. pod: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch 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 # 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 all # three categories with different subPaths - one library volume, one # sub-directory per category, e.g. downloads / movies / tv-shows. # # 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. # Getting hardlinks would mean giving the apps a single mount that contains all # categories (the "one /data mount" layout the *arr guides recommend) instead of # one mount per category - a separate change to the chart's mount layout, not a # values setting. media: downloads: existingClaim: null subPath: null movies: existingClaim: null subPath: null tv: existingClaim: null subPath: null # Ingress defaults. There is deliberately no global domain: every app carries # its own .ingress.host, so the hosts are independent of each other and # may live in completely different domains. 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). ingress: # Master switch: false suppresses every app ingress regardless of the # per-app toggles. enabled: true 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: qmcgaw/gluetun tag: v3.41.1 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: linuxserver/qbittorrent tag: 5.2.1 # 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. clusterIssuer, className and # annotations may be set here to override the global ingress block. # (Same block for prowlarr, radarr, sonarr, seerr and bazarr below.) ingress: enabled: true host: null # clusterIssuer: null # className: null # annotations: {} storage: size: 1Gi # 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-interface init # container below; setting it to null or "" removes that init container and # leaves the binding to the user (not recommended). networkInterface: tun0 # The init container that seeds qbittorrent.networkInterface into the # persisted qBittorrent config before the app starts. Runs as puid/pgid so # the config file keeps the ownership the app expects. configInit: # Auxiliary busybox image. Pinned independently of appVersion (which # tracks gluetun); reviewed alongside every app-version update round. image: repository: 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: linuxserver/prowlarr tag: 2.3.5 # See the qbittorrent ingress comment. ingress: enabled: true host: null # 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: linuxserver/radarr tag: 6.1.1 # See the qbittorrent ingress comment. ingress: enabled: true host: null # 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.6.0 # 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: linuxserver/sonarr tag: 4.0.17 # See the qbittorrent ingress comment. ingress: enabled: true host: null # 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.3.0 # See the qbittorrent ingress comment. ingress: enabled: true host: null # 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: linuxserver/bazarr tag: 1.5.6 # See the qbittorrent ingress comment. ingress: enabled: true host: null # 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