diff --git a/content/docs/guides/img/paperless-ngx/paperless-login.png b/content/docs/guides/img/paperless-ngx/paperless-login.png
new file mode 100644
index 000000000..b31d666ab
Binary files /dev/null and b/content/docs/guides/img/paperless-ngx/paperless-login.png differ
diff --git a/content/docs/guides/paperless-ngx.mdx b/content/docs/guides/paperless-ngx.mdx
new file mode 100644
index 000000000..5c48ef01e
--- /dev/null
+++ b/content/docs/guides/paperless-ngx.mdx
@@ -0,0 +1,148 @@
+---
+title: Secure Paperless-ngx with Pomerium
+sidebar_label: Paperless-ngx
+lang: en-US
+keywords:
+ [
+ pomerium,
+ paperless-ngx,
+ paperless,
+ sso,
+ document management,
+ identity aware proxy,
+ self-hosted,
+ django,
+ ]
+description: Put self-hosted Paperless-ngx behind Pomerium so every request is authenticated and authorized at the front door before it reaches your documents.
+# cSpell:ignore paperless ngx
+---
+
+import TabItem from '@theme/TabItem';
+import Tabs from '@theme/Tabs';
+
+import Config from '/content/examples/guides/paperless-ngx/config.yaml.md';
+import Compose from '/content/examples/guides/paperless-ngx/docker-compose.yaml.md';
+
+# Secure Paperless-ngx with Pomerium
+
+## What this guide does
+
+Put a self-hosted [Paperless-ngx](https://docs.paperless-ngx.com/) instance behind Pomerium so every request is authenticated against your identity provider (IdP) and checked against the route policy before it reaches Paperless-ngx; unauthenticated requests are blocked at the front door. You get single sign-on (SSO), group-based policy, and an audit log of who reached the route. Paperless-ngx keeps its own login and per-user document permissions on top.
+
+```mermaid
+flowchart LR
+ Browser --> Pomerium["Pomerium
SSO + route policy"]
+ Pomerium -.->|"sign in"| IdP[Identity provider]
+ Pomerium --> Paperless["Paperless-ngx
own login + permissions"]
+```
+
+Paperless-ngx is a document management system that stores scanned and digitized records, often a household's or a company's most sensitive paperwork: tax filings, contracts, medical records, and IDs. That makes it a high-value target to keep off the open internet.
+
+## When to use this guide
+
+Use it when you run self-hosted Paperless-ngx and want only people from your organization to reach it. This guide layers Pomerium in front of Paperless-ngx's stock login; if you want Pomerium to sign users into Paperless-ngx directly, Paperless-ngx supports trusted-header SSO natively (see [Next steps](#next-steps)).
+
+## 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 route uses the starter domain that comes with it
+- For the Pomerium Core path: a domain you control for the route (this guide uses `paperless.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
+
+This guide was last tested with Paperless-ngx 2.18.4 and Pomerium 0.32.7.
+
+:::tip Prefer to self-host the identity provider?
+
+This guide uses the hosted authenticate service so you don't have to run an 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
+
+
+
+
+In the [Zero Console](https://console.pomerium.app):
+
+1. Create a **Route**. In **From**, enter `https://paperless.`; in **To**, enter `http://paperless:8000`.
+2. On the route's settings, enable **Preserve Host Header**. Paperless-ngx is a Django application that validates the incoming `Host` against its `ALLOWED_HOSTS` (derived from `PAPERLESS_URL`) and uses it for cross-site request forgery (CSRF) checks, so the original host must reach Paperless-ngx unchanged.
+3. Set the policy to scope access to who should reach Paperless-ngx (for example, **Any Authenticated User** or a specific group or domain).
+
+
+
+
+Create a `config.yaml`. It routes `paperless.yourdomain.com` to the Paperless-ngx container and preserves the host header so Django's `ALLOWED_HOSTS` and CSRF checks pass.
+
+
+
+Replace `paperless.yourdomain.com` with your domain and `you@example.com` with the email (or switch to a group or domain match) that should be allowed through.
+
+
+
+
+## Configure Paperless-ngx
+
+Paperless-ngx runs as a Django application backed by PostgreSQL and Redis. Pomerium terminates TLS at the front door, so Paperless-ngx serves plain HTTP on the internal Docker network. The key settings in the Compose file below:
+
+- `PAPERLESS_URL: https://paperless.yourdomain.com`: Paperless-ngx derives Django's `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from this. It must equal the route's **From** URL, or Django answers `HTTP 400` to every request that arrives behind the proxy.
+- `PAPERLESS_REDIS` and the `PAPERLESS_DB*` values: point Paperless-ngx at the Redis broker and PostgreSQL database that ship in the same Compose file.
+- `PAPERLESS_SECRET_KEY`: Django's signing key. Generate your own with `openssl rand -base64 48`; never reuse the placeholder.
+- `PAPERLESS_ADMIN_USER` / `PAPERLESS_ADMIN_PASSWORD`: bootstrap the first superuser on first startup.
+
+The Compose file runs Pomerium Core alongside Paperless-ngx, PostgreSQL, and Redis. For Zero, drop the Core `pomerium` service, keep `paperless`, `db`, and `redis` on `paperless-internal`, and attach your Zero `pomerium` service (the [Quickstart](/docs/get-started/quickstart) Compose service with your `POMERIUM_ZERO_TOKEN`) to `paperless-internal` so it can resolve `paperless` by name. On the Zero path, also set `PAPERLESS_URL` to the route's **From** URL, `https://paperless.`, so Django's host and CSRF checks accept the proxied requests.
+
+
+
+## Run the stack
+
+Start the stack:
+
+```bash
+docker compose up -d
+```
+
+Paperless-ngx runs database migrations and builds its search index on first boot, so the container can take a couple of minutes before it answers requests. Watch `docker compose logs -f paperless` until it reports that the web server is listening.
+
+## Verify the setup
+
+1. **The route requires authentication.** In a fresh browser, open `https://paperless.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight to Paperless-ngx.
+2. **An allowed user reaches Paperless-ngx.** Sign in with a user your policy allows. Pomerium redirects you back and Paperless-ngx's own sign-in page loads.
+
+
+
+3. **Sign in to Paperless-ngx.** Use the admin account you bootstrapped. Paperless-ngx authenticates you and lands you on its document dashboard, served through Pomerium.
+4. **A request that bypasses Pomerium fails.** In the Compose file above, Paperless-ngx sits on an internal-only Docker network with no published host ports, so a direct probe of the upstream cannot resolve or connect; the only path in is through Pomerium.
+
+When you're done testing, stop the stack with `docker compose down`. Add `-v` only if you mean to delete the database, media, Redis, and credential volumes.
+
+## What Pomerium protects — and what it doesn't
+
+Everything in this guide lives on one host behind one route, so Pomerium's SSO and policy stand in front of every way into Paperless-ngx:
+
+| Access channel | What gates it | Credential the client presents |
+| --- | --- | --- |
+| Web interface in a browser | Pomerium route policy, then Paperless-ngx's login | Pomerium SSO session, then a Paperless-ngx login |
+| REST API | The same Pomerium route; API clients can't complete browser SSO, so the route blocks them | Paperless-ngx API token, on a path you deliberately provide |
+| Mobile scanner apps | The same Pomerium route, with the same constraint as the API | Stored Paperless-ngx credentials or API token |
+
+API clients and scanner apps authenticate to Paperless-ngx directly and can't complete browser SSO, so they don't work through this route. If you need them, the options are a separate [public access](/docs/reference/routes/public-access) route (not identity-protected, so Paperless-ngx's own auth becomes the only control), [a TCP tunnel](/docs/capabilities/non-http), or access over the private network. API clients that can send custom headers have one more option on Pomerium Zero or Enterprise: authenticate to this protected route with a [Pomerium service account](/docs/capabilities/service-accounts) token, with Paperless-ngx's API token authorizing the call as usual.
+
+## Common failure modes
+
+- **`HTTP 400 Bad Request` on every page.** `PAPERLESS_URL` doesn't match the route's **From** URL, so Django rejects the host. Set `PAPERLESS_URL` to exactly `https://paperless.yourdomain.com` and make sure `preserve_host_header` is enabled on the route.
+- **Redirects or links point at the container name or the wrong host.** `preserve_host_header` isn't set, so Paperless-ngx sees `paperless:8000` instead of the public name. Enable it on the route.
+- **`502` or `503` right after `docker compose up`.** Paperless-ngx hasn't finished its first-boot migrations and search-index build yet. Wait until `docker compose logs -f paperless` shows the web server listening; first boot routinely takes a couple of minutes.
+- **CSRF verification failures when signing in or uploading.** The browser's `Origin` doesn't match Django's `CSRF_TRUSTED_ORIGINS`. This is the same root cause as the `400` above: keep `PAPERLESS_URL` and the route host identical, over HTTPS.
+
+## Security considerations
+
+- **Don't expose Paperless-ngx directly**: only Pomerium should reach `paperless:8000`. The Compose file keeps Paperless-ngx (and its PostgreSQL and Redis) on an internal-only Docker network with no published host ports, so the only path in is through Pomerium and the policy can't be bypassed.
+- Scope the route policy (group or domain) to who should have any access to Paperless-ngx at all. Paperless-ngx's per-user document permissions still apply on top of that.
+- Paperless-ngx exposes an API and admin interface under the same host as the web interface. Because the whole host sits behind Pomerium, those surfaces inherit the same SSO and policy; don't add a second public route that bypasses them.
+- Generate a unique `PAPERLESS_SECRET_KEY` and strong database and admin passwords. The placeholders in this guide are examples, not safe defaults.
+
+## Next steps
+
+- **Let Pomerium sign users in.** Paperless-ngx supports trusted-header SSO ([`PAPERLESS_ENABLE_HTTP_REMOTE_USER`](https://docs.paperless-ngx.com/configuration/)). Set `pass_identity_headers: true` on the route so Pomerium forwards the verified identity as an `X-Pomerium-Claim-*` header, then point `PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME` at that header so Paperless-ngx logs the user in directly instead of keeping a separate login. Only do this when Pomerium is the sole path in and strips any client-supplied copy of that header.
+- [Build policies](/docs/get-started/fundamentals/zero/zero-build-policies)
+- [Custom domains](/docs/capabilities/custom-domains)
+- [Self-host the identity provider](/docs/integrations/user-identity/oidc)
diff --git a/content/examples/guides/paperless-ngx/config.yaml b/content/examples/guides/paperless-ngx/config.yaml
new file mode 100644
index 000000000..b74c6a48d
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/config.yaml
@@ -0,0 +1,20 @@
+# Pomerium Core configuration for Paperless-ngx. 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
+
+routes:
+ - from: https://paperless.yourdomain.com
+ to: http://paperless:8000
+ # Paperless-ngx is a Django app: it validates the Host header against
+ # ALLOWED_HOSTS (derived from PAPERLESS_URL) and uses it for CSRF checks, so
+ # forward the original Host unchanged or it answers HTTP 400.
+ preserve_host_header: true
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
diff --git a/content/examples/guides/paperless-ngx/config.yaml.md b/content/examples/guides/paperless-ngx/config.yaml.md
new file mode 100644
index 000000000..ed7c82402
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/config.yaml.md
@@ -0,0 +1,22 @@
+```yaml title="config.yaml"
+# Pomerium Core configuration for Paperless-ngx. 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
+
+routes:
+ - from: https://paperless.yourdomain.com
+ to: http://paperless:8000
+ # Paperless-ngx is a Django app: it validates the Host header against
+ # ALLOWED_HOSTS (derived from PAPERLESS_URL) and uses it for CSRF checks, so
+ # forward the original Host unchanged or it answers HTTP 400.
+ preserve_host_header: true
+ policy:
+ - allow:
+ or:
+ - email:
+ is: you@example.com
+```
diff --git a/content/examples/guides/paperless-ngx/docker-compose.yaml b/content/examples/guides/paperless-ngx/docker-compose.yaml
new file mode 100644
index 000000000..1a1ee3d09
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/docker-compose.yaml
@@ -0,0 +1,75 @@
+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 is the only service on both networks: the default network for public
+ # traffic, and the internal-only network to reach Paperless. This bridge is the
+ # single path in, so the policy can't be bypassed.
+ networks:
+ default: {}
+ paperless-internal: {}
+ restart: always
+
+ paperless:
+ image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57
+ depends_on:
+ - db
+ - redis
+ environment:
+ PAPERLESS_REDIS: redis://redis:6379
+ PAPERLESS_DBHOST: db
+ PAPERLESS_DBUSER: paperless
+ PAPERLESS_DBPASS: change-this-database-password
+ PAPERLESS_DBNAME: paperless
+ # Generate your own: openssl rand -base64 48
+ PAPERLESS_SECRET_KEY: change-this-to-a-long-random-string
+ # Must equal the public route host below, or Django answers HTTP 400 behind
+ # the proxy (ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS are derived from this).
+ PAPERLESS_URL: https://paperless.yourdomain.com
+ # Bootstraps the first superuser on initial startup only.
+ PAPERLESS_ADMIN_USER: admin
+ PAPERLESS_ADMIN_PASSWORD: change-this-admin-password
+ volumes:
+ - paperless-data:/usr/src/paperless/data
+ - paperless-media:/usr/src/paperless/media
+ networks:
+ - paperless-internal
+ restart: always
+
+ db:
+ image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
+ environment:
+ POSTGRES_DB: paperless
+ POSTGRES_USER: paperless
+ POSTGRES_PASSWORD: change-this-database-password
+ volumes:
+ - paperless-db:/var/lib/postgresql/data
+ networks:
+ - paperless-internal
+ restart: always
+
+ redis:
+ image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
+ volumes:
+ - paperless-redis:/data
+ networks:
+ - paperless-internal
+ restart: always
+
+networks:
+ # Internal-only: no route to the outside, so Paperless, Postgres, and Redis are
+ # reachable only via Pomerium, which is the lone service bridging it to default.
+ paperless-internal:
+ internal: true
+
+volumes:
+ pomerium-cache:
+ paperless-data:
+ paperless-media:
+ paperless-db:
+ paperless-redis:
diff --git a/content/examples/guides/paperless-ngx/docker-compose.yaml.md b/content/examples/guides/paperless-ngx/docker-compose.yaml.md
new file mode 100644
index 000000000..5b1445964
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/docker-compose.yaml.md
@@ -0,0 +1,77 @@
+```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 is the only service on both networks: the default network for public
+ # traffic, and the internal-only network to reach Paperless. This bridge is the
+ # single path in, so the policy can't be bypassed.
+ networks:
+ default: {}
+ paperless-internal: {}
+ restart: always
+
+ paperless:
+ image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57
+ depends_on:
+ - db
+ - redis
+ environment:
+ PAPERLESS_REDIS: redis://redis:6379
+ PAPERLESS_DBHOST: db
+ PAPERLESS_DBUSER: paperless
+ PAPERLESS_DBPASS: change-this-database-password
+ PAPERLESS_DBNAME: paperless
+ # Generate your own: openssl rand -base64 48
+ PAPERLESS_SECRET_KEY: change-this-to-a-long-random-string
+ # Must equal the public route host below, or Django answers HTTP 400 behind
+ # the proxy (ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS are derived from this).
+ PAPERLESS_URL: https://paperless.yourdomain.com
+ # Bootstraps the first superuser on initial startup only.
+ PAPERLESS_ADMIN_USER: admin
+ PAPERLESS_ADMIN_PASSWORD: change-this-admin-password
+ volumes:
+ - paperless-data:/usr/src/paperless/data
+ - paperless-media:/usr/src/paperless/media
+ networks:
+ - paperless-internal
+ restart: always
+
+ db:
+ image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
+ environment:
+ POSTGRES_DB: paperless
+ POSTGRES_USER: paperless
+ POSTGRES_PASSWORD: change-this-database-password
+ volumes:
+ - paperless-db:/var/lib/postgresql/data
+ networks:
+ - paperless-internal
+ restart: always
+
+ redis:
+ image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
+ volumes:
+ - paperless-redis:/data
+ networks:
+ - paperless-internal
+ restart: always
+
+networks:
+ # Internal-only: no route to the outside, so Paperless, Postgres, and Redis are
+ # reachable only via Pomerium, which is the lone service bridging it to default.
+ paperless-internal:
+ internal: true
+
+volumes:
+ pomerium-cache:
+ paperless-data:
+ paperless-media:
+ paperless-db:
+ paperless-redis:
+```
diff --git a/content/examples/guides/paperless-ngx/validate/assert.spec.ts b/content/examples/guides/paperless-ngx/validate/assert.spec.ts
new file mode 100644
index 000000000..916c7877d
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/validate/assert.spec.ts
@@ -0,0 +1,72 @@
+import { test, expect } from "@playwright/test";
+import { login, alice } from "../lib/authn";
+import { shot } from "../lib/shot";
+
+// Real end-to-end test for the Paperless-ngx front-door gate. Pomerium makes the
+// access decision; Paperless-ngx keeps its own Django login on top. This drives the
+// whole chain: an unauthenticated request is bounced to the IdP, an allowed user
+// passes the gate, and Paperless-ngx then serves its own sign-in page (HTTP 200
+// with the Paperless-ngx login markers) behind that gate. A final negative control
+// proves the upstream is reachable only through Pomerium.
+const BASE = process.env.POMERIUM_URL as string;
+
+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("allowed user passes the gate and reaches the Paperless-ngx login", async ({ page }) => {
+ await login(page, BASE, alice);
+
+ // Past the Pomerium gate, Paperless-ngx serves its own Django sign-in page.
+ // preserve_host_header keeps Django's ALLOWED_HOSTS / CSRF checks satisfied, so a
+ // 400 here would flag a host-header misconfiguration rather than a dead upstream.
+ const res = await page.request.get(`${BASE}/accounts/login/`, { ignoreHTTPSErrors: true });
+ expect(
+ res.status(),
+ "Paperless-ngx should serve its login page (200) behind the Pomerium gate",
+ ).toBe(200);
+
+ const body = await res.text();
+ // Shape check: the Django sign-in page carries the Paperless-ngx brand and the
+ // login/password form fields. A Pomerium error page or a Django 400 would not.
+ expect(body, "response should be the Paperless-ngx sign-in page").toContain("Paperless-ngx");
+ expect(body, "the sign-in page should render its login field").toContain('name="login"');
+ expect(body, "the sign-in page should render its password field").toContain('name="password"');
+
+ // Render the gated login page in the browser and capture it for the guide.
+ await page.goto(`${BASE}/accounts/login/`, { waitUntil: "networkidle" });
+ await expect(page.locator('input[name="login"]')).toBeVisible();
+ await expect(page.locator('input[name="password"]')).toBeVisible();
+ await shot(page, "paperless-login");
+});
+
+test("Paperless-ngx is not reachable except through Pomerium", async ({ page }) => {
+ // Positive control first: Paperless 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}/accounts/login/`, {
+ ignoreHTTPSErrors: true,
+ });
+ expect(viaPomerium.ok(), "the route through Pomerium should work").toBeTruthy();
+
+ // Paperless sits on an internal-only network shared with Pomerium alone. The
+ // test-runner is not on that network, so a direct hit at paperless:8000
+ // (bypassing Pomerium) must fail at name resolution / connection, NOT with an
+ // HTTP response. Asserting the specific error keeps a typo or a down service from
+ // masquerading as isolation.
+ let directError = "";
+ try {
+ await page.request.get("http://paperless:8000/accounts/login/", {
+ ignoreHTTPSErrors: true,
+ timeout: 5000,
+ });
+ } catch (e) {
+ directError = String(e);
+ }
+ expect(
+ directError,
+ "Paperless must be unreachable directly; the only path in is through Pomerium",
+ ).toMatch(/ENOTFOUND|getaddrinfo|EAI_AGAIN|ECONNREFUSED/i);
+});
diff --git a/content/examples/guides/paperless-ngx/validate/compose.validate.yaml b/content/examples/guides/paperless-ngx/validate/compose.validate.yaml
new file mode 100644
index 000000000..453b70402
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/validate/compose.validate.yaml
@@ -0,0 +1,119 @@
+# Sealed E2E validation for the Paperless-ngx guide. Reuses the shared harness
+# (Keycloak + certs + headless runner) and adds the Paperless-ngx stack behind a
+# Pomerium wired to the in-network IdP.
+# Run with: scripts/validate-guide-fixtures.sh paperless-ngx
+#
+# This is a real end-to-end test: it proves Pomerium gates the route, an allowed
+# user passes the gate, and Paperless-ngx then serves its own Django sign-in page
+# behind that gate (HTTP 200 with the Paperless-ngx login markers). The admin
+# credential below is a throwaway bootstrap user for this sealed, ephemeral stack.
+#
+# Network isolation mirrors the guide's trust boundary: Paperless, Postgres, and
+# Redis sit ONLY on the internal-only `paperless-internal` network, shared with
+# pomerium alone. The test-runner is on `default`, so it cannot reach Paperless
+# directly (the spec proves this); the only path in is through Pomerium, which
+# bridges both networks.
+#
+# PAPERLESS_URL MUST equal the public route host. Paperless-ngx is a Django app and
+# derives ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS from it; a mismatch yields HTTP 400
+# behind the proxy. Paperless runs migrations + builds its search index on first
+# boot, so its healthcheck uses a generous start_period before Pomerium waits on it.
+
+include:
+ - ../../_harness/compose/compose.harness.yaml
+
+services:
+ redis:
+ image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
+ networks:
+ paperless-internal:
+ aliases:
+ - redis
+
+ db:
+ image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
+ environment:
+ POSTGRES_DB: paperless
+ POSTGRES_USER: paperless
+ POSTGRES_PASSWORD: paperless-e2e-db-pass
+ networks:
+ paperless-internal:
+ aliases:
+ - db
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U paperless -d paperless']
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ start_period: 20s
+
+ paperless:
+ image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4@sha256:3421ebe06ed27662d014046cf5089e612de853aae0c676a2bc72f73b38080e57
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_started
+ environment:
+ PAPERLESS_REDIS: redis://redis:6379
+ PAPERLESS_DBHOST: db
+ PAPERLESS_DBUSER: paperless
+ PAPERLESS_DBPASS: paperless-e2e-db-pass
+ PAPERLESS_DBNAME: paperless
+ PAPERLESS_SECRET_KEY: paperless-e2e-throwaway-secret-key
+ # Must equal the public route host or Django answers HTTP 400 behind Pomerium.
+ PAPERLESS_URL: https://paperless.localhost.pomerium.io
+ PAPERLESS_ADMIN_USER: admin
+ PAPERLESS_ADMIN_PASSWORD: paperless-e2e-admin-pass
+ networks:
+ paperless-internal:
+ aliases:
+ - paperless
+ healthcheck:
+ # python3 ships in the image; hit the local Django sign-in page. 200 means
+ # migrations ran and the app is serving (not just the port being open).
+ test:
+ [
+ 'CMD-SHELL',
+ 'python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen(''http://localhost:8000/accounts/login/'', timeout=5).status==200 else 1)"',
+ ]
+ interval: 10s
+ timeout: 10s
+ retries: 40
+ start_period: 180s
+
+ 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
+ paperless:
+ condition: service_healthy
+ networks:
+ # On `default` for the IdP + test-runner, and on the internal-only network to
+ # reach Paperless. This is the only service bridging the two.
+ default:
+ aliases:
+ - authenticate.localhost.pomerium.io
+ - paperless.localhost.pomerium.io
+ paperless-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 Paperless is reachable only via Pomerium.
+ paperless-internal:
+ internal: true
diff --git a/content/examples/guides/paperless-ngx/validate/routes.validate.yaml b/content/examples/guides/paperless-ngx/validate/routes.validate.yaml
new file mode 100644
index 000000000..223fad1d4
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/validate/routes.validate.yaml
@@ -0,0 +1,12 @@
+# Routes-only validation config. Shared settings (IdP, secrets, signing key, certs)
+# come from ../../_harness/pomerium/validation.env via env_file in compose.validate.yaml.
+# Paperless-ngx runs its own Django login, so the route is a front-door gate.
+# preserve_host_header keeps Django's ALLOWED_HOSTS / CSRF checks happy behind the proxy.
+routes:
+ - from: https://paperless.localhost.pomerium.io
+ to: http://paperless:8000
+ preserve_host_header: true
+ policy:
+ - allow:
+ or:
+ - authenticated_user: true
diff --git a/content/examples/guides/paperless-ngx/validate/url.txt b/content/examples/guides/paperless-ngx/validate/url.txt
new file mode 100644
index 000000000..69fa710ed
--- /dev/null
+++ b/content/examples/guides/paperless-ngx/validate/url.txt
@@ -0,0 +1 @@
+https://paperless.localhost.pomerium.io