Merge pull request #59 from jk-cluster/feat/freesailarr-chart

Add freesailarr chart: gluetun-tunneled media automation stack
This commit is contained in:
jklein authored and GitHub committed 2026-08-24 13:38:58 +00:00
commit 9c81061f92
14 files changed
+1661 -1

No files matched your search

+5
View File
@@ -35,6 +35,11 @@ on:
- minecraft-router
- minecraft-server
- rss-bridge
# freesailarr is deliberately absent: none of its nine images takes
# its tag from appVersion (they are all pinned in its values.yaml),
# so this workflow would rewrite appVersion and republish the chart
# without changing a single deployed image. Update its images through
# a Renovate PR, and its appVersion by hand.
workflow_call:
inputs:
app-version:
+15
View File
@@ -0,0 +1,15 @@
name: Publish Freesailarr Helm Chart
on:
workflow_dispatch:
push:
branches:
- main
paths:
- freesailarr/**
jobs:
publish-freesailarr:
name: Publish Freesailarr Helm Chart
uses: ./.github/workflows/publish-helm-chart.yml
with:
chart-name: 'freesailarr'
secrets: inherit
+7 -1
View File
@@ -9,6 +9,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo
| `apache-tika` | Apache Tika | Deployment + Service only (no ingress) |
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
| `cyberchef` | CyberChef | Stateless Deployment |
| `freesailarr` | gluetun VPN media stack (qbittorrent, prowlarr, flaresolverr, radarr, recyclarr, sonarr, seerr, bazarr) | Single-pod StatefulSet: all apps share gluetun's network namespace; config volumes via volumeClaimTemplates, media PVCs as existingClaims (optional per-category `subPath`, so one claim can serve all three categories). One ClusterIP Service and one Ingress **per app** — no global `ingress.domain`, each app has its own required host (documented ingress exception below) |
| `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
| `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests |
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
@@ -65,10 +66,13 @@ What Renovate keeps current:
| --- | --- |
| Chart app versions (`appVersion:` in `Chart.yaml`) | One `customManagers` entry per chart, hard-coding the image repository |
| Sidecar/init images in `values.yaml` (nginx, guacd, busybox, alpine) | Built-in `helm-values` manager |
| **All** images of `freesailarr`, including its main one | Built-in `helm-values` manager — the chart pins every tag in `values.yaml` and has no app-version block |
| GitHub Actions | Built-in `github-actions` manager |
| Tool versions pinned as workflow env vars (`TRIVY_VERSION`) | `# renovate: datasource=… depName=…` comment above the line |
Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one.
Each chart renders its main image as `<repo>:{{ .Chart.AppVersion }}` — repository in the template, version in `Chart.yaml`. Renovate cannot join two files, so the mapping lives in `renovate.json5`, one block per chart. It is deliberately *not* a `# renovate:` comment inside each `Chart.yaml`: that would touch all chart directories and force a version bump plus a publish per chart for a pure comment change. Charts whose tags carry a flavour or date suffix (`apache-tika`, `minecraft-server`, `rss-bridge`, `teamspeak-server`) override `versioning` in their block; the reasoning sits next to each one. `freesailarr` has no block on purpose — see the images exception above; a block would either duplicate the `helm-values` updates or rewrite an `appVersion` that selects no image.
A consequence of pinning every tag in `values.yaml`: `ci/renovate-bump-chart.sh` sees an unchanged `appVersion` and bumps the chart **patch**. That holds for all nine images alike — none of them is special — and it is the intended outcome, since `appVersion` here is the chart's own marker rather than a tracked upstream version.
One dependency is constrained beyond its versioning: **nginx** (the Synapse delegation sidecar) is limited to **even minors** via `allowedVersions`. nginx numbers its release branches by minor — even is stable, odd is mainline — and the chart belongs on stable. Patch updates inside the stable branch still flow through normally. The constraint tolerates a variant suffix (`1.30.4-alpine`) on purpose: docker versioning only ever offers candidates carrying the current tag's suffix, so a suffix-blind constraint would silently freeze nginx the day the sidecar moves to a slimmer base image — a switch to `1.30.4-alpine` that #84 has commissioned a check on — with no error and no warning.
@@ -142,6 +146,7 @@ These guidelines are **binding for every current and future chart** in this repo
### 3. Images
- The main app image tag comes from `{{ .Chart.AppVersion }}` — never hardcoded.
- **Documented exception — freesailarr**: the chart bundles nine independently versioned upstreams, so none of them is "the" app version. **No** tag comes from `appVersion`; all nine images are pinned as `<app>.image.repository` / `<app>.image.tag` in `values.yaml`, which is also what puts every one of them under the built-in `helm-values` manager. Its `appVersion` is the chart's own version marker, freely chosen and tied to no upstream — raised by hand when a new edition of the chart is marked as a whole. Consequently the chart has **no** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list.
- Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round.
### 4. Ingress
@@ -151,6 +156,7 @@ These guidelines are **binding for every current and future chart** in this repo
- `ingress.annotations` takes optional extra annotations that are merged with the cert-manager `cluster-issuer` annotation the chart always sets.
- The TLS secret is named `tls-<release>-<chart>-ingress`.
- **Documented exception — homeassistant**: keeps its upstream-style flexible ingress values (host/tls lists, no required values); only its `ingress.className` default follows the standard (`nginx`, clearable to omit the field).
- **Documented exception — freesailarr**: the chart bundles several independently exposed apps in one pod, so it has **no** `ingress.domain`. Each app carries its own `<app>.ingress.enabled` plus a required `<app>.ingress.host` (required only while that app's ingress is enabled) and renders its own Ingress object `<release>-<chart>-<app>-ingress` with its own TLS secret `tls-<release>-<chart>-<app>-ingress`. `clusterIssuer`, `className` and `annotations` stay global defaults under `ingress.` and are overridable per app.
### 5. Security hardening
+1
View File
@@ -1,4 +1,5 @@
# Charts excluded from the PR install-test, one per line: <chart> <reason>.
# ci/run-install-test.sh skips these with the given reason.
freesailarr gluetun never becomes healthy without a real Wireguard key and VPN connectivity, and every app container sits behind its firewall - the pod cannot become Ready on a CI runner
satisfactory the game-server image is multiple GB and Steam downloads 10GB+ game files on first start - impractical on a CI runner
template-chart scaffold with placeholder image "ghcr.io/ORG/IMAGE:xxx", never published
+39
View File
@@ -0,0 +1,39 @@
# Render values for the misconfiguration scan only (ci/run-trivy-config.sh).
#
# freesailarr has no ci/freesailarr/test-values.yaml because it is excluded from
# the install-test (gluetun never becomes healthy without a real Wireguard key),
# but it is still scanned - the chart runs nine containers with several
# documented hardening deviations, which is exactly what the scan should see.
#
# Only the values the chart declares `required` - nothing that changes the
# security posture (no securityContext, image, capability or resource overrides).
# The ingress hosts are per app since the chart has no global ingress.domain.
secrets:
gluetunSecretRef: op://Vault/scan/placeholder
ingress:
clusterIssuer: letsencrypt
media:
downloads:
existingClaim: scan-downloads
movies:
existingClaim: scan-movies
tv:
existingClaim: scan-tv
qbittorrent:
ingress:
host: qbittorrent.scan.example.com
prowlarr:
ingress:
host: prowlarr.scan.example.com
radarr:
ingress:
host: radarr.scan.example.com
sonarr:
ingress:
host: sonarr.scan.example.com
seerr:
ingress:
host: seerr.scan.example.com
bazarr:
ingress:
host: bazarr.scan.example.com
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+24
View File
@@ -0,0 +1,24 @@
apiVersion: v2
name: freesailarr
description: A Helm chart to deploy a Wireguard-tunneled media automation stack on Kubernetes - a VPN gateway with indexer, downloader, library managers, subtitle fetcher and request front end in one pod, all reaching the internet only through the tunnel
# No icon on purpose: the stack has no single upstream, and every other chart's
# icon is its one project's logo. Picking any of the nine here would suggest
# the same false "main app" the versioning deliberately avoids. The field is
# optional, so it is omitted rather than filled with an arbitrary choice.
type: application
version: 1.0.0+up1.0.0
# The chart's own version marker, freely chosen and tied to no upstream. The
# stack bundles nine independently versioned projects, so there is no single
# "app version" to track here.
#
# It is NOT the source of any image tag: all nine images are pinned in
# values.yaml, where Renovate's helm-values manager proposes them. Nothing
# rewrites this field automatically - the chart has no app-version block in
# renovate.json5 and is not in the change-app-version.yml choice list. Raise it
# by hand when you want to mark a new edition of the chart as a whole.
appVersion: "1.0.0"
maintainers:
- name: jklein
+220
View File
@@ -0,0 +1,220 @@
{{/*
Render the body of a container "resources:" block from a values dict of the form
resources:
limitFactor: 3 # optional, default 3
requests: { cpu, memory, ephemeral-storage / ephemeralStorage, ... }
limits: { ... } # optional, every entry optional
Rules (variant B, see issue #19):
- requests are rendered as given (camelCase "ephemeralStorage" is normalized
to the Kubernetes key "ephemeral-storage").
- an explicitly set limit always wins.
- a missing memory / ephemeral-storage limit is computed as
limitFactor x the corresponding request.
- a missing cpu limit is NOT set at all (the kubelet then treats CPU as
unlimited; CPU is compressible, so this cannot kill the workload).
Usage:
resources:
{{- include "freesailarr.resources" .Values.<app>.resources | nindent 12 }}
NOTE: charts in this repo deliberately have no dependencies, so this helper is
duplicated into every chart's templates/_helpers.tpl with the chart's name as
define prefix. The reference implementation lives in template-chart.
*/}}
{{- define "freesailarr.resources" -}}
{{- $resources := . | default dict -}}
{{- $factor := 3.0 -}}
{{- if not (kindIs "invalid" $resources.limitFactor) -}}
{{- $factor = float64 $resources.limitFactor -}}
{{- end -}}
{{- $requests := dict -}}
{{- range $key, $value := ($resources.requests | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $requests (include "freesailarr.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- $limits := dict -}}
{{- range $key, $value := ($resources.limits | default dict) -}}
{{- if not (kindIs "invalid" $value) -}}
{{- $_ := set $limits (include "freesailarr.resourceKey" $key) $value -}}
{{- end -}}
{{- end -}}
{{- range $key, $request := $requests -}}
{{- if and (ne $key "cpu") (not (hasKey $limits $key)) -}}
{{- $_ := set $limits $key (include "freesailarr.multiplyQuantity" (dict "quantity" $request "factor" $factor)) -}}
{{- end -}}
{{- end -}}
{{- $out := dict -}}
{{- with $requests }}{{- $_ := set $out "requests" . }}{{- end -}}
{{- with $limits }}{{- $_ := set $out "limits" . }}{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
Normalize a resource name from values to its Kubernetes key.
*/}}
{{- define "freesailarr.resourceKey" -}}
{{- if eq . "ephemeralStorage" -}}ephemeral-storage{{- else -}}{{ . }}{{- end -}}
{{- end -}}
{{/*
Multiply a Kubernetes quantity by a factor, keeping its suffix.
Input: dict "quantity" <string|number> "factor" <float64>.
Handles plain numbers ("2", "0.5", 1073741824), decimal ("1.5Gi"), binary
("512Mi", "1Gi", "10Ki") and milli/SI suffixes ("100m", "500M"). Integer
results are printed without a decimal point.
*/}}
{{- define "freesailarr.multiplyQuantity" -}}
{{- $quantity := printf "%v" .quantity -}}
{{- $number := regexFind "^[0-9]+(\\.[0-9]+)?" $quantity -}}
{{- if not $number -}}
{{- fail (printf "cannot parse resource quantity %q" $quantity) -}}
{{- end -}}
{{- $suffix := trimPrefix $number $quantity -}}
{{- if not (regexMatch "^(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$" $suffix) -}}
{{- fail (printf "unsupported resource quantity suffix %q in %q" $suffix $quantity) -}}
{{- end -}}
{{- $product := mulf (float64 $number) .factor -}}
{{- if eq $product (floor $product) -}}
{{- printf "%d%s" (int64 $product) $suffix -}}
{{- else -}}
{{- printf "%v%s" $product $suffix -}}
{{- end -}}
{{- end -}}
{{/*
Render the replica count for a Deployment/StatefulSet.
Input: dict
"scaleDown" the chart-global .Values.scaleDown flag (may be nil)
"replicas" the workload's replicas value (may be nil)
Rules (issue #25):
- scaleDown: true takes precedence and always yields 0.
- otherwise an explicitly set replicas value is used as given, including an
explicit 0 — Helm's "default" would swallow 0, hence the explicit
kindIs "invalid" nil check.
- a missing replicas value defaults to 1.
Usage:
replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }}
NOTE: like the resources helper above, this is duplicated into every chart's
templates/_helpers.tpl with the chart's name as define prefix. The reference
implementation lives in template-chart.
*/}}
{{- define "freesailarr.replicas" -}}
{{- if .scaleDown -}}
0
{{- else if not (kindIs "invalid" .replicas) -}}
{{- .replicas -}}
{{- else -}}
1
{{- end -}}
{{- end -}}
{{/*
Render additive env entries (the repo-wide <app>.env convention: tpl-rendered,
supporting the value, secretKeyRef and configMapKeyRef forms). Shared as a
helper here because this chart has nine containers, each with its own env block.
Usage:
{{- include "freesailarr.env" (dict "root" $ "env" .Values.<app>.env) | nindent 12 }}
*/}}
{{- define "freesailarr.env" -}}
{{- $root := .root -}}
{{- range $value := .env }}
{{- if $value.value }}
- name: {{ $value.name }}
value: {{ tpl $value.value $root | quote }}
{{- else if $value.valueFrom }}
{{- if $value.valueFrom.secretKeyRef }}
- name: {{ $value.name }}
valueFrom:
secretKeyRef:
name: {{ tpl $value.valueFrom.secretKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.secretKeyRef.key $root | quote }}
{{- else if $value.valueFrom.configMapKeyRef }}
- name: {{ $value.name }}
valueFrom:
configMapKeyRef:
name: {{ tpl $value.valueFrom.configMapKeyRef.name $root | quote }}
key: {{ tpl $value.valueFrom.configMapKeyRef.key $root | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end -}}
{{/*
The enabled app containers that listen on a port, as a JSON array of
{name, port} dicts (container port name == app name == service name suffix).
Consumed by the per-app services, the per-app ingresses and the firewall-ports
helper below:
{{- $apps := include "freesailarr.portApps" . | fromJsonArray }}
recyclarr is deliberately absent: in daemon mode it serves no port, so it gets
no container port, no service and no ingress. flaresolverr is present - it
listens on 8191 and therefore gets a service - but it has no ingress block in
values and is never exposed from outside the cluster.
*/}}
{{- define "freesailarr.portApps" -}}
{{- $apps := list -}}
{{- range $app := list
(dict "name" "qbittorrent" "port" 8080)
(dict "name" "prowlarr" "port" 9696)
(dict "name" "flaresolverr" "port" 8191)
(dict "name" "radarr" "port" 7878)
(dict "name" "sonarr" "port" 8989)
(dict "name" "seerr" "port" 5055)
(dict "name" "bazarr" "port" 6767) -}}
{{- if (index $.Values $app.name).enabled -}}
{{- $apps = append $apps $app -}}
{{- end -}}
{{- end -}}
{{- toJson $apps -}}
{{- end -}}
{{/*
The apps that render an Ingress: the enabled port apps whose values carry an
ingress block with enabled: true. Used by the ingress template.
{{- $apps := include "freesailarr.ingressApps" . | fromJsonArray }}
*/}}
{{- define "freesailarr.ingressApps" -}}
{{- $apps := list -}}
{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
{{- $config := index $.Values $app.name -}}
{{- if and $config.ingress $config.ingress.enabled -}}
{{- $apps = append $apps $app -}}
{{- end -}}
{{- end -}}
{{- toJson $apps -}}
{{- end -}}
{{/*
Comma-separated FIREWALL_INPUT_PORTS for gluetun: the ports of the enabled app
containers (ingress traffic and the kubelet probes both arrive on the pod IP)
plus 9999 for gluetun's own health server.
Measured caveat: inside a pod network these rules never actually match. gluetun
detects the pod's own subnet and inserts
"-A INPUT -d <pod-subnet> -i eth0 -j ACCEPT" ahead of the per-port rules, and
every packet arriving at the pod IP matches it by destination - the packet
counters on the --dport rules stayed at 0 while the subnet rule absorbed the
traffic. The declaration is kept as an explicit statement of which ports are
meant to be reachable, and because that subnet detection is gluetun's, not a
guarantee of the chart's: a CNI that hands out a /32 or a future gluetun that
drops the rule would make these the only thing keeping probes and ingress
alive. It is not what opens the ports today.
*/}}
{{- define "freesailarr.firewallInputPorts" -}}
{{- $ports := list -}}
{{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
{{- $ports = append $ports (int $app.port) -}}
{{- end -}}
{{- $ports = append $ports 9999 -}}
{{- join "," $ports -}}
{{- end -}}
@@ -0,0 +1,6 @@
apiVersion: onepassword.com/v1
kind: OnePasswordItem
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret
spec:
itemPath: {{ required "You must provide a gluetunSecret Reference" .Values.secrets.gluetunSecretRef }}
@@ -0,0 +1,51 @@
{{/*
One Ingress object per app whose ingress is enabled, each with its own host and
its own TLS secret. The hosts are independent values - they do not have to
share a domain - and each ingress points at its own app's service.
<app>.ingress.host is required, but only for an app whose ingress is actually
enabled: the required call sits inside the loop over the enabled apps, so
turning an app's ingress off must never make the chart demand a host for it.
*/}}
{{- if .Values.ingress.enabled }}
{{- range $app := include "freesailarr.ingressApps" $ | fromJsonArray }}
{{- $config := (index $.Values $app.name).ingress }}
{{- /* clusterIssuer, className and annotations default to the global ingress
block and are overridable per app; an explicitly set per-app value wins
even when it is empty, hence the nil check instead of "default". */}}
{{- $clusterIssuer := $.Values.ingress.clusterIssuer }}
{{- if not (kindIs "invalid" $config.clusterIssuer) }}{{- $clusterIssuer = $config.clusterIssuer }}{{- end }}
{{- $className := $.Values.ingress.className }}
{{- if not (kindIs "invalid" $config.className) }}{{- $className = $config.className }}{{- end }}
{{- $annotations := $.Values.ingress.annotations }}
{{- if not (kindIs "invalid" $config.annotations) }}{{- $annotations = $config.annotations }}{{- end }}
{{- $host := required (printf "%s.ingress.host must be provided while %s.ingress.enabled is true" $app.name $app.name) $config.host }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" $clusterIssuer }}
{{- with $annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ $className }}
rules:
- host: {{ $host | quote }}
http:
paths:
- backend:
service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
port:
name: {{ $app.name }}
path: /
pathType: Prefix
tls:
- hosts:
- {{ $host | quote }}
secretName: tls-{{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress
{{- end }}
{{- end }}
@@ -0,0 +1,25 @@
{{/*
One ClusterIP service per enabled app container that listens on a port
(recyclarr has none in daemon mode, so it gets no service).
All containers share gluetun's network namespace, so every one of these
services resolves to the same pod IP and they differ only in their port. Their
purpose is a stable, per-app cluster-internal DNS name - each ingress points at
its own app's service - not network separation between the apps.
*/}}
{{- range $app := include "freesailarr.portApps" $ | fromJsonArray }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
spec:
selector:
app: {{ $.Release.Name }}-{{ $.Chart.Name }}-sfs
ports:
- protocol: TCP
port: {{ int $app.port }}
targetPort: {{ $app.name }}
name: {{ $app.name }}
type: ClusterIP
{{- end }}
+524
View File
@@ -0,0 +1,524 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }}
# Governing service name. Each app has its own service (see
# freesailarr.service.yaml); nothing consumes the per-pod DNS name a
# governing service would provide, so the chart deliberately creates no
# object under this name - the field only has to be set.
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
selector:
matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
template:
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
labels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec:
automountServiceAccountToken: false
securityContext:
{{- toYaml .Values.securityContext.pod | nindent 8 }}
{{- if and .Values.qbittorrent.enabled .Values.qbittorrent.networkInterface }}
initContainers:
# qBittorrent has no environment variable for the session's network
# interface - it only reads it from the persisted config file, which
# the app itself creates on first start. Without the binding
# libtorrent listens and announces on every interface of the shared
# pod network namespace (eth0/pod IP and loopback included); gluetun's
# kill switch drops those packets, trackers stay unreachable and
# torrents stall at 0 bytes. Seeding the two keys before qbittorrent
# starts closes that leak and makes a fresh install work out of the
# box. The edit is idempotent and preserves all other settings:
# existing keys are replaced, missing ones are inserted into the
# [BitTorrent] section (qBittorrent 5.x reads them there), and a
# missing file or section is created.
- name: qbittorrent-config-interface
image: "{{ .Values.qbittorrent.configInit.image.repository }}:{{ .Values.qbittorrent.configInit.image.tag }}"
imagePullPolicy: IfNotPresent
command:
- sh
- -c
- |
set -eu
conf=/config/qBittorrent/qBittorrent.conf
mkdir -p /config/qBittorrent
[ -f "$conf" ] || printf '[BitTorrent]\n' > "$conf"
# A file without a trailing newline would glue an appended
# section header onto the last key.
if [ -s "$conf" ] && [ -n "$(tail -c 1 "$conf")" ]; then
printf '\n' >> "$conf"
fi
grep -q '^\[BitTorrent\]$' "$conf" || printf '[BitTorrent]\n' >> "$conf"
# Drop any existing binding keys, then re-insert them directly
# after the [BitTorrent] header.
awk -v ifc="$QBT_INTERFACE" '
/^Session\\Interface=/ { next }
/^Session\\InterfaceName=/ { next }
{ print }
/^\[BitTorrent\]$/ {
print "Session\\Interface=" ifc
print "Session\\InterfaceName=" ifc
}
' "$conf" > "${conf}.tmp"
mv "${conf}.tmp" "$conf"
echo "qBittorrent session bound to interface $QBT_INTERFACE"
env:
- name: QBT_INTERFACE
value: {{ .Values.qbittorrent.networkInterface | quote }}
resources:
{{- include "freesailarr.resources" .Values.qbittorrent.configInit.resources | nindent 12 }}
securityContext:
runAsUser: {{ .Values.puid }}
runAsGroup: {{ .Values.pgid }}
{{- toYaml .Values.qbittorrent.configInit.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /config
name: qbittorrent-config
{{- end }}
containers:
# gluetun is the VPN gateway: all app containers share the pod's
# network namespace, talk to each other over localhost and reach the
# internet only through the Wireguard tunnel (gluetun's firewall acts
# as kill switch).
- name: gluetun
image: "{{ .Values.gluetun.image.repository }}:{{ .Values.gluetun.image.tag }}"
imagePullPolicy: IfNotPresent
# An uncleanly exited gluetun (crash, OOM killer, node issue) leaves
# its ip rule, routing table and tun device behind in the shared pod
# network namespace, and the restarted container then fails forever
# ("adding ip rule 101 ... file exists" / silent tunnel breakage)
# while the kill switch blocks all egress. The upstream-recommended
# postStart hook is racy (it runs concurrently with the entrypoint
# and can delete the rule the new instance just created), so the
# stale state is cleaned synchronously before gluetun starts.
command:
- /bin/sh
- -c
- 'while ip rule del table 51820 2>/dev/null; do :; done; while ip -6 rule del table 51820 2>/dev/null; do :; done; ip link del tun0 2>/dev/null; exec /gluetun-entrypoint'
env:
- name: TZ
value: {{ .Values.timezone | quote }}
- name: VPN_SERVICE_PROVIDER
value: {{ .Values.gluetun.vpn.serviceProvider | quote }}
- name: VPN_TYPE
value: {{ .Values.gluetun.vpn.type | quote }}
- name: WIREGUARD_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret
key: wireguard-private-key
- name: WIREGUARD_ADDRESSES
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret
key: wireguard-addresses
{{- with .Values.gluetun.vpn.serverCities }}
- name: SERVER_CITIES
value: {{ . | quote }}
{{- end }}
{{- with .Values.gluetun.vpn.serverCountries }}
- name: SERVER_COUNTRIES
value: {{ . | quote }}
{{- end }}
{{- with .Values.gluetun.vpn.dnsUpstreamResolvers }}
- name: DNS_UPSTREAM_RESOLVERS
value: {{ . | quote }}
{{- end }}
{{- with .Values.gluetun.vpn.firewallOutboundSubnets }}
- name: FIREWALL_OUTBOUND_SUBNETS
value: {{ . | quote }}
{{- end }}
# Declare the UI/probe ports of the enabled apps and the health
# server as reachable. Note that inside a pod network gluetun's own
# pod-subnet rule already accepts this traffic and these per-port
# rules never match (measured - see the helper's comment); they are
# the explicit statement of intent and the fallback if that subnet
# detection ever stops covering the pod IP.
- name: FIREWALL_INPUT_PORTS
value: {{ include "freesailarr.firewallInputPorts" . | quote }}
# Bind the health server to all interfaces so the kubelet HTTP
# probes (which connect to the pod IP, not localhost) reach it.
- name: HEALTH_SERVER_ADDRESS
value: ":9999"
{{- include "freesailarr.env" (dict "root" $ "env" .Values.gluetun.env) | nindent 12 }}
ports:
- containerPort: 9999
protocol: TCP
name: health
livenessProbe:
{{- toYaml .Values.gluetun.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.gluetun.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.gluetun.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.gluetun.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.gluetun.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /gluetun
name: gluetun-config
- mountPath: /dev/net/tun
name: dev-net-tun
# gluetun creates /tmp/gluetun (its public-IP cache) mode 0644,
# i.e. without the execute bit it needs to enter its own
# directory. It runs as root but with all capabilities except
# NET_ADMIN dropped, so DAC_OVERRIDE no longer papers over that
# and every write fails with "clearing public IP data: open
# /tmp/gluetun/ip: permission denied". An emptyDir provides the
# path with usable permissions.
# Remaining, cosmetic: gluetun also chowns the file, which needs
# CAP_CHOWN. With all capabilities but NET_ADMIN dropped that call
# is denied and logs "clearing public IP data: chown
# /tmp/gluetun/ip: operation not permitted" on every VPN restart.
# The file itself is written and the public IP is persisted
# (verified at runtime), so this is a log line, not a failure -
# granting CHOWN just to silence it would widen the container's
# capabilities for nothing.
- mountPath: /tmp/gluetun
name: gluetun-tmp
{{- if .Values.qbittorrent.enabled }}
- name: qbittorrent
image: "{{ .Values.qbittorrent.image.repository }}:{{ .Values.qbittorrent.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
- name: WEBUI_PORT
value: "8080"
{{- include "freesailarr.env" (dict "root" $ "env" .Values.qbittorrent.env) | nindent 12 }}
ports:
- containerPort: 8080
protocol: TCP
name: qbittorrent
livenessProbe:
{{- toYaml .Values.qbittorrent.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.qbittorrent.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.qbittorrent.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.qbittorrent.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.qbittorrent.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /config
name: qbittorrent-config
- mountPath: /downloads
name: media-downloads
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.prowlarr.enabled }}
- name: prowlarr
image: "{{ .Values.prowlarr.image.repository }}:{{ .Values.prowlarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }}
ports:
- containerPort: 9696
protocol: TCP
name: prowlarr
livenessProbe:
{{- toYaml .Values.prowlarr.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.prowlarr.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.prowlarr.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.prowlarr.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.prowlarr.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /config
name: prowlarr-config
{{- end }}
{{- if .Values.flaresolverr.enabled }}
- name: flaresolverr
image: "{{ .Values.flaresolverr.image.repository }}:{{ .Values.flaresolverr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.flaresolverr.env) | nindent 12 }}
ports:
- containerPort: 8191
protocol: TCP
name: flaresolverr
livenessProbe:
{{- toYaml .Values.flaresolverr.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.flaresolverr.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.flaresolverr.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.flaresolverr.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.flaresolverr.securityContext | nindent 12 }}
volumeMounts:
# Writable paths for headless chromium so the root filesystem
# stays read-only; /dev/shm is a memory-backed emptyDir because
# chromium needs more shared memory than the container default.
- mountPath: /tmp
name: flaresolverr-tmp
- mountPath: /dev/shm
name: flaresolverr-shm
{{- end }}
{{- if .Values.radarr.enabled }}
- name: radarr
image: "{{ .Values.radarr.image.repository }}:{{ .Values.radarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }}
ports:
- containerPort: 7878
protocol: TCP
name: radarr
livenessProbe:
{{- toYaml .Values.radarr.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.radarr.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.radarr.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.radarr.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.radarr.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /config
name: radarr-config
- mountPath: /movies
name: media-movies
{{- with .Values.media.movies.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /downloads
name: media-downloads
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.recyclarr.enabled }}
- name: recyclarr
image: "{{ .Values.recyclarr.image.repository }}:{{ .Values.recyclarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: {{ .Values.timezone | quote }}
- name: RECYCLARR_CREATE_CONFIG
value: "true"
{{- include "freesailarr.env" (dict "root" $ "env" .Values.recyclarr.env) | nindent 12 }}
livenessProbe:
{{- toYaml .Values.recyclarr.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.recyclarr.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.recyclarr.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.recyclarr.resources | nindent 12 }}
securityContext:
runAsUser: {{ .Values.puid }}
runAsGroup: {{ .Values.pgid }}
{{- toYaml .Values.recyclarr.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /config
name: recyclarr-config
# Writable scratch space for the .NET runtime so the root
# filesystem stays read-only.
- mountPath: /tmp
name: recyclarr-tmp
{{- end }}
{{- if .Values.sonarr.enabled }}
- name: sonarr
image: "{{ .Values.sonarr.image.repository }}:{{ .Values.sonarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }}
ports:
- containerPort: 8989
protocol: TCP
name: sonarr
livenessProbe:
{{- toYaml .Values.sonarr.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.sonarr.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.sonarr.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.sonarr.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.sonarr.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /config
name: sonarr-config
- mountPath: /tv
name: media-tv
{{- with .Values.media.tv.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /downloads
name: media-downloads
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
{{- if .Values.seerr.enabled }}
- name: seerr
image: "{{ .Values.seerr.image.repository }}:{{ .Values.seerr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.seerr.env) | nindent 12 }}
ports:
- containerPort: 5055
protocol: TCP
name: seerr
livenessProbe:
{{- toYaml .Values.seerr.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.seerr.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.seerr.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.seerr.resources | nindent 12 }}
securityContext:
runAsUser: {{ .Values.puid }}
runAsGroup: {{ .Values.pgid }}
{{- toYaml .Values.seerr.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /app/config
name: seerr-config
- mountPath: /tmp
name: seerr-tmp
{{- end }}
{{- if .Values.bazarr.enabled }}
- name: bazarr
image: "{{ .Values.bazarr.image.repository }}:{{ .Values.bazarr.image.tag }}"
imagePullPolicy: IfNotPresent
env:
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
- name: TZ
value: {{ .Values.timezone | quote }}
{{- include "freesailarr.env" (dict "root" $ "env" .Values.bazarr.env) | nindent 12 }}
ports:
- containerPort: 6767
protocol: TCP
name: bazarr
livenessProbe:
{{- toYaml .Values.bazarr.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.bazarr.readinessProbe | nindent 12 }}
startupProbe:
{{- toYaml .Values.bazarr.startupProbe | nindent 12 }}
resources:
{{- include "freesailarr.resources" .Values.bazarr.resources | nindent 12 }}
securityContext:
{{- toYaml .Values.bazarr.securityContext | nindent 12 }}
volumeMounts:
- mountPath: /config
name: bazarr-config
- mountPath: /movies
name: media-movies
{{- with .Values.media.movies.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /tv
name: media-tv
{{- with .Values.media.tv.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }}
restartPolicy: Always
volumes:
# The tun device gluetun needs to create the Wireguard tunnel.
- name: dev-net-tun
hostPath:
path: /dev/net/tun
type: CharDevice
# Writable /tmp/gluetun for gluetun's public-IP cache (see the mount).
- name: gluetun-tmp
emptyDir: {}
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }}
- name: media-downloads
persistentVolumeClaim:
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
{{- end }}
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
- name: media-movies
persistentVolumeClaim:
claimName: {{ required "media.movies.existingClaim is required when radarr or bazarr is enabled" .Values.media.movies.existingClaim }}
{{- end }}
{{- if or .Values.sonarr.enabled .Values.bazarr.enabled }}
- name: media-tv
persistentVolumeClaim:
claimName: {{ required "media.tv.existingClaim is required when sonarr or bazarr is enabled" .Values.media.tv.existingClaim }}
{{- end }}
{{- if .Values.flaresolverr.enabled }}
- name: flaresolverr-tmp
emptyDir: {}
- name: flaresolverr-shm
emptyDir:
medium: Memory
sizeLimit: 512Mi
{{- end }}
{{- if .Values.recyclarr.enabled }}
- name: recyclarr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.seerr.enabled }}
- name: seerr-tmp
emptyDir: {}
{{- end }}
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
# are immutable - toggling an app's enabled flag later requires recreating
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
# before upgrading; existing PVCs and their data survive).
volumeClaimTemplates:
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" }}
{{- $config := index $.Values $app }}
{{- if or (eq $app "gluetun") $config.enabled }}
- metadata:
name: {{ $app }}-config
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ $config.storage.size }}
{{- with $.Values.storage.storageClass }}
storageClassName: {{ . }}
{{- end }}
{{- end }}
{{- end }}
+712
View File
@@ -0,0 +1,712 @@
# freesailarr runs gluetun plus the whole media automation stack in a single
# StatefulSet pod: every app container shares gluetun's network namespace, the
# containers talk to each other over localhost and all traffic leaves through
# the Wireguard tunnel.
#
# Image pinning (documented deviation from the Chart.AppVersion standard):
# NO image tag comes from appVersion. All nine images, gluetun included, are
# pinned here via <app>.image.repository / <app>.image.tag, because the stack
# combines nine independently versioned upstream projects and none of them is
# "the" one app version.
#
# That is also what puts every image under Renovate: the built-in helm-values
# manager reads image.repository/image.tag pairs out of this file and proposes
# one update per image, and ci/renovate-bump-chart.sh bumps the chart version
# along with it. A chart whose main image came from appVersion would instead
# need a hard-coded block in renovate.json5 - freesailarr deliberately has none.
#
# Chart.yaml's appVersion is therefore 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.
pod:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
storage:
# StorageClass for all config volumeClaimTemplates (null = cluster default).
storageClass: null
# Media libraries as pre-existing PVCs (shared with the separately deployed
# Jellyfin) - the chart mounts but never creates them. Each claim is required
# as soon as an app that mounts it is enabled:
# downloads: qbittorrent, radarr, sonarr
# movies: radarr, bazarr
# tv: sonarr, bazarr
#
# subPath mounts a sub-directory of the claim instead of its root (null = the
# claim root, i.e. one claim per category). The same existingClaim may back all
# three categories with different subPaths - one library volume, one
# sub-directory per category, e.g. downloads / movies / tv-shows.
#
# What this buys and what it does NOT buy - measured in a k3d cluster against
# this chart, because the usual argument for a shared volume is hardlinking:
# - It buys the layout: one claim to provision, back up and share with
# Jellyfin, with the categories as sibling directories instead of three
# separate volumes.
# - It does NOT buy hardlinked imports. Radarr and Sonarr import by moving,
# and a move within one filesystem can be a hardlink or a rename instead of
# a copy - but Kubernetes turns every volumeMount into its own bind mount,
# and link()/rename() across two mount points fails with EXDEV even when
# both sides are the same claim on the same device. Measured: /downloads and
# /movies in the radarr container both report device 65025 and resolve to
# the same PV directory, yet `ln /downloads/f /movies/f` fails with
# "Cross-device link", while the identical link succeeds when the claim root
# is mounted once and both categories are sub-directories under it.
# This is not a property of subPath: two plain mounts of the same claim
# without any subPath fail exactly the same way, so the chart has never
# supported cross-category hardlinks.
# - Consequence today: an import still copies, so download and library each
# hold a full copy until the torrent is removed.
# Getting hardlinks would mean giving the apps a single mount that contains all
# categories (the "one /data mount" layout the *arr guides recommend) instead of
# one mount per category - a separate change to the chart's mount layout, not a
# values setting.
media:
downloads:
existingClaim: null
subPath: null
movies:
existingClaim: null
subPath: null
tv:
existingClaim: null
subPath: null
# Ingress defaults. There is deliberately no global domain: every app carries
# its own <app>.ingress.host, so the hosts are independent of each other and
# may live in completely different domains. clusterIssuer, className and
# annotations are the defaults for all apps and can be overridden per app in
# the <app>.ingress block (an explicitly set per-app value always wins).
ingress:
# Master switch: false suppresses every app ingress regardless of the
# per-app toggles.
enabled: true
clusterIssuer: null
className: nginx
# Extra annotations for the Ingress objects; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
annotations: {}
gluetun:
# Pinned here like every other image in this chart, so Renovate's helm-values
# manager proposes gluetun updates too. Docker Hub tags carry a v prefix.
image:
repository: docker.io/qmcgaw/gluetun
tag: v3.41.1
vpn:
serviceProvider: mullvad
type: wireguard
serverCities: Zurich
serverCountries: Switzerland
dnsUpstreamResolvers: quad9
# Comma-separated CIDRs the gluetun firewall allows outbound traffic to
# outside the VPN tunnel. Set this to the cluster pod and service CIDRs
# (e.g. "10.42.0.0/16,10.43.0.0/16" on k3s) - without it the firewall
# blocks cluster-internal egress such as DNS lookups against cluster DNS
# and seerr talking to the in-cluster Jellyfin.
firewallOutboundSubnets: null
storage:
# Size of the gluetun config volume (server list cache under /gluetun).
size: 1Gi
# Extra environment variables, appended to the ones the chart always sets.
# Values are rendered through tpl; value, secretKeyRef and configMapKeyRef
# forms are supported. (Same for every <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.1
# 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. clusterIssuer, className and
# annotations may be set here to override the global ingress block.
# (Same block for prowlarr, radarr, sonarr, seerr and bazarr below.)
ingress:
enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage:
size: 1Gi
# Network interface qBittorrent binds its BitTorrent session to - gluetun's
# Wireguard device. Without this binding libtorrent listens and announces on
# every interface in the shared pod network namespace, including eth0 (the
# pod IP) and loopback. Those announces bypass the tunnel, so gluetun's kill
# switch (OUTPUT DROP except tun0 and the allowed cluster subnets) drops
# them: trackers report "skipping tracker announce (unreachable)", no peers
# are found and torrents stall at 0 bytes in stalledDL. Binding to tun0 is
# therefore both the functional fix and a leak fix - it stops qBittorrent
# from listening on the pod IP past the tunnel. The value is written into
# /config/qBittorrent/qBittorrent.conf by the config-interface init
# container below; setting it to null or "" removes that init container and
# leaves the binding to the user (not recommended).
networkInterface: tun0
# The init container that seeds qbittorrent.networkInterface into the
# persisted qBittorrent config before the app starts. Runs as puid/pgid so
# the config file keeps the ownership the app expects.
configInit:
# Auxiliary busybox image 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.3.5
# See the qbittorrent ingress comment.
ingress:
enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage:
size: 1Gi
env: []
# The *arr apps expose /ping as an unauthenticated health endpoint.
livenessProbe:
httpGet:
path: /ping
port: 9696
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ping
port: 9696
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s.
startupProbe:
httpGet:
path: /ping
port: 9696
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# linuxserver.io image - see the qbittorrent securityContext comment.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
flaresolverr:
enabled: true
image:
repository: ghcr.io/flaresolverr/flaresolverr
tag: v3.5.0
# No ingress and no config volume: flaresolverr is stateless and only
# consumed by prowlarr over localhost.
env: []
# /health is flaresolverr's dedicated lightweight health endpoint.
livenessProbe:
httpGet:
path: /health
port: 8191
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8191
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s.
startupProbe:
httpGet:
path: /health
port: 8191
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.2
memory: 512Mi
ephemeralStorage: 200Mi
# Deviations from the hardening baseline, forced by the flaresolverr image:
# - readOnlyRootFilesystem: false: on startup flaresolverr copies and
# patches the chromedriver binary to the hardcoded path /app/chromedriver
# and writes chromedriver caches under /app (its home directory).
# - runAsUser/runAsGroup pin the image's "flaresolverr" user numerically
# (uid/gid 1000; the image declares it by name only, which the kubelet's
# runAsNonRoot check cannot verify).
# Chromium's scratch paths (/tmp, /dev/shm) are emptyDirs.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
capabilities:
drop:
- ALL
radarr:
enabled: true
image:
repository: docker.io/linuxserver/radarr
tag: 6.1.1
# See the qbittorrent ingress comment.
ingress:
enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage:
size: 1Gi
env: []
livenessProbe:
httpGet:
path: /ping
port: 7878
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ping
port: 7878
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s.
startupProbe:
httpGet:
path: /ping
port: 7878
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# linuxserver.io image - see the qbittorrent securityContext comment.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
recyclarr:
enabled: true
image:
repository: ghcr.io/recyclarr/recyclarr
tag: 8.6.0
# No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and
# sonarr on a schedule (daemon mode).
storage:
size: 1Gi
env: []
# recyclarr serves no HTTP port in daemon mode, so all probes exec the CLI.
livenessProbe:
exec:
command:
- recyclarr
- --version
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
exec:
command:
- recyclarr
- --version
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
# The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s.
startupProbe:
exec:
command:
- recyclarr
- --version
periodSeconds: 5
timeoutSeconds: 10
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.05
memory: 128Mi
ephemeralStorage: 50Mi
# The image supports running as an arbitrary non-root user; runAsUser and
# runAsGroup are set from the global puid/pgid values in the template - do
# not set them here.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
sonarr:
enabled: true
image:
repository: docker.io/linuxserver/sonarr
tag: 4.0.17
# See the qbittorrent ingress comment.
ingress:
enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage:
size: 1Gi
env: []
livenessProbe:
httpGet:
path: /ping
port: 8989
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ping
port: 8989
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s.
startupProbe:
httpGet:
path: /ping
port: 8989
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# linuxserver.io image - see the qbittorrent securityContext comment.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
seerr:
enabled: true
image:
repository: ghcr.io/seerr-team/seerr
tag: v3.3.0
# See the qbittorrent ingress comment.
ingress:
enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage:
size: 1Gi
env: []
# /api/v1/settings/public answers unauthenticated once the app is up.
livenessProbe:
httpGet:
path: /api/v1/settings/public
port: 5055
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/v1/settings/public
port: 5055
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. First boot runs database
# migrations: budget 10s * 30 = 300s.
startupProbe:
httpGet:
path: /api/v1/settings/public
port: 5055
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# The image runs fine as an arbitrary non-root user; runAsUser and
# runAsGroup are set from the global puid/pgid values in the template - do
# not set them here.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
bazarr:
enabled: true
image:
repository: docker.io/linuxserver/bazarr
tag: 1.5.6
# See the qbittorrent ingress comment.
ingress:
enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage:
size: 1Gi
env: []
# bazarr has no unauthenticated health endpoint; / serves the UI with 200.
livenessProbe:
httpGet:
path: /
port: 6767
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 6767
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s.
startupProbe:
httpGet:
path: /
port: 6767
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# linuxserver.io image - see the qbittorrent securityContext comment.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
+9
View File
@@ -148,6 +148,15 @@
// mapping here leaves the charts untouched.
//
// When adding a chart, add a block here — see README "Creating a new chart".
//
// Exception, deliberately no block: `freesailarr`. It bundles nine
// independently versioned upstreams, so none of them is "the" app version
// and no image tag comes from appVersion — all nine are pinned as
// image.repository/image.tag in its values.yaml, where the built-in
// helm-values manager already finds them and proposes one update per
// image. Adding a block here would either duplicate those updates or
// rewrite an appVersion that selects nothing. Its appVersion is a
// hand-maintained marker; see freesailarr/Chart.yaml.
{
description: "apache-tika: four-part version plus flavour suffix (3.3.1.0-full); the compatibility group keeps the chart on -full.",