diff --git a/docs/index.md b/docs/index.md index fa9c320..9216998 100644 --- a/docs/index.md +++ b/docs/index.md @@ -88,6 +88,7 @@ The `proxy` network is an **external** bridge network created manually. All serv | `dozzle` | Container log viewer | [dozzle.md](dozzle.md) | | `rrr` | Media automation (VPN-routed) | [rrr.md](rrr.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) ├── prowlarr.home.jens.pub ──→ gluetun→prowlarr:9696 (indexers, 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) [portainer/portainer-ee] ──── Portainer UI :9443 diff --git a/docs/plex.md b/docs/plex.md new file mode 100644 index 0000000..abd4a20 --- /dev/null +++ b/docs/plex.md @@ -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 | | +| 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 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 — 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=" + ``` + + 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..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. diff --git a/knecht/README.md b/knecht/README.md index c3f50f6..119a0bb 100644 --- a/knecht/README.md +++ b/knecht/README.md @@ -33,7 +33,7 @@ The API token is generated in Portainer under **Account → Access tokens**. |---------|-------------| | `knecht` | Launch the TUI | | `knecht list` | Print all stacks with status and drift summary | -| `knecht deploy ` | Deploy a new stack from `services//docker-compose.yml` | +| `knecht deploy ` | Deploy a new stack from `services//docker-compose.yml`. Env values come from `--from ` and/or `-e KEY=VALUE` (otherwise empty) | | `knecht update ` | Update an existing stack, preserving Portainer env vars | | `knecht restart ` | Stop then start a stack | | `knecht diff ` | Print compose and env key drift | @@ -41,6 +41,20 @@ The API token is generated in Portainer under **Account → Access tokens**. `` 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 Run `knecht` with no arguments to launch the interactive TUI. diff --git a/knecht/cmd/deploy.go b/knecht/cmd/deploy.go index 974bb20..407f68c 100644 --- a/knecht/cmd/deploy.go +++ b/knecht/cmd/deploy.go @@ -2,16 +2,26 @@ package cmd import ( "fmt" + "sort" + "strings" "github.com/jensbecker/homelab/knecht/portainer" "github.com/jensbecker/homelab/knecht/stack" "github.com/spf13/cobra" ) +var ( + deployFrom string + deployEnv []string +) + var deployCmd = &cobra.Command{ Use: "deploy ", Short: "Deploy a new stack from services//docker-compose.yml", - Args: cobra.ExactArgs(1), + Long: "Deploy a new stack from services//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), RunE: func(cmd *cobra.Command, args []string) error { _, client, svcPath, err := setup() if err != nil { @@ -38,14 +48,14 @@ var deployCmd = &cobra.Command{ return err } - // Collect env vars from .env.example keys — values left empty for user to set in Portainer exampleKeys, err := local.EnvExampleKeys() if err != nil { return err } - env := make([]portainer.EnvVar, len(exampleKeys)) - for i, k := range exampleKeys { - env[i] = portainer.EnvVar{Name: k, Value: ""} + + env, empties, err := buildDeployEnv(client, exampleKeys) + if err != nil { + return err } s, err := client.CreateStack(name, compose, env) @@ -53,9 +63,10 @@ var deployCmd = &cobra.Command{ return err } 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:") - for _, k := range exampleKeys { + + if len(empties) > 0 { + 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) } } @@ -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 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() { + 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) } diff --git a/services/plex/.env.example b/services/plex/.env.example new file mode 100644 index 0000000..96dcd14 --- /dev/null +++ b/services/plex/.env.example @@ -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= diff --git a/services/plex/docker-compose.yml b/services/plex/docker-compose.yml new file mode 100644 index 0000000..111637c --- /dev/null +++ b/services/plex/docker-compose.yml @@ -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