diff --git a/content/docs/guides/img/servarr/servarr-sonarr.png b/content/docs/guides/img/servarr/servarr-sonarr.png
new file mode 100644
index 000000000..b240e25e9
Binary files /dev/null and b/content/docs/guides/img/servarr/servarr-sonarr.png differ
diff --git a/content/docs/guides/servarr.mdx b/content/docs/guides/servarr.mdx
new file mode 100644
index 000000000..2a6f7f8d7
--- /dev/null
+++ b/content/docs/guides/servarr.mdx
@@ -0,0 +1,184 @@
+---
+# cSpell:ignore servarr Sonarr Radarr Prowlarr SABnzbd usenet linuxserver Servarr
+title: Secure Sonarr, Radarr, Prowlarr, and SABnzbd with Pomerium
+sidebar_label: Servarr
+lang: en-US
+keywords:
+ [
+ pomerium,
+ servarr,
+ sonarr,
+ radarr,
+ prowlarr,
+ sabnzbd,
+ media automation,
+ sso,
+ oidc,
+ identity aware proxy,
+ self-hosted,
+ ]
+description: Put a Servarr media-automation suite (Sonarr, Radarr, Prowlarr, SABnzbd) behind one Pomerium front door so every web request is authenticated and authorized before it reaches an app.
+---
+
+import TabItem from '@theme/TabItem';
+import Tabs from '@theme/Tabs';
+
+import Config from '/content/examples/guides/servarr/config.yaml.md';
+import Compose from '/content/examples/guides/servarr/docker-compose.yaml.md';
+
+# Secure Sonarr, Radarr, Prowlarr, and SABnzbd with Pomerium
+
+## What this guide does
+
+The "Servarr" apps are a family of self-hosted media-automation tools that are almost always run together: [Sonarr](https://sonarr.tv/) (TV), [Radarr](https://radarr.video/) (movies), [Prowlarr](https://prowlarr.com/) (indexer management), and a download client like [SABnzbd](https://sabnzbd.org/) (usenet). Each ships its own web interface and HTTP API on its own port, and none of them speak single sign-on (SSO), the [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) protocol, or any header-based identity. Their built-in access control is per-app web UI authentication plus a static API key for HTTP API and inter-app calls.
+
+You'll put the whole suite behind one Pomerium so that Pomerium becomes the single front door for every app's web interface: each request is authenticated against your identity provider (IdP) and checked against your policy before it ever reaches Sonarr, Radarr, Prowlarr, or SABnzbd. The apps keep their own API keys for programmatic and inter-app calls; Pomerium protects browser access to all of them.
+
+## When to use this guide
+
+Use it when you run a Servarr stack and want one identity-aware front door for the whole suite instead of exposing four separate web UIs, each with its own local login and separate API key for automation. In a typical home or lab deployment these apps run on a separate host or network-attached storage (NAS) box and are only meant to be reached over your private network; Pomerium lets you reach them from anywhere with your existing identity, while keeping their ports off the public internet.
+
+You get centralized SSO, group-based policy, an audit trail of who reached which app, and a single authenticated entry point instead of four exposed ports. The trade-off: the apps consume no identity from Pomerium, so this is a front-door gate, and each app's API key remains a separate credential you still have to protect.
+
+### What Pomerium protects — and what it doesn't
+
+A Servarr stack is reached through three channels, and Pomerium sits in front of only one of them. The same model applies to all four apps:
+
+| Access channel | What gates it | Credential the client presents |
+| --- | --- | --- |
+| Web UI (browser) | Pomerium route policy (SSO and a policy check) before any request reaches an app | Pomerium SSO session |
+| HTTP API, including Prowlarr's calls into Sonarr and Radarr | The app itself, independent of any Pomerium session; keep these calls on the internal network (see [Security considerations](#security-considerations)) | The app's API key |
+| Native or mobile clients | The app itself: these clients can't run a browser SSO flow and need a separate path, such as a virtual private network (VPN) or a [TCP tunnel](/docs/capabilities/non-http) on a device that can run Pomerium CLI | The app's API key |
+
+```mermaid
+flowchart LR
+ Browser --> Pomerium["Pomerium
SSO + route policy"]
+ Pomerium -.->|"sign in"| IdP[Identity provider]
+ Pomerium --> Apps["Sonarr / Radarr /
Prowlarr / SABnzbd"]
+ Clients["API / native clients"] -->|"app API key"| Apps
+```
+
+Inter-app calls (Prowlarr into Sonarr and Radarr) happen on the internal Docker network with nothing but an API key, and any client that can reach an app's port is authorized the same way. Keep the app ports off published interfaces and reachable only through Pomerium, or the API key becomes the whole security model.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/)
+- For the Pomerium Zero path: a [Pomerium Zero](https://console.pomerium.app) account with its Pomerium instance running locally via the [Quickstart](/docs/get-started/quickstart) Compose file; the routes use the starter domain that comes with it
+- For the Pomerium Core path: a domain you control for the routes (this guide uses `sonarr.yourdomain.com`, `radarr.yourdomain.com`, `prowlarr.yourdomain.com`, and `sabnzbd.yourdomain.com`), with DNS pointed at the host running Pomerium and ports 80 and 443 reachable so `autocert` can provision certificates; the Compose file below runs Pomerium itself
+
+:::tip Prefer to self-host the identity provider?
+
+This guide uses the hosted authenticate service so you don't have to run your own IdP. To run your own instead, follow [Keycloak + Pomerium](/docs/integrations/user-identity/oidc) and swap the `authenticate_service_url` / `idp_*` settings into the config below.
+
+:::
+
+## Configure Pomerium
+
+You'll create one route per app. Each route does the same thing: Pomerium authenticates the user, checks policy, and proxies the request through without injecting any identity, because the apps have no header or token identity to consume.
+
+
+
+
+In the [Zero Console](https://console.pomerium.app), create one **Route** per app:
+
+1. **Sonarr:** **From** `https://sonarr.`, **To** `http://sonarr:8989`.
+2. **Radarr:** **From** `https://radarr.`, **To** `http://radarr:7878`.
+3. **Prowlarr:** **From** `https://prowlarr.`, **To** `http://prowlarr:9696`.
+4. **SABnzbd:** **From** `https://sabnzbd.`, **To** `http://sabnzbd:8080`. On this route, enable **Preserve Host Header**: SABnzbd checks the incoming host against its `host_whitelist`, so it must see the public name.
+
+Set each route's policy to scope access to who should reach the suite (for example, **Any Authenticated User** or a specific group). Zero manages the routes' TLS certificates behind your starter domain.
+
+
+
+
+Create a `config.yaml`. It defines one route per app and allows a single authorized user. SABnzbd preserves the host header so its `host_whitelist` check passes.
+
+
+
+Replace each `*.yourdomain.com` host with your domain and `you@example.com` with the email (or switch to a group or domain match) that should be allowed through. Restart Pomerium after saving.
+
+
+
+
+## Configure the Servarr apps
+
+The [LinuxServer.io](https://www.linuxserver.io/) images for these apps generate their config on first start, so there is little to set by hand. The two things that matter for running behind Pomerium:
+
+- **Authentication method.** For Sonarr, Radarr, and Prowlarr, `External` is configured in `config.xml`, not from the normal settings UI. Stop each container, edit its `/config/config.xml` so the top-level auth entries are `External` and `DisabledForLocalAddresses`, then restart. This tells the app to skip its own web-UI login for requests that arrive from a local/private network address, on the assumption that a reverse proxy already authenticated the user. The app does not validate a Pomerium identity or a signed header; it trusts the network position. Pomerium is that reverse proxy, so the app ports must be reachable only through Pomerium (see [Security considerations](#security-considerations)). The API key still guards API calls regardless of this setting.
+- **API keys.** Each app shows its API key under **Settings > General**. Prowlarr uses these keys to push indexer config into Sonarr and Radarr, and any external client uses them too. Treat each key like a password.
+
+For SABnzbd, note its API key under **Config > General**, and add your public SABnzbd host to **Config > Special > host_whitelist** so it accepts the host Pomerium forwards.
+
+:::caution The API key is a separate credential from your SSO identity
+
+Signing in through Pomerium does not authenticate you to an app's API. The API key is the app's own credential and is not derived from your Pomerium session. Anyone who can present a valid API key to an app, and reach its port, is authorized by that app; that's why the next sections keep the app ports off the network.
+
+:::
+
+## Run the stack
+
+The Compose file runs Pomerium Core and all four apps together. Pomerium publishes ports 80 and 443; the app containers publish no host ports, so their web ports are reachable only through Pomerium, while the apps keep outbound access so Sonarr, Radarr, and Prowlarr can reach indexers and metadata providers and SABnzbd can reach Usenet servers. For Zero, drop the `pomerium` service and use the `compose.yaml` from the Quickstart with your `POMERIUM_ZERO_TOKEN`, keeping the four app services and the `servarr-internal` network below, and attach the Quickstart's `pomerium` service to `servarr-internal` so it can resolve the apps by name.
+
+
+
+Start it:
+
+```bash
+docker compose up -d
+```
+
+The apps take a moment to migrate their databases on first start; watch `docker compose ps` until each reports `Up`, then check the app logs for the listening message if a page is not ready yet. When you're done testing, stop the stack:
+
+```bash
+docker compose down
+```
+
+Add `-v` only if you mean to delete the named volumes, including each app's config and API keys.
+
+## Verify the setup
+
+1. **The route requires authentication.** In a fresh browser, open `https://sonarr.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight into Sonarr. Repeat for `radarr`, `prowlarr`, and `sabnzbd` to confirm every app is gated.
+2. **An allowed user reaches each app.** Sign in with a user your policy allows. Pomerium redirects you back and the app's own dashboard loads.
+
+
+
+3. **The API answers its own key, not your session.** Pomerium still gates the route, so reach these from your signed-in browser. For Pomerium Zero or Enterprise routes, you can also use a [Pomerium service account](/docs/capabilities/service-accounts) token that your route policy allows; for self-managed Core without service accounts, use an interactive browser session. A plain `curl` with no Pomerium session is redirected to sign in. Once you're signed in, each app's status API responds to its own API key. The path differs by app (Sonarr and Radarr use `/api/v3`, Prowlarr uses `/api/v1`, and SABnzbd uses its `mode` query):
+
+```bash
+export POMERIUM_SERVICE_ACCOUNT_JWT='raw-service-account-jwt'
+
+curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \
+ -H "X-Api-Key: YOUR_SONARR_KEY" \
+ "https://sonarr.yourdomain.com/api/v3/system/status"
+curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \
+ -H "X-Api-Key: YOUR_RADARR_KEY" \
+ "https://radarr.yourdomain.com/api/v3/system/status"
+curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \
+ -H "X-Api-Key: YOUR_PROWLARR_KEY" \
+ "https://prowlarr.yourdomain.com/api/v1/system/status"
+curl -H "Authorization: Bearer Pomerium-${POMERIUM_SERVICE_ACCOUNT_JWT}" \
+ "https://sabnzbd.yourdomain.com/api?mode=queue&output=json&apikey=YOUR_SABNZBD_KEY"
+```
+
+Pomerium protects browser access; the apps keep their own API keys behind it. If you leave Forms or Basic auth enabled instead of `External`, that local login remains as an additional prompt. First-run setup is each app's concern, not Pomerium's.
+
+## Common failure modes
+
+- **`421 Misdirected Request` or a host error from SABnzbd.** SABnzbd's `host_whitelist` doesn't include the host Pomerium forwards. Add `sabnzbd.yourdomain.com` to the whitelist and make sure the route sets `preserve_host_header`.
+- **An \*arr app shows its own login prompt after you sign in to Pomerium.** Its authentication method is still set to a local form login. Stop the container, edit `/config/config.xml` to use `External` and `DisabledForLocalAddresses`, then restart. Or leave a form login in place if you want the app's password as a second prompt.
+- **`404` from a status API.** You used the wrong API version for that app. Prowlarr is `/api/v1`; Sonarr and Radarr are `/api/v3`; SABnzbd uses `/api?mode=...`.
+- **Redirect loop or certificate errors.** Make sure DNS for each host points at Pomerium and that Pomerium can obtain a TLS certificate. On the Core path, `autocert` needs ports 80 and 443 reachable for Let's Encrypt; Zero manages certificates for you.
+
+## Security considerations
+
+- **Network isolation is the real boundary.** With **External** auth set to **Disabled for Local Addresses**, each app serves its web UI to anything that reaches it from a local network address, and its API authorizes any caller that presents the key. The app trusts the network position, not a verified identity, so the whole model depends on Pomerium being the only thing on that network path. Keep the apps off published host ports on a Docker network shared with Pomerium, as the Compose file does, so the only way in is through Pomerium. The inbound isolation comes from not publishing host ports, not from cutting the network off entirely; these apps still need outbound access to reach indexers and Usenet, so the network stays egress-capable. If an app is reachable on its own port, a caller from that network skips the web-UI login outright, and anyone who learns the API key bypasses Pomerium entirely. In production these apps often live on a separate host or NAS; reach them only through Pomerium, never by exposing their ports.
+- **Treat each API key like a password.** The keys live in the apps' config and are passed between them (Prowlarr to Sonarr and Radarr) and to any external client. They are not tied to your Pomerium session, so rotate them if leaked. Prefer the `X-Api-Key` header for Sonarr, Radarr, and Prowlarr instead of putting keys in URLs you'd paste into a shared channel.
+- **Scope the route policy.** Limit each route to the users or groups who should reach the suite rather than allowing every authenticated user. The apps have no per-user roles of their own, so Pomerium's policy is your only place to express who gets in.
+- **Front-door gate, not identity injection.** Unlike apps that accept a signed header or JWT from Pomerium, the \*arr apps gain no per-user identity from this setup. If you need the upstream to know which user acted, these apps can't consume that today; Pomerium's audit log is where that record lives.
+
+## Next steps
+
+- [Secure Transmission with Pomerium](/docs/guides/transmission): the same front-door pattern for a BitTorrent download client, including its RPC host-whitelist mechanics.
+- [Build policies](/docs/get-started/fundamentals/zero/zero-build-policies)
+- [Non-HTTP (TCP) routes](/docs/capabilities/non-http) for native or mobile clients that can't run a browser SSO flow.
+- [Custom domains](/docs/capabilities/custom-domains)
diff --git a/content/examples/guides/servarr/config.yaml b/content/examples/guides/servarr/config.yaml
new file mode 100644
index 000000000..971fb2b0c
--- /dev/null
+++ b/content/examples/guides/servarr/config.yaml
@@ -0,0 +1,46 @@
+# Pomerium Core configuration for a Servarr media-automation suite. Uses the hosted
+# authenticate service, so you don't run your own identity provider. To self-host the
+# IdP, see the Keycloak guide:
+# https://www.pomerium.com/docs/integrations/user-identity/oidc
+authenticate_service_url: https://authenticate.pomerium.app
+
+# Obtain TLS certificates automatically from Let's Encrypt.
+autocert: true
+
+# One route per app. Each app keeps its own API key, so these are front-door gates,
+# not header-trust integrations.
+routes:
+ - from: https://sonarr.yourdomain.com
+ to: http://sonarr:8989
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+
+ - from: https://radarr.yourdomain.com
+ to: http://radarr:7878
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+
+ - from: https://prowlarr.yourdomain.com
+ to: http://prowlarr:9696
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+
+ - from: https://sabnzbd.yourdomain.com
+ to: http://sabnzbd:8080
+ # SABnzbd validates the Host header against its host_whitelist, so forward the
+ # original host unchanged and add sabnzbd.yourdomain.com to that whitelist.
+ preserve_host_header: true
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
diff --git a/content/examples/guides/servarr/config.yaml.md b/content/examples/guides/servarr/config.yaml.md
new file mode 100644
index 000000000..fdff30dcd
--- /dev/null
+++ b/content/examples/guides/servarr/config.yaml.md
@@ -0,0 +1,48 @@
+```yaml title="config.yaml"
+# Pomerium Core configuration for a Servarr media-automation suite. Uses the hosted
+# authenticate service, so you don't run your own identity provider. To self-host the
+# IdP, see the Keycloak guide:
+# https://www.pomerium.com/docs/integrations/user-identity/oidc
+authenticate_service_url: https://authenticate.pomerium.app
+
+# Obtain TLS certificates automatically from Let's Encrypt.
+autocert: true
+
+# One route per app. Each app keeps its own API key, so these are front-door gates,
+# not header-trust integrations.
+routes:
+ - from: https://sonarr.yourdomain.com
+ to: http://sonarr:8989
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+
+ - from: https://radarr.yourdomain.com
+ to: http://radarr:7878
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+
+ - from: https://prowlarr.yourdomain.com
+ to: http://prowlarr:9696
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+
+ - from: https://sabnzbd.yourdomain.com
+ to: http://sabnzbd:8080
+ # SABnzbd validates the Host header against its host_whitelist, so forward the
+ # original host unchanged and add sabnzbd.yourdomain.com to that whitelist.
+ preserve_host_header: true
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+```
diff --git a/content/examples/guides/servarr/docker-compose.yaml b/content/examples/guides/servarr/docker-compose.yaml
new file mode 100644
index 000000000..a51c6a720
--- /dev/null
+++ b/content/examples/guides/servarr/docker-compose.yaml
@@ -0,0 +1,76 @@
+services:
+ pomerium:
+ image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c
+ volumes:
+ - ./config.yaml:/pomerium/config.yaml:ro
+ - pomerium-cache:/data
+ ports:
+ - 443:443
+ - 80:80
+ # Pomerium bridges both networks: `default` for autocert/Let's Encrypt and the
+ # hosted authenticate service, and `servarr-internal` to reach the apps.
+ networks:
+ - default
+ - servarr-internal
+ restart: always
+
+ sonarr:
+ image: lscr.io/linuxserver/sonarr:4.0.17@sha256:0b3f344388bd7bed4f2f770058de795e76447e4a481b83c8d5f8fed489371fde
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - sonarr-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+ radarr:
+ image: lscr.io/linuxserver/radarr:6.1.1@sha256:c0a4335d4249b46102f64cf6fa27ffc3bddfd9138fac1e4ddf238afd37f02d1f
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - radarr-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+ prowlarr:
+ image: lscr.io/linuxserver/prowlarr:2.3.5@sha256:2489c6dbaf11e3a6d71aeb2e6980d04193d4af611aa7064a974851222fd41722
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - prowlarr-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+ sabnzbd:
+ image: lscr.io/linuxserver/sabnzbd:5.0.3@sha256:3de84922d3b4c5e7062b3cbd1e08f57d8dc113a8be4dc0447d33e2da293bab26
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - sabnzbd-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+networks:
+ # The *arr apps publish no host ports, so Pomerium (which shares this network) is
+ # the only way in. Outbound access stays enabled so the apps can reach indexers,
+ # metadata providers, and Usenet servers.
+ servarr-internal: {}
+
+volumes:
+ pomerium-cache:
+ sonarr-config:
+ radarr-config:
+ prowlarr-config:
+ sabnzbd-config:
diff --git a/content/examples/guides/servarr/docker-compose.yaml.md b/content/examples/guides/servarr/docker-compose.yaml.md
new file mode 100644
index 000000000..46aee6e36
--- /dev/null
+++ b/content/examples/guides/servarr/docker-compose.yaml.md
@@ -0,0 +1,78 @@
+```yaml title="docker-compose.yaml"
+services:
+ pomerium:
+ image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c
+ volumes:
+ - ./config.yaml:/pomerium/config.yaml:ro
+ - pomerium-cache:/data
+ ports:
+ - 443:443
+ - 80:80
+ # Pomerium bridges both networks: `default` for autocert/Let's Encrypt and the
+ # hosted authenticate service, and `servarr-internal` to reach the apps.
+ networks:
+ - default
+ - servarr-internal
+ restart: always
+
+ sonarr:
+ image: lscr.io/linuxserver/sonarr:4.0.17@sha256:0b3f344388bd7bed4f2f770058de795e76447e4a481b83c8d5f8fed489371fde
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - sonarr-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+ radarr:
+ image: lscr.io/linuxserver/radarr:6.1.1@sha256:c0a4335d4249b46102f64cf6fa27ffc3bddfd9138fac1e4ddf238afd37f02d1f
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - radarr-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+ prowlarr:
+ image: lscr.io/linuxserver/prowlarr:2.3.5@sha256:2489c6dbaf11e3a6d71aeb2e6980d04193d4af611aa7064a974851222fd41722
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - prowlarr-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+ sabnzbd:
+ image: lscr.io/linuxserver/sabnzbd:5.0.3@sha256:3de84922d3b4c5e7062b3cbd1e08f57d8dc113a8be4dc0447d33e2da293bab26
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - sabnzbd-config:/config
+ networks:
+ - servarr-internal
+ restart: always
+
+networks:
+ # The *arr apps publish no host ports, so Pomerium (which shares this network) is
+ # the only way in. Outbound access stays enabled so the apps can reach indexers,
+ # metadata providers, and Usenet servers.
+ servarr-internal: {}
+
+volumes:
+ pomerium-cache:
+ sonarr-config:
+ radarr-config:
+ prowlarr-config:
+ sabnzbd-config:
+```
diff --git a/content/examples/guides/servarr/validate/assert.spec.ts b/content/examples/guides/servarr/validate/assert.spec.ts
new file mode 100644
index 000000000..063de1cfd
--- /dev/null
+++ b/content/examples/guides/servarr/validate/assert.spec.ts
@@ -0,0 +1,175 @@
+import { test, expect } from "@playwright/test";
+import { login, alice } from "../lib/authn";
+import { shot } from "../lib/shot";
+
+// Real end-to-end test for the Servarr suite behind a single Pomerium front door.
+// Pomerium makes the access decision for every app; each *arr app keeps its own
+// API key on top (it has no OIDC/JWT/header identity). This drives the whole
+// chain: an unauthenticated request is bounced to the IdP; an allowed user passes
+// the gate; behind the gate each app's status API answers 200 to its seeded key;
+// and the apps are unreachable except through Pomerium (network isolation).
+//
+// SERVARRTESTKEY0123456789 is the shared, test-only API key seeded into each app's
+// committed config (sonarr/radarr/prowlarr config.xml and sabnzbd.ini). It is a
+// fixture credential for localhost-only containers destroyed after the run, in the
+// same spirit as the harness's committed alice/password123 and pomerium-e2e-secret.
+const BASE = process.env.POMERIUM_URL as string;
+const API_KEY = "SERVARRTESTKEY0123456789";
+
+const RADARR = BASE.replace("sonarr.", "radarr.");
+const PROWLARR = BASE.replace("sonarr.", "prowlarr.");
+const SABNZBD = BASE.replace("sonarr.", "sabnzbd.");
+const KEYCLOAK_HOST = "keycloak.localhost.pomerium.io";
+
+// After the first interactive sign-in, the authenticate + Keycloak sessions are
+// warm, so reaching another app host completes single sign-on silently (no
+// credential prompt). Navigate and wait to land back on the app host; if Pomerium
+// briefly bounces through Keycloak, wait that out too.
+async function reach(page: import("@playwright/test").Page, url: string): Promise {
+ const host = new URL(url).hostname;
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
+ await page.waitForURL(
+ (u) => u.hostname === host && u.hostname !== KEYCLOAK_HOST,
+ { timeout: 30000 },
+ );
+}
+
+test("unauthenticated request is redirected to the identity provider", async ({ page }) => {
+ await page.goto(BASE, { waitUntil: "domcontentloaded" });
+ await expect(page).toHaveURL(/keycloak\.localhost\.pomerium\.io/);
+});
+
+test("an allowed user passes the gate and every app's API answers its seeded key", async ({
+ page,
+}) => {
+ // Interactive sign-in once, through the Sonarr route. This proves the gate and
+ // warms the SSO session reused for the other three apps.
+ await login(page, BASE, alice);
+
+ // Sonarr (TV). The status API only answers 200 to a request carrying the API key,
+ // so a 200 here proves both that Pomerium let us through and that the seeded key
+ // reached the app. The request rides the browser context, so it also carries the
+ // Pomerium session cookie set by the login above.
+ const sonarr = await page.request.get(
+ `${BASE}/api/v3/system/status?apiKey=${API_KEY}`,
+ { ignoreHTTPSErrors: true },
+ );
+ expect(sonarr.status(), "Sonarr status API should answer 200 to the seeded key").toBe(200);
+ expect((await sonarr.json()).appName, "Sonarr should identify itself").toBe("Sonarr");
+
+ // Radarr (Movies).
+ await reach(page, RADARR);
+ const radarr = await page.request.get(
+ `${RADARR}/api/v3/system/status?apiKey=${API_KEY}`,
+ { ignoreHTTPSErrors: true },
+ );
+ expect(radarr.status(), "Radarr status API should answer 200 to the seeded key").toBe(200);
+ expect((await radarr.json()).appName, "Radarr should identify itself").toBe("Radarr");
+
+ // Prowlarr (indexers). Prowlarr's API is versioned v1, unlike Sonarr/Radarr (v3).
+ await reach(page, PROWLARR);
+ const prowlarr = await page.request.get(
+ `${PROWLARR}/api/v1/system/status?apiKey=${API_KEY}`,
+ { ignoreHTTPSErrors: true },
+ );
+ expect(prowlarr.status(), "Prowlarr status API should answer 200 to the seeded key").toBe(200);
+ expect((await prowlarr.json()).appName, "Prowlarr should identify itself").toBe("Prowlarr");
+
+ // SABnzbd (usenet). Its `mode=version` is public, so assert on `mode=queue`,
+ // which returns "API Key Required" without the key and the queue JSON with it.
+ await reach(page, SABNZBD);
+ const sab = await page.request.get(
+ `${SABNZBD}/api?mode=queue&output=json&apikey=${API_KEY}`,
+ { ignoreHTTPSErrors: true },
+ );
+ expect(sab.status(), "SABnzbd queue API should answer 200 to the seeded key").toBe(200);
+ expect((await sab.json()).queue, "SABnzbd should return its queue object").toBeTruthy();
+
+ // Capture a *arr UI rendered behind the Pomerium gate for the guide. Sonarr's
+ // SPA polls its API continuously, so wait on the rendered chrome rather than
+ // network idle (which never settles). The selector wait is best-effort so a
+ // future UI markup change can't fail the gate assertion that already passed.
+ await reach(page, BASE);
+ await page.waitForLoadState("domcontentloaded");
+ await page.waitForSelector("header, nav, [class*='Page']", { timeout: 10000 }).catch(() => {});
+ await shot(page, "servarr-sonarr");
+});
+
+test("each app's own API key gates its API, independent of the Pomerium session", async ({
+ page,
+}) => {
+ // The guide's load-bearing claim is that signing in through Pomerium does NOT
+ // authenticate you to an app's API: the API key is the app's own, separate
+ // credential. Prove it. Even past the Pomerium gate (authenticated session in
+ // this context), a request that omits the key must be rejected by the app itself.
+ // Sonarr/Radarr/Prowlarr answer 401; SABnzbd answers 200 with an "API Key
+ // Required" error body (its API never uses HTTP status for auth failures).
+ await login(page, BASE, alice);
+
+ const sonarr = await page.request.get(`${BASE}/api/v3/system/status`, {
+ ignoreHTTPSErrors: true,
+ });
+ expect(sonarr.status(), "Sonarr must reject an API call with no key").toBe(401);
+
+ await reach(page, RADARR);
+ const radarr = await page.request.get(`${RADARR}/api/v3/system/status`, {
+ ignoreHTTPSErrors: true,
+ });
+ expect(radarr.status(), "Radarr must reject an API call with no key").toBe(401);
+
+ await reach(page, PROWLARR);
+ const prowlarr = await page.request.get(`${PROWLARR}/api/v1/system/status`, {
+ ignoreHTTPSErrors: true,
+ });
+ expect(prowlarr.status(), "Prowlarr must reject an API call with no key").toBe(401);
+
+ await reach(page, SABNZBD);
+ const sab = await page.request.get(`${SABNZBD}/api?mode=queue&output=json`, {
+ ignoreHTTPSErrors: true,
+ });
+ const sabBody = await sab.text();
+ expect(
+ sabBody,
+ "SABnzbd must reject an authenticated-but-keyless API call",
+ ).toMatch(/API Key Required/i);
+});
+
+test("the *arr apps are not reachable except through Pomerium", async ({ page }) => {
+ // Positive control first: Sonarr IS serving through Pomerium after SSO. This
+ // proves the service is up, so the direct-hit failure below is caused by network
+ // topology, not a dead/typo'd endpoint.
+ await login(page, BASE, alice);
+ const viaPomerium = await page.request.get(
+ `${BASE}/api/v3/system/status?apiKey=${API_KEY}`,
+ { ignoreHTTPSErrors: true },
+ );
+ expect(viaPomerium.ok(), "the route through Pomerium should work").toBeTruthy();
+
+ // Each app's only built-in API auth is a static key, so network isolation is
+ // the real trust boundary and must be real: the apps sit on an internal-only
+ // network shared with Pomerium alone. The test-runner is not on that network,
+ // so direct hits must fail at name resolution / connection, NOT with HTTP
+ // responses. Asserting the specific error keeps a typo or a down service from
+ // masquerading as isolation.
+ const directUrls = [
+ "http://sonarr:8989/api/v3/system/status",
+ "http://radarr:7878/api/v3/system/status",
+ "http://prowlarr:9696/api/v1/system/status",
+ "http://sabnzbd:8080/api?mode=queue&output=json",
+ ];
+ for (const url of directUrls) {
+ let directError = "";
+ try {
+ await page.request.get(url, {
+ ignoreHTTPSErrors: true,
+ timeout: 5000,
+ });
+ } catch (e) {
+ directError = String(e);
+ }
+ expect(
+ directError,
+ `${url} must be unreachable directly; the only path in is through Pomerium`,
+ ).toMatch(/ENOTFOUND|getaddrinfo|EAI_AGAIN|ECONNREFUSED/i);
+ }
+});
diff --git a/content/examples/guides/servarr/validate/compose.validate.yaml b/content/examples/guides/servarr/validate/compose.validate.yaml
new file mode 100644
index 000000000..f1edebe82
--- /dev/null
+++ b/content/examples/guides/servarr/validate/compose.validate.yaml
@@ -0,0 +1,187 @@
+# Sealed E2E validation for the Servarr suite guide. Reuses the shared harness
+# (Keycloak + certs + headless runner) and adds Sonarr, Radarr, Prowlarr, and
+# SABnzbd behind a single Pomerium wired to the in-network IdP.
+# Run with: scripts/validate-guide-fixtures.sh servarr
+#
+# Network isolation mirrors the guide's trust boundary: the *arr apps sit ONLY on
+# the internal-only `servarr-internal` network, shared with pomerium alone. The
+# test-runner is on `default`, so it cannot reach the apps directly (the spec proves
+# this); the only path in is through Pomerium, which bridges both networks.
+#
+# Each app's API key is seeded by a one-shot init container that copies a pristine
+# config into the app's /config volume. The *arr apps rewrite their config on
+# startup (schema migration), so they can't run off a read-only mount; seeding via a
+# volume keeps the committed fixture pristine. The shared test-only key is
+# SERVARRTESTKEY0123456789. Never reuse it anywhere real.
+
+include:
+ - ../../_harness/compose/compose.harness.yaml
+
+services:
+ sonarr-init:
+ image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
+ volumes:
+ - sonarr-config:/config
+ - ./seed/sonarr.config.xml:/seed/config.xml:ro
+ command: ['/bin/sh', '-c', 'cp /seed/config.xml /config/config.xml']
+
+ radarr-init:
+ image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
+ volumes:
+ - radarr-config:/config
+ - ./seed/radarr.config.xml:/seed/config.xml:ro
+ command: ['/bin/sh', '-c', 'cp /seed/config.xml /config/config.xml']
+
+ prowlarr-init:
+ image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
+ volumes:
+ - prowlarr-config:/config
+ - ./seed/prowlarr.config.xml:/seed/config.xml:ro
+ command: ['/bin/sh', '-c', 'cp /seed/config.xml /config/config.xml']
+
+ sabnzbd-init:
+ image: alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
+ volumes:
+ - sabnzbd-config:/config
+ - ./seed/sabnzbd.ini:/seed/sabnzbd.ini:ro
+ command: ['/bin/sh', '-c', 'cp /seed/sabnzbd.ini /config/sabnzbd.ini']
+
+ sonarr:
+ image: lscr.io/linuxserver/sonarr:4.0.17@sha256:0b3f344388bd7bed4f2f770058de795e76447e4a481b83c8d5f8fed489371fde
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - sonarr-config:/config
+ depends_on:
+ sonarr-init:
+ condition: service_completed_successfully
+ networks:
+ servarr-internal:
+ aliases:
+ - sonarr
+ healthcheck:
+ test: ['CMD', 'curl', '-fsS', 'http://localhost:8989/ping']
+ interval: 5s
+ timeout: 5s
+ retries: 60
+ start_period: 60s
+
+ radarr:
+ image: lscr.io/linuxserver/radarr:6.1.1@sha256:c0a4335d4249b46102f64cf6fa27ffc3bddfd9138fac1e4ddf238afd37f02d1f
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - radarr-config:/config
+ depends_on:
+ radarr-init:
+ condition: service_completed_successfully
+ networks:
+ servarr-internal:
+ aliases:
+ - radarr
+ healthcheck:
+ test: ['CMD', 'curl', '-fsS', 'http://localhost:7878/ping']
+ interval: 5s
+ timeout: 5s
+ retries: 60
+ start_period: 60s
+
+ prowlarr:
+ image: lscr.io/linuxserver/prowlarr:2.3.5@sha256:2489c6dbaf11e3a6d71aeb2e6980d04193d4af611aa7064a974851222fd41722
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - prowlarr-config:/config
+ depends_on:
+ prowlarr-init:
+ condition: service_completed_successfully
+ networks:
+ servarr-internal:
+ aliases:
+ - prowlarr
+ healthcheck:
+ test: ['CMD', 'curl', '-fsS', 'http://localhost:9696/ping']
+ interval: 5s
+ timeout: 5s
+ retries: 60
+ start_period: 60s
+
+ sabnzbd:
+ image: lscr.io/linuxserver/sabnzbd:5.0.3@sha256:3de84922d3b4c5e7062b3cbd1e08f57d8dc113a8be4dc0447d33e2da293bab26
+ environment:
+ PUID: 1000
+ PGID: 1000
+ TZ: Etc/UTC
+ volumes:
+ - sabnzbd-config:/config
+ depends_on:
+ sabnzbd-init:
+ condition: service_completed_successfully
+ networks:
+ servarr-internal:
+ aliases:
+ - sabnzbd
+ healthcheck:
+ test:
+ ['CMD', 'curl', '-fsS', 'http://localhost:8080/api?mode=version&output=json']
+ interval: 5s
+ timeout: 5s
+ retries: 60
+ start_period: 60s
+
+ pomerium:
+ image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c
+ command: ['--config', '/pomerium/config.yaml']
+ env_file:
+ - ../../_harness/pomerium/validation.env
+ volumes:
+ - certs:/certs:ro
+ - ./routes.validate.yaml:/pomerium/config.yaml:ro
+ depends_on:
+ certs-init:
+ condition: service_completed_successfully
+ keycloak:
+ condition: service_healthy
+ sonarr:
+ condition: service_healthy
+ radarr:
+ condition: service_healthy
+ prowlarr:
+ condition: service_healthy
+ sabnzbd:
+ condition: service_healthy
+ networks:
+ # On `default` for the IdP + test-runner, and on the internal-only network to
+ # reach the apps. This is the only service bridging the two.
+ default:
+ aliases:
+ - authenticate.localhost.pomerium.io
+ - sonarr.localhost.pomerium.io
+ - radarr.localhost.pomerium.io
+ - prowlarr.localhost.pomerium.io
+ - sabnzbd.localhost.pomerium.io
+ servarr-internal: {}
+ healthcheck:
+ test: ['CMD', 'pomerium', 'health']
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ start_period: 10s
+
+networks:
+ # Internal-only: no route to the outside, and the test-runner is not attached,
+ # so the *arr apps are reachable only via Pomerium.
+ servarr-internal:
+ internal: true
+
+volumes:
+ sonarr-config:
+ radarr-config:
+ prowlarr-config:
+ sabnzbd-config:
diff --git a/content/examples/guides/servarr/validate/routes.validate.yaml b/content/examples/guides/servarr/validate/routes.validate.yaml
new file mode 100644
index 000000000..4eb6b8684
--- /dev/null
+++ b/content/examples/guides/servarr/validate/routes.validate.yaml
@@ -0,0 +1,34 @@
+# Routes-only validation config. Shared settings (IdP, secrets, signing key, certs)
+# come from ../../_harness/pomerium/validation.env via env_file in compose.validate.yaml.
+# Each *arr app runs its own auth (an API key), so the route is a front-door gate.
+routes:
+ - from: https://sonarr.localhost.pomerium.io
+ to: http://sonarr:8989
+ policy:
+ - allow:
+ or:
+ - authenticated_user: true
+
+ - from: https://radarr.localhost.pomerium.io
+ to: http://radarr:7878
+ policy:
+ - allow:
+ or:
+ - authenticated_user: true
+
+ - from: https://prowlarr.localhost.pomerium.io
+ to: http://prowlarr:9696
+ policy:
+ - allow:
+ or:
+ - authenticated_user: true
+
+ - from: https://sabnzbd.localhost.pomerium.io
+ to: http://sabnzbd:8080
+ # SABnzbd validates the Host header against its host_whitelist, so forward the
+ # original host unchanged.
+ preserve_host_header: true
+ policy:
+ - allow:
+ or:
+ - authenticated_user: true
diff --git a/content/examples/guides/servarr/validate/seed/prowlarr.config.xml b/content/examples/guides/servarr/validate/seed/prowlarr.config.xml
new file mode 100644
index 000000000..0836142a9
--- /dev/null
+++ b/content/examples/guides/servarr/validate/seed/prowlarr.config.xml
@@ -0,0 +1,13 @@
+
+ *
+ 9696
+ False
+ False
+ SERVARRTESTKEY0123456789
+ External
+ DisabledForLocalAddresses
+ master
+ info
+
+ Prowlarr
+
diff --git a/content/examples/guides/servarr/validate/seed/radarr.config.xml b/content/examples/guides/servarr/validate/seed/radarr.config.xml
new file mode 100644
index 000000000..a4ab87cd9
--- /dev/null
+++ b/content/examples/guides/servarr/validate/seed/radarr.config.xml
@@ -0,0 +1,13 @@
+
+ *
+ 7878
+ False
+ False
+ SERVARRTESTKEY0123456789
+ External
+ DisabledForLocalAddresses
+ master
+ info
+
+ Radarr
+
diff --git a/content/examples/guides/servarr/validate/seed/sabnzbd.ini b/content/examples/guides/servarr/validate/seed/sabnzbd.ini
new file mode 100644
index 000000000..1b2e1e52b
--- /dev/null
+++ b/content/examples/guides/servarr/validate/seed/sabnzbd.ini
@@ -0,0 +1,331 @@
+__version__ = 19
+__encoding__ = utf-8
+[misc]
+config_conversion_version = 5
+helpful_warnings = 1
+queue_complete = ""
+queue_complete_pers = 0
+bandwidth_perc = 100
+refresh_rate = 0
+interface_settings = ""
+queue_limit = 20
+config_lock = 0
+fixed_ports = 1
+notified_new_skin = 0
+direct_unpack_tested = 0
+sorters_converted = 1
+check_new_rel = 1
+auto_browser = 0
+language = en
+enable_https_verification = 1
+host = ::
+port = 8080
+https_port = ""
+username = ""
+password = ""
+bandwidth_max = ""
+cache_limit = 1G
+web_dir = Glitter
+web_color = Auto
+https_cert = server.cert
+https_key = server.key
+https_chain = ""
+enable_https = 0
+inet_exposure = 4
+api_key = SERVARRTESTKEY0123456789
+nzb_key = deea96e976a44e00bd3b2f48fa544503
+socks5_proxy_url = ""
+permissions = ""
+download_dir = Downloads/incomplete
+download_free = 500M
+complete_dir = Downloads/complete
+complete_free = ""
+fulldisk_autoresume = 0
+script_dir = ""
+nzb_backup_dir = ""
+admin_dir = admin
+backup_dir = ""
+dirscan_dir = ""
+dirscan_speed = 5
+password_file = ""
+log_dir = logs
+max_art_tries = 3
+top_only = 0
+sfv_check = 1
+script_can_fail = 0
+enable_recursive = 1
+flat_unpack = 0
+par_option = ""
+pre_check = 0
+nice = ""
+win_process_prio = 3
+ionice = ""
+fail_hopeless_jobs = 1
+fast_fail = 1
+auto_disconnect = 1
+pre_script = None
+end_queue_script = None
+no_dupes = 0
+no_series_dupes = 0
+no_smart_dupes = 0
+dupes_propercheck = 1
+pause_on_pwrar = 1
+ignore_samples = 0
+deobfuscate_final_filenames = 1
+auto_sort = ""
+direct_unpack = 0
+propagation_delay = 0
+folder_rename = 1
+replace_spaces = 0
+replace_underscores = 0
+replace_dots = 0
+safe_postproc = 1
+pause_on_post_processing = 0
+enable_all_par = 0
+sanitize_safe = 0
+cleanup_list = ,
+unwanted_extensions = ,
+action_on_unwanted_extensions = 0
+unwanted_extensions_mode = 0
+new_nzb_on_failure = 0
+history_retention = ""
+history_retention_option = all
+history_retention_number = 0
+quota_size = ""
+quota_day = ""
+quota_resume = 0
+quota_period = m
+enable_tv_sorting = 0
+tv_sort_string = ""
+tv_categories = tv,
+enable_movie_sorting = 0
+movie_sort_string = ""
+movie_sort_extra = -cd%1
+movie_categories = movies,
+enable_date_sorting = 0
+date_sort_string = ""
+date_categories = tv,
+schedlines = ,
+rss_rate = 60
+ampm = 0
+start_paused = 0
+preserve_paused_state = 0
+enable_par_cleanup = 1
+process_unpacked_par2 = 1
+enable_unrar = 1
+enable_7zip = 1
+enable_filejoin = 1
+enable_tsjoin = 1
+overwrite_files = 0
+ignore_unrar_dates = 0
+backup_for_duplicates = 0
+wait_for_dfolder = 0
+rss_filenames = 0
+api_logging = 1
+html_login = 1
+disable_archive = 0
+warn_dupl_jobs = 0
+keep_awake = 1
+tray_icon = 1
+allow_incomplete_nzb = 0
+enable_broadcast = 1
+ipv6_hosting = 0
+ipv6_staging = 0
+api_warnings = 1
+no_penalties = 0
+x_frame_options = 1
+allow_old_ssl_tls = 0
+enable_season_sorting = 1
+verify_xff_header = 1
+direct_write = 1
+rss_odd_titles = nzbindex.nl/, nzbindex.com/, nzbclub.com/
+quick_check_ext_ignore = nfo, sfv, srr
+req_completion_rate = 100.2
+selftest_host = self-test.sabnzbd.org
+movie_rename_limit = 100M
+episode_rename_limit = 20M
+size_limit = 0
+direct_unpack_threads = 3
+history_limit = 10
+wait_ext_drive = 5
+max_foldername_length = 246
+nomedia_marker = ""
+ipv6_servers = 1
+url_base = ""
+host_whitelist = sabnzbd.localhost.pomerium.io, sabnzbd, localhost, 127.0.0.1,
+local_ranges = ,
+max_url_retries = 10
+downloader_sleep_time = 10
+receive_threads = 2
+assembler_max_queue_size = 12
+switchinterval = 0.005
+ssdp_broadcast_interval = 15
+ext_rename_ignore = ,
+unrar_parameters = ""
+outgoing_nntp_ip = ""
+email_server = ""
+email_to = ,
+email_from = ""
+email_account = ""
+email_pwd = ""
+email_endjob = 0
+email_full = 0
+email_dir = ""
+email_rss = 0
+email_cats = *,
+[logging]
+log_level = 1
+max_log_size = 5242880
+log_backups = 5
+[ncenter]
+ncenter_enable = 0
+ncenter_cats = *,
+ncenter_prio_startup = 0
+ncenter_prio_download = 0
+ncenter_prio_pause_resume = 0
+ncenter_prio_pp = 0
+ncenter_prio_complete = 1
+ncenter_prio_failed = 1
+ncenter_prio_disk_full = 1
+ncenter_prio_quota = 1
+ncenter_prio_new_login = 0
+ncenter_prio_warning = 0
+ncenter_prio_error = 0
+ncenter_prio_queue_done = 0
+ncenter_prio_other = 1
+[acenter]
+acenter_enable = 0
+acenter_cats = *,
+acenter_prio_startup = 0
+acenter_prio_download = 0
+acenter_prio_pause_resume = 0
+acenter_prio_pp = 0
+acenter_prio_complete = 1
+acenter_prio_failed = 1
+acenter_prio_disk_full = 1
+acenter_prio_quota = 1
+acenter_prio_new_login = 0
+acenter_prio_warning = 0
+acenter_prio_error = 0
+acenter_prio_queue_done = 0
+acenter_prio_other = 1
+[ntfosd]
+ntfosd_enable = 1
+ntfosd_cats = *,
+ntfosd_prio_startup = 0
+ntfosd_prio_download = 0
+ntfosd_prio_pause_resume = 0
+ntfosd_prio_pp = 0
+ntfosd_prio_complete = 1
+ntfosd_prio_failed = 1
+ntfosd_prio_disk_full = 1
+ntfosd_prio_quota = 1
+ntfosd_prio_new_login = 0
+ntfosd_prio_warning = 0
+ntfosd_prio_error = 0
+ntfosd_prio_queue_done = 0
+ntfosd_prio_other = 1
+[prowl]
+prowl_enable = 0
+prowl_cats = *,
+prowl_apikey = ""
+prowl_prio_startup = -3
+prowl_prio_download = -3
+prowl_prio_pause_resume = -3
+prowl_prio_pp = -3
+prowl_prio_complete = 0
+prowl_prio_failed = 1
+prowl_prio_disk_full = 1
+prowl_prio_quota = 0
+prowl_prio_new_login = -3
+prowl_prio_warning = -3
+prowl_prio_error = -3
+prowl_prio_queue_done = -3
+prowl_prio_other = 0
+[pushover]
+pushover_token = ""
+pushover_userkey = ""
+pushover_device = ""
+pushover_emergency_expire = 3600
+pushover_emergency_retry = 60
+pushover_enable = 0
+pushover_cats = *,
+pushover_prio_startup = -3
+pushover_prio_download = -2
+pushover_prio_pause_resume = -2
+pushover_prio_pp = -3
+pushover_prio_complete = -1
+pushover_prio_failed = -1
+pushover_prio_disk_full = 1
+pushover_prio_quota = -1
+pushover_prio_new_login = -3
+pushover_prio_warning = 1
+pushover_prio_error = 1
+pushover_prio_queue_done = -3
+pushover_prio_other = -1
+[pushbullet]
+pushbullet_enable = 0
+pushbullet_cats = *,
+pushbullet_apikey = ""
+pushbullet_device = ""
+pushbullet_prio_startup = 0
+pushbullet_prio_download = 0
+pushbullet_prio_pause_resume = 0
+pushbullet_prio_pp = 0
+pushbullet_prio_complete = 1
+pushbullet_prio_failed = 1
+pushbullet_prio_disk_full = 1
+pushbullet_prio_quota = 1
+pushbullet_prio_new_login = 0
+pushbullet_prio_warning = 0
+pushbullet_prio_error = 0
+pushbullet_prio_queue_done = 0
+pushbullet_prio_other = 1
+[apprise]
+apprise_enable = 0
+apprise_cats = *,
+apprise_urls = ""
+apprise_target_startup = ""
+apprise_target_startup_enable = 0
+apprise_target_download = ""
+apprise_target_download_enable = 0
+apprise_target_pause_resume = ""
+apprise_target_pause_resume_enable = 0
+apprise_target_pp = ""
+apprise_target_pp_enable = 0
+apprise_target_complete = ""
+apprise_target_complete_enable = 1
+apprise_target_failed = ""
+apprise_target_failed_enable = 1
+apprise_target_disk_full = ""
+apprise_target_disk_full_enable = 0
+apprise_target_quota = ""
+apprise_target_quota_enable = 1
+apprise_target_new_login = ""
+apprise_target_new_login_enable = 1
+apprise_target_warning = ""
+apprise_target_warning_enable = 0
+apprise_target_error = ""
+apprise_target_error_enable = 0
+apprise_target_queue_done = ""
+apprise_target_queue_done_enable = 0
+apprise_target_other = ""
+apprise_target_other_enable = 1
+[nscript]
+nscript_enable = 0
+nscript_cats = *,
+nscript_script = ""
+nscript_parameters = ""
+nscript_prio_startup = 0
+nscript_prio_download = 0
+nscript_prio_pause_resume = 0
+nscript_prio_pp = 0
+nscript_prio_complete = 1
+nscript_prio_failed = 1
+nscript_prio_disk_full = 1
+nscript_prio_quota = 1
+nscript_prio_new_login = 0
+nscript_prio_warning = 0
+nscript_prio_error = 0
+nscript_prio_queue_done = 0
+nscript_prio_other = 1
diff --git a/content/examples/guides/servarr/validate/seed/sonarr.config.xml b/content/examples/guides/servarr/validate/seed/sonarr.config.xml
new file mode 100644
index 000000000..0b328e2bc
--- /dev/null
+++ b/content/examples/guides/servarr/validate/seed/sonarr.config.xml
@@ -0,0 +1,13 @@
+
+ *
+ 8989
+ False
+ False
+ SERVARRTESTKEY0123456789
+ External
+ DisabledForLocalAddresses
+ main
+ info
+
+ Sonarr
+
diff --git a/content/examples/guides/servarr/validate/url.txt b/content/examples/guides/servarr/validate/url.txt
new file mode 100644
index 000000000..caad1e0ec
--- /dev/null
+++ b/content/examples/guides/servarr/validate/url.txt
@@ -0,0 +1 @@
+https://sonarr.localhost.pomerium.io