Compare commits

..

2 Commits

Author SHA1 Message Date
24db4cfbb8 add plex 2026-09-13 17:00:56 +02:00
1709657472 update 2026-09-13 16:23:52 +02:00
7 changed files with 277 additions and 11 deletions

View File

@@ -88,6 +88,7 @@ The `proxy` network is an **external** bridge network created manually. All serv
| `dozzle` | Container log viewer | [dozzle.md](dozzle.md) | | `dozzle` | Container log viewer | [dozzle.md](dozzle.md) |
| `rrr` | Media automation (VPN-routed) | [rrr.md](rrr.md) | | `rrr` | Media automation (VPN-routed) | [rrr.md](rrr.md) |
| `jellyfin` | Media server | [jellyfin.md](jellyfin.md) | | `jellyfin` | Media server | [jellyfin.md](jellyfin.md) |
| `plex` | Media server | [plex.md](plex.md) |
--- ---
@@ -112,7 +113,8 @@ Internet
├── radarr.home.jens.pub ──→ gluetun→radarr:7878 (movies, via Mullvad) ├── radarr.home.jens.pub ──→ gluetun→radarr:7878 (movies, via Mullvad)
├── prowlarr.home.jens.pub ──→ gluetun→prowlarr:9696 (indexers, via Mullvad) ├── prowlarr.home.jens.pub ──→ gluetun→prowlarr:9696 (indexers, via Mullvad)
├── sabnzbd.home.jens.pub ──→ gluetun→sabnzbd:8080 (Usenet DL, via Mullvad) ├── sabnzbd.home.jens.pub ──→ gluetun→sabnzbd:8080 (Usenet DL, via Mullvad)
└── jellyfin.home.jens.pub ──→ jellyfin:8096 (media server) ├── jellyfin.home.jens.pub ──→ jellyfin:8096 (media server)
└── plex.home.jens.pub ──→ plex:32400 (media server)
[adguard/adguardhome] ──── DNS :53 (TCP/UDP) [adguard/adguardhome] ──── DNS :53 (TCP/UDP)
[portainer/portainer-ee] ──── Portainer UI :9443 [portainer/portainer-ee] ──── Portainer UI :9443

122
docs/plex.md Normal file
View File

