added jellyfin

This commit is contained in:
Goyban
2026-08-18 15:03:27 +02:00
parent 28bcc8f9fb
commit 369bf69abf
13 changed files with 628 additions and 34 deletions
+18 -9
View File
@@ -1,12 +1,21 @@
# Ignore all contents of the folder # ─── Deny by default ────────────────────────────────────────────────
# Everything inside a service directory is ignored. This means a new
# service is safe automatically — you don't have to remember to add a
# rule for it before the first `docker compose up`.
*/*
openwebui/* # ─── …except the files that define a stack ──────────────────────────
!openwebui/docker-compose.yaml !*/compose.yaml
!*/docker-compose.yaml
overleaf/* !*/.env.example
!overleaf/docker-compose.yaml !*/README.md
owui-ollama/*
!owui-ollama/docker-compose.yaml
# Docs are not service data.
!docs/**
# ─── Never, anywhere ────────────────────────────────────────────────
.env
*.key
*.pem
*.crt
secrets/
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 goyban Copyright (c) 2025 Goyban
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including associated documentation files (the "Software"), to deal in the Software without restriction, including
+104 -2
View File
@@ -1,3 +1,105 @@
# docker-compose-repo # Self-hosted Compose Stacks
All used docker compose files Docker Compose files for the services I actually run, with notes on **why each
one looks the way it does** — the trade-offs, the things that broke once, the
settings that aren't obvious from the upstream docs.
Every stack is standalone. You need Docker and nothing else: no OMV, no
Kubernetes, no particular distro.
> Maintained by **Goyban** — I make videos about self-hosting, NAS, Linux and
> the terminal. TODO: YouTube link
>
> These stacks run on the setup documented in TODO: link to Homelab repo.
---
## Usage
```bash
git clone TODO:_repo_url && cd <repo>
cd jellyfin
cp .env.example .env
$EDITOR .env # set paths, ports, timezone
docker compose up -d
```
Each service directory is self-contained: a `compose.yaml`, a `.env.example`,
and a `README.md` explaining the choices.
---
## Services
Every service publishes a plain HTTP port. TLS, domains and authentication are
handled separately — see [Exposing services](#exposing-services).
| Service | Port | Notes |
|---------|------|-------|
| [jellyfin](jellyfin/) | 8096 | Media streaming. Also 7359/udp for auto-discovery |
| [openwebui](openwebui/) | 3033 | LLM chat UI, talks to an external model backend |
| [owui-ollama](owui-ollama/) | 3034 | Open WebUI bundled with Ollama |
| [overleaf](overleaf/) | 8020 | LaTeX editor. Needs Mongo + Redis |
<!-- One row per service. Add as you go — the table is the index people scan. -->
---
## Exposing services
Nothing in a compose file here is proxy-aware, so you can put whatever you like
in front:
- **[Reverse proxy with Caddy](docs/reverse-proxy.md)** — subdomain + automatic
TLS. The default choice.
- **[Cloudflare Tunnel](docs/cloudflare-tunnel.md)** — no open ports, no VPS.
Good for light services; **not** for media streaming or large uploads —
the doc explains why.
- **[Single sign-on with Authentik](docs/authentik-sso.md)** — OIDC where the
app supports it, forward auth where it doesn't.
---
## Conventions
A few rules that keep the stacks consistent and safe to publish:
- **Data lives outside the repo.** Paths come from `${DATA_PATH}` in `.env`, so
a clone never fills up with application state and `git status` stays clean.
- **Secrets live in `.env`**, which is git-ignored. Only `.env.example` is
committed, with placeholders.
- **Shared settings have defaults; host-specific ones fail loudly.** `PUID`,
`PGID`, `TZ` and ports are written `${PUID:-1000}` in the compose file, so a
fresh clone runs with no `.env` at all. Three layers can set them, in
increasing priority: the default in `compose.yaml`, the service's `.env`, then
anything exported in your shell — so a global `export PUID=…` in `~/.profile`
keeps working and overrides both. Values that *cannot* have a sensible
default — a media path, a `render` group GID — use `${VAR:?message}` instead
and abort with an explanation rather than silently interpolating an empty
string.
- **The repo ignores by default** — everything inside a service directory is
ignored except `compose.yaml`, `.env.example` and `README.md`. A new service
can't leak a database by accident.
- **No `version:` key** — obsolete in Compose v2.
- **LinuxServer.io images where they fit.** `lscr.io/linuxserver/*` images all
speak the same dialect: `PUID`/`PGID`/`TZ` for ownership and time, `/config`
for state, s6 as init, a shared base rebuilt on a regular cadence. Learn one
and you've learned the family — worth more across a dozen stacks than tuning
each image separately. Where a project ships a strong first-party image
(Open WebUI, Overleaf), I use that instead.
---
## Adding a service
```bash
cp -r _template <service>
```
Then follow the checklist at the top of `_template/README.md`.
---
## License
MIT — see [`LICENSE`](LICENSE).
+28
View File
@@ -0,0 +1,28 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
# Never put a real secret in this file.
# ─── Shared settings ────────────────────────────────────────────────
# compose.yaml already defaults these to 1000/1000/Etc/UTC. Three places
# can set them, in increasing priority:
#
# 1. the defaults in compose.yaml (a fresh clone just works)
# 2. this file (per-service)
# 3. exported in your shell (global, wins over this file)
#
# If you keep a global `export PUID=...` in ~/.profile, leave these commented.
#PUID=1000
#PGID=1000
#TZ=Europe/Paris
# ─── Paths ──────────────────────────────────────────────────────────
# Where persistent data lives. Keep it OUTSIDE the repo so a clone stays
# clean and a `git status` never shows application state.
#DATA_PATH=./data
# ─── Ports ──────────────────────────────────────────────────────────
# Host port for the web UI. Defaulted in compose.yaml.
#PORT=xxxx
# ─── Required ───────────────────────────────────────────────────────
# Anything written ${VAR:?...} in compose.yaml belongs here, uncommented,
# with a note on how to find the right value for this host.
+64
View File
@@ -0,0 +1,64 @@
<!--
DAILY CHECKLIST — copy this directory, then:
1. Paste your real compose file into compose.yaml
2. Replace host paths with ${DATA_PATH} etc. and list them in .env.example
3. Fill "Why it looks like this" — this is the part worth reading
4. Add a row to the service table in the root README.md
5. Commit: "add <service>"
Delete this comment when you're done.
-->
# <Service>
One line: what it is and why you run it.
## Quick start
```bash
cp .env.example .env
$EDITOR .env
docker compose up -d
```
Then open `http://<host>:<port>`.
## Port
| Port | Protocol | Purpose |
|------|----------|---------|
| xxxx | http | Web UI |
## Why it looks like this
<!--
The differentiator. Not "what the options do" — the docs already say that.
Why *these* choices, on a real machine, after living with it.
Prompts, delete what doesn't apply:
- Why this image? (linuxserver vs. official vs. a fork — what made you switch?)
- Why these mounts? What actually needs to persist, and what surprised you?
- PUID/PGID — what breaks when they're wrong?
- Anything here because something failed once? Those are the best notes.
- Anything you tried first that didn't work?
- Resource limits, hardware access (GPU, /dev/dri), device passthrough
- Why this restart policy
-->
## Gotchas
<!-- Things that cost you time. Empty is fine on day one — add as you hit them. -->
## Exposing it
Publishes a plain HTTP port; nothing here is proxy-aware.
- [Reverse proxy with Caddy](../docs/reverse-proxy.md)
- [Cloudflare Tunnel](../docs/cloudflare-tunnel.md)
- [Single sign-on with Authentik](../docs/authentik-sso.md)
<!-- If this service DOES need proxy-specific config (Nextcloud, Gitea), say so here. -->
## Links
- Upstream docs:
- Image:
+27
View File
@@ -0,0 +1,27 @@
# Paste your real compose file here, then adjust:
# - drop any `version:` key (obsolete in Compose v2)
# - replace hardcoded host paths with ${DATA_PATH}
# - remove `volumes:` blocks nothing references
# - publish the port; leave TLS and auth to the reverse proxy
# - give shared settings a default: ${PUID:-1000}, not ${PUID}
# - make host-specific values fail loudly: ${VAR:?how to find it}
# (a bare ${VAR} that isn't set becomes an empty string and starts anyway)
services:
<service>:
image: <image>
container_name: <service>
restart: unless-stopped
# Same on every host, so they're defaulted here. A service .env overrides
# them, and a global `export PUID=...` in your shell overrides that.
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Etc/UTC}
volumes:
- ${DATA_PATH:-./data}/config:/config
ports:
- "${PORT:-8080}:8080"
+47
View File
@@ -0,0 +1,47 @@
# Single sign-on with Authentik
TODO: fill in as you go. Outline below so the shape is decided in advance.
## Two ways to protect a service
1. **Native OIDC** — the app speaks OIDC itself (Gitea, Nextcloud,
Audiobookshelf, Immich). Better experience: real accounts, real logout,
group mapping.
2. **Forward auth** — the proxy asks Authentik before passing the request
through. Works for anything, including apps with no auth at all, but the
app has no idea who the user is.
Prefer native OIDC where the app supports it; use forward auth as the
fallback.
## Native OIDC — the shape
TODO: provider + application setup in Authentik, then the four values every
app asks for:
- Issuer / discovery URL
- Client ID
- Client secret → `.env`, never committed
- Redirect URI
## Forward auth — the shape
TODO: the Caddy snippet, and which services you apply it to.
## Groups and roles
TODO: how you map Authentik groups to per-app roles, and the admin/user
split.
## Gotchas
TODO: collect these as you hit them. Known ones worth writing down:
- Locking yourself out of an app whose only admin is now behind SSO — keep a
local fallback admin until the flow is proven.
- Redirect URI mismatches, which usually surface as a generic error.
## Related
- [Reverse proxy with Caddy](reverse-proxy.md)
- [Cloudflare Tunnel](cloudflare-tunnel.md)
+74
View File
@@ -0,0 +1,74 @@
# Cloudflare Tunnel
A tunnel exposes a service to the internet **without opening a port** on your
router and without a VPS. `cloudflared` makes an outbound connection to
Cloudflare, and Cloudflare routes traffic back down it.
## When to use it (and when not to)
This is the part most guides skip. A tunnel is **not** a drop-in replacement
for a reverse proxy on a VPS.
| Use a tunnel for | Use Caddy on a VPS for |
|---|---|
| Admin UIs, dashboards | Jellyfin, Plex — any video streaming |
| Gitea, small web apps | Nextcloud, Immich — large uploads |
| Anything low-bandwidth | Anything you want unmetered |
Two hard limits drive that split:
- **Upload size.** The free plan caps request bodies at roughly 100 MB. File
sync and photo backup break on this, often silently or with a confusing
413.
- **Terms of service.** Cloudflare's terms restrict serving large amounts of
non-HTML content — video in particular — through the proxy. Streaming a
media library through a tunnel is the single most common way people get
their account flagged.
For those services, expose them through a reverse proxy you control.
## Setup
TODO: your preferred flow — dashboard-created tunnel vs. `cloudflared tunnel
create`. The dashboard route is easier to show on video; the CLI route is
easier to keep in version control.
```yaml
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}
```
The tunnel token is a **real secret** — it grants the ability to route
traffic into your network. It belongs in `.env`, never in a committed file.
## Routing to a service
Public hostname → service, configured per tunnel:
```
app.example.com → http://<host-or-container>:<port>
```
TODO: note whether you attach `cloudflared` to each service's compose network
or run one shared tunnel that reaches services by host IP. The second is
simpler to maintain; the first is better isolated.
## Auth
Cloudflare Access can sit in front of a tunnel and handle authentication
before traffic ever reaches the service — useful for apps with weak or no
built-in auth.
TODO: whether you use Access, or terminate auth at Authentik instead. Note
that running both is usually redundant.
## Related
- [Reverse proxy with Caddy](reverse-proxy.md)
- [Single sign-on with Authentik](authentik-sso.md)
+57
View File
@@ -0,0 +1,57 @@
# Reverse proxy with Caddy
Every service in this repo publishes a plain HTTP port on the host. Nothing
in a service's compose file knows or cares about TLS, domains, or
authentication — that is all handled here, in one place.
This is deliberate: it means you can run any stack in this repo standalone,
and add a proxy later without touching the compose file.
## The basic pattern
Caddy gets a certificate automatically. For most services, one block is the
entire configuration:
```caddyfile
app.example.com {
reverse_proxy <host>:<port>
}
```
Look up `<port>` in the [service table](../README.md).
TODO: note where your Caddyfile lives and whether Caddy runs on the NAS or
on the VPS.
## Services that need more
Most apps don't care what's in front of them. A few build absolute URLs or
make security decisions based on the request, and those need to be told.
**Nextcloud** is the main one — it needs all of:
- `trusted_domains` — the public hostname, or it refuses the request
- `overwrite.cli.url` — so generated links use the public URL
- `overwriteprotocol=https` — or it builds `http://` links behind TLS and
breaks mixed content
- `TRUSTED_PROXIES` — the proxy's IP, or every client appears to come from
the proxy and rate limiting misfires
Milder cases:
- **Gitea** — `ROOT_URL`, or clone URLs point at the wrong host
- **Authentik** — knows its own external URL by configuration
- **Jellyfin** — only if you serve it from a subpath rather than a subdomain
TODO: your `header_up` defaults, and whether you set a shared snippet for
`X-Forwarded-*`.
## Large uploads
TODO: Caddy's defaults are usually fine, but note any `request_body`
`max_size` you set for Nextcloud/Immich, plus the matching app-side limit.
## Related
- [Cloudflare Tunnel](cloudflare-tunnel.md) — when you don't want to open a port
- [Single sign-on with Authentik](authentik-sso.md)
+54
View File
@@ -0,0 +1,54 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
# Nothing here is secret, but keep it that way.
# ─── Identity ───────────────────────────────────────────────────────
# These three are the same for every service on the machine, so compose.yaml
# already defaults them to 1000/1000/Etc/UTC. Three places can set them, in
# increasing priority:
#
# 1. the defaults in compose.yaml (a fresh clone just works)
# 2. this file (per-service)
# 3. exported in your shell (global, wins over this file)
#
# If you keep a global `export PUID=...` in ~/.profile, leave these commented.
#
# The host user that should own everything Jellyfin writes: `id -u` / `id -g`.
# This is the setting that bites hardest when it's wrong: the container starts
# fine and then can't write its database, or writes it as root and you can no
# longer touch the files from the host.
#PUID=1000
#PGID=1000
# Affects timestamps in logs and scheduled-task times.
#TZ=Europe/Paris
# ─── Paths ──────────────────────────────────────────────────────────
# Jellyfin's own state: database, metadata, artwork, subtitles, logs.
# Small enough to live next to the compose file — see the README. The
# repo's .gitignore keeps it out of git either way. Defaults to ./config.
#CONFIG_PATH=./config
# Your main media library. REQUIRED — compose refuses to start without it,
# rather than silently mounting nothing.
MEDIA_PATH=/srv/media
# Extra libraries on other disks. Uncomment the matching lines in
# compose.yaml too, otherwise these do nothing.
#MEDIA_PATH_2=/mnt/disk2/tv
#MEDIA_PATH_3=/mnt/scratch/downloads
# ─── Ports ──────────────────────────────────────────────────────────
# Defaulted in compose.yaml; set here only to move them.
#PORT=8096
#DISCOVERY_PORT=7359
# ─── Hardware transcoding ───────────────────────────────────────────
# GID of the host group that owns /dev/dri/renderD128. Host-specific:
#
# getent group render | cut -d: -f3
#
# It varies wildly between distros and installs (105, 992, 993, 989 are
# all real answers I've seen). Do not copy the number below — look it up.
# REQUIRED while the hardware-transcoding block is present in compose.yaml;
# delete that block and this line together if you transcode on the CPU.
RENDER_GID=105
+110
View File
@@ -0,0 +1,110 @@
# Jellyfin
Media server. Reads films and TV off disk and streams them to whatever is in
the room — no account, no subscription, nothing phoning home.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # MEDIA_PATH and RENDER_GID are required
docker compose up -d
```
Open `http://<host>:8096` and point your libraries at `/media` — the path
*inside* the container, whatever `MEDIA_PATH` is on the host.
## Ports
| Port | Proto | Purpose | |
|------|-------|---------|---|
| 8096 | http | Web UI and client API | published |
| 7359 | udp | LAN auto-discovery | published |
| 8920 | https | Jellyfin's own TLS listener | off — TLS belongs at the proxy |
| 1900 | udp | DLNA / SSDP | off — uncomment if you use DLNA |
## Why it looks like this
**The LinuxServer image.** I started with it years ago, it has worked every day
since, and I'm not changing a base image to fix nothing. The better reason: I
run several of their images and they all speak the same dialect —
`PUID`/`PGID`/`TZ`, `/config`, s6, one rebuild cadence. Learn one, learn the
family. Jellyfin's official image is also fine; it just doesn't do the
`PUID`/`PGID` dance, so ownership works differently.
**`/config` lives next to the compose file**, breaking this repo's own
"data outside the repo" rule on purpose. It's Jellyfin's database, metadata and
artwork — 988M after three years here. Under a gigabyte, and keeping it beside
the compose file means the whole service is one `tar` from being moved.
`.gitignore` keeps it out of git. Note that transcode scratch files land under
`/config` and *can* spike several GB — move that path in **Dashboard → Playback**
if the disk is small.
**`PUID`/`PGID`/`TZ` carry defaults** (`${PUID:-1000}`) because they're the same
for every service on a box. Compose resolves them from the default, then `.env`,
then the ambient environment — so a global `export PUID=…`, or OpenMediaVault's
compose plugin, wins. That last one is why my own copy has bare `${PUID}`: OMV
supplies those three, so they're never empty *on my machine*. Without OMV they
would be, and Compose interpolates an empty string and starts anyway rather than
failing. Hence the defaults.
Values that can't be guessed use `${VAR:?message}` instead and abort:
```console
$ docker compose up -d
error while interpolating services.jellyfin.group_add.[]: required variable
RENDER_GID is missing a value: find yours with: getent group render | cut -d: -f3
```
**One `/media` mount.** My real server has three libraries on separate disks;
that's my risk appetite, not yours. Add a line per library if you need more.
Upstream's example splits `/data/tvshows` and `/data/movies` — fine on separate
disks, but if they share a filesystem, mounting the common parent is the better
habit: hardlinks only survive within one mount point inside the container.
Mount media `:ro` if you like — Jellyfin never writes to it.
**Hardware transcoding needs `devices` *and* `group_add`.** Passing
`/dev/dri/renderD128` in is half the job; the node is owned by a host group and
the container user has to be in it or the device opens and does nothing. The
catch is that `group_add` wants a numeric GID and it differs per host — mine is
`105`, on the laptop I wrote this on it's `992`. Copying someone's compose file
verbatim is exactly how you get silently broken acceleration.
```bash
getent group render | cut -d: -f3
```
Not on Intel? Delete both keys — CPU transcoding works, it just costs cores.
NVIDIA and AMD variants:
[LinuxServer's hardware acceleration docs](https://docs.linuxserver.io/images/docker-jellyfin/#hardware-acceleration).
**`restart: unless-stopped`, not `always`** — if I stop it deliberately, it
should stay stopped across a reboot.
## Gotchas
- **`:latest` is a choice.** Pin a tag if that makes you nervous. Back up
`/config` either way — that directory *is* your server.
- **Auto-discovery is LAN-only.** 7359/udp is broadcast; behind a proxy it does
nothing for you. Set `JELLYFIN_PublishedServerUrl` there instead.
- **On a subpath, set the base URL** under **Dashboard → Networking** first, or
the UI loads and every asset 404s. A subdomain avoids the question.
- **Verify hardware transcoding actually engaged.** Enabling VAAPI and it
*working* are different states — force a transcode and check
**Dashboard → Playback**. A wrong GID falls back to software silently.
## Exposing it
Publishes plain HTTP; nothing here is proxy-aware.
- [Caddy reverse proxy](../docs/reverse-proxy.md) — what I do.
- [Cloudflare Tunnel](../docs/cloudflare-tunnel.md) — **not for this one.**
Streaming video through a tunnel breaks Cloudflare's terms and performs badly.
- [Authentik SSO](../docs/authentik-sso.md) — works for the web UI, but native
clients can't handle a forward-auth login page.
## Links
- Jellyfin docs: <https://jellyfin.org/docs/>
- Hardware acceleration: [upstream](https://jellyfin.org/docs/general/administration/hardware-acceleration/) · [this image](https://docs.linuxserver.io/images/docker-jellyfin/#hardware-acceleration)
- Image: <https://docs.linuxserver.io/images/docker-jellyfin/>
+44
View File
@@ -0,0 +1,44 @@
services:
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
# Defaults make a fresh clone work with no .env at all. Anything you set
# -- in this service's .env, or exported globally in your shell -- wins.
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Etc/UTC}
# The address Jellyfin advertises to clients during auto-discovery. Worth
# setting behind a reverse proxy, where clients otherwise get handed the
# container's internal address and fail to connect.
#- JELLYFIN_PublishedServerUrl=https://jellyfin.example.com
volumes:
# Jellyfin's own state. Stays next to this file on purpose — see README.
- ${CONFIG_PATH:-./config}:/config
- "${MEDIA_PATH:?set MEDIA_PATH in .env to your media library}:/media"
# Extra libraries on other disks. One line per library; the path after
# the colon is what you browse to when adding the library in the UI.
#- ${MEDIA_PATH_2}:/media2
#- ${MEDIA_PATH_3}:/media3
ports:
- "${PORT:-8096}:8096"
# LAN client auto-discovery. Drop it if you always reach Jellyfin by
# name or through a reverse proxy.
- "${DISCOVERY_PORT:-7359}:7359/udp"
# Jellyfin's own HTTPS listener. Off here: TLS terminates at the proxy.
#- "8920:8920"
# DLNA / SSDP. Only for devices that discover servers that way.
#- "1900:1900/udp"
# ─── Hardware transcoding: Intel Quick Sync / VAAPI ──────────────────
# Delete both keys below if you transcode on the CPU, or replace them
# with the NVIDIA/AMD equivalent. RENDER_GID is host-specific — the
# .env.example explains how to find yours.
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
group_add:
- "${RENDER_GID:?find yours with: getent group render | cut -d: -f3}"
-22
View File
@@ -1,22 +0,0 @@
version: "3.7"
services:
jellyfin:
image: linuxserver/jellyfin
restart: unless-stopped
#hostname: "${DEVICE_HOSTNAME}"
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Paris
volumes:
- ./config:/config
- ../media:/media
# - /media/umbrl/GPass_Serie:/hard_Drive
# - /media/umbrl/Videos:/hdd_movie_all
ports:
# Service auto-discovery
- 7359:7359/udp
- 8096:8096