Files
jklein 73c2ec704f
Validate Helm Charts / Build outline (pull_request) Successful in 8s
Validate Helm Charts / Build paperless-ngx (pull_request) Successful in 12s
Validate Helm Charts / Build patchmon (pull_request) Successful in 11s
Validate Helm Charts / Build rss-bridge (pull_request) Successful in 9s
Validate Helm Charts / Build samba (pull_request) Successful in 6s
Validate Helm Charts / Build satisfactory (pull_request) Successful in 10s
Validate Helm Charts / Build searxng (pull_request) Successful in 7s
Validate Helm Charts / Build stalwart (pull_request) Successful in 7s
Validate Helm Charts / Build sure (pull_request) Successful in 6s
Validate Helm Charts / Build template-chart (pull_request) Successful in 9s
Validate Helm Charts / Build teamspeak-server (pull_request) Successful in 8s
Validate Helm Charts / Build urlaubsverwaltung (pull_request) Successful in 20s
Validate Helm Charts / Build valkey (pull_request) Successful in 10s
Validate Helm Charts / Build voucher-vault (pull_request) Successful in 10s
Validate Helm Charts / Build ytdlp-web-player (pull_request) Successful in 9s
Validate Helm Charts / Build zipline (pull_request) Successful in 8s
Validate Helm Charts / Trivy config bentopdf (pull_request) Successful in 17s
Validate Helm Charts / Trivy config bulwark (pull_request) Successful in 17s
Validate Helm Charts / Trivy config calibre-web-automated (pull_request) Successful in 18s
Validate Helm Charts / Trivy config cyberchef (pull_request) Successful in 18s
Validate Helm Charts / Trivy config freesailarr (pull_request) Successful in 15s
Validate Helm Charts / Trivy config garage (pull_request) Successful in 17s
Validate Helm Charts / Trivy config gotenberg (pull_request) Successful in 27s
Validate Helm Charts / Trivy config homeassistant (pull_request) Successful in 18s
Validate Helm Charts / Trivy config keycloak (pull_request) Successful in 15s
Validate Helm Charts / Trivy config minecraft-server (pull_request) Successful in 17s
Validate Helm Charts / Trivy config rallly (pull_request) Successful in 11s
Validate Helm Charts / Trivy config samba (pull_request) Successful in 12s
Validate Helm Charts / Trivy config satisfactory (pull_request) Successful in 14s
Validate Helm Charts / Trivy config matrix-synapse (pull_request) Successful in 20s
Validate Helm Charts / Trivy config minecraft-router (pull_request) Successful in 17s
Validate Helm Charts / Trivy config outline (pull_request) Successful in 15s
Validate Helm Charts / Trivy config paperless-ngx (pull_request) Successful in 15s
Validate Helm Charts / Trivy config patchmon (pull_request) Successful in 13s
Validate Helm Charts / Trivy config rss-bridge (pull_request) Successful in 15s
Validate Helm Charts / Detect changed charts (pull_request) Successful in 4s
Validate Helm Charts / Trivy config stalwart (pull_request) Successful in 21s
Validate Helm Charts / Trivy config sure (pull_request) Successful in 14s
Validate Helm Charts / Trivy config teamspeak-server (pull_request) Successful in 16s
Validate Helm Charts / Trivy config urlaubsverwaltung (pull_request) Successful in 13s
Validate Helm Charts / Trivy config searxng (pull_request) Successful in 24s
Validate Helm Charts / Resource quantities (pull_request) Successful in 13s
Validate Helm Charts / HTTPRoute match (pull_request) Successful in 10s
Validate Helm Charts / Bump level (pull_request) Successful in 5s
Validate Helm Charts / NetworkPolicy (pull_request) Successful in 12s
Validate Helm Charts / Selector match (pull_request) Successful in 10s
Validate Helm Charts / Trivy config template-chart (pull_request) Successful in 13s
Validate Helm Charts / YAML scalars (pull_request) Successful in 17s
Validate Helm Charts / Trivy config valkey (pull_request) Successful in 26s
Validate Helm Charts / Trivy config voucher-vault (pull_request) Successful in 21s
Validate Helm Charts / Trivy config ytdlp-web-player (pull_request) Successful in 15s
Validate Helm Charts / Trivy config zipline (pull_request) Successful in 13s
Validate Helm Charts / Build apache-tika (pull_request) Successful in 5s
Validate Helm Charts / Trivy config apache-tika (pull_request) Successful in 22s
Validate Helm Charts / Build bulwark (pull_request) Successful in 6s
Validate Helm Charts / Build calibre-web-automated (pull_request) Successful in 5s
Validate Helm Charts / Build cyberchef (pull_request) Successful in 4s
Validate Helm Charts / Build bentopdf (pull_request) Successful in 8s
Validate Helm Charts / Build freesailarr (pull_request) Successful in 6s
Validate Helm Charts / Build garage (pull_request) Successful in 7s
Validate Helm Charts / Build gotenberg (pull_request) Successful in 6s
Validate Helm Charts / Build homeassistant (pull_request) Successful in 6s
Validate Helm Charts / Build keycloak (pull_request) Successful in 6s
Validate Helm Charts / Build matrix-synapse (pull_request) Successful in 6s
Validate Helm Charts / Build minecraft-router (pull_request) Successful in 5s
Validate Helm Charts / Build minecraft-server (pull_request) Successful in 5s
Validate Helm Charts / Build rallly (pull_request) Successful in 6s
Install-test / Install-test apache-tika (pull_request) Successful in 1m7s
Install-test / Install-test bentopdf (pull_request) Successful in 52s
Install-test / Install-test bulwark (pull_request) Successful in 1m16s
Install-test / Install-test calibre-web-automated (pull_request) Successful in 1m45s
Install-test / Install-test cyberchef (pull_request) Successful in 52s
Install-test / Install-test freesailarr (pull_request) Successful in 3m33s
Install-test / Install-test garage (pull_request) Successful in 1m10s
Install-test / Install-test gotenberg (pull_request) Successful in 1m10s
Install-test / Install-test homeassistant (pull_request) Successful in 2m8s
Install-test / Install-test keycloak (pull_request) Successful in 2m13s
Install-test / Install-test minecraft-server (pull_request) Successful in 1m41s
Install-test / Install-test rss-bridge (pull_request) Successful in 44s
Install-test / Install-test matrix-synapse (pull_request) Successful in 1m24s
Install-test / Install-test minecraft-router (pull_request) Successful in 38s
Install-test / Install-test outline (pull_request) Successful in 2m6s
Install-test / Install-test paperless-ngx (pull_request) Successful in 3m5s
Install-test / Install-test patchmon (pull_request) Successful in 1m21s
Install-test / Install-test rallly (pull_request) Successful in 1m45s
Install-test / Install-test samba (pull_request) Successful in 1m9s
Install-test / Install-test sure (pull_request) Successful in 2m11s
Install-test / Install-test urlaubsverwaltung (pull_request) Successful in 1m48s
Install-test / Install-test valkey (pull_request) Successful in 1m17s
Install-test / Install-test searxng (pull_request) Successful in 1m12s
Install-test / Install-test stalwart (pull_request) Successful in 1m37s
Install-test / Install-test teamspeak-server (pull_request) Successful in 1m14s
Install-test / Install-test voucher-vault (pull_request) Successful in 2m4s
Install-test / Install-test ytdlp-web-player (pull_request) Successful in 59s
Install-test / Install-test zipline (pull_request) Successful in 1m55s
Validate Helm Charts / Install-test (pull_request) Successful in 44m24s
feat!: NetworkPolicy in the last five charts - the sweep is complete
matrix-synapse, paperless-ngx, freesailarr, valkey, stalwart. One major each.
All 29 installable charts plus template-chart now render a policy, and
ci/run-networkpolicy-test.sh passes over all 30 with 206 assertions held.

