# Self-Hosting Jellyfin Media Server

> Octelium documentation. Canonical page: <https://octelium.com/docs/octelium/latest/management/guide/service/homelab/jellyfin-helm-self-host>.

[Jellyfin](https://jellyfin.org/) is a popular open source media server that lets you stream your own movies, TV shows and music to your browsers, TVs, phones and other devices, and a common self-hosted alternative to Plex and Emby. This guide shows you how to deploy Jellyfin via its official Helm chart on the same underlying Kubernetes cluster that is running the Octelium *Cluster*, and then serve it as an Octelium *Service*. Octelium provides the following:

- Private client-based access over WireGuard/QUIC via the `octelium connect` command (read more [here](https://octelium.com/docs/octelium/latest/user/cli/connect.md)) from your laptops and desktops.
- Public clientless BeyondCorp access (read more [here](https://octelium.com/docs/octelium/latest/management/core/service/clientless.md)) from any web browser, including mobile browsers, where your *Users* authenticate via OpenID Connect, SAML 2.0 or GitHub *IdentityProviders* (read more [here](https://octelium.com/docs/octelium/latest/management/core/identity-providers.md)) as well as Passkeys before they are able to reach Jellyfin at all.
- Access for your TVs and other devices at home that cannot run the `octelium` client, by publishing the *Service* to your home network from a single connected device.
- Serving an existing Jellyfin server running at your home behind NAT without opening any ports.
- Identity-based, context-aware access control on a per-request basis via policy-as-code with CEL and OPA (read more about *Policies* and access control [here](https://octelium.com/docs/octelium/latest/management/core/policy.md)).
- OpenTelemetry-native, identity-based, L7 aware visibility and auditing (read more [here](https://octelium.com/docs/octelium/latest/management/core/visibility.md)).

> **Note:**
>
> If you installed the *Cluster* via the quick installation guide, you can simply use the command `export KUBECONFIG="/etc/rancher/k3s/k3s.yaml"` in your *Cluster* VM/VPS before running the `kubectl` and `helm` commands below.

## Install Jellyfin

In this guide we use the official [Jellyfin Helm chart](https://github.com/jellyfin/jellyfin-helm). First, we create a `values.yaml` file for the chart as follows:

```yaml
image:
  tag: "12.1"

persistence:
  config:
    size: 10Gi
  # !mark(1:4)
  media:
    type: hostPath
    hostPath: /mnt/media
    readOnly: true
```

The `config` volume is a persistent volume claim that stores Jellyfin's database, settings and metadata. The `media` volume mounts your media library, in this example the `/mnt/media` directory of the node, at the `/media` path inside the container. We also mount it as read-only since Jellyfin only needs to read your media files.

> **Note:**
>
> A `hostPath` volume is mainly suitable for a single-node Kubernetes cluster, such as the k3s cluster installed by the quick installation guide, where your media files are located on that node's own disk. If your media is stored elsewhere, such as on a NAS, you can instead create your own persistent volume claim (e.g. an NFS-backed one) and use it via the `persistence.media.existingClaim` value.

Now install the chart and wait for Jellyfin to become ready as follows:

```bash
helm repo add jellyfin https://jellyfin.github.io/jellyfin-helm
helm repo update
helm install jellyfin jellyfin/jellyfin --version 3.2.0 --namespace jellyfin --create-namespace -f /PATH/TO/VALUES.YAML
kubectl rollout status deployment/jellyfin -n jellyfin --timeout=10m
```

> **Note:**
>
> The chart's default image tag is the Jellyfin version that the chart was released with, which is why we explicitly pin the Jellyfin version via the `image.tag` value. Note that Jellyfin `12` removed the legacy authorization methods and route prefixes, so very old third-party clients might no longer work. Make sure to read the Jellyfin release notes before upgrading, and to back up the `config` volume first.

> **Note:**
>
> You can also enable hardware-accelerated transcoding by exposing your node's GPU to the container via the chart's `volumes`, `volumeMounts` and `securityContext` values. Read more about hardware acceleration in the Jellyfin docs [here](https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/).

The chart creates the `jellyfin` Kubernetes service in the `jellyfin` Kubernetes namespace which listens over the port `8096`.

## Create the Service

Now we create an Octelium *Service* for Jellyfin whose upstream is the `jellyfin` Kubernetes service. We also define a `family` *Group* (read more about *Groups* [here](https://octelium.com/docs/octelium/latest/management/core/group.md)) where only its members are allowed to access the *Service* as follows:

```yaml
kind: Group
metadata:
  name: family
spec: {}
---
kind: Service
metadata:
  name: jellyfin
spec:
  mode: WEB
  isPublic: true
  config:
    upstream:
      url: http://jellyfin.jellyfin.svc:8096
    # !mark(1:3)
    http:
      header:
        authorizationMode: PASS
  authorization:
    inlinePolicies:
      - spec:
          rules:
            - effect: ALLOW
              condition:
                match: '"family" in ctx.user.spec.groups'
```

The highlighted `authorizationMode` field is **required** for Jellyfin. Jellyfin clients, including its web app, authenticate to Jellyfin via the `Authorization` header in the `MediaBrowser` scheme, while the *Service* deletes the downstream's `Authorization` header by default (read more [here](https://octelium.com/docs/octelium/latest/management/core/service/http.md#authorization-header)). Setting the `PASS` mode passes the header to Jellyfin as is. This does not interfere with the *Cluster*'s own authentication since the *Cluster* only uses the `Authorization` header for `Bearer` tokens, while browsers authenticate to the *Cluster* via its own cookie.

The *Service* port is automatically inferred from the upstream URL, which means that the *Service* also listens over the port `8096` (read more [here](https://octelium.com/docs/octelium/latest/management/core/service/overview.md#port)). Now add your *Users* to the `family` *Group*. Here is an example:

```yaml
kind: User
metadata:
  name: john
spec:
  type: HUMAN
  email: john@example.com
  # !mark
  groups: ["family"]
```

You can now apply the creation of the *Group*, the *Service* and the *Users* via the `octeliumctl apply` command (read more [here](https://octelium.com/docs/octelium/latest/management/core/overview.md)) as follows:

```bash
octeliumctl apply /PATH/TO/YAML_FILE_OR_PARENT_DIRECTORY
```

Now open Jellyfin, either via the client-based or the clientless mode as shown in the following sections, and complete the setup wizard where you create the Jellyfin admin account and add a library whose folder is `/media`. Unlike exposing Jellyfin directly to the internet, only your authorized *Users* are able to reach the setup wizard in the first place.

> **Note:**
>
> The *Service* drops the `Forwarded`, `X-Forwarded-*` and `X-Real-IP` request headers by default (read more [here](https://octelium.com/docs/octelium/latest/management/core/service/http.md#forwarded-header)). Therefore, Jellyfin sees every request as coming from the private IP address of the *Cluster*, which by default belongs to Jellyfin's local network. This means, for example, that Jellyfin's bitrate limit for remote clients does not apply. You can instead set per-user bitrate limits in Jellyfin's user settings.

## Client-Based Access

Once connected to the *Cluster* via the `octelium connect` command (read more [here](https://octelium.com/docs/octelium/latest/user/cli/connect.md)), you can simply visit Jellyfin from your browser at the URL `http://jellyfin:8096`. You can also use the same address as the server address in desktop clients such as Jellyfin Media Player.

You can also connect via the rootless mode and map the *Service* to your host's localhost (read more [here](https://octelium.com/docs/octelium/latest/user/cli/connect.md#rootless)) as follows:

```bash
# Connect as non-root/unprivileged OS user
octelium connect -p jellyfin:8096

# Now access the Service at localhost
curl http://localhost:8096/health
```

### TVs and Other Devices at Home

Smart TVs, streaming sticks and game consoles cannot run the `octelium` client. Instead, you can use a single always-on device in your home network, such as a Raspberry Pi or a home server, to connect to the *Cluster* and publish the *Service* to the whole home network (read more about publishing *Services* [here](https://octelium.com/docs/octelium/latest/user/cli/connect.md#mapping-services-to-host)). First, we create a dedicated `WORKLOAD` *User* (read more about *User* types [here](https://octelium.com/docs/octelium/latest/management/core/user.md#types)) for that device as follows:

```yaml
kind: User
metadata:
  name: living-room
spec:
  type: WORKLOAD
  groups: ["family"]
  authorization:
    inlinePolicies:
      - spec:
          rules:
            - effect: DENY
              condition:
                match: ctx.service.metadata.name != "jellyfin.default"
```

The inline *Policy* makes sure that the device's identity can only be used to access the `jellyfin` *Service* and nothing else in the *Cluster*. After applying the *User* via `octeliumctl apply`, create an authentication token *Credential* (read more [here](https://octelium.com/docs/octelium/latest/management/core/credential.md#authentication-tokens)) for it as follows:

```bash
octeliumctl create cred --user living-room living-room
```

Now run the `octelium` client as a container (read more [here](https://octelium.com/docs/octelium/latest/user/cli/connect.md#containers)) on that device and publish the *Service* to all of its network interfaces as follows:

```bash
docker run -d --name octelium-jellyfin --restart unless-stopped -p 8096:8096 \
  ghcr.io/octelium/octelium connect --domain <DOMAIN> --auth-token <AUTHENTICATION_TOKEN> -p jellyfin:0.0.0.0:8096
```

The container does not need any additional Linux capabilities since it only publishes the *Service* to its own network namespace via the unprivileged gVisor netstack mode (read more [here](https://octelium.com/docs/octelium/latest/user/cli/connect.md#tunnel-implementation)). Now you can use the address `http://<DEVICE_IP>:8096` as the server address in the Jellyfin apps of your TVs and other devices in your home network.

> **Note:**
>
> Any device in your home network can now reach Jellyfin through that device, although every device still needs to login with a Jellyfin account. All of such requests are authorized and logged as requests of the `living-room` *User*.

## Clientless Access

Authorized `HUMAN` *Users* can access Jellyfin via the clientless BeyondCorp mode directly from their browsers, including mobile browsers, at the URL `https://jellyfin.<DOMAIN>` without having to install any client. An unauthenticated *User* is first redirected to the *Cluster*'s login page in order to authenticate via an *IdentityProvider* (read more [here](https://octelium.com/docs/octelium/latest/management/core/identity-providers.md)). There are currently 3 types:

- GitHub OAuth *IdentityProvider* as shown in detail [here](https://octelium.com/docs/octelium/latest/management/core/identity-providers.md#github-oauth2)
- OpenID Connect *IdentityProviders* (e.g. Okta, Auth0, etc...) as shown [here](https://octelium.com/docs/octelium/latest/management/core/identity-providers.md#openid-connect).
- SAML 2.0 *IdentityProviders* (e.g. Okta, Entra ID, etc...) as shown [here](https://octelium.com/docs/octelium/latest/management/core/identity-providers.md#saml-20).

Furthermore, you can register a FIDO2 *Authenticator* (e.g. Yubikeys) in order to directly login later via Passkey (read more [here](https://octelium.com/docs/octelium/latest/management/core/authenticator.md#passkey-login)) without having to use an *IdentityProvider*.

Once authenticated to the *Cluster*, the *User* then logs in to Jellyfin itself with their Jellyfin account.

> **Note:**
>
> Native Jellyfin apps cannot go through the *Cluster*'s browser-based login. For such apps, you can use the client-based mode or publish the *Service* to your home network as shown above, while you can use the mobile browser on your phone whenever you are away from home.

## Serving Jellyfin from Behind NAT

You do not have to run Jellyfin inside the *Cluster*. If you already run Jellyfin at home, for example as a Docker container on a NAS that stores your media, while the *Cluster* runs on a cloud VM, you can remotely serve it via a connected `octelium` client without opening any ports in your home network (read more about remotely serving upstreams [here](https://octelium.com/docs/octelium/latest/management/core/service/overview.md#remotely-via-a-connected-user)). First, we create a `WORKLOAD` *User* for the home server as follows:

```yaml
kind: User
metadata:
  name: home-server
spec:
  type: WORKLOAD
```

Now we change the upstream of the *Service* to the local address of Jellyfin on the home server and set the `user` field to the `home-server` *User* as follows:

```yaml
kind: Service
metadata:
  name: jellyfin
spec:
  mode: WEB
  isPublic: true
  config:
    upstream:
      url: http://localhost:8096
      # !mark
      user: home-server
    http:
      header:
        authorizationMode: PASS
  authorization:
    inlinePolicies:
      - spec:
          rules:
            - effect: ALLOW
              condition:
                match: '"family" in ctx.user.spec.groups'
```

After applying both via `octeliumctl apply`, create an authentication token *Credential* for the `home-server` *User* via `octeliumctl create cred --user home-server home-server` and then connect to the *Cluster* from the home server and serve the *Service* via the `--serve` flag (read more [here](https://octelium.com/docs/octelium/latest/user/cli/connect.md#serving-services)) as follows:

```bash
export OCTELIUM_DOMAIN=<DOMAIN>
octelium connect --auth-token <AUTHENTICATION_TOKEN> --serve jellyfin
```

> **Note:**
>
> In this setup, every stream goes from your home server to the *Cluster* and then to the *User*. Therefore, the upload bandwidth of your home internet connection limits the quality and number of the streams that you can watch while away from home.

## Access Control

Since every request to Jellyfin is authorized by the *Service*, you can control access in an identity-based and context-aware way on top of Jellyfin's own permissions (read more about *Policies* and access control [here](https://octelium.com/docs/octelium/latest/management/core/policy.md)). Here is an example where the members of the `family` *Group* can always access Jellyfin, while the members of a `kids` *Group* can only access it between 08:00 and 20:00 of the `Europe/Berlin` time zone:

```yaml
kind: Group
metadata:
  name: kids
spec: {}
---
kind: Service
metadata:
  name: jellyfin
spec:
  mode: WEB
  isPublic: true
  config:
    upstream:
      url: http://jellyfin.jellyfin.svc:8096
    http:
      header:
        authorizationMode: PASS
  authorization:
    inlinePolicies:
      - spec:
          rules:
            - effect: ALLOW
              condition:
                match: '"family" in ctx.user.spec.groups'
            # !mark(1:8)
            - effect: ALLOW
              condition:
                all:
                  of:
                    - match: '"kids" in ctx.user.spec.groups'
                    - match: now().getHours("Europe/Berlin") >= 8
                    - match: now().getHours("Europe/Berlin") < 20
```

Since the *Service* authorizes every single request, any new request of the `kids` *Group* members is denied from 20:00 onward, which means that they can no longer browse Jellyfin or start new streams. You can read more about the available CEL functions [here](https://octelium.com/docs/octelium/latest/management/guide/cel.md).

## Visibility

Octelium also provides OpenTelemetry-ready, application-layer L7 aware visibility and access logging in real time (read more about visibility [here](https://octelium.com/docs/octelium/latest/management/core/visibility.md)). Here is an example of an access *Log* of a video stream request:

```yaml
{
  "apiVersion": "core/v1",
  "entry": {
    "common": {
      // Omitted for the sake of brevity of the example
    },
    # !mark(1:16)
    "info": {
      "http": {
        "request": {
          "path": "/Videos/3f1c9a4be2d54f0c9d6e3a2b1c0d4e5f/stream.mkv",
          "userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
          "method": "GET",
          "uri": "/Videos/3f1c9a4be2d54f0c9d6e3a2b1c0d4e5f/stream.mkv"
        },
        "response": {
          "code": 206,
          "bodyBytes": "52428800",
          "contentType": "video/x-matroska"
        },
        "httpVersion": "HTTP11"
      }
    }
  },
  "kind": "AccessLog",
  "metadata": {
    // Omitted for the sake of brevity of the example
  }
}
```

> **Note:**
>
> Jellyfin clients pass their Jellyfin access token in the `ApiKey` query parameter of some requests such as streams and WebSocket connections. Access *Logs* never include the query parameters of the request URI, and sensitive request headers such as `Authorization` and `Cookie` are always removed from the logged headers, so Jellyfin's tokens never end up in your logs.

Here are a few more features that you might be interested in:

- Self-hosting Immich for your photos and videos (read more [here](https://octelium.com/docs/octelium/latest/management/guide/service/homelab/immich-helm-self-host.md)).
- Using Octelium as a self-hosted exit node and VPN for your devices (read more [here](https://octelium.com/docs/octelium/latest/management/guide/service/homelab/self-hosted-vpn-exit-node-socks5.md)).
- Request/response header manipulation (read more [here](https://octelium.com/docs/octelium/latest/management/core/service/http.md#header-manipulation)).
- Dynamic routing to different upstreams according to identity and context (read more [here](https://octelium.com/docs/octelium/latest/management/core/service/dynamic-config.md)).
