docs: add Docker Compose, k8s manifests, bare dotnet HA setup, and DigitalOcean link
This commit is contained in:
@@ -130,41 +130,298 @@ Standard [StackExchange.Redis connection string format](https://stackexchange.gi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker / Kubernetes
|
## Deployment
|
||||||
|
|
||||||
The `Dockerfile.runtime` in this repo produces a runtime-only image. The `.NET publish` step is intended to run on the CI host (not inside Docker) for I/O performance reasons.
|
> **This project is designed to run as a container.** Running it as a bare `dotnet` process 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](https://www.digitalocean.com/?refcode=b9012919f7ff&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge) 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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 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):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build locally
|
|
||||||
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||||
--configuration Release \
|
--configuration Release \
|
||||||
--runtime linux-x64 \
|
--runtime linux-x64 \
|
||||||
--self-contained false \
|
--self-contained false \
|
||||||
--output ./publish-output
|
--output ./publish-output
|
||||||
|
|
||||||
# Build image
|
docker compose up
|
||||||
docker build -f Dockerfile.runtime -t jellyfin-ha:local \
|
|
||||||
--platform linux/amd64 .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Kubernetes environment variables for HA:**
|
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:
|
||||||
|
|
||||||
|
1. A Kubernetes cluster (k3s, kubeadm, EKS, GKE, DigitalOcean Kubernetes, etc.)
|
||||||
|
2. A Redis instance (in-cluster or managed)
|
||||||
|
3. A `ReadWriteMany` storage class for shared transcode scratch space (NFS, Longhorn RWX, Ceph RBD, or a cloud-managed RWX PVC)
|
||||||
|
|
||||||
|
#### Redis (in-cluster, standalone)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
env:
|
apiVersion: apps/v1
|
||||||
- name: Jellyfin__TranscodeStore__RedisConnectionString
|
kind: Deployment
|
||||||
valueFrom:
|
metadata:
|
||||||
secretKeyRef:
|
name: redis
|
||||||
name: jellyfin-redis
|
namespace: jellyfin
|
||||||
key: connection-string
|
spec:
|
||||||
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
|
replicas: 1
|
||||||
value: "30"
|
selector:
|
||||||
- name: JELLYFIN_HA_POD_NAME
|
matchLabels:
|
||||||
valueFrom:
|
app: redis
|
||||||
fieldRef:
|
template:
|
||||||
fieldPath: metadata.name
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Shared storage (NFS, Longhorn RWX, or similar) must be mounted at the same path on all pods for segment file access to work across pod boundaries.
|
#### Redis connection secret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl create secret generic jellyfin-redis \
|
||||||
|
--namespace jellyfin \
|
||||||
|
--from-literal=connection-string="redis.jellyfin.svc.cluster.local:6379,abortConnect=false"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Shared transcode PVC (RWX)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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=false` is 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:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user