---
title: Jellyfin
description: Install Jellyfin, a free and open-source media server, to stream your movies and TV shows from anywhere without a paid subscription.
---


:ellipsis{left=0px width=40rem top=10rem blur=140px zIndex=60}

![Jellyfin](/img/serveex/jellyfin.png)

[Jellyfin](https://jellyfin.org) is a free, open-source alternative to Plex and Emby. Unlike Plex, it has no paid tiers, no telemetry, and doesn't require an online account to use or manage your server: everything runs locally and stays yours.

It covers the same basics: a media library with metadata and artwork, transcoding (including hardware acceleration), and apps for TV, Android, iOS, Windows, and macOS.

As always, we'll use the [linuxserver.io image](https://docs.linuxserver.io/images/docker-jellyfin).

::note{to="/serveex/core/wireguard"}

Unlike Plex, Jellyfin has no cloud relay: to access your server outside your local network, you must expose it yourself (see below), or use a VPN like **Wireguard**.
::

## Install Jellyfin

::file-tree
---
tree:
  /:
    - srv:
      - docker:
        - jellyfin:
          - compose.yaml
          - .env
          - config/
    - media:
      - tvseries/
      - movies/
      - library/
---
::

::steps{level="3"}
### Create the media folders

Create the `movies`, `tvseries`, and `library` folders in `/media`:

```bash [Terminal]
mkdir -p /media/movies /media/library /media/tvseries
```

### Deploy the stack

Open Dockge, click `compose`, name the stack `jellyfin`, and add the following config:

```yaml [compose.yaml]
---
services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin:latest
    container_name: jellyfin
    environment:
      - PUID=${PUID}
      - PGID=${GUID}
      - TZ=Europe/Paris
    volumes:
      - /srv/docker/jellyfin/config:/config
      - /media:/media
    restart: unless-stopped
    devices:
      - /dev/dri:/dev/dri
    ports:
      - 8096:8096
```

::tip{icon=""}
✨ Add the Watchtower label to automate updates:

```yaml [compose.yaml]
---
services:
  jellyfin:
    #...
    labels:
      - com.centurylinklabs.watchtower.enable=true
```
::

### Set your environment variables

Find your PUID and GUID by running:

```bash [Terminal]
id username
```

Fill in your `.env` file with the retrieved values, for example:

```properties [.env]
PUID=1000
GUID=1000
```

Deploy the stack. The local interface is available at `http://yourserverip:8096`.

::note{to="https://docs.linuxserver.io/images/docker-jellyfin/#hardware-acceleration"}

The `/dev/dri` device is only needed for hardware-accelerated transcoding on Intel/AMD GPUs. Remove it if your server doesn't have one, or adapt it for an NVIDIA GPU following **linuxserver.io's documentation**.
::

### Done !
::

## Configure Jellyfin
On first visit, Jellyfin walks you through a setup wizard:

- Choose a display language and create your admin account.
- Add a media library, pointing to `/media/movies` for movies and `/media/tvseries` for TV shows.

And that's it! Add your media to `/media/movies` and `/media/tvseries` on your server. See below to start watching, at home or remotely.

::note{to="/general/networking/samba"}

If your media is stored on a network disk (e.g. NAS or external hard drive over the network), refer to the **Samba mount guide** so Jellyfin can access it.
::

## Hardware Transcoding

Jellyfin re-encodes video on the fly whenever a client can't play a file as-is: the device's screen resolution is smaller than the source, its network connection is too slow for the file's bitrate, or it doesn't support the file's codec, HDR format, or subtitle type. This is called **transcoding**. Done purely in software, on the CPU, it's slow and can max out a modest server with just one or two simultaneous streams. **Hardware transcoding** offloads this work to your CPU's built-in GPU (Intel QuickSync, on most homelab hardware), which handles it far faster and lets the CPU stay free for everything else.

**Tone mapping** is a related, separate feature: converting HDR video (which needs a compatible HDR display to look right) down to SDR so it displays correctly on a screen, TV, or client that doesn't support HDR, instead of looking washed out or overly dark.

::note{to="https://docs.linuxserver.io/images/docker-jellyfin/#hardware-acceleration"}
This only applies to Intel (and some AMD) integrated GPUs passed through with `/dev/dri`, already included in this guide's `compose.yaml`. NVIDIA GPUs need a different setup entirely, covered in **linuxserver.io's documentation**. If your server has no compatible GPU, transcoding still works, just entirely on the CPU.
::

::steps{level="3"}
### Add the OpenCL mod

Tone mapping specifically needs an Intel OpenCL runtime, which this image doesn't ship with by default. Add the dedicated mod to your `compose.yaml`:

```yaml [compose.yaml]
services:
  jellyfin:
    environment:
      - DOCKER_MODS=linuxserver/mods:jellyfin-opencl-intel
```

::note
Don't use the generic `universal-package-install` mod with `intel-opencl-icd` for this: it installs an older runtime from Ubuntu's own repositories that's a known source of tone mapping bugs. This dedicated mod ships a newer, purpose-built one.
::

Redeploy the stack for the mod to take effect.

### Enable hardware acceleration

In Jellyfin, go to _Dashboard > Playback_ and set **Hardware acceleration** to **Intel QuickSync (QSV)** (or **VA-API**, if QSV isn't available for your CPU generation).

Then uncheck any codec your specific GPU doesn't support hardware-decode for. [Intel's own hardware acceleration page](https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/) lists exactly which codecs each CPU generation supports.

### Enable tone mapping

Further down the same page, enable **Tone mapping**. This only takes effect once the OpenCL mod above is installed and the stack redeployed; enabling it without that step first will make HDR transcodes fail instead of just skipping the tone mapping.

### Done !
::

## Watching at Home

While connected to your home network (Wi-Fi or Ethernet, on the same LAN as your server), no exposure or VPN is needed at all. Use [Moonfin](https://github.com/Moonfin-Client/Moonfin-Core) wherever it's available (phones, tablets, Android TV/Google TV, desktop, web) instead of the official Jellyfin app: it looks and works the same everywhere, and can sync your settings across devices via its own server plugin. Connect to `http://yourserverlanip:8096`.

Samsung Tizen and LG webOS get a separate, dedicated [Moonfin app](https://github.com/Moonfin-Client/Smart-TV) instead (same name, different codebase), and Apple TV is best served by [Neptune](https://apps.apple.com/us/app/neptune-media-player/id6756797773), a dedicated tvOS client with native HEVC/AV1/VP9 decoding and full Atmos passthrough. Where none of these are available (Fire TV, Roku, Kodi), see the table below:

| Platform | Recommended app |
|----------|-----------------|
| Phone / Tablet (Android, iOS) | [Moonfin](https://github.com/Moonfin-Client/Moonfin-Core) (Play Store / App Store) |
| Android TV / Google TV | [Moonfin](https://github.com/Moonfin-Client/Moonfin-Core) (Play Store or APK) |
| Apple TV | [Neptune](https://apps.apple.com/us/app/neptune-media-player/id6756797773) (App Store) |
| Samsung Tizen / LG webOS | [Moonfin](https://github.com/Moonfin-Client/Smart-TV) (sideloaded); the official apps exist but lag behind |
| Fire TV | Official Jellyfin app (Amazon Appstore) |
| Roku | Official Jellyfin app |
| Kodi | [Jellyfin for Kodi](https://jellyfin.org/docs/general/clients/kodi/) (syncs your library into Kodi), or [JellyCon](https://github.com/jellyfin/jellycon) for lighter streaming-only boxes |

::caution

__If it fails:__ check your firewall rules.
::

## Watching Away From Home

::warning
Jellyfin won't be exposed to the internet on this site, unlike most other apps. Its native apps (phone, TV, Chromecast...) talk directly to its API rather than a browser, so a forward-auth layer like [TinyAuth](/serveex/security/tinyauth) or [Authentik](/serveex/advanced/authentik) can't sit in front of it without breaking login and playback on those clients, and Jellyfin itself has no built-in 2FA or OIDC to fall back on. So instead of exposing it, we'll reach it through the [WireGuard VPN](/serveex/core/wireguard) already set up earlier in this guide.
::

Instead of routing a whole device's traffic through the VPN, or even the whole `10.8.0.0/24` VPN subnet, we'll use a tighter **split tunnel**: each remote device only routes traffic for your server's own VPN address through the tunnel. Everything else (browsing, other apps) stays on the device's normal connection, and the VPN is only ever used to reach that one machine.

::note{to="/serveex/core/wireguard#client-server-setup"}

This assumes your Jellyfin server already runs its own WireGuard client, connecting it to your VPN as described in **Client Server Setup**. Note the VPN address wg-easy assigned it (e.g. `10.8.0.2`); that's the address every device below will target.
::

::caution{to="#restricting-a-third-party-peer-to-just-jellyfin"}
This split tunnel is a client-side convention, not a server-enforced security boundary: `AllowedIPs` is a routing setting, and wg-easy doesn't add any firewall rule to back it up by default. Anyone with access to a device (or its `.conf` file) could edit `AllowedIPs` back to something broader and reach whatever else your WireGuard server can route to, including other connected peers. If a peer is one of your own devices, that's usually an acceptable risk. If you're handing a peer to a third party (a friend, family member) who should only ever reach Jellyfin, see **Restricting a third-party peer to just Jellyfin** below for a way to actually enforce that server-side.
::

::steps{level="3"}
### Create a client in wg-easy for each device

Following [Retrieve Configuration Files](/serveex/core/wireguard#retrieve-configuration-files), create one client per device (phone, computer, TV/streaming box) and download its config file.

### Narrow the tunnel to just the server

Open each downloaded `.conf` file and replace its `AllowedIPs` line so it only covers your Jellyfin server's VPN address, as a `/32` (a single IP, not a range):

```properties [wg0.conf]
AllowedIPs = 10.8.0.2/32
```

::note

Replace `10.8.0.2` with whatever address your server actually got. Leaving the default `10.8.0.0/24` here would tunnel traffic for every VPN peer, not just your server.
::

### Import the config into each device

- **Phone:** install the WireGuard app, then import the edited `.conf` file (or scan a QR code generated from it).
- **Computer:** install the [WireGuard client](https://www.wireguard.com/install/) and import the file.
- **TV:** if it runs Android TV or Google TV, install WireGuard from the Play Store. Copy the `.conf` file onto a USB drive, plug it into the TV or box, then use the app's **Add from file** option to browse the USB drive and import it. Most other TV operating systems (Tizen, webOS...) can't install a VPN client directly, so connect through a device that already has the tunnel active instead (an Android TV box, or casting from a phone or computer that's connected).

::warning
A `.conf` file is a credential, not just a settings file: whoever has it can connect to your VPN as that device. Once it's imported, delete it from the USB drive (and anywhere else you copied it, like Downloads folders), and never share it with anyone else.
::

### Done !
::

### Restricting a third-party peer to just Jellyfin

[wg-easy](/serveex/core/wireguard) (v15.3+) can enforce this itself, with a real firewall rule inside its own container rather than a client-side setting. This is the right approach when the peer belongs to someone else, not one of your own devices.

::steps{level="4"}
#### Enable the per-client firewall

In [wg-easy](/serveex/core/wireguard)'s admin panel, go to the interface settings and enable **Per-Client Firewall**. It's marked experimental, but works by generating a dedicated `iptables` chain inside the wg-easy container.

#### Restrict the peer's allowed destination

Edit the third party's client and set its **Firewall Allowed IPs** to your Jellyfin server's VPN address, e.g. `10.8.0.2`, or narrower still, `10.8.0.2:8096/tcp` to also restrict the port and protocol.

::note

Leaving this field empty falls back to the client's own `AllowedIPs`, which isn't restrictive: it must be set explicitly for this peer. This also cuts off that peer's regular internet access through the tunnel, since only traffic to Jellyfin is now allowed, which is the intended effect for a guest peer.
::

#### Verify the rule was applied

```bash [Terminal]
docker exec wg-easy iptables -L WG_CLIENTS -n -v
```

You should see an `ACCEPT` line for that peer's IP pointing only at your Jellyfin server, followed by a catch-all `DROP`.

#### Done !
::

Once connected, open the Jellyfin app or `http://10.8.0.2:8096` (your server's VPN address) from that device. Only traffic to that address goes through the VPN; the rest of the device's connection is untouched.
