# 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. (In GATEWAY MODE that second object is gone: the Gateway API's # URLRewrite filter is per RULE, not per object, so qBittorrent is one rule # in the shared route while its neighbours stay unrewritten. The traps # below are the application's and apply in both modes.) CALL ITS PATH WITH # A TRAILING SLASH: the bare /qbittorrent is stripped to "/", answered 200 # by qBittorrent, and its relative links then resolve to the root of the # shared host - which is seerr. Gateway mode renders a 301 for this itself; # Ingress mode cannot, and the nginx remedy plus its price is written out # at qbittorrent.ingress below (Gitea #118). Two further 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. # # Mutually exclusive with httpRoute.enabled below (Gitea #114): with both # true the render aborts, naming both values. Mind that this defaults to # TRUE, so turning the Gateway mode on means setting enabled: false in the # same values file. 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. # # Read by BOTH routing modes: it names the public address of the stack and is # not an Ingress switch. Same for the per-app .ingress.host. host: "" clusterIssuer: null className: nginx # Extra annotations for the Ingress objects; merged with the cert-manager # cluster-issuer annotation the chart always sets. # # NO COUNTERPART IN GATEWAY MODE, and the same goes for the per-app # overrides: the Gateway API has no annotation channel for controller # behaviour, so anything set here is simply gone when httpRoute.enabled is # used instead. This chart NEEDS nothing from it - the one behaviour-bearing # annotation it sets itself, qBittorrent's prefix-stripping rewrite, has a # real and better equivalent in that mode (a per-rule URLRewrite filter). But # an annotation YOU added here is lost without a word; a body-size cap for # qBittorrent's torrent-file uploads is the likely candidate, and it then has # to be set at the Gateway or through its implementation's own policy object. # # ONE ANNOTATION IS WORTH ADDING HERE IN INGRESS MODE and the chart cannot # ship it: the configuration-snippet that redirects the bare /qbittorrent to # /qbittorrent/. It is written out, with its allow-snippet-annotations price, # at qbittorrent.ingress below (Gitea #118). In Gateway mode it is neither # needed nor possible - the chart renders that redirect as a rule of the # route - so an operator who set it here does not lose a behaviour on # migration, unlike the body-size case above. annotations: {} # Gateway-API alternative to the Ingress above (Gitea #114). Off by default, so # an existing release is unaffected; exactly one of ingress.enabled and # httpRoute.enabled may be true - and ingress.enabled defaults to true, so it # has to be switched off explicitly. # # The chart renders ONLY HTTPRoutes with parentRefs - no Gateway, no # gatewayClassName, no Certificate and no implementation-specific policy # object. Class and certificate belong to the Gateway and therefore to the # platform, which is what keeps this chart independent of whether the cluster # runs nginx-gateway-fabric, Cilium, Envoy Gateway or Istio. # # Both shapes above work unchanged, and the sub-path mode gets SIMPLER: the # Gateway API's URLRewrite filter is per rule, so qBittorrent no longer needs a # second routing object of its own. One route, one rule per app, the filter on # qBittorrent's rule only. # # There is deliberately NO httpRoute.hostnames here, unlike every other chart # in the repository: this chart has no ingress.domain either (it bundles ten # independently exposed apps - README, guideline 4, documented exception), so a # chart-global list of hostnames could not say which app it belonged to. The # hostnames come from ingress.host and .ingress.host, which is where this # chart's single source of truth for them already is. # # The Gateway-API CRDs must be installed in the cluster: the chart does not and # cannot detect them (.Capabilities.APIVersions is unreliable at template # time), so with httpRoute.enabled: true and no CRDs the INSTALL fails. httpRoute: enabled: false # The Gateway(s) every route of this release attaches to - a platform object, # usually in another namespace, which must allow this namespace in its # listener's allowedRoutes. One entry per parent, the fields are the Gateway # API's ParentReference (name, and optionally namespace, sectionName, port, # group, kind), passed through unchanged: # # parentRefs: # - name: public # namespace: gateway # sectionName: https # # REQUIRED while httpRoute.enabled is true - the render aborts on an empty # list, because a route without a parent is created, is valid and routes # nothing, with no event to say so. The same list is used for every route # this chart renders; the hosts differ, the Gateway does not. parentRefs: [] 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.4 # 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.) # # CALL THIS PATH WITH A TRAILING SLASH IN INGRESS MODE (Gitea #118) # # qBittorrent has no base-path setting, so the prefix is stripped before the # request reaches it. A request for the bare path - https://host/qbittorrent, # no trailing slash - is therefore stripped to "/", which qBittorrent answers # 200 with its login page. Nothing reports an error anywhere. But the # browser's document URL stays prefixless, so every RELATIVE link in that # page resolves against the host root - and "/" on the shared host is SEERR. # The result is an unstyled login form whose assets, XHRs and form posts are # all answered by a different application, which is a symptom that points # nowhere useful. https://host/qbittorrent/ works correctly. The same applies # to any custom path set here; it is a property of prefix stripping, not of # the default value. # # IN GATEWAY MODE THE CHART RENDERS THE REDIRECT ITSELF. qBittorrent's rule # in the shared HTTPRoute is joined by a second rule matching the bare path # EXACTLY, whose RequestRedirect filter answers 301 to the slashed form. The # Gateway API ranks an Exact match above every prefix match, so it wins over # both qBittorrent's own PathPrefix rule and seerr's "/" regardless of # ordering. 301 rather than 308 because only 301 and 302 are Core in that # API - an Extended value an implementation declines makes it set # Accepted=False on the WHOLE route, taking the neighbouring apps' routing # with it. One asymmetry to the nginx remedy below: the Gateway API's # redirect filter has no field for the query string (it replaces the full # PATH), while the nginx snippet carries it over explicitly with # $is_args$args. It costs nothing on the UI entry path, which takes no query # parameters, but it is a real difference. # # IN INGRESS MODE THE CHART RENDERS NO REDIRECT, and that is a limitation, # not an oversight. The remedy exists but cannot be shipped: put an nginx # snippet into the global ingress.annotations (it is merged into both of this # chart's Ingress 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, which is why it is documented rather than added: snippet # annotations are only honoured on a controller started with # allow-snippet-annotations=true, and since ingress-nginx 1.9 that flag is # OFF by default. Switching it on also means accepting # annotations-risk-level: Critical, because a configuration snippet is # arbitrary nginx configuration injected from a namespaced object. A chart # that shipped this annotation would silently render a no-op on every cluster # that keeps the default, which is worse than naming the gap. The decision # belongs to whoever runs the ingress controller. # # AND A THIRD INGRESS OBJECT WITH pathType: Exact IS NOT A SUBSTITUTE here, # which is the obvious-looking alternative and does not work. In # ingress-nginx controller-v1.14.5, internal/ingress/controller/template/ # template.go, buildLocation() returns a plain regex location # (~* "^") in its enforceRegex branch and RETURNS THERE - before it # ever reaches the PathTypeExact branch that would emit the = "" # 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 a hostname, and it # returns true as soon as one of them needs a rewrite. qBittorrent's own # object carries rewrite-target on this very hostname, so enforceRegex is # true for the whole server and an Exact path would render as an ordinary # regex location. The exact-match precedence the idea depends on never comes # into being. The unsuitability follows from that short-circuit alone; 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. 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.6.5 # Networks whose X-Forwarded-For / -Proto / -Host headers prowlarr honours, # as comma-separated CIDRs (e.g. "10.42.0.0/16"). Rendered as # PROWLARR__SERVER__TRUSTEDNETWORKS, and only when this value is non-empty. # # EMPTY IS THE DEFAULT, AND WITH IT PROWLARR TRUSTS NO PROXY AT ALL. That is # a change in behaviour and not a preserved one. Up to 2.5.2 the app # 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 in Startup.cs; 2.6.3 removed all five # and replaced them with this setting, whose own upstream default is # string.Empty - ForwardedHeadersConfigurator returns without registering a # single KnownNetwork while it is empty. Upstream's reason for the change is # that trusting X-Forwarded-For from every private network is a spoofing # vector, and this chart follows that intent rather than papering over it. # # What it costs while this stays empty and the app is published through an # Ingress or an HTTPRoute: the client address prowlarr logs and shows is the # ingress controller's pod IP rather than the real client, and the app sees # the scheme as http even behind TLS termination. If its authentication is # set to "Disabled for Local Addresses", a proxied request no longer counts # as local and is asked to authenticate. # # The chart cannot fill this in for you. The network to trust is the one your # ingress controller's pods sit in - the cluster pod CIDR, or that single # controller pod's IP - which is a property of the cluster and not of the # release. Set it yourself if you want the forwarded headers honoured. # # AllowedHosts, the other key 2.6.3 added, is deliberately NOT exposed as a # value here. Its upstream default is string.Empty as well, and while it is # empty the app accepts requests for any host ("Allowed Hosts is not # configured, accepting requests for any host"), so there is nothing to # repair. One trap to know before anyone sets it through the env block # anyway: the app only ever appends localhost, 127.0.0.1, [::1] and its own # hostname to that 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, so an # incomplete list answers them with HTTP 400. trustedNetworks: "" # 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.2 # 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 # The same setting, the same empty default and the same consequence as # prowlarr's - read the trustedNetworks comment there for the whole picture. # Radarr dropped the five hard-coded private networks in 6.4.3 (6.3.0, the # version this chart shipped before, still had them) and reads this as # RADARR__SERVER__TRUSTEDNETWORKS. trustedNetworks: "" # 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.3 # 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.20 # The same setting as prowlarr's - read the trustedNetworks comment there. # Reads as SONARR__SERVER__TRUSTEDNETWORKS. # # One difference worth stating plainly: sonarr did NOT lose the forwarded # header trust with this chart version. 4.0.19, the version this chart # shipped before, already had no hard-coded networks and the same # empty-default TrustedNetworks, so the value is being supplied here for a # state that already existed rather than for a new one. It had arrived # unnoticed in what the convention classes as a plain app-version bump. trustedNetworks: "" # 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.5.0 # 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.2 # 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