Files
helm-charts/freesailarr/values.yaml
T
jklein 8db757103a refactor(freesailarr): ambientMesh becomes an object carrying Istio's two constants
Gitea #143, maintainer's instruction. The HBONE port and the redirected probe
source are ISTIO CONSTANTS, not standards, so they move from template literals
into values with documented defaults - an operator can then correct them when
Istio changes a number, without waiting for a chart release.

One object under one "enabled", so the two halves still cannot be set
separately; two values could drift apart, which is what this design exists to
prevent. Every read is null-safe (#99), so a deleted object or one carrying
only "enabled: true" falls back to the defaults declared here rather than
dereferencing nil - the defect #136 shipped one value away.

Provenance now written on each constant: 15008 is Istio's HBONE port, not
IANA-registered for this and unknown to Kubernetes; 169.254.7.127 is link-local
(RFC 3927, never routed), its IPv6 counterpart carries the same digits plus
1337, and neither the NetworkPolicy API nor the kubelet knows the address -
only Istio's data plane rewrites the probe source onto it. Both read for
ztunnel 1.30.6, and a change in either fails silently.

The exception in ci/run-networkpolicy-test.sh stays pinned to the numbers that
test names, and additionally asserts this chart declares the same defaults -
never "whatever the value says", which would let a mistyped port in through a
configurable field.

No version change: nothing is published yet, the default is still off, and the
render at defaults is byte-identical - re-diffed against origin/main.
2026-10-19 17:56:59 +00:00

1560 lines
71 KiB
YAML

# 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 <app>.image.repository / <app>.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 <app>.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
# <app>.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
# (<app>.ingress.path, default /<app>), 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 <app>.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: /<urlbase>/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/<asset>
# 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 <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 <app>.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 <app>.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 <app>.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 <release>-<chart>-<app>-ingress)
# with its own TLS secret tls-<release>-<chart>-<app>-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 /<app> 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
# (~* "^<path>") in its enforceRegex branch and RETURNS THERE - before it
# ever reaches the PathTypeExact branch that would emit the = "<path>"
# location nginx gives precedence to. And enforceRegex is computed once per
# nginx SERVER, not per location: rootfs/etc/nginx/template/nginx.tmpl
# evaluates enforceRegexModifier over every location of 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/<directory>/incomplete work in progress
# /downloads/<directory>/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
# ---------------------------------------------------------------------------
# NetworkPolicy (Gitea #5). On by default. The reference implementation with
# the full reasoning is template-chart; the convention is in README, "Chart
# style guidelines", and the operator's view on this chart's wiki page.
# ---------------------------------------------------------------------------
networkPolicy:
# TEN CONTAINERS IN ONE POD, AND THE FIRST THING TO KNOW: they talk to each
# other over LOCALHOST. They share a network namespace, so that traffic never
# touches an interface and no NetworkPolicy sees it. This object governs only
# what enters or leaves the pod.
#
# WHAT IT ALLOWS, inbound: the nine probed ports as one rule (gluetun's
# health server 9999, plus qBittorrent 8080, Prowlarr 9696, FlareSolverr
# 8191, Radarr 7878, Sonarr 8989, Seerr 5055, Bazarr 6767, Mediathekarr
# 5007), and mediathekarr's newznab port 5008 RESTRICTED by source - nothing
# probes it, and its only consumer is Prowlarr over localhost, so restricting
# it costs nothing.
#
# OUTBOUND, and this chart is unlike every other one here: the pod's network
# namespace belongs to gluetun, so everything the *arr containers do is
# encapsulated into the WireGuard tunnel before it leaves. Three rules
# describe the whole outbound surface - DNS, UDP 51820 for the tunnel, and
# 443 for gluetun's server-list API, which cannot go through the tunnel
# because it runs before the tunnel exists. Indexer and tracker traffic needs
# no rule: it is already inside.
#
# THE TUNNEL RULE IS THE ONE THAT CAN BREAK THIS CHART, and it is also the
# best-covered: gluetun's health server returns 200 only while the tunnel is
# up, and all three of its probes address it, so a blocked 51820 means a pod
# that never becomes Ready - visibly, and in the install-test, which brings
# up a real tunnel. 51820 is WireGuard's and Mullvad's standard port; Mullvad
# also offers 53, 123, 500 and 4500 for obfuscation, and a release using one
# of those must widen the rule in the TEMPLATE, because egress.extraPorts is
# TCP only. That is a limit of the shared values shape, said plainly.
enabled: true
# Namespaces whose pods may reach this workload, in addition to the release's
# own namespace, which is always allowed. Empty means: own namespace only.
#
# Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every
# namespace itself, so no labelling work is needed on a namespace this chart
# does not own - and no typo protection either: a name that does not exist
# matches nothing and is silently ineffective. Note the granularity: an entry
# opens the port to EVERY pod in that namespace.
allowedNamespaces: []
# Source CIDRs allowed to reach the nine probed ports (gluetun's health server and the eight web interfaces), which is every inbound port except mediathekarr's newznab port.
#
# EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a
# "from" selector, so the port is reachable from anywhere the CNI routes to -
# inside the cluster, since it carries no NodePort.
#
# That is the deliberate choice between two bad defaults. The kubelet's probes
# originate from the IP of the WORKER NODE running the pod, an address this
# chart cannot know and must not guess: a CIDR that excludes the real node
# blocks every probe, the pod never becomes Ready, the rollout times out, and
# the symptom reads as a broken application rather than as a policy.
#
# Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind
# the companion trap: once this list is non-empty the rule IS restricted, so
# everything else that needs the port has to be in it. The chart adds the own
# namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason.
probeSources: []
# The ingress controller allowed to reach the routed port once probeSources
# restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in
# the restricted branch - with the default (open) rule the controller reaches
# the port anyway. The two keys become ONE "from" entry and are therefore an
# AND: pods carrying podLabels IN namespace. That is the point of having them
# at all - the controller's namespace in allowedNamespaces would open the port
# to every pod in kube-system.
#
# MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone,
# the only IngressClass is "traefik" and it is the default, and the controller
# is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the
# Gateway API here, so this one source covers the Ingress and the HTTPRoute
# path alike. A policy written against the wrong controller is accepted, looks
# strict, and silently blocks all web traffic - and ingress.className says
# nothing about it, because a class names an implementation, not the pods the
# traffic comes from.
ingressController:
namespace: kube-system
podLabels:
app.kubernetes.io/name: rke2-traefik
# The policy's INBOUND side. Mind the three names that overlap as substrings,
# because a search for "ingress" matches all of them and they are different
# things:
#
# ingress (top level) the Ingress OBJECT this chart
# renders - host, TLS, className
# networkPolicy.ingress this block: what the policy lets IN
# networkPolicy.ingressController the SOURCE SELECTOR for the ingress
# controller's pods. A SIBLING of this block
# and NO PART OF IT, despite the name.
ingress:
# Opens the pod for Istio's ambient mesh (Gitea #143). OFF by default and
# additive: while it is false this chart renders byte for byte as it did
# before the value existed.
#
# IT RENDERS TWO THINGS, and that they come together is the point:
#
# 1. 15008/TCP inbound - ztunnel's HBONE tunnel. Every inbound mesh
# connection arrives on it, and ambient "does not and will never bypass
# Kubernetes NetworkPolicy enforcement", so without this port a pod
# whose namespace has joined the mesh is unreachable.
# 2. 169.254.7.127/32 as an additional peer - but ONLY where the rule
# restricts sources at all, which here means probeSources is non-empty.
# Inside the mesh the kubelet's probes are redirected and no longer come
# from the worker node's address.
#
# WHY ONE SWITCH AND NOT TWO: point 2 is the one nobody thinks of. Whoever
# adds "15008" to a port list by hand writes a policy that passes review, and
# the first time probeSources is narrowed every probe is dropped - the pod
# never becomes Ready and the symptom reads as a broken application rather
# than as a policy. A switch whose halves can only be had together cannot be
# set halfway, which is why this is a named intention and not a port list.
#
# TWO THINGS IT DOES NOT PROVE:
# - That 15008 alone is enough. That comes from Istio's documentation;
# measured on this cluster is only which listeners ztunnel opens.
# Provable at the first namespace to join the mesh - none carries
# istio.io/dataplane-mode yet.
# - That the probe-source half works. probeSources is empty in every chart
# here, so no release reaches that branch. CI proves its render, not its
# effect.
#
# Note where the port lands: it joins the rule that carries the probe port,
# so once probeSources restricts that rule, 15008 is restricted with it.
# Whether ztunnel needs a peer of its own is open - it runs with its own pod
# IPs in istio-system (measured: no hostNetwork on the DaemonSet, Istio
# 1.30.6), so if one proves necessary it is expressible as a
# namespaceSelector plus podSelector and needs no unknowable node CIDR.
# BOTH NUMBERS BELOW ARE ISTIO CONSTANTS, NOT STANDARDS, and they are values
# rather than template literals for exactly that reason: a vendor constant
# can change, and an operator should be able to correct it without waiting
# for a chart release. Their provenance is written on each one.
#
# DELETING THIS OBJECT IS SAFE. Every read falls back to the defaults
# declared here (#99), so "ambientMesh: null", or an object carrying only
# "enabled: true", renders the documented defaults instead of failing.
ambientMesh:
# The switch. Everything else here is inert while it is false, and the
# chart renders exactly as it did before the object existed.
enabled: false
# ISTIO'S HBONE PORT. A constant Istio chose, not a standard: not
# registered with IANA for this, prescribed by no protocol, and unknown to
# Kubernetes. Read for ztunnel 1.30.6 (measured on this cluster: ztunnel
# opens [::]:15008 as its inbound listener).
#
# If Istio changes it, a pod in the mesh cannot be reached. Change it here
# in all 30 charts AND in the pinned expectation inside
# ci/run-networkpolicy-test.sh - the test is deliberately what forces
# someone to look, because the exception it grants this port is pinned to
# the number IT knows and not to whatever this value says. A value-driven
# exception would let a mistyped port through, which is the hole design A
# of #143 was rejected for.
#
# NOT MEASURED: that this port ALONE suffices comes from Istio's
# documentation. Measured here is only which listeners ztunnel opens, and
# no namespace carries istio.io/dataplane-mode yet.
port: 15008
# SOURCES THE KUBELET'S PROBES ARRIVE FROM ONCE ISTIO HAS REDIRECTED THEM.
#
# NOT THE SAME VALUE AS networkPolicy.probeSources one level up, and the
# two are easy to confuse, so:
#
# networkPolicy.probeSources
# YOUR worker node CIDRs. Empty by default, and empty means the
# probe rule is OPEN. The chart cannot know these addresses.
# networkPolicy.ingress.ambientMesh.redirectedProbeSources
# ISTIO's rewrite address. Shipped filled, because unlike the node
# IPs it is a constant the chart CAN know. Only rendered where the
# rule above is restricted, i.e. where probeSources is non-empty.
#
# WHERE 169.254.7.127 COMES FROM - vendor magic, not protocol:
#
# - LINK-LOCAL, 169.254.0.0/16 (RFC 3927): a range reserved for
# node-local use that is never routed - exactly the range one picks a
# vendor's magic address out of.
# - Istio's IPv6 counterpart gives it away:
# fd16:9254:7127:1337:ffff:ffff:ffff:ffff carries the SAME DIGITS
# (9254:7127 is 169.254.7.127) plus 1337. Numbers built that way are
# vendor constants, not protocols.
# - KUBERNETES DOES NOT KNOW THIS ADDRESS. Neither the NetworkPolicy API
# nor the kubelet has any notion of it; only Istio's own data plane
# rewrites the probe source onto it.
#
# Read for ztunnel 1.30.6. IF ISTIO CHANGES IT, THE FAILURE IS SILENT:
# probes would be dropped, and only where probeSources is non-empty - which
# is nowhere in this repository today - so nothing here and nothing in
# Kubernetes would report it. What to re-check then: the address Istio's
# release notes name for probe redirection, this list in all 30 charts, and
# the pinned expectation in ci/run-networkpolicy-test.sh.
#
# A LIST, not a string, and the one case where that does not contradict "a
# constant, not a configuration": on a DUAL-STACK cluster BOTH entries are
# needed at the same time, not one of two. This cluster is SingleStack
# IPv4, so the IPv6 counterpart is left out - an ipBlock of the wrong
# family is accepted by the API server and matches nothing. Add it
# ALONGSIDE, never instead, if the cluster gains IPv6.
#
# Clearing this list restores the default rather than removing the ipBlock,
# deliberately: the two halves of this switch are not separable. To not
# open the port at all, set enabled: false.
#
# UNPROVEN IN OPERATION: probeSources is empty in every chart here, so no
# release reaches this branch. CI proves the render, not the effect.
redirectedProbeSources:
- 169.254.7.127/32
egress:
# Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and
# has no switch - a knob whose only other setting breaks every name lookup
# in the pod is a trap, not a feature.
#
# PORTS, NOT DESTINATIONS, and that is a limit of the object rather than
# laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames,
# so "only to the database host" cannot be written at all. Each entry
# permits its port to ANY destination. What the cap still buys is real: a
# compromised pod cannot open arbitrary outbound connections.
#
# TCP ONLY - an alternative WireGuard UDP port cannot be added here. The
# usual entry is an in-cluster service Seerr talks to outside the tunnel
# (Jellyfin on 8096), which also needs gluetun.vpn.firewallOutboundSubnets
# set, or gluetun's own firewall blocks it before this policy is reached.
extraPorts: []