@@ -0,0 +1,122 @@
# Plex
**Purpose:** Self-hosted media server for streaming TV and movies from the local library.
| Property | Value |
| ------------ | ----------------------------------------------------------------------------- |
| Image | `lscr.io/linuxserver/plex:latest` |
| Web UI | <https://plex.home.jens.pub> |
| Compose file | [`../services/plex/docker-compose.yml`](../services/plex/docker-compose.yml) |
Current upstream version: **1.43.4.10903** (`1.43.4.10903-e5521bd8c-ls324`). The `latest` tag is used
deliberately — LinuxServer recommends it for Plex, and `VERSION=docker` lets the image track the
current public build. Plex also self-updates inside the container.
---
## Environment Variables
| Variable | Description |
| ------------- | ---------------------------------------- |
| `PUID` | Host user ID for volume ownership |
| `PGID` | Host group ID for volume ownership |
| `TZ` | Timezone (e.g. `Europe/Berlin`) |
| `TV_PATH` | Host path to TV library |
| `MOVIES_PATH` | Host path to movies library |
| `PLEX_CLAIM` | Claim token for first-time setup |
`VERSION=docker` is set in the compose file (not Portainer) and means "let the image decide the Plex
version".
---
## First-time setup (claiming)
Plex refuses the setup wizard when the request arrives through a reverse proxy with an unknown
`Host` header (it treats it as non-local), so the web UI shows *"Not authorized — You do not have
access to this server"* until the server is claimed. Claim it with a **claim token**:
1. Open <https://plex.tv/claim> while logged in and copy the token (it expires after ~4 minutes).
2. Put it in the `PLEX_CLAIM` value of the `plex` stack in Portainer.
3. Restart the stack: `knecht restart plex` (or `knecht update plex`).
4. Open <https://plex.home.jens.pub/web> — the server is now claimed to your account.
`PLEX_CLAIM` can be emptied again afterwards; it is only read while the server is unclaimed.
### After claiming
Plex clients (Apple TV, mobile, TV apps) do **not** use the Traefik domain. They read the server's
connection list from plex.tv and connect directly, so two things are needed:
1. **Publish the client ports** — see [Ports](#ports) below. Otherwise the only advertised address is
the container IP (`172.18.0.11`), which no client can reach.
2. **Custom server access URLs** = `http://192.168.178.193:32400` (host LAN IP) and optionally
`https://plex.home.jens.pub`. Set via **Settings → Network** or the API:
```sh
curl -X PUT "http://192.168.178.193:32400/:/prefs\
?customConnections=http://192.168.178.193:32400,https://plex.home.jens.pub\
&allowedNetworks=192.168.178.0/24,172.18.0.0/16\
&X-Plex-Token=<PlexOnlineToken>"
```
The token lives in `/config/Library/Application Support/Plex Media Server/Preferences.xml`
(`PlexOnlineToken`).
`allowedNetworks` (LAN Networks) makes the LAN and the Docker subnet count as local, avoiding the
remote bandwidth cap. After this, plex.tv advertises e.g. `https://192-168-178-193.<hash>.plex.direct:32400`
(`local=1`) and clients connect directly.
---
## Ports
Bridge networking means Plex clients can only reach the server if its ports are published on the
host. GDM multicast discovery does **not** traverse the Docker bridge — clients rely on the plex.tv
connection list instead.
| Port | Purpose |
| --------------- | ------------------------- |
| `32400/tcp` | Web UI + client streaming |
| `8324/tcp` | Plex Companion |
| `32469/tcp` | DLNA |
| `32410-32414/udp` | GDM local discovery |
`1900/udp` (SSDP) and `5353/udp` (mDNS) are deliberately **not** published — they clash with the
host's own Avahi/SSDP services and aren't needed for connectivity.
If clients still misbehave (e.g. rely on GDM discovery), `network_mode: host` is LinuxServer's
recommended mode and makes discovery work — at the cost of the Traefik route for the web UI.
---
## Volumes
| Volume | Mount | Purpose |
| -------------- | ------------------ | ---------------------------- |
| `plex_config` | `/config` | Database, metadata, settings |
| `${TV_PATH}` | `/media/tv:ro` | TV library (read-only) |
| `${MOVIES_PATH}`| `/media/movies:ro`| Movies library (read-only) |
| tmpfs | `/transcode` (4 GB)| RAM transcoding scratch |
Media is mounted read-only — Plex only reads, Sonarr/Radarr manage the files.
The transcode directory is a **tmpfs** rather than a named volume: LinuxServer removed `/transcode`
volume support, and transcoding to RAM is faster and avoids SSD wear. The 4 GB is a cap, not a
reservation.
---
## Networks
- `proxy` (external) — Traefik routes `plex.home.jens.pub` → port `32400`
- Host ports `32400`, `8324`, `32469`, `32410-32414/udp` published directly (see [Ports](#ports))
Plex is intentionally **not** routed through the VPN. Like Jellyfin it serves local clients and
doesn't need outbound anonymity.
### Remote access
Because `32400` is published on the host, Plex's remote-access check passes and it is reachable
from outside the LAN (router port-forward / UPnP required). The public address is reported by Plex
as the WAN IP.

View File

@@ -33,7 +33,7 @@ The API token is generated in Portainer under **Account → Access tokens**.
|---------|-------------| |---------|-------------|
| `knecht` | Launch the TUI | | `knecht` | Launch the TUI |
| `knecht list` | Print all stacks with status and drift summary | | `knecht list` | Print all stacks with status and drift summary |
| `knecht deploy <stack>` | Deploy a new stack from `services/<stack>/docker-compose.yml` | | `knecht deploy <stack>` | Deploy a new stack from `services/<stack>/docker-compose.yml`. Env values come from `--from <stack>` and/or `-e KEY=VALUE` (otherwise empty) |
| `knecht update <stack>` | Update an existing stack, preserving Portainer env vars | | `knecht update <stack>` | Update an existing stack, preserving Portainer env vars |
| `knecht restart <stack>` | Stop then start a stack | | `knecht restart <stack>` | Stop then start a stack |
| `knecht diff <stack>` | Print compose and env key drift | | `knecht diff <stack>` | Print compose and env key drift |
@@ -41,6 +41,20 @@ The API token is generated in Portainer under **Account → Access tokens**.
`<stack>` always maps to a folder name under `services/`. `<stack>` always maps to a folder name under `services/`.
### Deploying
`knecht deploy` takes env var *keys* from `.env.example`. Values are empty by default, which
breaks stacks with bind mounts — seed them from an existing stack or pass them explicitly:
```sh
knecht deploy plex --from jellyfin # reuse jellyfin's PUID/PGID/TZ/TV_PATH/MOVIES_PATH
knecht deploy plex --from jellyfin -e TV_PATH=/mnt/external/tv
knecht deploy foo -e API_KEY=abc -e TZ=Europe/Berlin
```
`--from` copies values for keys that also exist in the new stack's `.env.example`; `-e` overrides
afterwards. Any keys still left empty are reported at the end.
## TUI ## TUI
Run `knecht` with no arguments to launch the interactive TUI. Run `knecht` with no arguments to launch the interactive TUI.

View File

@@ -2,15 +2,25 @@ package cmd
import ( import (
"fmt" "fmt"
"sort"
"strings"
"github.com/jensbecker/homelab/knecht/portainer" "github.com/jensbecker/homelab/knecht/portainer"
"github.com/jensbecker/homelab/knecht/stack" "github.com/jensbecker/homelab/knecht/stack"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var (
deployFrom string
deployEnv []string
)
var deployCmd = &cobra.Command{ var deployCmd = &cobra.Command{
Use: "deploy <stack>", Use: "deploy <stack>",
Short: "Deploy a new stack from services/<stack>/docker-compose.yml", Short: "Deploy a new stack from services/<stack>/docker-compose.yml",
Long: "Deploy a new stack from services/<stack>/docker-compose.yml.\n\n" +
"Env var keys are taken from .env.example. Values are empty unless provided via\n" +
"--from (copy values from an existing stack) or -e KEY=VALUE.",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
_, client, svcPath, err := setup() _, client, svcPath, err := setup()
@@ -38,14 +48,14 @@ var deployCmd = &cobra.Command{
return err return err
} }
// Collect env vars from .env.example keys — values left empty for user to set in Portainer
exampleKeys, err := local.EnvExampleKeys() exampleKeys, err := local.EnvExampleKeys()
if err != nil { if err != nil {
return err return err
} }
env := make([]portainer.EnvVar, len(exampleKeys))
for i, k := range exampleKeys { env, empties, err := buildDeployEnv(client, exampleKeys)
env[i] = portainer.EnvVar{Name: k, Value: ""} if err != nil {
return err
} }
s, err := client.CreateStack(name, compose, env) s, err := client.CreateStack(name, compose, env)
@@ -53,9 +63,10 @@ var deployCmd = &cobra.Command{
return err return err
} }
fmt.Printf("Deployed stack %q (id %d)\n", s.Name, s.ID) fmt.Printf("Deployed stack %q (id %d)\n", s.Name, s.ID)
if len(exampleKeys) > 0 {
fmt.Println("Set the following env vars in Portainer:") if len(empties) > 0 {
for _, k := range exampleKeys { fmt.Println("Warning: these env vars have no value yet — set them in Portainer (or redeploy with --from/-e):")
for _, k := range empties {
fmt.Printf(" • %s\n", k) fmt.Printf(" • %s\n", k)
} }
} }
@@ -63,6 +74,71 @@ var deployCmd = &cobra.Command{
}, },
} }
// buildDeployEnv assembles the env var list for a new stack.
// Keys come from .env.example (empty by default); values are seeded from
// --from <stack> and then overridden by -e KEY=VALUE flags.
// It returns the ordered env slice and the names of keys left empty.
func buildDeployEnv(client *portainer.Client, exampleKeys []string) ([]portainer.EnvVar, []string, error) {
envMap := make(map[string]string, len(exampleKeys))
for _, k := range exampleKeys {
envMap[k] = ""
}
if deployFrom != "" {
src, err := client.GetStackByName(deployFrom)
if err != nil {
return nil, nil, err
}
if src == nil {
return nil, nil, fmt.Errorf("source stack %q not found", deployFrom)
}
for _, e := range src.Env {
if _, ok := envMap[e.Name]; ok {
envMap[e.Name] = e.Value
}
}
}
for _, kv := range deployEnv {
k, v, ok := strings.Cut(kv, "=")
k = strings.TrimSpace(k)
if !ok || k == "" {
return nil, nil, fmt.Errorf("invalid -e %q, expected KEY=VALUE", kv)
}
envMap[k] = v
}
// Ordered output: .env.example order first, then any extra keys (sorted).
ordered := make([]string, 0, len(envMap))
seen := make(map[string]bool, len(envMap))
for _, k := range exampleKeys {
if !seen[k] {
ordered = append(ordered, k)
seen[k] = true
}
}
var extra []string
for k := range envMap {
if !seen[k] {
extra = append(extra, k)
}
}
sort.Strings(extra)
ordered = append(ordered, extra...)
env := make([]portainer.EnvVar, 0, len(ordered))
var empties []string
for _, k := range ordered {
env = append(env, portainer.EnvVar{Name: k, Value: envMap[k]})
if envMap[k] == "" {
empties = append(empties, k)
}
}
return env, empties, nil
}
func init() { func init() {
deployCmd.Flags().StringVar(&deployFrom, "from", "", "copy env var values from an existing deployed stack")
deployCmd.Flags().StringArrayVarP(&deployEnv, "env", "e", nil, "set env var value (KEY=VALUE, repeatable)")
rootCmd.AddCommand(deployCmd) rootCmd.AddCommand(deployCmd)
} }

View File

@@ -0,0 +1,11 @@
# Generated by knecht from Portainer env vars
PUID=
PGID=
TZ=
TV_PATH=
MOVIES_PATH=
# Claim token for first-time setup: https://plex.tv/claim
# Only needed until the server is claimed; tokens expire after 4 minutes.
# Can be emptied again afterwards.
PLEX_CLAIM=

View File

@@ -0,0 +1,41 @@
services:
plex:
image: lscr.io/linuxserver/plex:latest
container_name: plex
restart: unless-stopped
environment:
- PUID=${PUID}
- PGID=${PGID}
- TZ=${TZ}
- VERSION=docker
- PLEX_CLAIM=${PLEX_CLAIM}
volumes:
- plex_config:/config
- ${TV_PATH}:/media/tv:ro
- ${MOVIES_PATH}:/media/movies:ro
tmpfs:
- /transcode:size=4g
ports:
# Plex clients (Apple TV, mobile) don't use the Traefik domain — they connect
# directly to the addresses Plex advertises. These must be reachable on the LAN.
- "32400:32400" # main web UI + client connections
- "8324:8324" # Plex Companion
- "32469:32469" # DLNA
- "32410:32410/udp" # GDM local discovery
- "32412:32412/udp"
- "32413:32413/udp"
- "32414:32414/udp"
networks:
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.plex.rule=Host(`plex.home.jens.pub`)"
- "traefik.http.routers.plex.entrypoints=websecure"
- "traefik.http.services.plex.loadbalancer.server.port=32400"
volumes:
plex_config:
networks:
proxy:
external: true

View File

@@ -29,7 +29,7 @@ services:
environment: environment:
- NAMECHEAP_API_USER=jsbeckr - NAMECHEAP_API_USER=jsbeckr
- NAMECHEAP_API_KEY=a2d922b7d4e245dc9f146ab6448c7dd2 - NAMECHEAP_API_KEY=a2d922b7d4e245dc9f146ab6448c7dd2
- NAMECHEAP_SOURCE_IP=79.219.109.215 - NAMECHEAP_SOURCE_IP=87.149.107.4
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_acme:/acme - traefik_acme:/acme