Add freesailarr chart: gluetun-tunneled media automation stack

Single-pod StatefulSet running gluetun (appVersion 3.41.1) as Wireguard
VPN gateway with qbittorrent, prowlarr, flaresolverr, radarr, recyclarr,
sonarr, seerr and bazarr sharing its network namespace. Config volumes
via volumeClaimTemplates, media libraries as existing claims, per-app
enable/ingress toggles, computed FIREWALL_INPUT_PORTS, hardened
security contexts with documented per-image deviations. Wires up
publish workflow, change-app-version choice, install-test exclusion
and README overview.
This commit is contained in:
jklein committed 2026-08-24 13:41:46 +02:00
1 parent 178d4e9a6a
commit 914c2d0205
12 files changed
+1334

No files matched your search

+1
View File
@@ -35,6 +35,7 @@ on:
- minecraft-router
- minecraft-server
- rss-bridge
- freesailarr
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
+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 |
| `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 |
+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
+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/
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v2
name: freesailarr
description: A Helm chart to deploy a Wireguard-tunneled media automation stack (gluetun, qbittorrent, the *arrs, seerr) on Kubernetes
icon: https://raw.githubusercontent.com/qdm12/gluetun/master/doc/logo_256.png
type: application
version: 1.0.0+up3.41.1
appVersion: "3.41.1"
maintainers:
- name: jklein
+189
View File
@@ -0,0 +1,189 @@
{{/*
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 apps that expose a web UI, as a JSON array of {name, port} dicts
(container port name == app name == ingress subdomain). Consumed by the
service, the ingress and the firewall-ports helper below:
{{- $apps := include "freesailarr.uiApps" . | fromJsonArray }}
*/}}
{{- define "freesailarr.uiApps" -}}
{{- $apps := list -}}
{{- range $app := list
(dict "name" "qbittorrent" "port" 8080)
(dict "name" "prowlarr" "port" 9696)
(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 -}}
{{/*
Comma-separated FIREWALL_INPUT_PORTS for gluetun: the UI ports of the enabled
apps (ingress traffic arrives on the pod IP), flaresolverr's 8191 when enabled
(the kubelet probes connect to the pod IP too) and 9999 for gluetun's own
health server. Without these the gluetun firewall drops the traffic.
*/}}
{{- define "freesailarr.firewallInputPorts" -}}
{{- $ports := list -}}
{{- range $app := include "freesailarr.uiApps" . | fromJsonArray -}}
{{- $ports = append $ports (int $app.port) -}}
{{- end -}}
{{- if .Values.flaresolverr.enabled -}}
{{- $ports = append $ports 8191 -}}
{{- 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,38 @@
{{- $apps := list }}
{{- range $app := include "freesailarr.uiApps" . | fromJsonArray }}
{{- if (index $.Values $app.name).ingress.enabled }}
{{- $apps = append $apps $app }}
{{- end }}
{{- end }}
{{- if and .Values.ingress.enabled (gt (len $apps) 0) }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress
annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
{{- range $apps }}
- host: {{ .name }}.{{ required "A Domain/Host must be provided" $.Values.ingress.domain }}
http:
paths:
- backend:
service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service
port:
name: {{ .name }}
path: /
pathType: Prefix
{{- end }}
tls:
- hosts:
{{- range $apps }}
- {{ .name }}.{{ $.Values.ingress.domain }}
{{- end }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }}
@@ -0,0 +1,18 @@
{{- $apps := include "freesailarr.uiApps" . | fromJsonArray }}
{{- if gt (len $apps) 0 }}
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service
spec:
selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
ports:
{{- range $apps }}
- protocol: TCP
port: {{ int .port }}
targetPort: {{ .name }}
name: {{ .name }}
{{- end }}
type: ClusterIP
{{- end }}
+459
View File
@@ -0,0 +1,459 @@
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) }}
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
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 }}
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: "qmcgaw/gluetun:{{ .Chart.AppVersion }}"
imagePullPolicy: IfNotPresent
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 }}
# Open the firewall for the UI/probe ports of the enabled apps and
# the health server - kubelet probes and ingress traffic arrive on
# the pod IP and would be dropped otherwise.
- 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
{{- 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
# Writable paths for s6-overlay so the root filesystem stays
# read-only (same pattern for all linuxserver.io containers).
- mountPath: /run
name: qbittorrent-run
- mountPath: /tmp
name: qbittorrent-tmp
{{- 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
- mountPath: /run
name: prowlarr-run
- mountPath: /tmp
name: prowlarr-tmp
{{- 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
- mountPath: /downloads
name: media-downloads
- mountPath: /run
name: radarr-run
- mountPath: /tmp
name: radarr-tmp
{{- 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
- mountPath: /downloads
name: media-downloads
- mountPath: /run
name: sonarr-run
- mountPath: /tmp
name: sonarr-tmp
{{- 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
- mountPath: /tv
name: media-tv
- mountPath: /run
name: bazarr-run
- mountPath: /tmp
name: bazarr-tmp
{{- 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
{{- 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.qbittorrent.enabled }}
- name: qbittorrent-run
emptyDir: {}
- name: qbittorrent-tmp
emptyDir: {}
{{- end }}
{{- if .Values.prowlarr.enabled }}
- name: prowlarr-run
emptyDir: {}
- name: prowlarr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.flaresolverr.enabled }}
- name: flaresolverr-tmp
emptyDir: {}
- name: flaresolverr-shm
emptyDir:
medium: Memory
sizeLimit: 512Mi
{{- end }}
{{- if .Values.radarr.enabled }}
- name: radarr-run
emptyDir: {}
- name: radarr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.recyclarr.enabled }}
- name: recyclarr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.sonarr.enabled }}
- name: sonarr-run
emptyDir: {}
- name: sonarr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.seerr.enabled }}
- name: seerr-tmp
emptyDir: {}
{{- end }}
{{- if .Values.bazarr.enabled }}
- name: bazarr-run
emptyDir: {}
- name: bazarr-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 }}
+571
View File
@@ -0,0 +1,571 @@
# 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):
# appVersion pins the gluetun image only. All other app images are pinned via
# values (<app>.image.repository / <app>.image.tag) because the stack combines
# nine independently versioned upstream projects; the pinned tags are reviewed
# alongside every app-version update round, like side images in other charts.
# 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
media:
downloads:
existingClaim: null
movies:
existingClaim: null
tv:
existingClaim: null
ingress:
enabled: true
clusterIssuer: null
domain: null
className: nginx
# Extra annotations for the Ingress; merged with the cert-manager
# cluster-issuer annotation the chart always sets.
annotations: {}
gluetun:
# Image tag comes from Chart.AppVersion (gluetun is the chart's main app).
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.
livenessProbe:
httpGet:
path: /
port: 9999
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
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: linuxserver/qbittorrent
tag: 5.2.1
ingress:
enabled: true
storage:
size: 1Gi
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 create the abc user
# from PUID/PGID and chown /config.
# - 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).
# readOnlyRootFilesystem stays true: the writable paths (/run, /tmp) are
# mounted as emptyDirs. (Same block for all linuxserver.io containers:
# qbittorrent, prowlarr, radarr, sonarr, bazarr.)
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
prowlarr:
enabled: true
image:
repository: linuxserver/prowlarr
tag: 2.3.5
ingress:
enabled: true
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: true
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
# The image runs as its own non-root user; chromium's writable paths (/tmp,
# /dev/shm) are emptyDirs, so the full hardening baseline holds.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
radarr:
enabled: true
image:
repository: linuxserver/radarr
tag: 6.1.1
ingress:
enabled: true
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: true
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
recyclarr:
enabled: true
image:
repository: ghcr.io/recyclarr/recyclarr
tag: 8.6.0
# No ingress: recyclarr has no web UI, it syncs TRaSH guides to radarr and
# sonarr on a schedule (daemon mode).
storage:
size: 1Gi
env: []
# recyclarr serves no HTTP port in daemon mode, so all probes exec the CLI.
livenessProbe:
exec:
command:
- recyclarr
- --version
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
exec:
command:
- recyclarr
- --version
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
# The startup probe gates liveness/readiness. Startup budget = 5s * 30 = 150s.
startupProbe:
exec:
command:
- recyclarr
- --version
periodSeconds: 5
timeoutSeconds: 10
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.05
memory: 128Mi
ephemeralStorage: 50Mi
# The image supports running as an arbitrary non-root user; runAsUser and
# runAsGroup are set from the global puid/pgid values in the template - do
# not set them here.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
sonarr:
enabled: true
image:
repository: linuxserver/sonarr
tag: 4.0.17
ingress:
enabled: true
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: true
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
seerr:
enabled: true
image:
repository: ghcr.io/seerr-team/seerr
tag: v3.3.0
ingress:
enabled: true
storage:
size: 1Gi
env: []
# /api/v1/settings/public answers unauthenticated once the app is up.
livenessProbe:
httpGet:
path: /api/v1/settings/public
port: 5055
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/v1/settings/public
port: 5055
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# The startup probe gates liveness/readiness. First boot runs database
# migrations: budget 10s * 30 = 300s.
startupProbe:
httpGet:
path: /api/v1/settings/public
port: 5055
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 30
resources:
limitFactor: 3
requests:
cpu: 0.1
memory: 256Mi
ephemeralStorage: 100Mi
# The image runs fine as an arbitrary non-root user; runAsUser and
# runAsGroup are set from the global puid/pgid values in the template - do
# not set them here.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
bazarr:
enabled: true
image:
repository: linuxserver/bazarr
tag: 1.5.6
ingress:
enabled: true
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: true
capabilities:
drop:
- ALL
add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID