Upstream Jellyfin 10.11.7 targets net9.0. Our PostgreSQL provider was on net10.0 with Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 which requires EF Core 10+. Downgrade to 9.0.4 to match upstream's EF Core 9.0.11. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jellyfin-ha
A fork of Jellyfin adding high-availability transcoding support for multi-pod Kubernetes deployments.
What is this?
Jellyfin's default assumption is that exactly one server instance is running at a time. Transcode state is held entirely in-memory — when the process dies, so do all active HLS streams. For homelab deployments that want Kubernetes-managed redundancy (rolling restarts, node drain, pod rescheduling), that's a problem.
This fork adds a thin HA layer on top of unmodified Jellyfin core:
ITranscodeSessionStore— a new interface for durable, distributed transcode session trackingRedisTranscodeSessionStore— a Redis-backed implementation using atomic Lua takeover scripts and TTL-based lease expiryNullTranscodeSessionStore— a no-op fallback so single-instance deployments work with zero configuration change- Lease-aware
DeleteTranscodeFileTask— coordinates cleanup across replicas so a restarting pod doesn't delete segments another pod is actively streaming SessionManagerHA recovery — safe takeover of live HLS streams when a pod takes over after lease expiry- PostgreSQL database provider — alternative to SQLite for shared-database HA setups (experimental, under
src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL)
Architecture
┌─────────────┐ ┌─────────────┐
│ Jellyfin │ │ Jellyfin │
│ Pod A │ │ Pod B │
│ │ │ │
│ ┌─────────┐ │ │ ┌─────────┐ │
│ │Transcode│ │ │ │Transcode│ │
│ │Manager │ │ │ │Manager │ │
│ └────┬────┘ │ │ └────┬────┘ │
└──────┼──────┘ └──────┼──────┘
│ │
└─────────┬─────────┘
│
┌───────▼───────┐
│ Redis │ ← ITranscodeSessionStore
│ (lease store)│ TTL-based ownership
└───────────────┘
┌─────────────────────┐
│ Shared NAS / NFS │ ← HLS segments + manifests
│ (shared storage) │
└─────────────────────┘
How takeover works:
- Pod A starts an HLS transcode and writes a
TranscodeSessionto Redis with a 30-second lease. - Pod A renews the lease every
LeaseDurationSeconds / 2seconds. - If Pod A dies, the lease expires in Redis after 30 seconds.
- Pod B receives a client request for the same play session, calls
TryTakeoverAsync, and atomically claims ownership via a Lua script. - Pod B resumes FFmpeg from the last durable segment index. The client sees a brief stutter, not an error.
Quick Start
Single instance (no Redis)
No configuration required. NullTranscodeSessionStore is used automatically. Behavior is identical to upstream Jellyfin.
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
--datadir /var/lib/jellyfin \
--webdir /usr/share/jellyfin/web
HA mode with Redis
Set the Jellyfin:TranscodeStore:RedisConnectionString configuration key. You can pass it as an environment variable, a DOTNET_ prefixed env var, or in a JSON config file.
Environment variable:
export Jellyfin__TranscodeStore__RedisConnectionString="redis:6379"
export Jellyfin__TranscodeStore__LeaseDurationSeconds="30"
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
--datadir /var/lib/jellyfin \
--webdir /usr/share/jellyfin/web
appsettings.json section:
{
"Jellyfin": {
"TranscodeStore": {
"RedisConnectionString": "redis:6379,abortConnect=false",
"LeaseDurationSeconds": 30
}
}
}
When RedisConnectionString is set, RedisTranscodeSessionStore is registered in DI. If the Redis connection fails at startup, the server throws and refuses to start — this is intentional so you don't silently fall back to broken HA behavior.
Configuration Reference
| Key | Default | Description |
|---|---|---|
Jellyfin:TranscodeStore:RedisConnectionString |
(empty) | StackExchange.Redis connection string. Empty = single-instance mode. |
Jellyfin:TranscodeStore:LeaseDurationSeconds |
30 |
How long a pod's transcode lease is valid before another pod may take over. |
Redis connection string examples
# Standalone Redis
redis:6379
# With password
redis:6379,password=secret
# With TLS
redis.example.com:6380,ssl=true,abortConnect=false
# Redis Sentinel
sentinel-host:26379,serviceName=mymaster
Standard StackExchange.Redis connection string format is accepted.
Deployment
This project is designed to run as a container. Running it as a bare
dotnetprocess is fine for development and testing, but the HA benefits only materialize when you have multiple replicas managed by a container orchestrator. Docker Compose gets you Redis + Jellyfin wired together locally. Kubernetes (k3s, k8s, or a managed cloud cluster) gets you the actual pod-death-and-recovery story.Don't have a Kubernetes cluster yet? DigitalOcean Kubernetes is the fastest path to a managed cluster if you don't want to run your own nodes.
Option 1 — Local HA with Docker Compose
The simplest way to test the full HA stack locally: two Jellyfin replicas sharing a Redis instance and a local volume for transcode output.
# docker-compose.yml
version: "3.9"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
jellyfin-1:
build:
context: .
dockerfile: Dockerfile.runtime
environment:
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
JELLYFIN_HA_POD_NAME: "jellyfin-1"
volumes:
- ./data/config:/config
- ./data/media:/media:ro
- transcode-tmp:/transcode
ports:
- "8096:8096"
depends_on:
- redis
jellyfin-2:
build:
context: .
dockerfile: Dockerfile.runtime
environment:
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
JELLYFIN_HA_POD_NAME: "jellyfin-2"
volumes:
- ./data/config:/config
- ./data/media:/media:ro
- transcode-tmp:/transcode
ports:
- "8097:8096"
depends_on:
- redis
volumes:
transcode-tmp:
Build the image first (the dotnet publish step runs outside Docker for I/O performance):
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--output ./publish-output
docker compose up
Both replicas share the transcode-tmp volume and register sessions in Redis. Kill one container mid-stream (docker kill jellyfin-1) and the other takes over within LeaseDurationSeconds.
Option 2 — Kubernetes (k3s / k8s)
This is the intended production deployment. You need:
- A Kubernetes cluster (k3s, kubeadm, EKS, GKE, DigitalOcean Kubernetes, etc.)
- A Redis instance (in-cluster or managed)
- A
ReadWriteManystorage class for shared transcode scratch space (NFS, Longhorn RWX, Ceph RBD, or a cloud-managed RWX PVC)
Redis (in-cluster, standalone)
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: jellyfin
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: jellyfin
spec:
selector:
app: redis
ports:
- port: 6379
Redis connection secret
kubectl create secret generic jellyfin-redis \
--namespace jellyfin \
--from-literal=connection-string="redis.jellyfin.svc.cluster.local:6379,abortConnect=false"
Shared transcode PVC (RWX)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-transcode
namespace: jellyfin
spec:
accessModes:
- ReadWriteMany
storageClassName: longhorn # or nfs-client, csi-driver-nfs, etc.
resources:
requests:
storage: 20Gi
Jellyfin Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: jellyfin
namespace: jellyfin
spec:
replicas: 2
selector:
matchLabels:
app: jellyfin
template:
metadata:
labels:
app: jellyfin
spec:
containers:
- name: jellyfin
image: your-registry/jellyfin-ha:latest
ports:
- containerPort: 8096
env:
- name: Jellyfin__TranscodeStore__RedisConnectionString
valueFrom:
secretKeyRef:
name: jellyfin-redis
key: connection-string
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
value: "30"
- name: JELLYFIN_HA_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /media
readOnly: true
- name: transcode
mountPath: /transcode
livenessProbe:
httpGet:
path: /health
port: 8096
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8096
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: config
persistentVolumeClaim:
claimName: jellyfin-config # RWO is fine — config is single-writer
- name: media
nfs:
server: your-nas.local
path: /media
- name: transcode
persistentVolumeClaim:
claimName: jellyfin-transcode # Must be RWX
---
apiVersion: v1
kind: Service
metadata:
name: jellyfin
namespace: jellyfin
spec:
type: ClusterIP
selector:
app: jellyfin
ports:
- port: 8096
targetPort: 8096
Important: storage requirements
| Volume | Access mode | Why |
|---|---|---|
Config (/config) |
ReadWriteOnce |
One writer, SQLite DB lives here |
Media (/media) |
ReadOnlyMany |
All pods read the same library |
Transcode (/transcode) |
ReadWriteMany |
Pods read each other's HLS segments during takeover |
The transcode volume is the critical one. If it's ReadWriteOnce, pod takeover will fail because Pod B cannot read the .ts segments Pod A wrote. Use NFS, Longhorn with RWX enabled, or a cloud-managed RWX storage class.
Building the image
# Publish (run on host, not inside Docker)
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--output ./publish-output
# Build for amd64 (required for most clusters)
docker buildx build \
--platform linux/amd64 \
--provenance=false \
-f Dockerfile.runtime \
-t your-registry/jellyfin-ha:latest \
--push .
Note:
--provenance=falseis required if your cluster runs containerd (k3s, most kubeadm setups). Without it, Docker adds OCI attestation manifests that containerd cannot resolve.
Option 3 — Bare dotnet (development only)
For local development and testing without containers. HA mode still works — you just run two terminal sessions pointing at the same Redis and a shared local directory.
Terminal 1:
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
export JELLYFIN_HA_POD_NAME="dev-pod-1"
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
--datadir /tmp/jellyfin-1/data \
--cachedir /tmp/jellyfin-1/cache \
--transcodes /tmp/jellyfin-shared/transcode \
--webdir /usr/share/jellyfin/web \
--port 8096
Terminal 2:
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
export JELLYFIN_HA_POD_NAME="dev-pod-2"
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
--datadir /tmp/jellyfin-2/data \
--cachedir /tmp/jellyfin-2/cache \
--transcodes /tmp/jellyfin-shared/transcode \
--webdir /usr/share/jellyfin/web \
--port 8097
Both instances share /tmp/jellyfin-shared/transcode. Kill one process mid-stream to test takeover. Start a local Redis with redis-server or docker run -p 6379:6379 redis:7-alpine.
PostgreSQL (experimental)
This fork includes a PostgreSQL database provider under src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL. It is experimental — the SQLite provider remains the default and the recommended choice for most deployments.
To use PostgreSQL, set the migration provider at startup and run migrations:
dotnet ef migrations add InitialCreate \
--project "src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL" \
-- --migration-provider Jellyfin-PostgreSQL
See src/Jellyfin.Database/readme.md for full migration instructions.
Building and Testing
Prerequisites
Build
dotnet build Jellyfin.Server/Jellyfin.Server.csproj
Run all tests
dotnet test Jellyfin.sln \
--configuration Release \
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
Run HA-specific tests
The transcode session store and HA recovery tests live in:
tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cstests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs
dotnet test tests/Jellyfin.Server.Implementations.Tests \
--configuration Release \
--filter "FullyQualifiedName~TranscodeSession"
Run with code coverage
dotnet test Jellyfin.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings
Project Structure
MediaBrowser.Controller/MediaEncoding/
ITranscodeSessionStore.cs ← Interface (DI contract)
TranscodeSession.cs ← Session record model
TranscodeStoreOptions.cs ← Configuration options
NullTranscodeSessionStore.cs ← No-op, single-instance fallback
Emby.Server.Implementations/MediaEncoding/
RedisTranscodeSessionStore.cs ← Redis-backed HA implementation
src/Jellyfin.Database/
Jellyfin.Database.Providers.PostgreSQL/ ← Experimental PostgreSQL provider
tests/
Jellyfin.Server.Implementations.Tests/MediaEncoding/
RedisTranscodeSessionStoreTests.cs
Jellyfin.MediaEncoding.Tests/Fakes/
InMemoryTranscodeSessionStore.cs
Contributing
This is a personal experiment, not an officially maintained fork. Issues and PRs are welcome but response time may vary.
If you're interested in getting proper HA transcoding into upstream Jellyfin, that conversation belongs in the upstream repo. The changes here are deliberately narrow and designed to be upstream-friendly if there's maintainer interest.
Code conventions follow the upstream Jellyfin rules:
async/awaiteverywhere — no.Resultor.Wait()- All public members need XML doc comments
- Use
Directory.Packages.propsfor NuGet versions — never addVersion=to a<PackageReference> .NET 10required- Warnings are treated as errors
Relationship to upstream
This fork tracks jellyfin/jellyfin master. The HA additions are intentionally isolated to:
- New interfaces and models in
MediaBrowser.Controller - New implementations in
Emby.Server.Implementations - DI wiring in
Jellyfin.Server/CoreAppHost.cs - New test projects
No core Jellyfin logic was modified — only extended via existing DI extension points.
License
GPL-2.0, same as upstream Jellyfin. See LICENSE.
Upstream README preserved below for reference.
Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 6 development will work, but two options are recent versions of Visual Studio (at least 2022) and Visual Studio Code.
ffmpeg will also need to be installed.
Cloning the Repository
After dependencies have been installed you will need to clone a local copy of this repository. If you just want to run the server from source you can clone this repository directly, but if you are intending to contribute code changes to the project, you should set up your own fork of the repository. The following example shows how you can clone the repository directly over HTTPS.
git clone https://github.com/jellyfin/jellyfin.git
Installing the Web Client
The server is configured to host the static files required for the web client in addition to serving the backend by default. Before you can run the server, you will need to get a copy of the web client since they are not included in this repository directly.
Note that it is recommended for development to host the web client separately from the web server with some additional configuration, in which case you can skip this step.
There are two options to get the files for the web client.
- Build them from source following the instructions on the jellyfin-web repository
- Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at
C:\Program Files\Jellyfin\Server\jellyfin-web
Running The Server
The following instructions will help you get the project up and running via the command line, or your preferred IDE.
Running With Visual Studio
To run the project with Visual Studio you can open the Solution (.sln) file and then press F5 to run the server.
Running With Visual Studio Code
To run the project with Visual Studio Code you will first need to open the repository directory with Visual Studio Code using the Open Folder... option.
Second, you need to install the recommended extensions for the workspace. Note that extension recommendations are classified as either "Workspace Recommendations" or "Other Recommendations", but only the "Workspace Recommendations" are required.
After the required extensions are installed, you can run the server by pressing F5.
Running From the Command Line
To run the server from the command line you can use the dotnet run command. The example below shows how to do this if you have cloned the repository into a directory named jellyfin (the default directory name) and should work on all operating systems.
cd jellyfin # Move into the repository directory
dotnet run --project Jellyfin.Server --webdir /absolute/path/to/jellyfin-web/dist # Run the server startup project
A second option is to build the project and then run the resulting executable file directly. When running the executable directly you can easily add command line options. Add the --help flag to list details on all the supported command line options.
- Build the project
dotnet build # Build the project
cd Jellyfin.Server/bin/Debug/net10.0 # Change into the build output directory
- Execute the build output. On Linux, Mac, etc. use
./jellyfinand on Windows usejellyfin.exe.
Accessing the Hosted Web Client
If the Server is configured to host the Web Client, and the Server is running, the Web Client can be accessed at http://localhost:8096 by default.
API documentation can be viewed at http://localhost:8096/api-docs/swagger/index.html
Running from GitHub Codespaces
As Jellyfin will run on a container on a GitHub hosted server, JF needs to handle some things differently.
NOTE: Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 seconds to load all extensions and prepare the environment while VS Code is already open. Just give it some time and wait until you see Downloading .NET version(s) 7.0.15~x64 ...... Done! in the output tab.
NOTE: If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VS Code window to public.
NOTE: When first opening the server instance with any WebUI, you will be sent to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup.
There are two configurations for you to choose from.
Default - Development Jellyfin Server
This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run through the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the .NET Launch (nowebclient) launch config to start the server.
Keep in mind that as this has no web client you have to connect to it via an external client. This can be just another codespace container running the WebUI. vuejs does not work from the get-go as it does not support the setup steps.
Development Jellyfin Server ffmpeg
this extends the default server with a default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual
If you want to install a specific ffmpeg version, follow the comments embedded in the .devcontainer/Dev - Server Ffmpeg/install.ffmpeg.sh file.
Use the ghcs .NET Launch (nowebclient, ffmpeg) launch config to run with the jellyfin-ffmpeg enabled.
Running The Tests
This repository also includes unit tests that are used to validate functionality as part of a CI pipeline on Azure. There are several ways to run these tests.
- Run tests from the command line using
dotnet test - Run tests in Visual Studio using the Test Explorer
- Run individual tests in Visual Studio Code using the associated CodeLens annotation
Advanced Configuration
The following sections describe some more advanced scenarios for running the server from source that build upon the standard instructions above.
Hosting The Web Client Separately
It is not necessary to host the frontend web client as part of the backend server. Hosting these two components separately may be useful for frontend developers who would prefer to host the client in a separate webpack development server for a tighter development loop. See the jellyfin-web repo for instructions on how to do this.
To instruct the server not to host the web content, there is a nowebclient configuration flag that must be set. This can be specified using the command line
switch --nowebclient or the environment variable JELLYFIN_NOWEBCONTENT=true.
Since this is a common scenario, there is also a separate launch profile defined for Visual Studio called Jellyfin.Server (nowebcontent) that can be selected from the 'Start Debugging' dropdown in the main toolbar.
NOTE: The setup wizard cannot be run if the web client is hosted separately.