Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ TRANSMISSION_RPC_PASSWORD=
# Host port to expose Grafana on. Defaults to 3000 if unset.
GRAFANA_PORT=3000

# ============ Dashboard (AutoPlexx Command Center) ============
# Host interface to publish the dashboard on. Defaults to 0.0.0.0 (reachable
# from other machines on your LAN). Set to 127.0.0.1 if you front the dashboard
# with an authenticating reverse proxy — a port published on all interfaces
# lets anyone reach the dashboard directly and skip that proxy.
DASHBOARD_BIND=0.0.0.0
# Host port for the unified dashboard. Defaults to 8090 if unset.
# Nothing else here is required: the dashboard reads each service's API key
# from the config file that service already writes (see the read-only
# /discover mounts in docker-compose.yml), so it configures itself as the rest
# of the stack comes up.
DASHBOARD_PORT=8090

# ============ Checkrr ============
# checkrr has no multi-arch image; every tag is architecture-suffixed.
# Defaults to latest-amd64. On arm64 hosts, set this to latest-arm64v8.
Expand Down
54 changes: 54 additions & 0 deletions .github/workflows/dashboard-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Dashboard CI

on:
pull_request:
paths:
- 'dashboard/**'
- '.github/workflows/dashboard-ci.yml'
push:
branches: [main]
paths:
- 'dashboard/**'
- '.github/workflows/dashboard-ci.yml'

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dashboard

steps:
# persist-credentials: false — `npm ci` runs dependency lifecycle scripts,
# and leaving the checkout token in .git/config exposes it to them.
- uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: dashboard/package-lock.json

- run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Test
run: npm test

- name: Build
run: npm run build

# Catches Dockerfile regressions on PRs, where the release workflow
# (which only runs on main) wouldn't.
- name: Build image
run: docker build -t autoplexx-dashboard:ci .
73 changes: 73 additions & 0 deletions .github/workflows/dashboard-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Publish dashboard image

# Publishes ghcr.io/joshdev8/autoplexx-dashboard so users who clone this repo
# pull a prebuilt image instead of running a Node build on their own machine.
#
# One-time maintainer step after the first successful run: make the package
# public (GitHub -> Packages -> autoplexx-dashboard -> Package settings ->
# Change visibility). Until then anonymous `docker compose pull` will fail and
# users fall back to the `build:` stanza in docker-compose.yml.

# No `paths` filter here on purpose: a `paths` filter applies to tag pushes too,
# so tagging a release whose commit didn't happen to touch dashboard/ would
# silently skip publishing. Unrelated pushes to main just hit the build cache
# below and finish quickly, which is the cheaper mistake to make.
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:

permissions:
contents: read
packages: write

concurrency:
group: dashboard-release-${{ github.ref }}
cancel-in-progress: true

env:
IMAGE: ghcr.io/${{ github.repository_owner }}/autoplexx-dashboard

jobs:
publish:
runs-on: ubuntu-latest

steps:
# The build runs dependency lifecycle scripts; don't leave the checkout
# token in .git/config where they can read it.
- uses: actions/checkout@v4
with:
persist-credentials: false

- uses: docker/setup-qemu-action@v3

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=tag
type=sha,format=short

# arm64 is built alongside amd64 because this stack already accommodates
# arm64 hosts — see the CHECKRR_IMAGE_TAG note in docker-compose.yml.
- uses: docker/build-push-action@v6
with:
context: dashboard
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ Thumbs.db

# Logs
*.log

