Per-app ingress and service, media subPaths

Three review changes to the freesailarr chart.

Ingress: the chart no longer derives hosts as <app>.<ingress.domain> from one
Ingress object. The global ingress.domain is gone; every app carries its own
<app>.ingress.host and renders its own Ingress <release>-<chart>-<app>-ingress
with its own TLS secret tls-<release>-<chart>-<app>-ingress. The hosts are
therefore independent and may live in unrelated domains. The host is required,
but the required() call sits inside the loop over the *enabled* app ingresses,
so switching an app's ingress off must not make the chart demand a host for it.
clusterIssuer, className and annotations stay global defaults and are
overridable per app; an explicitly set per-app value wins (nil check instead of
"default", so an empty override is honoured).

Media: media.<category>.subPath mounts a sub-directory of the claim instead of
its root, default null (claim root = previous behaviour). The same
existingClaim may back all three categories with different subPaths. That is
the point rather than cosmetics: Radarr and Sonarr import by moving, and a move
within one filesystem can be a hardlink instead of a copy - across volume
boundaries every import duplicates the file.

Services: the collective Service with named ports is replaced by one ClusterIP
Service <release>-<chart>-<app>-service per enabled app that listens on a port,
flaresolverr included; recyclarr serves no port in daemon mode and gets none.
Each ingress points at its own app's service. All containers share gluetun's
network namespace, so the services differ only in their port - they provide
stable per-app DNS names, not network separation. The StatefulSet's governing
serviceName no longer names a removed object.
This commit is contained in:
jklein committed 2026-08-24 13:45:38 +02:00
1 parent d4f03cdfd9
commit cdb66a32f4
6 files changed
+169 -46

No files matched your search