MATRIX-SYNAPSE renders TWO policies, one per workload, and the split is the
decision in that file. Synapse federates - it must be able to reach any
homeserver on the internet, so 443 and 8448 go to any destination - while the
delegation nginx serves two static JSON documents and calls nobody, so its
egress is DNS and nothing else. A single policy over both pods would have to
permit the union, handing full federation reach and the database port to an
nginx whose job is to return a constant, and it would read as one decision
rather than as a widening. networkPolicy.egress.extraPorts deliberately does
not reach the delegation policy for the same reason.

PAPERLESS-NGX names the two pods of its own release that this object does NOT
cover: the valkey subchart's pod, which carries its own labels and needs no
cover because the valkey chart ships a tighter policy of its own; and the
consume CronJob's pods, whose pod template carries NO LABELS AT ALL, so no
podSelector can match them. That second one is a real gap in the convention. It
is tolerable only because the job moves files between mounted volumes and
reaches the SMB share through a CSI volume the node mounts - the pod opens no
connection of its own - and if that ever changes the CronJob needs labels
before it can need a policy.

FREESAILARR is ten containers in one pod behind a VPN, and simpler than that
sounds: they share a network namespace, so all inter-container traffic is
localhost and no policy sees any of it. Because the namespace belongs to
gluetun, everything the *arr containers send is encapsulated before it leaves,
so 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. The tunnel rule is the one
that can break this chart and also the best covered: gluetun's health server
answers 200 only while the tunnel is up and all three of its probes address it,
so a blocked 51820 is a pod that never goes Ready - in the install-test, which
brings up a real tunnel.

VALKEY AND STALWART, the two charts that had a policy before this issue, are
pulled onto the convention without a single rule changing. What changed:

  - the reasoning moved out of "#" comments, which are part of the rendered
    object and travel into the cluster, into template comments. Both files
    failed the new check; that is what it is for.
  - stalwart gains networkPolicy.ingressController, which fixes a hole its own
    comment described without being able to solve: it advised putting the
    controller's namespace into allowedNamespaces, which is namespace-granular
    and would have opened the http port to every pod in kube-system.
  - stalwart's example was WRONG and is corrected. It suggested
    ["ingress-nginx"]; measured on 2026-10-14, ingress-nginx is gone from this
    cluster entirely and the controller is rke2-traefik in kube-system. A policy
    written against the old one would have been accepted, looked strict, and
    silently blocked all web traffic.

Their justified specifics are untouched: valkey stays Ingress-only, because it
opens no outbound connection and an egress rule would constrain nothing while
making the object look stricter; and it keeps 6379 restricted by source with no
probeSources at all, which is safe only because all three of its probes are
exec probes and put no packet on the network. The convention was written to
accommodate both, and homeassistant and satisfactory now invoke valkey's rule
in the other direction.

ONE CHANGE TO THE TEST, forced by a structural fact rather than by convenience:
the rendered-comment assertion now skips documents from a SUBCHART. A
dependency is fetched from the Helm registry, not from the copy in this
repository, so the policy paperless-ngx and stalwart bundle is whatever valkey
version is PUBLISHED - and nothing in the parent chart can change its rendered
comments. Holding the parent responsible would have made the test unfixable
until valkey's next release and would fail a chart for a file its own diff does
not contain. The subchart's own pull request is where it is caught, because this
test runs over every chart including valkey itself. All twelve failure paths
were re-run against the change.

Part of #5
2026-10-14 14:46:11 +00:00

1431 lines
63 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
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: []