# Dashboard build artifacts
node_modules/
dashboard/*/dist/
37 changes: 31 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What this repo is

AutoPlexx is a Docker Compose stack that orchestrates a Plex Media Server ecosystem — there is no application source code to build, lint, or test. Work here means editing `docker-compose.yml`, the `.env`/`.env.example` contract, or the Kometa / Telegraf / Prometheus config files.
AutoPlexx is a Docker Compose stack that orchestrates a Plex Media Server ecosystem. Most work here means editing `docker-compose.yml`, the `.env`/`.env.example` contract, or the Kometa / Telegraf / Prometheus config files.

The one exception is `dashboard/` — a real application (React + Vite frontend, Fastify backend) that does have a build, a lint, and a test suite. See "The dashboard app" below.

## Common commands

Expand All @@ -17,23 +19,46 @@ docker compose pull && docker compose up -d # update images (Watchtower also doe
docker compose down # stop everything (volumes preserved)
```

There is no test suite. After changing `docker-compose.yml`, always run `docker compose config` to catch interpolation errors before bringing services up.
After changing `docker-compose.yml`, always run `docker compose config` to catch interpolation errors before bringing services up. Note that `docker compose config` fails on `.env.example`'s blank placeholders for the `${VAR:?...}` vars — fill them with throwaway values first, as `.github/workflows/compose-validate.yml` does.

For `dashboard/`, run from that directory:

```bash
npm ci && npm run typecheck && npm run lint && npm test && npm run build
```

## The dashboard app

`dashboard/` is an npm workspace root with two workspaces: `server/` (Fastify BFF) and `web/` (React SPA). It is published to `ghcr.io/joshdev8/autoplexx-dashboard` by `.github/workflows/dashboard-release.yml`; `docker-compose.yml` declares both `image:` and `build:` so users pull and contributors build. **Never remove the `image:` line in favour of `build:` alone** — that would force every person who clones this public repo to run a Node build on first `docker compose up`, which the design explicitly rules out.

Things that aren't obvious:

- **`server/src/services.ts` is the single source of truth** for the service list. Its `container` field must match `container_name` in `docker-compose.yml` exactly, or health lookups silently report the service as `absent`. Adding a service to compose means adding it here too.
- **Container status comes from `docker-socket-proxy`, never a direct socket mount.** `:ro` on a socket does not make the Docker API read-only — it only affects the file node — so mounting it into the dashboard would be a second root-equivalent exposure alongside Portainer's. The proxy runs with `CONTAINERS=1` and everything else off. If asked to "simplify" by mounting the socket directly, push back.
- **API keys are auto-discovered, not configured.** The dashboard reads each service's own config file (`config.xml` for the \*arrs, `config.ini` for Tautulli, `settings.json` for Seerr) through the read-only `/discover/*` mounts. Resolution order is env var → discovered file → unconfigured. Discovery re-runs at runtime because on a clean install those files don't exist until each service's first boot — so a panel must recover on its own without a dashboard restart.
- **Nothing may throw on missing configuration.** An unset key or a dead upstream degrades that one panel. One failed integration must never blank the page, and a failed refresh keeps the last good data on screen.
- **`web/src/styles/nocturne.css` is a vendored design system** from the issue #48 handoff. Take colors, spacing, radii and shadows from its `var(--*)` tokens rather than hard-coding values. Inter and Phosphor icons are self-hosted on purpose — a self-hosted media stack may have no outbound internet, so don't "optimize" them back to a CDN.
- **Both new containers are named `autoplexx-*`** (`autoplexx-dashboard`, `autoplexx-socket-proxy`) rather than the bare `dashboard` / `docker-socket-proxy`, because those names are generic enough to collide with something a user already runs — and a `container_name` collision fails `docker compose up` outright.

## Architecture notes that aren't obvious from a glance

**Network isolation matters.** Services are split across four bridge networks and one host-mode service. A service can only reach another if they share a network — adding a new service requires picking the right one (or declaring multiple):

- `monitoring_network` — tautulli, grafana, telegraf, watchtower, portainer, prometheus, cadvisor, node-exporter
- `media_network` — seerr, radarr, sonarr, prowlarr, bazarr, flaresolverr, maintainerr, checkrr
- `download_network` — transmission, watchlistarr, cleanarr, requestrr, decluttarr, radarr, sonarr
- `monitoring_network` — tautulli, grafana, telegraf, watchtower, portainer, prometheus, cadvisor, node-exporter, dashboard, docker-socket-proxy
- `media_network` — seerr, radarr, sonarr, prowlarr, bazarr, flaresolverr, maintainerr, checkrr, dashboard
- `download_network` — transmission, watchlistarr, cleanarr, requestrr, decluttarr, radarr, sonarr, dashboard
- `tracearr-network` — tracearr, timescale (PostgreSQL), redis
- **host network** — plex only (required for proper streaming/discovery)

`dashboard` is on all three service networks because it aggregates from all of them. It reaches Plex — which is on the host network — via `host.docker.internal`, hence its `extra_hosts` entry.

Radarr and Sonarr are deliberately on both `media_network` (so Seerr, Prowlarr, and Bazarr can reach them) and `download_network` (so Watchlistarr/Transmission can reach them). Prowlarr and Bazarr only need `media_network` because their only inbound/outbound peers are the *arr APIs.

**Portainer mounts the Docker socket.** `portainer/portainer-ce` binds `/var/run/docker.sock` read-write, which is root-equivalent access to the host. If a user reports security concerns, this is the relevant exposure — flag it before recommending Portainer-based workflows.

**Tracearr is the only "app" in the stack.** Everything else is a single off-the-shelf container. Tracearr is a three-container subsystem (app + TimescaleDB + Redis) with healthcheck-gated `depends_on`, and it is the only service whose env vars use the `${VAR:?must be set}` fail-fast form — `DB_PASSWORD`, `JWT_SECRET`, and `COOKIE_SECRET` are required or the stack will refuse to start. Its external port is `3001` mapped to internal `3000` because Grafana already owns `3000` on the host.
**Tracearr is the only multi-container subsystem.** Every other third-party service is a single off-the-shelf container. Tracearr is three containers (app + TimescaleDB + Redis) with healthcheck-gated `depends_on`. Its external port is `3001` mapped to internal `3000` because Grafana already owns `3000` on the host. (The only first-party *application* in this repo is `dashboard/` — see above.)

**Two groups of vars use the `${VAR:?must be set}` fail-fast form**, and a blank value in either fails `docker compose up` for the whole stack, not just that service: Tracearr's `DB_PASSWORD` / `JWT_SECRET` / `COOKIE_SECRET`, and Transmission's `OPENVPN_PROVIDER` / `OPENVPN_CONFIG` / `OPENVPN_USERNAME` / `OPENVPN_PASSWORD`.

**Volume paths are intentionally user-specific.** All bind mounts are rooted at `${USERDIR}` from `.env`. When advising the user, do not assume any particular host path layout — the README explicitly tells them to update paths to match their drive mounts.

Expand Down
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ A complete, opinionated [Plex Media Server](https://www.plex.tv/) stack delivere
docker compose up -d
```

6. Open the dashboard at **<http://localhost:8090>** (or whatever `DASHBOARD_PORT` you set — `8090` is the default).

There is nothing to configure — it shows every container's live status straight away, and links out to each service's own UI. As the rest of the stack finishes its first boot, the dashboard picks up each service's API key from the config file that service writes and lights up the corresponding panels on its own. See [Dashboard](#dashboard) for details.

## Common Operations

```bash
Expand Down Expand Up @@ -168,6 +172,7 @@ A ready-to-use [Kometa](https://kometa.wiki/) (Plex Meta Manager) configuration
| [Telegraf](https://www.influxdata.com/time-series-platform/telegraf/) | Metrics collection agent | N/A |
| [Tracearr](https://github.com/connorgallopo/tracearr) | Stream tracking and account sharing detection | `3001` |
| [Portainer](https://www.portainer.io/) | Docker management UI ([note on socket access](#a-note-on-portainer)) | `9000` |
| AutoPlexx Dashboard | Unified status and launcher for the whole stack — [details](#dashboard) | `8090` (default, set by `DASHBOARD_PORT`) |

### Infrastructure

Expand All @@ -176,6 +181,7 @@ A ready-to-use [Kometa](https://kometa.wiki/) (Plex Meta Manager) configuration
| [Watchtower](https://containrrr.dev/watchtower/) | Automated container updates | N/A |
| [TimescaleDB](https://www.timescale.com/) | Time-series database (used by Tracearr) | N/A (internal) |
| [Redis](https://redis.io/) | Cache/queue (used by Tracearr) | N/A (internal) |
| [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) | Read-only Docker API for the dashboard — [why](#dashboard) | N/A (internal) |

### Not included but recommended

Expand All @@ -188,17 +194,60 @@ These services pair well with this stack but are not included in the default `do

Services are isolated into separate Docker networks:

- **`monitoring_network`** - Tautulli, Grafana, Telegraf, Watchtower, Portainer, Prometheus, cAdvisor, node-exporter
- **`media_network`** - Seerr, Radarr, Sonarr, Prowlarr, Bazarr, FlareSolverr, Maintainerr, Checkrr
- **`download_network`** - Transmission, Watchlistarr, Cleanarr, Requestrr, Decluttarr, Radarr, Sonarr
- **`monitoring_network`** - Tautulli, Grafana, Telegraf, Watchtower, Portainer, Prometheus, cAdvisor, node-exporter, Dashboard, docker-socket-proxy
- **`media_network`** - Seerr, Radarr, Sonarr, Prowlarr, Bazarr, FlareSolverr, Maintainerr, Checkrr, Dashboard
- **`download_network`** - Transmission, Watchlistarr, Cleanarr, Requestrr, Decluttarr, Radarr, Sonarr, Dashboard
- **`tracearr-network`** - Tracearr, TimescaleDB, Redis

Plex runs in host network mode for optimal streaming performance. Radarr and Sonarr are attached to both `media_network` (so Seerr, Prowlarr, and Bazarr can reach them) and `download_network` (so Watchlistarr and Transmission can reach them).
Plex runs in host network mode for optimal streaming performance. Radarr and Sonarr are attached to both `media_network` (so Seerr, Prowlarr, and Bazarr can reach them) and `download_network` (so Watchlistarr and Transmission can reach them). The dashboard joins all three service networks because it aggregates from all of them, and reaches Plex through `host.docker.internal`.

### A note on Portainer

Portainer mounts the host's Docker socket (`/var/run/docker.sock`) so it can manage every container. **This grants the Portainer UI root-equivalent access to the host** — anyone who logs in can stop, restart, or exec into any container, including those handling secrets. Set a strong admin password on first launch and don't expose port `9000` to the public internet.

## Dashboard

The AutoPlexx Command Center — at **<http://localhost:8090>** by default, or whichever port `DASHBOARD_PORT` names — is a single pane over the whole stack: live container status for every service, and a launcher that opens each one's own UI.

### Zero configuration

It works out of the box, with no required configuration. `DASHBOARD_PORT` (default `8090`) sets the published port; everything else the dashboard reads is optional and has a working default — see [`dashboard/README.md`](dashboard/README.md#environment) for the full list, including the `*_API_KEY` overrides you only need if you run a service outside this stack.

API keys are **not** something you paste in. Each service writes its own key to a config file — Sonarr, Radarr, Prowlarr and Bazarr to `config.xml`, Tautulli to `config.ini`, Seerr to `settings.json` — and the dashboard reads those files through the read-only `/discover` mounts declared in `docker-compose.yml`. Discovery re-runs while the dashboard is running, so on a first boot each panel lights up by itself shortly after the service behind it comes up. No restart, no wizard.

If you run one of these services outside this stack, set the matching `*_API_KEY` variable in `.env` and it takes precedence over discovery.

### Why a separate socket proxy

Container status has to come from the Docker API, but the dashboard does **not** mount the Docker socket. Note that `:ro` on a socket only affects the file node — it does not make the Docker API read-only — so a direct mount would be a second root-equivalent exposure alongside Portainer's.

Instead, `docker-socket-proxy` sits in front with `CONTAINERS=1` and everything else off. It permits `GET /containers/json` and denies the rest, including image access and `exec`. The dashboard talks HTTP to that proxy and never sees the socket.

### Security posture

The dashboard is **read-only** — it issues no mutating calls to any service — and it ships **no authentication**. Treat it as LAN-only.

By default Compose publishes it on all interfaces (`0.0.0.0`), which is what makes `http://<your-server>:8090` work from another machine on your LAN. **If you put it behind a reverse proxy for authentication, publishing on all interfaces lets anyone reach the dashboard directly and skip that proxy entirely.** Bind it to loopback so only the proxy can reach it:

```bash
# in .env
DASHBOARD_BIND=127.0.0.1
```

Then point your reverse proxy at `127.0.0.1:8090`. If the proxy runs in Docker rather than on the host, drop the `ports:` mapping for `dashboard` altogether and let the proxy reach it over a shared Docker network instead — a published port always bypasses the proxy. Restricting `8090` at the firewall works too, but the bind address is the harder thing to get wrong.

The same reasoning applies to every other service in this stack, none of which are authenticated by default either.

### Building it yourself

`docker compose up -d` pulls a prebuilt image, so no Node toolchain is needed. To build from source instead:

```bash
docker compose build dashboard && docker compose up -d dashboard
```

To work on it locally, see [`dashboard/README.md`](dashboard/README.md).

## Transmission VPN setup

Transmission uses the [`haugene/transmission-openvpn`](https://github.com/haugene/docker-transmission-openvpn) image, which runs an OpenVPN client inside the container and tunnels all torrent traffic through it. The container fails to start without valid VPN credentials.
Expand Down
5 changes: 5 additions & 0 deletions dashboard/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
**/node_modules
**/dist
.git
*.log
Loading
Loading