Compare commits
2 Commits
397cbea7fb
...
24db4cfbb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 24db4cfbb8 | |||
| 1709657472 |
@@ -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
122
docs/plex.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -2,16 +2,26 @@ 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",
|
||||||
Args: cobra.ExactArgs(1),
|
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),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
_, client, svcPath, err := setup()
|
_, client, svcPath, err := setup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
11
services/plex/.env.example
Normal file
11
services/plex/.env.example
Normal 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=
|
||||||
41
services/plex/docker-compose.yml
Normal file
41
services/plex/docker-compose.yml
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user