If you’re building a home media server, Jellyfin is the absolute best no-strings-attached solution. But deploying it on a Kubernetes cluster (K3s) running on an immutable OS like Fedora Silverblue? That brings some massive headaches, especially around networking and file permissions.
After spending way too much time falling down the Calico and iptables rabbit holes, I finally got it running flawlessly using the official Helm chart and K3s’s default Flannel network. Here is how to get it working without compromising your host’s security.
The Fedora Silverblue Networking Gotcha #
Out of the box, K3s uses Flannel for internal pod networking. Flannel relies on dynamically created virtual network bridges.
Here is the problem: Fedora Silverblue is immutable and strictly relies on firewalld (backed by nftables). When K3s spins up these virtual interfaces, firewalld has no idea what they are, shoves them into the restrictive public zone, and ruthlessly drops all your internal pod-to-pod traffic. The result? You’ll be staring at a 502 Bad Gateway error because your Traefik Ingress simply can’t route traffic to your Jellyfin pod.
You might be tempted to just disable firewalld or hack the kernel to support legacy iptables. Don’t. You can keep your server fully secure by simply telling firewalld to explicitly trust the subnets K3s uses.
Step 1: Whitelist K3s in Firewalld #
K3s uses 10.42.0.0/16 for Pods and 10.43.0.0/16 for Services. We just need to add these to the trusted zone.
Run these commands on your host:
sudo systemctl enable --now firewalld
sudo firewall-cmd --permanent --zone=trusted --add-source=10.42.0.0/16
sudo firewall-cmd --permanent --zone=trusted --add-source=10.43.0.0/16
sudo firewall-cmd --reloadWith the network unblocked, go ahead and install K3s and Helm:
# Install K3s
curl -sfL https://get.k3s.io | sh -
# Grab your kubeconfig
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
# Install Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bashCreate a dedicated namespace for the deployment:
kubectl create namespace jellyfinStep 2: Map Your Media (PV & PVC) #
Jellyfin needs access to your actual media files on the host machine. I use a hostPath Persistent Volume to pass a local directory straight into the cluster.
Create a file named media-storage.yaml:
apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-movies-pv
labels:
type: local
spec:
storageClassName: manual
capacity:
storage: 500Gi
accessModes:
- ReadWriteMany
hostPath:
path: "/home/joeri/Movies/" # Change this to your actual media path
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-movies-pvc
namespace: jellyfin
spec:
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 500GiApply it:
kubectl apply -f media-storage.yamlStep 3: Helm Values & The Empty /media Bug #
Before we deploy, we need to fix two major configuration traps.
First: File Permissions. Jellyfin runs as a non-root user. If the container’s UID doesn’t match the host user that owns your media folder, Jellyfin won’t see your files. We fix this by passing your host’s UID/GID into a podSecurityContext. (Run id on your host terminal to find yours—it’s usually 1000).
Second: The Empty /media Folder. The official Helm chart defaults to creating a blank emptyDir at /media. If you try to use generic Helm injection keys to override it, it will fail silently and leave you with an empty directory. The fix is to disable the native media block completely and inject standard Kubernetes volumes and volumeMounts arrays.
Create jellyfin-values.yaml:
image:
repository: jellyfin/jellyfin
tag: latest
pullPolicy: IfNotPresent
# Match the Pod ID to the Host ID so Jellyfin can read your files
podSecurityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
# Set up Traefik Ingress
ingress:
enabled: true
annotations:
kubernetes.io/ingress.class: traefik
hosts:
- host: jellyfin.local # Point this to your server's IP in /etc/hosts
paths:
- path: /
pathType: Prefix
# Standard configuration PVC
persistence:
config:
enabled: true
size: 20Gi
storageClass: "local-path"
# Crucial: Disable the default empty media mount
media:
enabled: false
# Manually mount our PVC to exactly where we want it
volumes:
- name: movies-volume
persistentVolumeClaim:
claimName: jellyfin-movies-pvc
volumeMounts:
- name: movies-volume
mountPath: /media/Movies
readOnly: trueStep 4: Deploy #
With the config sorted out, deploy the official chart:
helm repo add jellyfin https://jellyfin.github.io/jellyfin-helm
helm repo update
helm install jellyfin jellyfin/jellyfin \
--namespace jellyfin \
-f jellyfin-values.yamlRun kubectl get pods -n jellyfin and wait for the pod to hit Running.
Once it’s up, map jellyfin.local to your server’s IP in your client machine’s /etc/hosts file, and open it in your browser. When you add your media library in the setup wizard, point it to /media/Movies and it will scan everything instantly.
Troubleshooting Ingress & Upgrades #
If you can’t reach the web interface, the fastest way to isolate the issue is to bypass your browser and test routing directly from your K3s node:
curl -v -H "Host: jellyfin.local" http://localhost302 Found: The cluster is working perfectly. Your client’s DNS/hosts file is wrong.502 Bad Gateway: Traefik is running, but it’s being blocked from talking to the pod. Double-check yourfirewalldtrusted zones from Step 1.Connection refused: Traefik isn’t listening. Ensure thesvclb-traefikpods are actually running in thekube-systemnamespace.
Applying Changes:
If you want to edit your jellyfin-values.yaml later (like adding a new volume for TV shows), Kubernetes won’t auto-detect the local file changes. You have to push the update using Helm:
helm upgrade jellyfin jellyfin/jellyfin --namespace jellyfin -f jellyfin-values.yamlHelm will calculate the diff and spin up a new pod with the fresh configuration. Happy self-hosting!