+2 -1
View File
@@ -9,7 +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) | | `apache-tika` | Apache Tika | Deployment + Service only (no ingress) |
| `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC | | `calibre-web-automated` | Calibre-Web Automated | StatefulSet, extra consume-PVC |
| `cyberchef` | CyberChef | Stateless Deployment | | `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 | | `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) | | `gotenberg` | Gotenberg | Deployment + Service only (no ingress) |
| `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests | | `homeassistant` | Home Assistant | StatefulSet, has `NOTES.txt` and Helm tests |
| `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service | | `matrix-synapse` | Matrix Synapse | StatefulSet plus an nginx delegation Deployment/Service |
@@ -152,6 +152,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. - `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`. - 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 — 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 ### 5. Security hardening
+34 -13
View File
@@ -148,17 +148,24 @@ Usage:
{{- end -}} {{- end -}}
{{/* {{/*
The enabled apps that expose a web UI, as a JSON array of {name, port} dicts The enabled app containers that listen on a port, as a JSON array of
(container port name == app name == ingress subdomain). Consumed by the {name, port} dicts (container port name == app name == service name suffix).
service, the ingress and the firewall-ports helper below: Consumed by the per-app services, the per-app ingresses and the firewall-ports
helper below:
{{- $apps := include "freesailarr.uiApps" . | fromJsonArray }} {{- $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.uiApps" -}} {{- define "freesailarr.portApps" -}}
{{- $apps := list -}} {{- $apps := list -}}
{{- range $app := list {{- range $app := list
(dict "name" "qbittorrent" "port" 8080) (dict "name" "qbittorrent" "port" 8080)
(dict "name" "prowlarr" "port" 9696) (dict "name" "prowlarr" "port" 9696)
(dict "name" "flaresolverr" "port" 8191)
(dict "name" "radarr" "port" 7878) (dict "name" "radarr" "port" 7878)
(dict "name" "sonarr" "port" 8989) (dict "name" "sonarr" "port" 8989)
(dict "name" "seerr" "port" 5055) (dict "name" "seerr" "port" 5055)
@@ -171,19 +178,33 @@ service, the ingress and the firewall-ports helper below:
{{- end -}} {{- end -}}
{{/* {{/*
Comma-separated FIREWALL_INPUT_PORTS for gluetun: the UI ports of the enabled The apps that render an Ingress: the enabled port apps whose values carry an
apps (ingress traffic arrives on the pod IP), flaresolverr's 8191 when enabled ingress block with enabled: true. Used by the ingress template.
(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. {{- $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. Without these the gluetun firewall
drops the traffic.
*/}} */}}
{{- define "freesailarr.firewallInputPorts" -}} {{- define "freesailarr.firewallInputPorts" -}}
{{- $ports := list -}} {{- $ports := list -}}
{{- range $app := include "freesailarr.uiApps" . | fromJsonArray -}} {{- range $app := include "freesailarr.portApps" . | fromJsonArray -}}
{{- $ports = append $ports (int $app.port) -}} {{- $ports = append $ports (int $app.port) -}}
{{- end -}} {{- end -}}
{{- if .Values.flaresolverr.enabled -}}
{{- $ports = append $ports 8191 -}}
{{- end -}}
{{- $ports = append $ports 9999 -}} {{- $ports = append $ports 9999 -}}
{{- join "," $ports -}} {{- join "," $ports -}}
{{- end -}} {{- end -}}
+33 -20
View File
@@ -1,38 +1,51 @@
{{- $apps := list }} {{/*
{{- range $app := include "freesailarr.uiApps" . | fromJsonArray }} One Ingress object per app whose ingress is enabled, each with its own host and
{{- if (index $.Values $app.name).ingress.enabled }} its own TLS secret. The hosts are independent values - they do not have to
{{- $apps = append $apps $app }} share a domain - and each ingress points at its own app's service.
{{- end }}
{{- end }} <app>.ingress.host is required, but only for an app whose ingress is actually
{{- if and .Values.ingress.enabled (gt (len $apps) 0) }} 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 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-ingress name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress
annotations: annotations:
cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" .Values.ingress.clusterIssuer }} cert-manager.io/cluster-issuer: {{ required "A Cluster-Issuer must be provided" $clusterIssuer }}
{{- with .Values.ingress.annotations }} {{- with $annotations }}
{{- toYaml . | nindent 4 }} {{- toYaml . | nindent 4 }}
{{- end }} {{- end }}
spec: spec:
ingressClassName: {{ .Values.ingress.className }} ingressClassName: {{ $className }}
rules: rules:
{{- range $apps }} - host: {{ $host | quote }}
- host: {{ .name }}.{{ required "A Domain/Host must be provided" $.Values.ingress.domain }}
http: http:
paths: paths:
- backend: - backend:
service: service:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-service name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
port: port:
name: {{ .name }} name: {{ $app.name }}
path: / path: /
pathType: Prefix pathType: Prefix
{{- end }}
tls: tls:
- hosts: - hosts:
{{- range $apps }} - {{ $host | quote }}
- {{ .name }}.{{ $.Values.ingress.domain }} secretName: tls-{{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-ingress
{{- end }} {{- end }}
secretName: tls-{{ .Release.Name }}-{{ .Chart.Name }}-ingress
{{- end }} {{- end }}
+16 -9
View File
@@ -1,18 +1,25 @@
{{- $apps := include "freesailarr.uiApps" . | fromJsonArray }} {{/*
{{- if gt (len $apps) 0 }} 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 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }}-service name: {{ $.Release.Name }}-{{ $.Chart.Name }}-{{ $app.name }}-service
spec: spec:
selector: selector:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs app: {{ $.Release.Name }}-{{ $.Chart.Name }}-sfs
ports: ports:
{{- range $apps }}
- protocol: TCP - protocol: TCP
port: {{ int .port }} port: {{ int $app.port }}
targetPort: {{ .name }} targetPort: {{ $app.name }}
name: {{ .name }} name: {{ $app.name }}
{{- end }}
type: ClusterIP type: ClusterIP
{{- end }} {{- end }}
+26 -1
View File
@@ -6,7 +6,11 @@ metadata:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
spec: spec:
replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }} replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }}
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service # 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: selector:
matchLabels: matchLabels:
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
@@ -200,6 +204,9 @@ spec:
name: qbittorrent-config name: qbittorrent-config
- mountPath: /downloads - mountPath: /downloads
name: media-downloads name: media-downloads
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }} {{- end }}
{{- if .Values.prowlarr.enabled }} {{- if .Values.prowlarr.enabled }}
- name: prowlarr - name: prowlarr
@@ -293,8 +300,14 @@ spec:
name: radarr-config name: radarr-config
- mountPath: /movies - mountPath: /movies
name: media-movies name: media-movies
{{- with .Values.media.movies.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /downloads - mountPath: /downloads
name: media-downloads name: media-downloads
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }} {{- end }}
{{- if .Values.recyclarr.enabled }} {{- if .Values.recyclarr.enabled }}
- name: recyclarr - name: recyclarr
@@ -357,8 +370,14 @@ spec:
name: sonarr-config name: sonarr-config
- mountPath: /tv - mountPath: /tv
name: media-tv name: media-tv
{{- with .Values.media.tv.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /downloads - mountPath: /downloads
name: media-downloads name: media-downloads
{{- with .Values.media.downloads.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }} {{- end }}
{{- if .Values.seerr.enabled }} {{- if .Values.seerr.enabled }}
- name: seerr - name: seerr
@@ -421,8 +440,14 @@ spec:
name: bazarr-config name: bazarr-config
- mountPath: /movies - mountPath: /movies
name: media-movies name: media-movies
{{- with .Values.media.movies.subPath }}
subPath: {{ . | quote }}
{{- end }}
- mountPath: /tv - mountPath: /tv
name: media-tv name: media-tv
{{- with .Values.media.tv.subPath }}
subPath: {{ . | quote }}
{{- end }}
{{- end }} {{- end }}
restartPolicy: Always restartPolicy: Always
volumes: volumes:
+58 -2
View File
@@ -57,20 +57,40 @@ storage:
# downloads: qbittorrent, radarr, sonarr # downloads: qbittorrent, radarr, sonarr
# movies: radarr, bazarr # movies: radarr, bazarr
# tv: sonarr, 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 be used
# for all three categories with different subPaths - that is more than a
# convenience: Radarr and Sonarr import finished downloads into the library by
# *moving* them, and a move within one filesystem can be a hardlink or rename
# instead of a copy. On one volume that costs no second copy of the data and no
# copy time; across volume boundaries a hardlink is impossible, so every import
# duplicates the file and the download and the library each hold a full copy
# until the torrent is removed. Example: one claim, subPaths downloads /
# movies / tv-shows.
media: media:
downloads: downloads:
existingClaim: null existingClaim: null
subPath: null
movies: movies:
existingClaim: null existingClaim: null
subPath: null
tv: tv:
existingClaim: null 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: ingress:
# Master switch: false suppresses every app ingress regardless of the
# per-app toggles.
enabled: true enabled: true
clusterIssuer: null clusterIssuer: null
domain: null
className: nginx className: nginx
# Extra annotations for the Ingress; merged with the cert-manager # Extra annotations for the Ingress objects; merged with the cert-manager
# cluster-issuer annotation the chart always sets. # cluster-issuer annotation the chart always sets.
annotations: {} annotations: {}
@@ -157,8 +177,19 @@ qbittorrent:
image: image:
repository: linuxserver/qbittorrent repository: linuxserver/qbittorrent
tag: 5.2.1 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: ingress:
enabled: true enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage: storage:
size: 1Gi size: 1Gi
# Network interface qBittorrent binds its BitTorrent session to - gluetun's # Network interface qBittorrent binds its BitTorrent session to - gluetun's
@@ -258,8 +289,13 @@ prowlarr:
image: image:
repository: linuxserver/prowlarr repository: linuxserver/prowlarr
tag: 2.3.5 tag: 2.3.5
# See the qbittorrent ingress comment.
ingress: ingress:
enabled: true enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage: storage:
size: 1Gi size: 1Gi
env: [] env: []
@@ -366,8 +402,13 @@ radarr:
image: image:
repository: linuxserver/radarr repository: linuxserver/radarr
tag: 6.1.1 tag: 6.1.1
# See the qbittorrent ingress comment.
ingress: ingress:
enabled: true enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage: storage:
size: 1Gi size: 1Gi
env: [] env: []
@@ -471,8 +512,13 @@ sonarr:
image: image:
repository: linuxserver/sonarr repository: linuxserver/sonarr
tag: 4.0.17 tag: 4.0.17
# See the qbittorrent ingress comment.
ingress: ingress:
enabled: true enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage: storage:
size: 1Gi size: 1Gi
env: [] env: []
@@ -523,8 +569,13 @@ seerr:
image: image:
repository: ghcr.io/seerr-team/seerr repository: ghcr.io/seerr-team/seerr
tag: v3.3.0 tag: v3.3.0
# See the qbittorrent ingress comment.
ingress: ingress:
enabled: true enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage: storage:
size: 1Gi size: 1Gi
env: [] env: []
@@ -574,8 +625,13 @@ bazarr:
image: image:
repository: linuxserver/bazarr repository: linuxserver/bazarr
tag: 1.5.6 tag: 1.5.6
# See the qbittorrent ingress comment.
ingress: ingress:
enabled: true enabled: true
host: null
# clusterIssuer: null
# className: null
# annotations: {}
storage: storage:
size: 1Gi size: 1Gi
env: [] env: []