# Centerpoint Home Lab

Description Specifically for Centerpoint set up

# Server Introduction

Hardware specifications, storage layout, network topology, and the base Docker environment that underpins the entire Centerpoint stack.

# Overview & Hardware

Overview & Hardware
  tags: [hardware, centerpoint, specs]
---

## Overview

Centerpoint is the primary homelab server — a Mini PC form factor running a full
self-hosted Docker stack. It acts as the central compute node for all containerised
services, reverse proxying, media, AI inference, and automation in the homelab.

| Property     | Value                                  |
|--------------|----------------------------------------|
| Hostname     | `centerpoint`                          |
| Role         | Primary Docker host                    |
| Form Factor  | Mini PC (NUC-style)                    |
| IP Address   | `192.168.1.85`                         |
| Tailscale IP | `100.117.158.101`                      |
| OS           | Ubuntu 24.04.4 LTS (Noble Numbat)      |
| Kernel       | `6.17.0-35-generic`                    |

## CPU

| Property          | Value                            |
|-------------------|----------------------------------|
| Model             | Intel Core Ultra 9 285H          |
| Core Architecture | P-cores only (no hyperthreading) |
| Physical Cores    | 16                               |
| Sockets           | 1                                |
| Threads           | 16 (1 per core)                  |

The 285H is an Intel Meteor Lake H-series mobile processor with a dedicated Neural
Processing Unit (NPU). The absence of E-cores or hyperthreading means all 16 logical
CPUs are full performance cores, which benefits parallel container workloads.

## Memory

| Property  | Value |
|-----------|-------|
| Total RAM | 96 GB |

At time of writing, approximately 33 GB is actively in use with ~9 GB free and ~49 GB
used as page cache — normal for a long-running Linux system running a large Docker
stack.

## Notes

- Uptime is typically measured in weeks; the system runs continuously.
- The NPU in the 285H is not currently leveraged by any container workload — it is
  available for future OpenVINO or AI inference optimisation.

---
*Last Updated: 2026-06-16*

# Storage Layout

## Overview

Centerpoint uses a three-tier storage strategy:

1. **Local NVMe** — fast system and application data storage
2. **Ceph OSD block devices** — two NVMe drives contributing to a distributed Ceph
   cluster for object/block storage across the homelab
3. **NFS mounts from UnRAID** — bulk media and data storage from the NAS server at
   `192.168.1.119`

## Local NVMe Drives

### System Drive — `nvme1n1` (1.8 TB)

The primary system disk, GPT-partitioned with LVM.

| Partition              | Size   | Mount Point | Purpose              |
|------------------------|--------|-------------|----------------------|
| `nvme1n1p1`            | 1 GB   | `/boot/efi` | EFI System Partition |
| `nvme1n1p2`            | 2 GB   | `/boot`     | Boot partition       |
| `nvme1n1p3` (LVM PV)  | 1.8 TB | —           | LVM physical volume  |
| `ubuntu-vg/ubuntu-lv` | 1.8 TB | `/`         | Root filesystem      |

**Current usage:** 520 GB used / 1.3 TB free

This drive holds the OS, all Docker image layers (`/var/lib/docker`), container
volumes, and the compose project files under `/home/jeeves/docker/`.

### Ceph OSD — `nvme0n1` (931.5 GB)

Configured as a Ceph OSD block device under LVM management.
Mounted at `/media/jeeves/1TB_Vol2`.

The volume appears nearly empty at the filesystem level because Ceph manages the
block device directly — actual utilised capacity is tracked by the Ceph cluster,
not the OS mount point.

### Ceph OSD — `nvme2n1` (931.5 GB)

Second Ceph OSD block device, LVM-managed.
Mounted at `/media/jeeves/1TB_Vol1`. Shows ~115 GB used at the OS level; the
remainder is managed by Ceph.

> Both Ceph OSD drives contribute to a distributed storage pool shared across the
> homelab. Ceph provides replication and data protection at the cluster level rather
> than at the individual host level.

## NFS Mounts — UnRAID (`192.168.1.119`)

UnRAID at `192.168.1.119` hosts a 38 TB storage pool (~32 TB in use) and exports
three NFS shares, auto-mounted on Centerpoint at boot.

| Mount Point       | NFS Source                               | Consumer Services                  |
|-------------------|------------------------------------------|------------------------------------|
| `/mnt/Photos`     | `192.168.1.119:/mnt/user/Photos`         | Immich                             |
| `/mnt/data`       | `192.168.1.119:/mnt/user/Data`           | General / miscellaneous            |
| `/mnt/Multimedia` | `192.168.1.119:/mnt/user/Multimedia`     | Plex, Jellyfin, Stash, Audiobookshelf |

These paths are bind-mounted into media containers — the media files themselves
are never stored locally on Centerpoint.

## Notes / Gotchas

- If UnRAID is offline or rebooting, NFS mounts will stall and any container with
  a bind mount into `/mnt/*` will hang or fail to start until the mount recovers.
- Docker named volumes (databases, config state, etc.) all reside on the system
  NVMe under `/var/lib/docker/volumes/` — these are **not** automatically backed up
  to UnRAID.
- The Ceph cluster should be monitored independently; degraded OSD status does not
  surface through Docker or the OS mounts in any obvious way.

---
*Last Updated: 2026-06-16*

# Networking

## Overview

Centerpoint is reachable via four distinct paths depending on the use case.

| Path              | Address / Domain                                        | Use Case                          |
|-------------------|---------------------------------------------------------|-----------------------------------|
| LAN (physical)    | `192.168.1.85`                                          | Direct IP access, management      |
| Tailscale VPN     | `100.117.158.101`                                       | Secure remote access              |
| Traefik (internal)| `*.home.local`                                          | Named HTTPS on LAN                |
| Traefik (external)| `*.jeeves5454.ddns.net` / `*.jeevesconsults.ca`         | Internet-facing HTTPS             |

## Physical Network

| Property         | Value                        |
|------------------|------------------------------|
| Interface        | `enp47s0`                    |
| IP Address       | `192.168.1.85/24`            |
| Default Gateway  | `192.168.1.1` (Unifi router) |
| Subnet           | `192.168.1.0/24`             |

The IP is assigned via DHCP with a static lease on the Unifi gateway, making it
functionally static.

## Tailscale Mesh VPN

Centerpoint runs as a Tailscale node and **exit node**, allowing remote devices to
route all traffic through the home network.

| Peer        | Tailscale IP      | Platform | Notes                           |
|-------------|-------------------|----------|---------------------------------|
| centerpoint | `100.117.158.101` | Linux    | This host — exit node           |
| corsec      | `100.101.27.107`  | Linux    | HA OS server — also exit node   |
| halcyon     | `100.81.166.2`    | Windows  | Active peer (direct connection) |
| Mobile      | varies            | iOS      | Occasional peers                |

## Docker Network Architecture

Docker maintains approximately 25 bridge networks on the host. Each application
stack follows a consistent isolation pattern:

| Network          | Purpose                                                          |
|------------------|------------------------------------------------------------------|
| `traefik-net`    | Shared bridge — all Traefik-fronted containers attach here       |
| `*-internal`     | Per-stack isolated networks for app-to-database communication    |
| `media-network`  | Shared bridge for media stack containers                         |
| `bridge`         | Docker default (not used for production workloads)               |

**Typical stack pattern:**
- Application container → attached to both `traefik-net` and `<stack>-internal`
- Database / cache sidecar → attached to `<stack>-internal` only (never on Traefik)

## DNS

**Internal (`*.home.local`):** Resolved by AdGuard Home on the LAN. A wildcard DNS
record points `*.home.local` → `192.168.1.85` so Traefik receives all requests and
routes by hostname.

**External (`*.jeeves5454.ddns.net`):** DDNS via No-IP, keeps the external hostname
updated with the home WAN IP.

**External (`*.jeevesconsults.ca`):** Managed via DNS provider with appropriate
A / CNAME records pointing to the home WAN.

## Notes / Gotchas

- If AdGuard Home is down, all `*.home.local` DNS resolution fails. Services remain
  accessible via direct IP but HTTPS by hostname will not work.
- Tailscale must be running (`tailscaled`) for the `100.x` address to be reachable.
- The large number of `br-*` interfaces visible on `ip addr` is normal — Docker
  creates one per network.
- Traefik handles all TLS termination; individual containers do not need to manage
  certificates.

---
*Last Updated: 2026-06-16*

# Docker Environment

## Overview

All services on Centerpoint run as Docker containers, managed through Docker Compose
project files and monitored via Portainer EE. Traefik v3 serves as the reverse proxy
and TLS termination point for every service.

## Docker Engine

| Property           | Value                 |
|--------------------|-----------------------|
| Docker Version     | 29.5.3                |
| Docker Compose     | v5.1.4                |
| Storage Driver     | `overlayfs`           |
| Docker Root        | `/var/lib/docker`     |
| Total Containers   | 112                   |
| Running            | 97                    |
| Stopped            | 15                    |
| Images             | 108                   |

## Project Structure

All compose projects live under `/home/jeeves/docker/`, with one subdirectory per
logical stack:

```
/home/jeeves/docker/
├── adguard/
├── ai-stack/         ← Ollama, Open Web UI, Faster-Whisper, Kokoro
├── arr/              ← Sonarr, Radarr, Prowlarr, Bazarr, NZBGet, etc.
├── authentik/
├── bookstack/
├── crowdsec/
├── homepage/
├── immich/
├── paperless/
├── traefik/
└── ...               (one directory per stack)
```

Each directory contains a `docker-compose.yml` and any local config files or
bind-mount targets specific to that stack.

## Container Management — Portainer EE

Portainer Enterprise Edition provides the web UI for container lifecycle management,
log viewing, stack deployment, and environment monitoring.

Portainer connects to the Docker daemon via a `dockerproxy` sidecar container
(Tecnativa Docker Socket Proxy) rather than mounting the Docker socket directly.
This limits the API surface exposed to Portainer and reduces the blast radius of
any container compromise.

## Traefik v3 — Reverse Proxy

Traefik is the single ingress point for all named HTTP/HTTPS traffic. It runs
permanently on `traefik-net` and discovers routes automatically from Docker container
labels — no manual reload required when stacks are added or removed.

| Property         | Value                                                      |
|------------------|------------------------------------------------------------|
| HTTP port        | `80` — auto-redirects all traffic to HTTPS                 |
| HTTPS port       | `443`                                                      |
| Dashboard port   | `8080` — internal only (`traefik.home.local`)              |
| Static config    | `/home/jeeves/docker/traefik/traefik.yml`                  |
| Dynamic config   | `/home/jeeves/docker/traefik/dynamic.yml` (file-watched)   |
| Access logs      | `/var/log/traefik/access.log` (JSON, buffered)             |

### Certificate Resolvers

| Resolver      | Scope              | Method         | Certificate Authority              |
|---------------|--------------------|----------------|------------------------------------|
| `letsencrypt` | External domains   | HTTP challenge | Let's Encrypt                      |
| `step-ca`     | `*.home.local`     | ACME           | Internal Step-CA (`ca.home.local`) |

Internal certificates have a 720-hour (30-day) duration and auto-renew via Traefik's
built-in ACME client against the Step-CA instance.

### Active Plugins

| Plugin                                          | Version | Purpose                                                  |
|-------------------------------------------------|---------|----------------------------------------------------------|
| `PascalMinder/geoblock`                         | v0.3.6  | Country-level block on external-facing routes            |
| `maxlerebourg/crowdsec-bouncer-traefik-plugin`  | v1.3.0  | Blocks IPs flagged by the local CrowdSec LAPI            |

### Standard Routing Pattern

Each service defines Traefik labels in its own `docker-compose.yml`. The typical
pattern for a dual-route service (internal + external) is:

```yaml
labels:
  - "traefik.enable=true"

  # External route — Let's Encrypt TLS + security middleware
  - "traefik.http.routers.<name>-ext.rule=Host(`<svc>.jeeves5454.ddns.net`)"
  - "traefik.http.routers.<name>-ext.entrypoints=websecure"
  - "traefik.http.routers.<name>-ext.tls.certresolver=letsencrypt"
  - "traefik.http.routers.<name>-ext.middlewares=authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file"

  # Internal route — Step-CA TLS, no extra middleware
  - "traefik.http.routers.<name>-int.rule=Host(`<svc>.home.local`)"
  - "traefik.http.routers.<name>-int.entrypoints=websecure"
  - "traefik.http.routers.<name>-int.tls.certresolver=step-ca"

  # Backend service port
  - "traefik.http.services.<name>-svc.loadbalancer.server.port=<port>"
```

Services that are internal-only omit the `-ext` router entirely. Services that
require OAuth2 authentication on external routes add `authentik@file` middleware.

## Notes / Gotchas

- `traefik-net` is an **externally created** network and must exist before any
  Traefik-fronted stack is started:
  ```bash
  docker network create traefik-net
  ```
- Never restart `dockerproxy` while Portainer is actively being used — it will lose
  its Docker connection until the proxy is back up.
- Compose files use the stack subdirectory as their working directory — relative bind
  mount paths resolve from there.
- `overlayfs` can accumulate orphaned image layers over time. Prune periodically:
  ```bash
  docker image prune
  docker volume prune   # caution — only remove truly unused volumes
  ```

---
*Last Updated: 2026-06-16*

# Infrastructure & Networking

# Chapter Introduction

## Overview

This chapter documents the foundational infrastructure layer that all other services
depend on. These nine services form the backbone of the Centerpoint stack — handling
reverse proxying, TLS, identity, DNS, threat detection, logging, and observability.

## Services in This Chapter

| Service            | Container(s)                                                | Purpose                                      |
|--------------------|-------------------------------------------------------------|----------------------------------------------|
| **Traefik v3**     | `traefik`                                                   | Reverse proxy and TLS termination            |
| **Step-CA**        | `step-ca`                                                   | Internal ACME certificate authority          |
| **Portainer EE**   | `portainer`                                                 | Docker container management UI               |
| **Authentik**      | `authentik-server`, `authentik-worker`, `authentik-postgresql`, `authentik-geoip` | SSO / identity provider |
| **AdGuard Home**   | `adguardhome`                                               | LAN DNS resolver and ad blocking             |
| **CrowdSec**       | `crowdsec`, `crowdsec-bouncer-traefik`                      | Collaborative threat detection and blocking  |
| **Dozzle**         | `dozzle`                                                    | Real-time container log viewer               |
| **Uptime-Kuma**    | `uptime-kuma`                                               | Service uptime and endpoint monitoring       |
| **Homepage**       | `homepage`                                                  | Homelab dashboard                            |

## Dependency Order

When starting from scratch, services must come up in this order:

1. **AdGuard Home** — `*.home.local` DNS must resolve before anything can find its neighbours
2. **Step-CA** — required for Traefik to issue internal certificates on first boot
3. **Traefik** — all named HTTPS routes depend on it
4. **Authentik** (PostgreSQL → Server → Worker) — required before any externally-accessible service that enforces SSO
5. **CrowdSec** → **CrowdSec Bouncer** — bouncer cannot connect to LAPI until CrowdSec is healthy
6. All other infrastructure services (Portainer, Dozzle, Uptime-Kuma, Homepage) can start in any order

---
*Last Updated: 2026-06-16*

# Traefik v3

## Overview

Traefik is the single ingress point for all named HTTP/HTTPS traffic on Centerpoint.
It runs on the `traefik-net` Docker bridge network and discovers routes automatically
from container labels — no manual reload is needed when stacks are added or removed.
All TLS termination happens at Traefik; individual application containers serve plain
HTTP internally.

## Access

| Type      | URL / Endpoint                 | Notes                               |
|-----------|--------------------------------|-------------------------------------|
| Dashboard | `https://traefik.home.local`   | Internal only — LAN access required |

The dashboard is exposed only on the internal Step-CA route. There is no external
(internet-facing) route for the Traefik dashboard.

## Configuration

**Image:** `traefik:v3.6.13`
**Compose project:** `traefik` (same project as `step-ca`)

### Ports

| Port   | Protocol | Purpose                                |
|--------|----------|----------------------------------------|
| `80`   | TCP      | HTTP — auto-redirects all traffic to HTTPS |
| `443`  | TCP      | HTTPS — primary entrypoint             |
| `8080` | TCP      | Traefik API / Dashboard (internal)     |

### Static Configuration — `traefik.yml`

Location: `/home/jeeves/docker/traefik/traefik.yml`

Key settings:

```yaml
api:
  dashboard: true
  insecure: false

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"

providers:
  docker:
    network: traefik-net
    exposedByDefault: false
  file:
    directory: /config
    watch: true

certificatesResolvers:
  letsencrypt:
    acme:
      email: <redacted>
      storage: /letsencrypt/acme.json
      httpChallenge:
        entryPoint: web
  step-ca:
    acme:
      email: <redacted>
      storage: /letsencrypt/step-ca.json
      caServer: https://ca.home.local:9000/acme/acme/directory
      certificatesDuration: 720

experimental:
  plugins:
    geoblock:
      moduleName: github.com/PascalMinder/geoblock
      version: v0.3.6
    crowdsec-bouncer-traefik-plugin:
      moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
      version: v1.3.0
```

### Certificate Resolvers

| Resolver      | Scope                              | Method         | CA                          | Duration |
|---------------|------------------------------------|----------------|-----------------------------|----------|
| `letsencrypt` | External (`*.jeeves5454.ddns.net`) | HTTP challenge | Let's Encrypt               | 90 days  |
| `step-ca`     | Internal (`*.home.local`)          | ACME           | Internal Step-CA at `ca.home.local:9000` | 30 days |

### Plugins

| Plugin                                            | Version | Purpose                                        |
|---------------------------------------------------|---------|------------------------------------------------|
| `PascalMinder/geoblock`                           | v0.3.6  | Blocks requests from countries not in allowlist |
| `maxlerebourg/crowdsec-bouncer-traefik-plugin`    | v1.3.0  | Forwards requests to CrowdSec bouncer for IP checks |

Geoblock is configured to allow **CA**, **US**, and **IN** only. The middleware is
named `plex-geoblock@file` and is defined in
`/home/jeeves/docker/traefik/config/crowdsec.yml`.

### Dynamic Configuration

Location: `/home/jeeves/docker/traefik/config/` (file-watched)

| File                | Contents                                              |
|---------------------|-------------------------------------------------------|
| `crowdsec.yml`      | `plex-geoblock` and `crowdsec-bouncer` middleware defs |
| `dynamic.yml`       | Static file-provider routes for Plex (no Docker labels) |
| `middlewares.yml`   | Additional shared middleware definitions               |

### Standard Dual-Route Label Pattern

Each service that requires both internal and external access uses this label pattern
in its `docker-compose.yml`:

```yaml
labels:
  - "traefik.enable=true"

  # External route — Let's Encrypt TLS + security middleware
  - "traefik.http.routers.<name>-ext.rule=Host(`<svc>.jeeves5454.ddns.net`)"
  - "traefik.http.routers.<name>-ext.entrypoints=websecure"
  - "traefik.http.routers.<name>-ext.tls.certresolver=letsencrypt"
  - "traefik.http.routers.<name>-ext.middlewares=authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file"

  # Internal route — Step-CA TLS, no SSO middleware
  - "traefik.http.routers.<name>-int.rule=Host(`<svc>.home.local`)"
  - "traefik.http.routers.<name>-int.entrypoints=websecure"
  - "traefik.http.routers.<name>-int.tls.certresolver=step-ca"

  # Backend
  - "traefik.http.services.<name>-svc.loadbalancer.server.port=<port>"
```

## Volumes / Bind Mounts

| Host Path                                   | Container Path   | Purpose                             |
|---------------------------------------------|------------------|-------------------------------------|
| `/home/jeeves/docker/traefik/traefik.yml`   | `/traefik.yml`   | Static config                       |
| `/home/jeeves/docker/traefik/config`        | `/config`        | Dynamic config (file-watched)       |
| `/home/jeeves/docker/traefik/letsencrypt`   | `/letsencrypt`   | ACME certificate storage            |
| `/var/log/traefik`                          | `/var/log/traefik` | Access logs (JSON)                |
| `/var/run/docker.sock`                      | `/var/run/docker.sock` | Docker label discovery        |

## Dependencies

- `traefik-net` Docker network (must be created manually before first start)
- `step-ca` container (must be healthy for internal certs to renew)
- CrowdSec LAPI at `crowdsec:8080` (bouncer requires it; Traefik starts without it but will error on blocked requests)

## Notes / Gotchas

- `traefik-net` is externally managed — always create it first:
  ```bash
  docker network create traefik-net
  ```
- Traefik discovers routes from container labels only on containers attached to `traefik-net`.
  If a container is not on this network it will be invisible to Traefik even with `traefik.enable=true`.
- `exposedByDefault: false` means every service must explicitly opt in with `traefik.enable=true`.
- The `step-ca` certificate resolver stores its ACME account and certs in `/letsencrypt/step-ca.json`
  (separate from the Let's Encrypt storage). Do not delete this file or certificates will be
  re-issued from scratch.
- Access logs are written to `/var/log/traefik/access.log` in JSON format and are also
  bind-mounted into the CrowdSec container for log ingestion.

---
*Last Updated: 2026-06-16*

# Step-CA (Internal Certificate Authority)

## Overview

Step-CA is an internal ACME-compatible certificate authority that issues TLS
certificates for all `*.home.local` domains. It allows Traefik to provision and
auto-renew trusted certificates for internal services without relying on Let's
Encrypt or exposing any ports to the internet.

Clients and browsers on the LAN trust `*.home.local` certificates because the
Step-CA root certificate has been manually installed as a trusted CA on each device.

## Access

| Type      | URL / Endpoint                              | Notes                          |
|-----------|---------------------------------------------|--------------------------------|
| ACME API  | `https://ca.home.local:9000/acme/acme/directory` | Used by Traefik only      |
| Step-CA UI | N/A                                        | No web UI — CLI managed only   |

Step-CA does not have a browser-accessible dashboard. Management is via the
`step` CLI tool.

## Configuration

**Image:** `smallstep/step-ca:latest`
**Compose project:** `traefik` (same project as `traefik` container)

### Ports

| Port   | Protocol | Purpose                            |
|--------|----------|------------------------------------|
| `9000` | TCP      | ACME API (host-bound: `0.0.0.0:9000`) |

Port 9000 is bound directly to the host so that Traefik (and any other ACME client
on the LAN) can reach it at `ca.home.local:9000`.

### CA Configuration — `ca.json`

Location: `/home/jeeves/docker/step-ca/config/config/ca.json`

Key settings (secrets redacted):

```json
{
  "root": "/home/step/certs/root_ca.crt",
  "crt": "/home/step/certs/intermediate_ca.crt",
  "key": "/home/step/secrets/intermediate_ca_key",
  "address": "0.0.0.0:9000",
  "dnsNames": ["ca.home.local"],
  "db": {
    "type": "badger",
    "dataSource": "/home/step/db"
  },
  "authority": {
    "provisioners": [
      {
        "type": "ACME",
        "name": "acme"
      }
    ]
  }
}
```

Certificate duration for ACME-issued certs is set to **720 hours (30 days)** in
Traefik's `step-ca` resolver config. Traefik renews automatically before expiry.

## Volumes / Bind Mounts

| Host Path                                  | Container Path | Purpose                                  |
|--------------------------------------------|----------------|------------------------------------------|
| `/home/jeeves/docker/step-ca/config`       | `/home/step`   | CA configuration, certificates, database |

### Directory Layout Inside the Volume

```
/home/jeeves/docker/step-ca/config/
├── config/
│   ├── ca.json          ← Main CA configuration
│   └── defaults.json    ← Step CLI defaults
├── certs/
│   ├── root_ca.crt      ← Root CA certificate (install this on client devices)
│   └── intermediate_ca.crt
├── secrets/             ← Private keys — never expose these
│   ├── root_ca_key
│   ├── intermediate_ca_key
│   └── password         ← Key encryption password (REDACTED)
└── db/                  ← BadgerDB certificate issuance database
```

## Dependencies

- AdGuard Home — `ca.home.local` must resolve on the LAN for Traefik to reach the ACME endpoint
- No other service dependencies

## Trusting the Root CA on Client Devices

Every device that accesses `*.home.local` URLs in a browser must trust the Step-CA
root certificate. The root cert is at:

```
/home/jeeves/docker/step-ca/config/certs/root_ca.crt
```

Also available at `/home/jeeves/docker/traefik/step-ca-root.crt` (Traefik keeps a
copy for its own ACME client trust store).

### macOS

```bash
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain root_ca.crt
```

### Windows

```powershell
Import-Certificate -FilePath root_ca.crt -CertStoreLocation Cert:\LocalMachine\Root
```

### Ubuntu / Debian

```bash
sudo cp root_ca.crt /usr/local/share/ca-certificates/step-ca.crt
sudo update-ca-certificates
```

## Notes / Gotchas

- The Step-CA password file (`/home/step/secrets/password`) is read on startup. If the
  volume is lost, the CA must be re-initialized and all client devices must re-import
  the new root certificate.
- Do not delete the `db/` directory — it contains the certificate issuance history.
  Loss means Step-CA cannot check for revoked certificates.
- Step-CA is in the same Portainer compose project as Traefik. Restarting the Traefik
  stack will also restart Step-CA briefly. Schedule this during off-hours.
- Traefik caches issued certs in its ACME storage file. If Step-CA is temporarily down,
  Traefik will continue serving existing certs until they near expiry.

---
*Last Updated: 2026-06-16*

# Portainer EE

## Overview

Portainer Enterprise Edition is the primary container management interface for
Centerpoint. It provides a web UI for deploying stacks, viewing container logs,
managing volumes and networks, and monitoring resource usage across the Docker
environment.

All compose stacks are deployed and managed through Portainer rather than by
running `docker compose` directly on the host. Portainer stores stack definitions
internally (under `/data/compose/`) and rebuilds containers from those definitions
when updated.

## Access

| Type      | URL                                   | Notes                            |
|-----------|---------------------------------------|----------------------------------|
| Internal  | `https://portainer.home.local`        | LAN access via Step-CA TLS       |
| HTTPS UI  | `https://192.168.1.85:9443`           | Direct IP fallback               |
| Tunnel    | Port `8001` (agent)                   | For Edge Agent / remote access   |

Portainer does not have an external (internet-facing) Traefik route. Access is
LAN-only or via Tailscale.

## Configuration

**Image:** `portainer/portainer-ee:latest`
**Compose project:** `portainer`

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `8001` | TCP      | Edge Agent tunnel port               |
| `9443` | TCP      | HTTPS management UI (host-bound)     |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.portainer.rule: Host(`portainer.home.local`)
traefik.http.routers.portainer.entrypoints: websecure
traefik.http.routers.portainer.tls.certresolver: step-ca
traefik.http.services.portainer.loadbalancer.server.port: 9443
```

Internal-only route — no external Traefik router.

## Volumes / Bind Mounts

| Host Path / Volume          | Container Path           | Purpose                    |
|-----------------------------|--------------------------|----------------------------|
| `portainer_portainer_data`  | `/data`                  | Portainer state and stack data (named volume, external) |
| `/var/run/docker.sock`      | `/var/run/docker.sock`   | Direct Docker socket access |

> The `portainer_portainer_data` volume is declared `external: true` — it must exist
> before the stack is started.

Note: Portainer mounts the Docker socket directly. This is intentional for Portainer
EE; it is the only service with direct socket access.

## Dependencies

- Docker socket (`/var/run/docker.sock`) — no other service dependencies
- Traefik on `traefik-net` for the `portainer.home.local` route
- Step-CA for the internal TLS certificate

## Notes / Gotchas

- Portainer stores all deployed stack definitions under `/data/compose/<id>/v<version>/`.
  These are the authoritative copies of each stack's `docker-compose.yml` while managed
  through Portainer. Files under `/home/jeeves/docker/` may be out of date if a stack
  was edited directly in the Portainer UI.
- **Portainer EE licence key** is stored in the Portainer UI and must be re-entered if
  the `portainer_portainer_data` volume is lost.
- The `portainer-ee:latest` tag follows the latest stable EE release. Pin to a specific
  version (e.g. `portainer-ee:2.22.0`) before any planned maintenance to avoid unintended
  upgrades.
- Portainer agent is not separately deployed on Centerpoint — Portainer connects to its
  local Docker daemon directly via the socket.
- Portainer does not work over Traefik - I have not been troubleshooting this, and is a future action or Task to manage

## FUTURE WORK

- Identify issue with Portainer and Traefik interaction
- Migrate Portainer cert with Step-CA cert for consistent cert management

---
*Last Updated: 2026-06-16*

# Authentik (SSO / Identity Provider)

## Overview

Authentik is the identity provider and single sign-on (SSO) gateway for all
externally-accessible services on Centerpoint. It implements ForwardAuth middleware
for Traefik, meaning that any request arriving at an external route tagged with
`authentik-auth@docker` is intercepted and validated by Authentik before being
forwarded to the backend service.

Authentik also serves as an OAuth2/OIDC provider — services like BookStack use
OIDC for native user login rather than ForwardAuth.

## Access

| Type      | URL                                       | Notes                                  |
|-----------|-------------------------------------------|----------------------------------------|
| Internal  | `https://auth.home.local`                 | LAN access via Step-CA TLS             |
| External  | `https://auth.jeevesconsults.ca`          | Internet-facing — OIDC redirect target |

The external URL (`auth.jeevesconsults.ca`) is the one registered as the OIDC issuer
with downstream services. It must be reachable by browsers completing OAuth2 flows.

## Containers in This Stack

| Container               | Image                                    | Role                                  |
|-------------------------|------------------------------------------|---------------------------------------|
| `authentik-server`      | `ghcr.io/goauthentik/server:2026.5.x`   | HTTP server + ForwardAuth endpoint    |
| `authentik-worker`      | `ghcr.io/goauthentik/server:2026.5.x`   | Background task worker (Rust entry)   |
| `authentik-postgresql`  | `postgres:16-alpine`                     | Primary database                      |
| `authentik-geoip`       | `ghcr.io/maxmind/geoipupdate:v7.x`      | GeoIP database updater (MaxMind)      |

### Startup Order

`authentik-postgresql` must reach a healthy state before `authentik-server` and
`authentik-worker` start. The `authentik-geoip` sidecar runs independently.

## Configuration

**Compose project:** `authentik`

### Key Environment Variables

| Variable                       | Value / Notes                                              |
|--------------------------------|------------------------------------------------------------|
| `AUTHENTIK_SECRET_KEY`         | **REDACTED** — long random string, must stay constant      |
| `AUTHENTIK_POSTGRESQL__HOST`   | `authentik-postgresql`                                     |
| `AUTHENTIK_POSTGRESQL__NAME`   | `authentik`                                                |
| `AUTHENTIK_POSTGRESQL__USER`   | `authentik`                                                |
| `AUTHENTIK_POSTGRESQL__PASSWORD` | **REDACTED**                                             |
| `AUTHENTIK_REDIS__HOST`        | `redis` (internal sidecar or external Redis)               |
| `AUTHENTIK_LISTEN__HTTP`       | `0.0.0.0:9000` — explicit bind required for Docker bridge  |
| `AUTHENTIK_ERROR_REPORTING__ENABLED` | `false`                                            |

> **Important:** `AUTHENTIK_LISTEN__HTTP: "0.0.0.0:9000"` is required in Authentik
> 2026.5+. Newer versions default to `[::]` (IPv6 wildcard) which Docker bridge
> networks cannot reach via IPv4. Without this override, ForwardAuth requests from
> Traefik fail silently.

### ForwardAuth Middleware

The Authentik ForwardAuth middleware is defined on the `authentik-server` container
labels and is available to all services on `traefik-net` as:

```
authentik-auth@docker
```

Example usage on a protected external route:

```yaml
labels:
  - "traefik.http.routers.<name>-ext.middlewares=authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file"
```

### OIDC Configuration (for native OIDC apps)

Applications using OIDC (e.g. BookStack) configure Authentik as their provider
with the following settings:

| Setting               | Value                                                           |
|-----------------------|-----------------------------------------------------------------|
| Issuer URL            | `https://auth.jeevesconsults.ca/application/o/<app-slug>/`     |
| JWKS URL              | `<issuer>/.well-known/jwks.json`                               |
| Client ID             | Per-application (set in Authentik admin UI)                    |
| Client Secret         | **REDACTED** — set per-application                              |
| `email_verified`      | Requires custom Scope Mapping returning `true` (2026.5+ change) |

## Volumes / Bind Mounts

| Host Path / Volume                  | Container Path    | Purpose                                 |
|-------------------------------------|-------------------|-----------------------------------------|
| `authentik_media`                   | `/media`          | Uploaded assets (logos, avatars)        |
| `authentik_custom-templates`        | `/templates`      | Custom email / flow templates           |
| `authentik_postgresql_data`         | `/var/lib/postgresql/data` | PostgreSQL data directory      |
| `authentik_geoip_data`              | `/usr/share/GeoIP` | MaxMind GeoIP databases                |

All volumes are named Docker volumes managed by Portainer.

### Sub-section: PostgreSQL

The `authentik-postgresql` container is a dedicated Postgres 16 sidecar that stores
all Authentik state: users, groups, policies, flows, tokens, and audit logs. It is not
shared with any other service.

Database credentials are passed to the server via `AUTHENTIK_POSTGRESQL__*` environment
variables. The container is on the `authentik-internal` network only — it is never
exposed to `traefik-net` or the host.

### Sub-section: GeoIP Updater

`authentik-geoip` runs the MaxMind GeoIPUpdate daemon, which downloads and refreshes
the `GeoLite2-City` and `GeoLite2-ASN` databases on a schedule. The databases are
shared into `authentik-server` via the `authentik_geoip_data` volume.

A MaxMind account and licence key (**REDACTED**) are required for the GeoIP databases.

### Sub-section: Worker

`authentik-worker` runs as the same container image as `authentik-server` but with
the worker entrypoint. It handles background tasks: email delivery, outpost health
checks, blueprint application, and event cleanup. Since Authentik 2025.10+, the
worker uses a Rust-based entrypoint for improved performance.

## Dependencies

- `authentik-postgresql` (must be healthy before server/worker start)
- `traefik-net` (server must be on this network for ForwardAuth to reach it)
- MaxMind account for GeoIP updates (not strictly required, but GeoIP features
  will be unavailable without it)

## Notes / Gotchas

- If the `AUTHENTIK_SECRET_KEY` changes, all tokens, sessions, and cookies are
  immediately invalidated — all users will be logged out across all services.
- Authentik's admin interface is at `/if/admin/`. Initial admin credentials are
  set via the `AUTHENTIK_BOOTSTRAP_PASSWORD` env var on first start.
- For OIDC apps: `email_verified` defaults to `false` since Authentik 2025.10. Create
  a custom Scope Mapping that hard-codes `"email_verified": True` and attach it to the
  OAuth2 provider.
- Existing local users who also log in via OIDC must have their **External Auth ID**
  set in Admin → Users → Edit → External Auth ID to match the `sub` claim from the
  OIDC token, otherwise two separate accounts will be created.
- `DOZZLE_ENABLE_SHELL=true` on Dozzle requires that this admin account is separate
  from the Authentik service account.

---
*Last Updated: 2026-06-16*

# AdGuard Home

## Overview

AdGuard Home serves as the **primary** LAN-wide DNS resolver for the entire homelab
network. All devices on the `192.168.1.0/24` subnet use it as their primary DNS
server (`192.168.1.85:53`). It provides:

- DNS-based ad and tracker blocking
- Wildcard DNS entry `*.home.local → 192.168.1.85` enabling all Traefik internal routes
- DNS rewrites for custom local hostnames
- Query logging and per-client statistics
- Upstream DNS forwarding (via DoH to external resolvers)

A **secondary AdGuard Home** instance runs on the CorSec server (Home Assistant
machine at `192.168.1.64`). DNS rewrites are also maintained there so that name
resolution continues during Centerpoint maintenance or restarts. Client devices
should have both IPs configured as DNS resolvers.

## Access

| Type      | URL                              | Notes                                |
|-----------|----------------------------------|--------------------------------------|
| Internal  | `https://adguard.home.local`     | LAN access via Step-CA TLS           |
| Direct    | `http://192.168.1.85:80`         | Plain HTTP UI (AdGuard internal port) |

DNS service itself runs on port `53` (TCP + UDP), bound to the host IP.

## Configuration

**Image:** `adguard/adguardhome:v0.107.71`
**Compose project:** `adguardhome` (managed via Portainer)

### Ports

| Port  | Protocol | Purpose                         |
|-------|----------|---------------------------------|
| `53`  | TCP+UDP  | DNS resolver (host-bound)       |
| `80`  | TCP      | AdGuard web UI (HTTP, internal) |
| `443` | TCP      | DNS-over-HTTPS                  |
| `853` | TCP      | DNS-over-TLS                    |
| `3000`| TCP      | Initial setup port              |

Only port `53` is bound to the host. Other ports are exposed only within `traefik-net`.

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.adguard.rule: Host(`adguard.home.local`)
traefik.http.routers.adguard.entrypoints: websecure
traefik.http.routers.adguard.tls.certresolver: step-ca
traefik.http.services.adguard.loadbalancer.server.port: 80
```

Internal-only route — no external Traefik router.

### Critical Wildcard DNS Entry

AdGuard Home (both primary and secondary) must have a DNS Rewrite configured as:

```
*.home.local → 192.168.1.85
```

This single wildcard record means Traefik receives all `*.home.local` HTTP requests
and routes them by hostname. Without it, no internal service domain resolves.

All custom DNS rewrites (for individual hostnames outside the wildcard) must be
kept in sync between the Centerpoint and CorSec instances.

### Upstream DNS Resolvers

Upstream DNS (for forwarding public queries) is configured in AdGuard's settings
UI. Common configuration:

- Primary: `https://dns.cloudflare.com/dns-query` (DoH)
- Secondary: `https://dns.google/dns-query` (DoH)

## Volumes / Bind Mounts

| Host Path                            | Container Path              | Purpose                       |
|--------------------------------------|-----------------------------|-------------------------------|
| `/home/jeeves/docker/adguard/conf`   | `/opt/adguardhome/conf`     | Configuration (`AdGuardHome.yaml`) |
| `/home/jeeves/docker/adguard/work`   | `/opt/adguardhome/work`     | Query logs and statistics database |

## Networks

AdGuard Home is on `traefik-net` for its web UI route and has port `53` bound
directly to the host for DNS service.

## Dependencies

- None (AdGuard must start first; it has no upstream service dependencies)
- AdGuard's own DNS resolution bootstraps via the upstream DoH resolvers using
  hardcoded IPs if DNS is not yet available

## High Availability

| Instance    | Host       | IP              | Role      |
|-------------|------------|-----------------|-----------|
| Primary     | Centerpoint | `192.168.1.85`  | Primary — full config, rewrites, logging |
| Secondary   | CorSec (Home Assistant) | `192.168.1.64` | Backup — same rewrites, ad-block lists |

Client devices should configure both DNS servers in priority order. The secondary
takes over automatically if the primary becomes unreachable.

DNS rewrites must be manually kept in sync between the two instances — there is no
automated synchronisation.

## Notes / Gotchas

- The `work/data` directory requires root-level permissions — the container runs as
  root. This is expected behaviour.
- If port `53` is already in use on the host (e.g. `systemd-resolved`), AdGuard will
  fail to start. On Ubuntu 24.04, disable the stub listener:
  ```bash
  sudo systemctl disable --now systemd-resolved
  ```
- Client-specific rules (blocking, bypass, custom upstream) are stored in
  `AdGuardHome.yaml` and are preserved across container upgrades as long as the
  `conf/` bind mount is intact.
- AdGuard Home should be pinned to a specific version tag rather than `latest` to
  avoid schema migrations breaking the config on unexpected upgrades.
- When adding a new `*.home.local` service, no DNS change is needed (the wildcard
  covers it). New custom hostnames outside the wildcard pattern must be added to
  both the primary and secondary AdGuard instances.

## FUTURE WORK

- Update the DNS blocklist to include more sites
- Provide a summary of sites blocked via AI for review and/or include in Grafana for trend analysis to know if the numbers go up or down over time- find rogue machines

---
*Last Updated: 2026-06-16*

# CrowdSec

## Overview

CrowdSec is a collaborative intrusion detection and prevention system integrated
directly into Traefik. It analyses Traefik access logs in real time, detects
attack patterns (brute force, CVE exploitation, bad bots, etc.), and instructs
Traefik to block flagged IPs before they reach any backend service.

The stack consists of two containers:

- **`crowdsec`** — the Security Engine (LAPI + agent). Ingests logs, applies detection
  scenarios, maintains a decision list, and shares signals with the CrowdSec community.
- **`crowdsec-bouncer-traefik`** — the Traefik bouncer. Traefik forwards every incoming
  request to it via ForwardAuth; the bouncer checks the IP against CrowdSec's local
  decision database and returns allow/deny.

## Access

Neither CrowdSec container has a web UI. Management is via the `cscli` CLI inside
the `crowdsec` container:

```bash
docker exec -it crowdsec cscli decisions list
docker exec -it crowdsec cscli alerts list
docker exec -it crowdsec cscli metrics
```

## Configuration

**Compose project:** `crowdsec` (managed via Portainer)

### Containers

#### `crowdsec` — Security Engine

**Image:** `crowdsecurity/crowdsec:latest`

| Property        | Value                                                     |
|-----------------|-----------------------------------------------------------|
| Network         | `traefik-net`                                             |
| Restart policy  | `unless-stopped`                                          |

Key environment variables:

| Variable         | Value / Notes                                              |
|------------------|------------------------------------------------------------|
| `GID`            | `1000`                                                     |
| `COLLECTIONS`    | `crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors` |
| `BOUNCER_KEY_TRAEFIK` | **REDACTED** — shared secret used by the bouncer to authenticate with LAPI |

**Collections installed:**

| Collection                              | Purpose                                       |
|-----------------------------------------|-----------------------------------------------|
| `crowdsecurity/traefik`                 | Detects attack patterns in Traefik access logs |
| `crowdsecurity/http-cve`                | Detects exploitation of known HTTP CVEs        |
| `crowdsecurity/whitelist-good-actors`   | Whitelists known-good crawlers and services    |

#### `crowdsec-bouncer-traefik` — Traefik Bouncer

**Image:** `fbonalair/traefik-crowdsec-bouncer:latest`

| Property        | Value                          |
|-----------------|--------------------------------|
| Network         | `traefik-net`                  |
| Internal port   | `8080` (ForwardAuth endpoint)  |

Key environment variables:

| Variable                  | Value / Notes                                     |
|---------------------------|---------------------------------------------------|
| `GIN_MODE`                | `release`                                         |
| `CROWDSEC_AGENT_HOST`     | `crowdsec:8080` — CrowdSec LAPI endpoint          |
| `CROWDSEC_BOUNCER_API_KEY`| **REDACTED** — must match `BOUNCER_KEY_TRAEFIK`   |

### How the Bouncer Integrates with Traefik

The middleware is defined in `/home/jeeves/docker/traefik/config/crowdsec.yml`:

```yaml
http:
  middlewares:
    crowdsec-bouncer:
      forwardAuth:
        address: http://crowdsec-bouncer-traefik:8080/api/v1/forwardAuth
        trustForwardHeader: true
```

This middleware is referenced on external Traefik routes as `crowdsec-bouncer@file`.

### Log Ingestion

CrowdSec reads Traefik access logs from two bind-mounted paths:

| Host Path                          | Container Path         | Notes                           |
|------------------------------------|------------------------|---------------------------------|
| `/var/log/traefik`                 | `/var/log/traefik:ro`  | Primary log location            |
| `/home/jeeves/docker/traefik/logs` | `/logs/traefik:ro`     | Secondary / rotated logs        |

The `acquis.yaml` config file at `/home/jeeves/docker/crowdsec/config/acquis.yaml`
defines which log files to tail and in what format.

## Volumes / Bind Mounts

| Host Path                                 | Container Path          | Purpose                          |
|-------------------------------------------|-------------------------|----------------------------------|
| `/home/jeeves/docker/crowdsec/data`       | `/var/lib/crowdsec/data`| Decision database and GeoIP data |
| `/home/jeeves/docker/crowdsec/config`     | `/etc/crowdsec`         | Scenarios, parsers, config       |
| `/var/log/traefik`                        | `/var/log/traefik:ro`   | Traefik access log (read-only)   |
| `/home/jeeves/docker/traefik/logs`        | `/logs/traefik:ro`      | Traefik rotated logs (read-only) |

## Dependencies

- Traefik (must be running and writing access logs for CrowdSec to process)
- `crowdsec` LAPI must be healthy before `crowdsec-bouncer-traefik` starts
- Internet access required for CrowdSec community hub sync (pulling updated block lists)

## Notes / Gotchas

- The `BOUNCER_KEY_TRAEFIK` / `CROWDSEC_BOUNCER_API_KEY` pair must match exactly.
  If mismatched, the bouncer will fail to authenticate with LAPI and Traefik will
  receive a 200 (passthrough) response from ForwardAuth — **effectively disabling the
  bouncer silently**.
- CrowdSec community sharing (`online_api_credentials.yaml`) requires an account at
  `app.crowdsec.net`. The credentials file is stored at
  `/home/jeeves/docker/crowdsec/config/online_api_credentials.yaml`.
- `crowdsec-bouncer@file` middleware must be applied to external routes only. Applying
  it to internal (`*.home.local`) routes will check LAN IPs against the community block
  list, which may block legitimate traffic unexpectedly.
- Manually ban an IP with:
  ```bash
  docker exec -it crowdsec cscli decisions add --ip <IP> --duration 24h --reason "manual"
  ```
- Remove a ban:
  ```bash
  docker exec -it crowdsec cscli decisions delete --ip <IP>
  ```

---
*Last Updated: 2026-06-16*

# Dozzle

## Overview

Dozzle is a lightweight, real-time log viewer for Docker containers. It provides a
web UI to stream, search, and follow container logs without needing to SSH into the
host and run `docker logs`. It is configured to monitor containers on both
Centerpoint and Lusankya (the second homelab server at `192.168.1.114`).

## Access

| Type      | URL                            | Notes                             |
|-----------|--------------------------------|-----------------------------------|
| Internal  | `https://dozzle.home.local`    | LAN access via Step-CA TLS        |

No external (internet-facing) Traefik route — LAN and Tailscale access only.

## Configuration

**Image:** `amir20/dozzle:latest` (v10.6.0 at time of writing)
**Compose project:** `dozzle` (managed via Portainer)

### Environment Variables

| Variable                | Value                                            | Purpose                              |
|-------------------------|--------------------------------------------------|--------------------------------------|
| `DOZZLE_HOSTNAME`       | `Centerpoint`                                    | Display name for the local host      |
| `DOZZLE_REMOTE_HOST`    | `tcp://192.168.1.114:2375\|Lusankya`             | Adds Lusankya as a remote Docker host |
| `DOZZLE_ENABLE_ACTIONS` | `false`                                          | Disables container start/stop from UI |
| `DOZZLE_ENABLE_SHELL`   | `true`                                           | Enables shell access to containers   |

> `DOZZLE_ENABLE_SHELL=true` allows executing shell commands inside any container
> from the Dozzle UI. This is a powerful capability — ensure Dozzle is not accessible
> externally.

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.dozzle.rule: Host(`dozzle.home.local`)
traefik.http.routers.dozzle.entrypoints: websecure
traefik.http.routers.dozzle.tls.certresolver: step-ca
traefik.http.services.dozzle.loadbalancer.server.port: 8080
```

Internal-only route. No Authentik ForwardAuth on this route — access is controlled
at the network level (LAN + Tailscale only).

## Volumes / Bind Mounts

| Host Path              | Container Path         | Purpose                          |
|------------------------|------------------------|----------------------------------|
| `/var/run/docker.sock` | `/var/run/docker.sock:ro` | Read-only Docker socket access |

Dozzle mounts the Docker socket read-only. No persistent data volume is required —
Dozzle streams logs directly from the Docker daemon and does not store them.

## Networks

| Network       | Purpose                         |
|---------------|---------------------------------|
| `traefik-net` | Exposes the Dozzle web UI       |

## Remote Hosts

Dozzle connects to Lusankya's Docker daemon at `tcp://192.168.1.114:2375`. This
requires Lusankya's Docker daemon to have TCP exposure enabled. Logs from both
Centerpoint and Lusankya containers are visible in a single Dozzle instance.

## Dependencies

- Docker socket on Centerpoint (`/var/run/docker.sock`)
- Lusankya Docker daemon accessible at `tcp://192.168.1.114:2375`
- Traefik on `traefik-net` for the `dozzle.home.local` route

## Notes / Gotchas

- `DOZZLE_ENABLE_SHELL=true` gives the ability to `exec` into any container from
  the browser. Treat this with the same care as SSH access to the host.
- Dozzle does not persist logs. Once a container is removed, its historical logs
  are no longer accessible from Dozzle (use `docker logs` or a log aggregation stack
  for archival).
- The `latest` image tag tracks the latest release. Dozzle updates frequently — check
  the changelog before pulling, especially for breaking UI changes.
- Lusankya's Docker TCP port (`2375`) is unencrypted. This is acceptable on the
  private LAN but should never be exposed externally.

---
*Last Updated: 2026-06-16*

# Uptime-Kuma

## Overview

Uptime-Kuma is a self-hosted uptime monitoring tool that tracks the availability
of services and endpoints, sends alerts on downtime, and displays historical uptime
statistics. It serves as the primary observability dashboard for the homelab —
monitoring both internal services and external URLs.

## Access

| Type      | URL                                    | Notes                               |
|-----------|----------------------------------------|-------------------------------------|
| Internal  | (via Tailscale or LAN direct)          | Port `3001` on `192.168.1.85`       |
| External  | `https://uptime.jeeves5454.ddns.net`   | Internet-facing, protected by Authentik + GeoBlock + CrowdSec |

## Configuration

**Image:** `louislam/uptime-kuma:2`
**Compose project:** `uptime-kuma` (managed via Portainer; local file at
`/home/jeeves/docker/uptime_kuma/docker-compose.yml`)

### Ports

| Port   | Protocol | Purpose                                     |
|--------|----------|---------------------------------------------|
| `3001` | TCP      | Web UI (also bound to host for direct access) |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.uptime.rule: Host(`uptime.jeeves5454.ddns.net`)
traefik.http.routers.uptime.entrypoints: websecure
traefik.http.routers.uptime.tls.certresolver: letsencrypt
traefik.http.routers.uptime.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file,uptime-headers
traefik.http.middlewares.uptime-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.uptime.loadbalancer.server.port: 3001
```

The `uptime-headers` middleware injects `X-Forwarded-Proto: https` — required
because Uptime-Kuma needs to know the original scheme for correct redirect handling.

External access is gated behind Authentik SSO, GeoBlock (CA/US/IN), and CrowdSec.

## Volumes / Bind Mounts

| Host Path                                       | Container Path | Purpose                          |
|-------------------------------------------------|----------------|----------------------------------|
| `/home/jeeves/docker/uptime_kuma/uptime-kuma-data` | `/app/data` | SQLite DB, config, logs           |

The data directory stores:
- `kuma.db` — SQLite database with monitor config, history, and incidents
- `error.log` — Uptime-Kuma application error log

## Networks

| Network                    | Purpose                               |
|----------------------------|---------------------------------------|
| `traefik-net`              | External Traefik route                |
| `uptime-kuma_monitoring`   | Internal per-stack network            |

## Dependencies

- Traefik on `traefik-net` for the external HTTPS route
- Authentik at `auth.jeevesconsults.ca` for SSO on the external route
- Network connectivity to all monitored endpoints

## Notes / Gotchas

- Uptime-Kuma v2 uses SQLite. The `kuma.db` file is the entire state of the service —
  back it up before any upgrade.
- The `X-Forwarded-Proto` header middleware (`uptime-headers`) is specifically required
  for Uptime-Kuma to generate correct URLs in status page embeds and notifications.
- Monitor configurations (which services to check, intervals, notifications) are all
  stored in the UI and persisted in the SQLite database. There is no YAML-based
  config file.
- If Uptime-Kuma reports false positives for internal services, verify that DNS
  resolution works correctly from within the Uptime-Kuma container on `traefik-net`.
- Notification integrations (email, Telegram, Slack, etc.) are configured in the
  UI under Settings → Notifications.

---
*Last Updated: 2026-06-16*

# Homepage.Dev

## Overview

Homepage (gethomepage.dev) is the self-hosted service dashboard for Centerpoint.
It provides a single-pane-of-glass view of all running services, organised into
three tabs that reflect the different access methods available for each service.
It also displays live Docker container status by connecting directly to the Docker
socket.

## Access

| Type     | URL                              | Notes                              |
|----------|----------------------------------|------------------------------------|
| Internal | `https://centerpoint.home.local` | LAN access via Step-CA TLS         |
| Direct   | `http://192.168.1.85:3003`       | Plain HTTP fallback                |

No external (internet-facing) Traefik route.

## Configuration

**Image:** `ghcr.io/gethomepage/homepage:latest` (v1.13.2 at time of writing)
**Compose project:** `homepage` (Portainer-managed; local file at
`/home/jeeves/docker/homepage/docker-compose.yml`)

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `3003` | TCP      | Web UI (mapped from internal `3000`) |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.homepage.rule: Host(`centerpoint.home.local`)
traefik.http.routers.homepage.entrypoints: websecure
traefik.http.routers.homepage.tls.certresolver: step-ca
traefik.http.services.homepage.loadbalancer.server.port: 3000
```

Internal-only route on `centerpoint.home.local`.

### Environment Variables

| Variable                  | Value                                                      |
|---------------------------|------------------------------------------------------------|
| `PUID`                    | `1000`                                                     |
| `PGID`                    | `1000`                                                     |
| `LOG_TARGETS`             | `stdout`                                                   |
| `HOMEPAGE_ALLOWED_HOSTS`  | `gethomepage.dev,192.168.1.64:3003,localhost:3003`         |

### Dashboard Structure

Homepage is configured with three tabs:

| Tab                  | Service suffix | Description                                   |
|----------------------|----------------|-----------------------------------------------|
| **External Secure**  | (plain names)  | Services via `*.jeeves5454.ddns.net` or `*.jeevesconsults.ca` |
| **Internal Secure**  | `(LAN)`        | Services via `*.home.local` through Traefik   |
| **Internal Unsecured** | `(IP)`       | Services via direct `http://IP:port`          |

The default tab is set to **Internal Secure**.

Services can appear in multiple tabs if they have multiple access routes. Docker
container status widgets (`server: centerpoint`, `container: <name>`) are applied
to services where applicable.

### Configuration Files

All config files live under `/home/jeeves/docker/homepage/config/`:

| File             | Purpose                                       |
|------------------|-----------------------------------------------|
| `settings.yaml`  | Dashboard title, theme, background, tab layout |
| `services.yaml`  | All service entries grouped by tab and category |
| `widgets.yaml`   | Top-bar info widgets (system stats, weather, etc.) |
| `bookmarks.yaml` | Bookmark groups (if used)                     |
| `docker.yaml`    | Docker socket connection config for container status |

## Volumes / Bind Mounts

| Host Path                              | Container Path        | Purpose                    |
|----------------------------------------|-----------------------|----------------------------|
| `/home/jeeves/docker/homepage/config`  | `/app/config`         | All dashboard config files |
| `/var/run/docker.sock`                 | `/var/run/docker.sock:ro` | Read-only Docker socket for container status |

## Dependencies

- Docker socket for live container status display
- Traefik on `traefik-net` for the `centerpoint.home.local` route
- API keys for service widgets (stored in `services.yaml` — secrets should be kept
  in a `secrets.yaml` file or environment variables, not committed to version control)

## Notes / Gotchas

- Homepage requires group names to be **globally unique** across all tabs. The
  `(LAN)` and `(IP)` suffixes on group names solve this constraint for the multi-tab
  layout.
- `HOMEPAGE_ALLOWED_HOSTS` must include any hostname or IP:port used to access the
  dashboard; otherwise Homepage returns a 403.
- The Docker socket mount allows Homepage to show live container state. Homepage
  accesses it read-only and does not have the ability to start or stop containers.
- Config changes take effect immediately on file save — no container restart required.
- Quick Launch search only finds services listed in `services.yaml`. To enable
  search indexing for descriptions, set `searchDescriptions: true` in `settings.yaml`.

---
*Last Updated: 2026-06-16*

# AI & Automation

# Chapter Introduction

## Overview

This chapter documents the AI inference, voice processing, and workflow automation
services running on Centerpoint. All GPU-accelerated workloads leverage an NVIDIA
GeForce RTX 5080 connected to the Mini PC via OcuLink, providing 16 GB of GDDR7
VRAM on the Blackwell architecture (Compute Capability 12.0).

## GPU Hardware

| Property               | Value                              |
|------------------------|------------------------------------|
| GPU                    | NVIDIA GeForce RTX 5080            |
| VRAM                   | 16 GB GDDR7 (16,303 MiB)           |
| Architecture           | Blackwell (Compute Capability 12.0) |
| Connection             | OcuLink (external GPU enclosure)   |
| NVIDIA Driver          | 580.159.03                         |
| CUDA Version           | 12.9                               |
| Container Runtime      | `nvidia` (all GPU containers)      |

All containers that use the GPU are launched with `runtime: nvidia` and
`NVIDIA_VISIBLE_DEVICES=all`. No device passthrough via `--device /dev/dri` is
used — the Intel Arc / IPEX-LLM path has been retired.

## Services in This Chapter

| Service            | Container(s)                             | GPU | Purpose                                      |
|--------------------|------------------------------------------|-----|----------------------------------------------|
| **Ollama**         | `ollama`                                 | Yes | Local LLM inference backend                  |
| **Open Web UI**    | `open-webui`                             | No  | Chat interface for Ollama and OpenAI APIs    |
| **Faster-Whisper** | `faster-whisper`                         | Yes | Speech-to-text (Whisper large-v3-turbo)      |
| **Kokoro**         | `kokoro`                                 | Yes | Text-to-speech (TTS) API                     |
| **Riffado**        | `riffado`, `riffado-db`                  | No  | AI audio podcast app (formerly OpenPlaud)    |
| **PaperlessAI**    | `paperless-ai`                           | No  | Autonomous document classification via Ollama |
| **N8N**            | `n8n`                                    | No  | Workflow automation platform                  |
| **Open Notebook**  | `open-notebook`, `open-notebook-db`      | No  | AI research notebook (SurrealDB backend)     |
| **MCP GitHub**     | `mcp-github-proxy`, `github-mcp-server`  | No  | GitHub MCP server with OAuth 2.1             |

## Compose Project

Most AI stack services (Ollama, Open Web UI, Kokoro, Faster-Whisper, Open Notebook,
Riffado) are managed as a single Portainer compose project called **`ai-stack`** on
a shared `ai-internal` bridge network plus `traefik-net`. N8N, PaperlessAI, and
MCP GitHub are separate Portainer stacks.

---
*Last Updated: 2026-06-16*

# Ollama

## Overview

Ollama is the local large language model (LLM) inference backend for the homelab.
It serves models via an OpenAI-compatible REST API and is consumed by Open Web UI,
PaperlessAI, and any other service that needs LLM inference without sending data to
external providers.

Ollama runs with full NVIDIA RTX 5080 acceleration via the `nvidia` container
runtime. All model weights are stored on the local NVMe system drive.

## Access

| Type      | URL                            | Notes                            |
|-----------|--------------------------------|----------------------------------|
| Internal  | `https://ollama.home.local`    | Traefik-proxied HTTPS            |
| Direct    | `http://192.168.1.85:11434`    | Raw API (no TLS)                 |

No external (internet-facing) route — LAN and Tailscale access only.

## Configuration

**Image:** `ollama/ollama:latest`
**Compose project:** `ai-stack`
**Runtime:** `nvidia`

### Ports

| Port    | Protocol | Purpose                              |
|---------|----------|--------------------------------------|
| `11434` | TCP      | Ollama REST API (host-bound)         |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.docker.network: traefik-net
traefik.http.routers.ollama.rule: Host(`ollama.home.local`)
traefik.http.routers.ollama.entrypoints: websecure
traefik.http.routers.ollama.tls.certresolver: step-ca
traefik.http.services.ollama.loadbalancer.server.port: 11434
```

Internal-only route, no authentication middleware — API access is unrestricted on
the LAN. Callers must be on the LAN or Tailscale.

### Environment Variables

| Variable                     | Value    | Purpose                                 |
|------------------------------|----------|-----------------------------------------|
| `OLLAMA_HOST`                | `0.0.0.0`| Listen on all interfaces                |
| `NVIDIA_VISIBLE_DEVICES`     | `all`    | Expose all NVIDIA GPUs to container     |
| `NVIDIA_DRIVER_CAPABILITIES` | `compute,utility` | Required NVIDIA driver caps  |
| `OLLAMA_NUM_GPU`             | `999`    | Use all available GPU layers            |
| `no_proxy`                   | `localhost,127.0.0.1` | Bypass proxy for local calls |

### GPU Acceleration

Ollama uses the `nvidia` container runtime. The RTX 5080 provides 16 GB of VRAM,
allowing large models (7B–27B parameter range) to run fully in VRAM without CPU
offloading.

```yaml
runtime: nvidia
environment:
  - NVIDIA_VISIBLE_DEVICES=all
  - NVIDIA_DRIVER_CAPABILITIES=compute,utility
```

## Volumes / Bind Mounts

| Host Path                        | Container Path   | Purpose                      |
|----------------------------------|------------------|------------------------------|
| `/home/jeeves/docker/ollama/`    | `/root/.ollama`  | Model weights and config     |

Model files are stored under `/home/jeeves/docker/ollama/models/` on the local
NVMe system drive (1.8 TB). Large models can consume significant space.

## Networks

| Network                 | Purpose                         |
|-------------------------|---------------------------------|
| `ai-stack_ai-internal`  | Internal communication with Open Web UI, PaperlessAI |
| `traefik-net`           | Exposes Ollama API via Traefik  |

## Dependencies

- NVIDIA container runtime (`nvidia`) on the Docker daemon
- RTX 5080 connected via OcuLink (must be recognised as a CUDA device)

## Notes / Gotchas

- `OLLAMA_NUM_GPU=999` is the conventional way to tell Ollama to use as many GPU
  layers as possible. It does not literally use 999 GPUs.
- Models are downloaded via `ollama pull <model>` or via the Open Web UI admin panel.
  Downloaded models persist in the bind-mounted `/root/.ollama` directory.
- If the OcuLink connection drops or the GPU is not recognised, Ollama falls back to
  CPU inference — responses will be significantly slower. Check with:
  ```bash
  docker exec -it ollama ollama ps
  ```
- The local file at `/home/jeeves/docker/ai-stack/ollama-intel-arc/docker-compose.yml`
  is the legacy IPEX-LLM config and is **no longer in use**. The current stack is
  managed via Portainer and uses `ollama/ollama:latest` with CUDA.

---
*Last Updated: 2026-06-16*

# Open-WebUI

## Overview

Open Web UI is the primary chat and AI management interface for the homelab. It
provides a ChatGPT-style web frontend connected to the local Ollama backend, with
support for conversation history, model selection, RAG (document chat), image
generation, and tool use. It can also proxy to external OpenAI-compatible APIs.

## Access

| Type      | URL                               | Notes                                         |
|-----------|-----------------------------------|-----------------------------------------------|
| Internal  | `https://ai.home.local`           | LAN access via Step-CA TLS                    |
| External  | `https://ai.jeeves5454.ddns.net`  | Internet-facing — Authentik SSO + GeoBlock + CrowdSec |

## Configuration

**Image:** `ghcr.io/open-webui/open-webui:main`
**Compose project:** `ai-stack`

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `3015` | TCP      | Web UI (mapped from internal `8080`) |

### Traefik Labels

```yaml
# Internal route
traefik.http.routers.openwebui-internal.rule: Host(`ai.home.local`)
traefik.http.routers.openwebui-internal.entrypoints: websecure
traefik.http.routers.openwebui-internal.tls.certresolver: step-ca
traefik.http.routers.openwebui-internal.service: openwebui-svc

# External route
traefik.http.routers.openwebui-external.rule: Host(`ai.jeeves5454.ddns.net`)
traefik.http.routers.openwebui-external.entrypoints: websecure
traefik.http.routers.openwebui-external.tls.certresolver: letsencrypt
traefik.http.routers.openwebui-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.openwebui-external.service: openwebui-svc

traefik.http.services.openwebui-svc.loadbalancer.server.port: 8080
```

### Environment Variables

| Variable                       | Value / Notes                                    |
|--------------------------------|--------------------------------------------------|
| `WEBUI_AUTH`                   | `False` — authentication handled by Authentik    |
| `ENABLE_OLLAMA_API`            | `True`                                           |
| `ENABLE_OPENAI_API`            | `True`                                           |
| `ENABLE_IMAGE_GENERATION`      | `True`                                           |
| `IMAGE_GENERATION_ENGINE`      | `automatic1111`                                  |
| `IMAGE_GENERATION_MODEL`       | `dreamshaper_8`                                  |
| `IMAGE_SIZE`                   | `400x400`                                        |
| `IMAGE_STEPS`                  | `8`                                              |
| `AUTOMATIC1111_BASE_URL`       | `http://stable-diffusion:7860/`                  |
| `AUTOMATIC1111_CFG_SCALE`      | `2`                                              |
| `AUTOMATIC1111_SAMPLER`        | `DPM++ SDE`                                      |
| `AUTOMATIC1111_SCHEDULER`      | `Karras`                                         |

> `WEBUI_AUTH=False` disables Open Web UI's own login page. Authentication is
> delegated entirely to Authentik ForwardAuth on the external route. On the
> internal LAN route, the interface is open — access is controlled by network
> boundary only.

## Volumes / Bind Mounts

| Host Path / Volume                  | Container Path        | Purpose                        |
|-------------------------------------|-----------------------|--------------------------------|
| `open_webui_open-webui-data`        | `/app/backend/data`   | Conversation history, settings, uploaded docs (named volume, external) |

## Networks

| Network                 | Purpose                                         |
|-------------------------|-------------------------------------------------|
| `ai-stack_ai-internal`  | Reaches Ollama backend on `ai-internal` network |
| `traefik-net`           | Exposes the web UI via Traefik                  |

## Dependencies

- `ollama` — must be running for model inference; Open Web UI will start without it
  but model requests will fail
- Authentik — required for external route SSO; LAN route is unaffected if Authentik
  is down

## Notes / Gotchas

- The `main` image tag tracks the latest development build. For stability, consider
  pinning to a tagged release (e.g. `v0.6.x`).
- `WEBUI_AUTH=False` means **anyone on the LAN** can access the internal URL without
  credentials. If untrusted devices are on the LAN, consider enabling WEBUI_AUTH and
  creating user accounts, or adding Authentik middleware to the internal route as well.
- Conversation history and user settings are stored in the named Docker volume. Back
  this up before upgrades.
- Open Web UI admin panel is at `https://ai.home.local/admin/` — first user to register
  (if auth is enabled) becomes the admin.
- Image generation requires the stable-diffusion container to be running (separate
  service, also on `ai-internal` network).

---
*Last Updated: 2026-06-16*

# Faster Whisper

## Overview

Faster-Whisper is the speech-to-text transcription service for the homelab. It
runs OpenAI's Whisper model via the `faster-whisper` library (CTranslate2 backend),
which offers significantly faster inference than the original Whisper implementation
at equivalent or lower VRAM usage.

It is used by N8N automation workflows for audio transcription tasks (e.g. podcast
processing in the Minuspod pipeline).

## Access

| Type      | URL                             | Notes                              |
|-----------|---------------------------------|------------------------------------|
| Internal  | `https://whisper.home.local`    | LAN access via Step-CA TLS         |

No external route — internal automation use only.

## Configuration

**Image:** `hwdsl2/whisper-server:cuda`
**Compose project:** `ai-stack`
**Runtime:** `nvidia`
**CUDA Version:** 12.9

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `9015` | TCP      | HTTP API (mapped from internal `9000`) |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.docker.network: traefik-net
traefik.http.routers.whisper.rule: Host(`whisper.home.local`)
traefik.http.routers.whisper.entrypoints: websecure
traefik.http.routers.whisper.tls.certresolver: step-ca
traefik.http.services.whisper.loadbalancer.server.port: 9000
```

### Environment Variables

| Variable                     | Value              | Purpose                               |
|------------------------------|--------------------|---------------------------------------|
| `WHISPER_MODEL`              | `large-v3-turbo`   | Whisper model variant to load         |
| `WHISPER_DEVICE`             | `cuda`             | Run inference on GPU                  |
| `WHISPER_COMPUTE_TYPE`       | `float16`          | FP16 precision (optimal for CUDA)     |
| `WHISPER_LANGUAGE`           | `en`               | Default transcription language        |
| `NVIDIA_VISIBLE_DEVICES`     | `all`              | Expose all NVIDIA GPUs                |
| `NVIDIA_DRIVER_CAPABILITIES` | `compute,utility`  | Required NVIDIA driver capabilities   |

**Model:** `large-v3-turbo` — the distilled variant of Whisper large-v3, offering
near-large accuracy at roughly 3× the speed and reduced VRAM usage.

### GPU Acceleration

```yaml
runtime: nvidia
environment:
  - NVIDIA_VISIBLE_DEVICES=all
  - WHISPER_DEVICE=cuda
  - WHISPER_COMPUTE_TYPE=float16
```

## Volumes / Bind Mounts

| Host Path                                     | Container Path        | Purpose                    |
|-----------------------------------------------|-----------------------|----------------------------|
| `/home/jeeves/docker/ai-stack/whisper/files`  | `/var/lib/whisper`    | Transcription input/output files |

Model weights are downloaded to a temp directory inside the container on first
start and cached within the container layer (not persisted in a named volume).

## Networks

| Network                 | Purpose                             |
|-------------------------|-------------------------------------|
| `ai-stack_ai-internal`  | Internal access from N8N workflows  |
| `traefik-net`           | Exposes API via Traefik             |

## API Usage

The whisper-server exposes a simple HTTP POST endpoint:

```bash
curl -X POST https://whisper.home.local/inference \
  -F file=@audio.mp3 \
  -F response_format=json
```

Response:

```json
{"text": "Transcribed text here..."}
```

## Dependencies

- NVIDIA container runtime with RTX 5080 access
- N8N (primary consumer via HTTP calls)

## Notes / Gotchas

- The `large-v3-turbo` model is downloaded on first container start. This can take
  several minutes and the container will appear unresponsive until the download
  completes.
- `float16` compute type requires a GPU with FP16 support. The RTX 5080 (Blackwell)
  supports this natively. On CPU-only fallback, use `int8` instead.
- The `files` bind mount (`/var/lib/whisper`) can be used to pre-stage audio files
  for batch transcription if needed.

---
*Last Updated: 2026-06-16*

# Kokoro

## Overview

Kokoro is a high-quality, locally-hosted text-to-speech (TTS) service. It exposes
an OpenAI-compatible TTS API, making it a drop-in replacement for external TTS
services in any application that supports the `/v1/audio/speech` endpoint.

It runs the Kokoro TTS model via the `kokoro-fastapi` server with full RTX 5080
GPU acceleration.

## Access

| Type      | URL                             | Notes                              |
|-----------|---------------------------------|------------------------------------|
| Internal  | `https://kokoro.home.local`     | LAN access via Step-CA TLS         |

No external route — internal service only.

## Configuration

**Image:** `ghcr.io/remsky/kokoro-fastapi-gpu:latest-cu128`
**Compose project:** `ai-stack`
**Runtime:** `nvidia`
**CUDA Version:** 12.8 (image) / 12.9 (host driver, backward compatible)

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `8880` | TCP      | Kokoro FastAPI TTS API               |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.docker.network: traefik-net
traefik.http.routers.kokoro.rule: Host(`kokoro.home.local`)
traefik.http.routers.kokoro.entrypoints: websecure
traefik.http.routers.kokoro.tls.certresolver: step-ca
traefik.http.services.kokoro.loadbalancer.server.port: 8880
```

### GPU Acceleration

```yaml
runtime: nvidia
environment:
  - NVIDIA_VISIBLE_DEVICES=all
  - DEVICE=gpu
  - USE_GPU=true
```

The image tag `latest-cu128` targets CUDA 12.8. The host driver (580.159.03) is
fully forward-compatible with this image.

## Volumes / Bind Mounts

| Host Path                         | Container Path | Purpose                          |
|-----------------------------------|----------------|----------------------------------|
| `/home/jeeves/docker/kokoro`      | `/app/data`    | Voice models and cached data     |

## Networks

| Network                 | Purpose                             |
|-------------------------|-------------------------------------|
| `ai-stack_ai-internal`  | Internal access from Open Web UI and N8N |
| `traefik-net`           | Exposes API via Traefik             |

## API Usage

Kokoro exposes an OpenAI-compatible TTS endpoint:

```bash
curl -X POST https://kokoro.home.local/v1/audio/speech \
  -H "Content-Type: application/json" \
  -d '{
    "model": "kokoro",
    "input": "Hello from the homelab.",
    "voice": "af_sky",
    "response_format": "mp3"
  }' --output speech.mp3
```

Available voices and model details are listed at `https://kokoro.home.local/docs`
(Swagger UI).

## Dependencies

- NVIDIA container runtime with RTX 5080 access

## Notes / Gotchas

- The `latest-cu128` tag pulls the latest build compiled against CUDA 12.8. NVIDIA
  driver 580.x supports CUDA 12.9 on the host, which is backward-compatible.
- Voice model files are cached in the `/app/data` bind mount on first use — initial
  synthesis requests may be slower while models are downloaded.
- Kokoro is OpenAI API-compatible, meaning Open Web UI can be configured to use it
  as its TTS provider via the admin settings.

---
*Last Updated: 2026-06-16*

# Riffado (OpenPlaud)

## Overview

Riffado (formerly known as OpenPlaud) is a self-hosted AI audio and podcast
management application. It handles audio file storage, playback, and AI-assisted
processing within the homelab ecosystem.

Audio files are stored on the Ceph OSD volume (`1TB_Vol1`) rather than the system
NVMe, keeping large media off the primary drive.

## Access

| Type      | URL                                    | Notes                                           |
|-----------|----------------------------------------|-------------------------------------------------|
| Internal  | `https://riffado.home.local`           | LAN access via Step-CA TLS                      |
| External  | `https://riffado.jeeves5454.ddns.net`  | Internet-facing — Authentik SSO + GeoBlock + CrowdSec |

## Containers in This Stack

| Container    | Image                           | Role                        |
|--------------|---------------------------------|-----------------------------|
| `riffado`    | `ghcr.io/riffado/riffado:latest`| Application server          |
| `riffado-db` | `postgres:16-alpine`            | PostgreSQL database backend |

## Configuration

**Compose project:** `ai-stack`

### Environment Variables

| Variable                 | Value / Notes                                        |
|--------------------------|------------------------------------------------------|
| `APP_URL`                | `https://riffado.jeeves5454.ddns.net`                |
| `HOSTNAME`               | `0.0.0.0`                                            |
| `DATABASE_URL`           | `postgresql://postgres:**REDACTED**@riffado-db:5432/riffado` |
| `DEFAULT_STORAGE_TYPE`   | `local`                                              |
| `LOCAL_STORAGE_PATH`     | `/app/storage`                                       |
| `DISABLE_REGISTRATION`   | `true`                                               |
| `BETTER_AUTH_SECRET`     | **REDACTED**                                         |
| `ENCRYPTION_KEY`         | **REDACTED**                                         |
| `NODE_ENV`               | `production`                                         |

`DISABLE_REGISTRATION=true` prevents new accounts from being created — access is
limited to pre-provisioned users and gated by Authentik on the external route.

### Traefik Labels

```yaml
# External route
traefik.http.routers.riffado-ext.rule: Host(`riffado.jeeves5454.ddns.net`)
traefik.http.routers.riffado-ext.entrypoints: websecure
traefik.http.routers.riffado-ext.tls.certresolver: letsencrypt
traefik.http.routers.riffado-ext.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.riffado-ext.service: riffado-svc

# Internal route
traefik.http.routers.riffado-int.rule: Host(`riffado.home.local`)
traefik.http.routers.riffado-int.entrypoints: websecure
traefik.http.routers.riffado-int.tls.certresolver: step-ca
traefik.http.routers.riffado-int.service: riffado-svc

traefik.http.services.riffado-svc.loadbalancer.server.port: 3000
```

## Volumes / Bind Mounts

| Host Path                                          | Container Path  | Purpose                      |
|----------------------------------------------------|-----------------|------------------------------|
| `/media/jeeves/1TB_Vol1/docker/riffado/audio`      | `/app/storage`  | Audio file storage (Ceph OSD) |

Audio files are stored on the Ceph-managed OSD volume (`nvme2n1`, mounted at
`/media/jeeves/1TB_Vol1`). This keeps large audio files off the system NVMe and
on the dedicated storage volume.

### Sub-section: PostgreSQL Database

`riffado-db` is a dedicated Postgres 16-alpine sidecar managing all Riffado
application state: user accounts, playlists, audio metadata, and processing
history. It is not shared with any other service.

| Host Path / Volume               | Container Path              | Purpose               |
|----------------------------------|-----------------------------|-----------------------|
| `/home/jeeves/docker/riffado/db` | `/var/lib/postgresql/data`  | PostgreSQL data files |

The database container is on the `riffado-internal` network only — it is never
exposed to `traefik-net`.

## Networks

| Network              | Purpose                                      |
|----------------------|----------------------------------------------|
| `traefik-net`        | Exposes the Riffado web UI                   |
| `riffado-internal`   | `riffado` ↔ `riffado-db` communication      |

## Dependencies

- `riffado-db` (must be healthy before `riffado` starts)
- Authentik for SSO on external route
- Ceph OSD volume must be mounted at `/media/jeeves/1TB_Vol1` before the container
  starts, otherwise the audio storage path is unavailable

## Notes / Gotchas

- If the Ceph OSD volume is unmounted or degraded, audio file operations will fail
  even though the container itself runs normally.
- `APP_URL` must match the externally accessible URL. Changing this after initial
  setup requires updating any stored links.
- The `BETTER_AUTH_SECRET` and `ENCRYPTION_KEY` must remain constant — changing
  them invalidates all existing sessions and encrypted data.

---
*Last Updated: 2026-06-16*

# Paperless-AI

## Overview

PaperlessAI is an AI companion service for Paperless-NGX that automates document
classification. It monitors the Paperless document inbox and uses a local Ollama
model to automatically assign tags, document types, correspondents, and titles to
newly ingested documents — eliminating the need for manual categorisation.

It communicates with Ollama over the internal `ai-internal` network and with
Paperless-NGX via its API over the external HTTPS URL.

## Access

| Type      | URL                                       | Notes                                           |
|-----------|-------------------------------------------|-------------------------------------------------|
| Internal  | `https://paperlessai.home.local`          | Authentik SSO required                          |
| External  | `https://paperlessai.jeeves5454.ddns.net` | Authentik SSO + GeoBlock + CrowdSec             |

## Configuration

**Image:** `clusterzx/paperless-ai:latest`
**Compose project:** Paperless stack (managed via Portainer, separate from `ai-stack`)

### Environment Variables

| Variable            | Value / Notes                                            |
|---------------------|----------------------------------------------------------|
| `LLM_PROVIDER`      | `ollama`                                                 |
| `OLLAMA_URL`        | `http://ollama.home.local:11434`                         |
| `OLLAMA_MODEL`      | `gemma4:12b`                                             |
| `PAPERLESS_URL`     | `https://paperless.jeeves5454.ddns.net`                  |
| `PAPERLESS_API_KEY` | **REDACTED**                                             |
| `AUTO_MODE`         | `true` — processes documents automatically               |
| `AUTO_TAG`          | `ai-processed` — applied to every AI-classified document |
| `NODE_ENV`          | `production`                                             |

`AUTO_MODE=true` means PaperlessAI polls the Paperless inbox on a schedule and
processes new documents without any manual trigger. The `ai-processed` tag is
applied to documents after classification, allowing easy filtering in Paperless.

### Traefik Labels

```yaml
# External route
traefik.http.routers.paperlessai-ext.rule: Host(`paperlessai.jeeves5454.ddns.net`)
traefik.http.routers.paperlessai-ext.entrypoints: websecure
traefik.http.routers.paperlessai-ext.tls.certresolver: letsencrypt
traefik.http.routers.paperlessai-ext.middlewares: plex-geoblock@file,crowdsec-bouncer@file,authentik-auth@docker
traefik.http.routers.paperlessai-ext.service: paperlessai-svc

# Internal route
traefik.http.routers.paperlessai-int.rule: Host(`paperlessai.home.local`)
traefik.http.routers.paperlessai-int.entrypoints: websecure
traefik.http.routers.paperlessai-int.tls.certresolver: step-ca
traefik.http.routers.paperlessai-int.middlewares: authentik-auth@docker
traefik.http.routers.paperlessai-int.service: paperlessai-svc

traefik.http.services.paperlessai-svc.loadbalancer.server.port: 3000
```

Note that Authentik middleware is applied on **both** internal and external routes.

## Volumes / Bind Mounts

| Host Path                                       | Container Path | Purpose                       |
|-------------------------------------------------|----------------|-------------------------------|
| `/home/jeeves/docker/paperless/paperless-ai-data` | `/app/data`  | PaperlessAI configuration and state |

## Networks

PaperlessAI must be on the same network as Ollama to use `http://ollama.home.local:11434`.
It reaches Paperless-NGX via its external HTTPS URL (outbound through Traefik).

## Dependencies

- **Ollama** — must be running with the `gemma4:12b` model loaded for classification
  to work. Pull the model first:
  ```bash
  docker exec -it ollama ollama pull gemma4:12b
  ```
- **Paperless-NGX** — must be accessible at `https://paperless.jeeves5454.ddns.net`
  with a valid API key
- Authentik for SSO on both routes

## Notes / Gotchas

- If Ollama is down or the model isn't loaded, documents will queue and not be
  processed until Ollama is available again.
- The `AUTO_TAG` (`ai-processed`) helps track which documents were classified by AI
  vs manually. Review newly tagged documents periodically to validate AI accuracy.
- `gemma4:12b` (12 billion parameters) fits comfortably within the RTX 5080's 16 GB
  VRAM. If Ollama is under pressure from simultaneous inference requests, PaperlessAI
  classification may be slower.
- PaperlessAI connects to Paperless via the external URL
  (`https://paperless.jeeves5454.ddns.net`). Ensure this domain remains reachable
  from within the container network.

---
*Last Updated: 2026-06-16*

# n8n

## Overview

N8N is the workflow automation platform for the homelab. It connects services
together through visual, node-based workflows — handling tasks such as document
pipeline automation, audio transcription orchestration (Minuspod), API integrations,
scheduled jobs, and webhook-triggered processing.

N8N is the glue layer that ties together Whisper, Ollama, Paperless, and external
APIs into end-to-end automated pipelines.

## Access

| Type      | URL                          | Notes                                        |
|-----------|------------------------------|----------------------------------------------|
| Internal  | `https://n8n.home.local`     | LAN access via Step-CA TLS — basic auth      |

No external (internet-facing) Traefik route. Remote access via Tailscale.

Webhooks are received at `https://n8n.home.local/webhook/...` — internal and
Tailscale-reachable only.

## Configuration

**Image:** `n8nio/n8n:latest`
**Compose project:** Standalone (managed via Portainer)

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `5678` | TCP      | N8N web UI and API (host-bound)      |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.docker.network: traefik-net
traefik.http.routers.n8n.rule: Host(`n8n.home.local`)
traefik.http.routers.n8n.entrypoints: websecure
traefik.http.routers.n8n.tls.certresolver: step-ca
traefik.http.services.n8n.loadbalancer.server.port: 5678
```

Internal-only route.

### Environment Variables

| Variable                  | Value / Notes                                        |
|---------------------------|------------------------------------------------------|
| `N8N_HOST`                | `n8n.home.local`                                     |
| `N8N_PROTOCOL`            | `https`                                              |
| `N8N_PORT`                | `5678`                                               |
| `N8N_EDITOR_BASE_URL`     | `https://n8n.home.local`                             |
| `WEBHOOK_URL`             | `https://n8n.home.local`                             |
| `N8N_BASIC_AUTH_ACTIVE`   | `true`                                               |
| `N8N_BASIC_AUTH_USER`     | `jeeves`                                             |
| `N8N_BASIC_AUTH_PASSWORD` | **REDACTED**                                         |
| `N8N_ENCRYPTION_KEY`      | **REDACTED** — encrypts stored credentials           |
| `N8N_PAYLOAD_SIZE_MAX`    | `16` (MB)                                            |
| `EXECUTIONS_PROCESS`      | `main`                                               |
| `GENERIC_TIMEZONE`        | `America/Toronto`                                    |

`N8N_ENCRYPTION_KEY` encrypts all stored credentials (API keys, passwords) in the
N8N database. This key must remain constant — changing it invalidates all stored
credentials and they must be re-entered.

## Volumes / Bind Mounts

| Host Path                       | Container Path       | Purpose                           |
|---------------------------------|----------------------|-----------------------------------|
| `/home/jeeves/docker/n8n/data`  | `/home/node/.n8n`    | Workflows, credentials, execution history |

All workflow definitions, credentials, and execution logs are stored in this
bind-mounted directory. Back up before upgrades.

## Networks

| Network       | Purpose                                   |
|---------------|-------------------------------------------|
| `traefik-net` | Exposes N8N UI and webhook endpoint       |

N8N calls other services by hostname (e.g. `http://ollama.home.local:11434`,
`https://whisper.home.local`) — it must be on `traefik-net` or have DNS
resolution for these names.

## Dependencies

- AdGuard Home for `*.home.local` DNS resolution (N8N calls other services by name)
- Step-CA / Traefik for the `n8n.home.local` route
- Services called by workflows: Ollama, Faster-Whisper, Paperless-NGX, external APIs

## Notes / Gotchas

- N8N stores all credentials encrypted with `N8N_ENCRYPTION_KEY`. If this key is
  lost or rotated, all stored API keys and passwords must be re-entered manually.
- `EXECUTIONS_PROCESS=main` runs workflow executions in the main process. For high
  concurrency, consider `EXECUTIONS_PROCESS=own` with worker processes, but this
  requires additional setup.
- Webhook URLs are internal only (`https://n8n.home.local/webhook/...`). External
  webhooks (e.g. from GitHub, Stripe) will not reach N8N unless a Tailscale exit
  node or Traefik external route is configured.
- `N8N_PAYLOAD_SIZE_MAX=16` limits incoming webhook payload size to 16 MB. Increase
  this if processing large audio or document payloads via webhook.
- The `latest` tag may introduce breaking changes between workflow schema versions.
  Pin to a specific N8N version before any significant workflow investments.

---
*Last Updated: 2026-06-16*

# Open Notebook

# Overview

Open Notebook is a self-hosted AI research notebook application — a local alternative
to Google NotebookLM. It allows structured AI-assisted research sessions where
sources (documents, URLs, text) are ingested into a notebook and queried via an LLM
for synthesis, summaries, and question-answering.

It uses SurrealDB as its backend database for storing notebooks, sources, and
conversation state.

## Access

| Type      | URL                                       | Notes                                           |
|-----------|-------------------------------------------|-------------------------------------------------|
| Internal  | `https://notebook.home.local`             | LAN access via Step-CA TLS                      |
| External  | `https://notebook.jeeves5454.ddns.net`    | Authentik SSO + GeoBlock + CrowdSec             |

## Containers in This Stack

| Container          | Image                            | Role                        |
|--------------------|----------------------------------|-----------------------------|
| `open-notebook`    | `lfnovo/open_notebook:v1-latest` | Application server (Streamlit) |
| `open-notebook-db` | `surrealdb/surrealdb:v2`         | SurrealDB database backend  |

## Configuration

**Compose project:** `ai-stack`

### Environment Variables

| Variable               | Value / Notes                                              |
|------------------------|------------------------------------------------------------|
| `API_URL`              | `https://notebook.home.local`                              |
| `CORS_ORIGINS`         | `https://notebook.home.local,https://notebook.jeeves5454.ddns.net` |
| `INTERNAL_API_URL`     | `http://localhost:5055`                                    |
| `SURREAL_URL`          | `ws://open-notebook-db:8000/rpc`                           |
| `SURREAL_NAMESPACE`    | `open_notebook`                                            |
| `SURREAL_DATABASE`     | `open_notebook`                                            |
| `SURREAL_USER`         | `root`                                                     |
| `SURREAL_PASSWORD`     | **REDACTED**                                               |
| `HOSTNAME`             | `0.0.0.0`                                                  |

### Traefik Labels

```yaml
# External route
traefik.http.routers.open-notebook-ext.rule: Host(`notebook.jeeves5454.ddns.net`)
traefik.http.routers.open-notebook-ext.entrypoints: websecure
traefik.http.routers.open-notebook-ext.tls.certresolver: letsencrypt
traefik.http.routers.open-notebook-ext.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.open-notebook-ext.service: open-notebook-ui-svc
traefik.http.routers.open-notebook-ext.priority: 1

# Internal route
traefik.http.routers.open-notebook-int.rule: Host(`notebook.home.local`)
traefik.http.routers.open-notebook-int.entrypoints: websecure
traefik.http.routers.open-notebook-int.tls.certresolver: step-ca
traefik.http.routers.open-notebook-int.service: open-notebook-ui-svc
traefik.http.routers.open-notebook-int.priority: 1

traefik.http.services.open-notebook-ui-svc.loadbalancer.server.port: 8502
```

## Volumes / Bind Mounts

| Host Path                                                      | Container Path | Purpose                     |
|----------------------------------------------------------------|----------------|-----------------------------|
| `/media/jeeves/1TB_Vol1/docker/open-notebook/notebook_data`    | `/app/data`    | Notebook content and uploads (Ceph OSD) |
| `/home/jeeves/docker/open-notebook/surreal_data`               | `/mydata`      | SurrealDB data files        |

Notebook data (uploaded sources, generated content) is stored on the Ceph OSD
volume (`1TB_Vol1`) to keep large research files off the system NVMe.

### Sub-section: SurrealDB

`open-notebook-db` runs SurrealDB v2, a multi-model database used by Open Notebook
to store all notebook definitions, source metadata, embeddings, and conversation
history.

SurrealDB is connected to `open-notebook` via WebSocket at
`ws://open-notebook-db:8000/rpc` over the `ai-internal` network. It is not
exposed to `traefik-net` — no external access.

## Networks

| Network                 | Purpose                                         |
|-------------------------|-------------------------------------------------|
| `ai-stack_ai-internal`  | `open-notebook` ↔ `open-notebook-db` communication |
| `traefik-net`           | Exposes the Open Notebook UI via Traefik        |

`open-notebook-db` is on `ai-internal` only and never on `traefik-net`.

## Dependencies

- `open-notebook-db` (SurrealDB must be running before the app starts)
- Ollama or an external LLM API configured within the app settings
- Authentik for SSO on the external route
- Ceph OSD volume mounted at `/media/jeeves/1TB_Vol1`

## Notes / Gotchas

- The `v1-latest` image tag tracks the latest v1 stable release. Breaking changes
  between major versions may require a database migration.
- SurrealDB stores its data in the bind-mounted `surreal_data` directory. This must
  be backed up before upgrades to SurrealDB v3+ as schema changes are not
  automatically reversible.
- LLM provider settings (which Ollama model to use, API keys for external providers)
  are configured inside the Open Notebook UI, not via environment variables.
- `CORS_ORIGINS` must include both the internal and external URLs — missing one will
  cause browser CORS errors for the corresponding route.

---
*Last Updated: 2026-06-16*

# 09-mcp-github.md

kstack:
  book: Centerpoint Home Lab
  chapter: AI & Automation
  page: MCP GitHub Server
  tags: [mcp, github, oauth2, ai, claude]
---

## Overview

The MCP GitHub Server exposes GitHub's Model Context Protocol (MCP) server over
HTTPS with OAuth 2.1 authentication, making it accessible to Claude.ai and other
MCP-compatible AI clients from anywhere on the internet.

The stack consists of two containers:

- **`mcp-github-proxy`** — a custom Node.js OAuth 2.1 proxy (locally built image)
  that validates JWTs from Authentik before forwarding requests to the MCP server.
- **`github-mcp-server`** — the official GitHub MCP server running in HTTP mode,
  internal-only, with access scoped to repos and issues.

## Access

| Type      | URL                                       | Notes                                             |
|-----------|-------------------------------------------|---------------------------------------------------|
| Internal  | `https://mcp-github.home.local`           | LAN access via Step-CA TLS                        |
| External  | `https://mcp-github.jeeves5454.ddns.net`  | OAuth 2.1 authenticated — no Authentik ForwardAuth |

The external route uses **OAuth 2.1** (not Authentik ForwardAuth) as the auth layer.
GeoBlock and CrowdSec are still applied. The OAuth 2.1 issuer is Authentik.

## Containers in This Stack

| Container            | Image                                       | Role                                     |
|----------------------|---------------------------------------------|------------------------------------------|
| `mcp-github-proxy`   | `mcp-github-proxy:latest` (local build)     | OAuth 2.1 proxy + MCP request forwarder  |
| `github-mcp-server`  | `ghcr.io/github/github-mcp-server:latest`   | GitHub MCP server (HTTP mode, internal)  |

## Configuration

**Compose project:** `mcp-github`
**Compose file:** `/home/jeeves/docker/mcp-github/proxy/docker-compose.yml`

### `mcp-github-proxy` Environment Variables

| Variable                | Value / Notes                                                   |
|-------------------------|-----------------------------------------------------------------|
| `PORT`                  | `3000`                                                          |
| `GITHUB_MCP_URL`        | `http://github-mcp-server:8080`                                 |
| `GITHUB_PERSONAL_ACCESS_TOKEN` | **REDACTED** — PAT with read-only scopes (Contents, Issues, Metadata) |
| `AUTHENTIK_ISSUER`      | `https://auth.jeevesconsults.ca/application/o/mcp-github/`     |
| `AUTHENTIK_JWKS_URL`    | `https://auth.jeevesconsults.ca/application/o/mcp-github/jwks/` |
| `SERVER_URL`            | `https://mcp-github.jeeves5454.ddns.net`                        |

### `github-mcp-server` Environment Variables

| Variable                          | Value / Notes                                         |
|-----------------------------------|-------------------------------------------------------|
| `GITHUB_PERSONAL_ACCESS_TOKEN`    | **REDACTED** — same read-only PAT as proxy            |
| `GITHUB_TOOLSETS`                 | `repos,issues` — restricts available tools            |
| `GITHUB_READ_ONLY`                | `1` — belt-and-suspenders read-only flag              |

> **Security note:** `GITHUB_READ_ONLY=1` has a known bug in HTTP mode
> (github/github-mcp-server #2156) — toolset flags may not fully enforce read-only
> behaviour. Primary read-only enforcement is the PAT scopes themselves
> (Contents/Issues/Metadata: read only).

### Traefik Labels

```yaml
# External route — GeoBlock + CrowdSec only (OAuth 2.1 handles auth)
traefik.http.routers.mcp-github-ext.rule: Host(`mcp-github.jeeves5454.ddns.net`)
traefik.http.routers.mcp-github-ext.entrypoints: websecure
traefik.http.routers.mcp-github-ext.tls.certresolver: letsencrypt
traefik.http.routers.mcp-github-ext.middlewares: plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.mcp-github-ext.service: mcp-github-proxy-svc

# Internal route
traefik.http.routers.mcp-github-int.rule: Host(`mcp-github.home.local`)
traefik.http.routers.mcp-github-int.entrypoints: websecure
traefik.http.routers.mcp-github-int.tls.certresolver: step-ca
traefik.http.routers.mcp-github-int.service: mcp-github-proxy-svc

traefik.http.services.mcp-github-proxy-svc.loadbalancer.server.port: 3000
```

Note: Authentik ForwardAuth (`authentik-auth@docker`) is **not** used here.
OAuth 2.1 is the authentication mechanism — the proxy validates the `Authorization`
header JWT against Authentik's JWKS endpoint directly.

### Sub-section: GitHub MCP Server

`github-mcp-server` runs the official GitHub MCP server in HTTP mode on port `8080`,
internal-only. It is never directly exposed to Traefik or the host — all requests
arrive through the `mcp-github-proxy` over the `mcp-github-internal` network.

The server is restricted to `repos` and `issues` toolsets, providing read-only
access to repository content, metadata, and issues. Write operations are not
available via the PAT scopes.

### Proxy Image Build

The `mcp-github-proxy` image is built locally on Centerpoint before deployment:

```bash
cd /home/jeeves/docker/mcp-github/proxy
docker build -t mcp-github-proxy:latest .
```

Source files: `/home/jeeves/docker/mcp-github/proxy/{Dockerfile,package.json,src/}`

## Volumes / Bind Mounts

No persistent volumes required. Both containers are stateless — the proxy holds no
state, and the MCP server reads from GitHub's API on every request.

## Networks

| Network                       | Purpose                                             |
|-------------------------------|-----------------------------------------------------|
| `traefik-net`                 | Exposes `mcp-github-proxy` via Traefik              |
| `mcp-github_mcp-github-internal` | `mcp-github-proxy` ↔ `github-mcp-server` communication |

`github-mcp-server` is on the internal network only — never on `traefik-net`.

## Healthchecks

Both containers have healthchecks defined:

```yaml
healthcheck:
  test: ["CMD", "wget", "-qO-", "http://localhost:<port>/health"]
  interval: 30s
  timeout: 5s
  retries: 3
```

- Proxy healthcheck: `http://localhost:3000/health`
- MCP server healthcheck: `http://localhost:8080/health`

## Authentik OAuth 2.1 Provider Setup

In Authentik, an OAuth2/OIDC provider is configured for MCP GitHub with:

| Setting              | Value                                                   |
|----------------------|---------------------------------------------------------|
| Redirect URI         | `https://claude.ai/api/mcp/auth_callback`               |
| Issuer               | `https://auth.jeevesconsults.ca/application/o/mcp-github/` |
| JWKS URL             | `<issuer>/jwks/`                                        |

## Dependencies

- Authentik (`auth.jeevesconsults.ca`) — proxy validates JWTs against Authentik JWKS
- GitHub API (internet access required for the MCP server to function)
- Traefik on `traefik-net` for both routes

## Notes / Gotchas

- The GitHub PAT must be rotated before expiry. If it expires, all GitHub MCP tool
  calls will fail with 401 errors. Update in both container environment variables.
- The proxy image must be rebuilt after any source code changes:
  ```bash
  cd /home/jeeves/docker/mcp-github/proxy
  docker build -t mcp-github-proxy:latest .
  docker compose up -d --force-recreate mcp-github-proxy
  ```
- Claude.ai connects to this server via the external URL. The redirect URI
  (`https://claude.ai/api/mcp/auth_callback`) must be registered in the Authentik
  OAuth2 provider — adding any other redirect URI will cause the OAuth flow to fail.
- GeoBlock allows CA, US, and IN. Claude.ai's servers may originate from other
  regions — if MCP calls fail, check Traefik logs for GeoBlock rejections and
  adjust the country allowlist accordingly.

---
*Last Updated: 2026-06-16*

# h04-media-entertainment

# 00-chapter-intro.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Chapter Introduction
  tags: [media, plex, jellyfin, immich, audiobookshelf, stash]
---

## Overview

This chapter covers all media consumption and photo management services on
Centerpoint. Media files themselves are not stored locally — they live on the
UnRAID NAS (`192.168.1.119`) and are bind-mounted into containers from the
NFS shares at `/mnt/Multimedia` and `/mnt/Photos`.

## Shared Storage

| NFS Mount         | Contents                              | Consumers                                    |
|-------------------|---------------------------------------|----------------------------------------------|
| `/mnt/Multimedia` | Movies, TV, adult content, audiobooks, podcasts | Plex, Jellyfin, Emby, Audiobookshelf, Stash |
| `/mnt/Photos`     | Photo library, Google Photos imports  | Immich                                       |

## Services in This Chapter

| Service            | Container(s)                                                     | Status  | Purpose                              |
|--------------------|------------------------------------------------------------------|---------|--------------------------------------|
| **Plex**           | `plex`                                                           | Active  | Primary media server                 |
| **Jellyfin**       | `jellyfin`                                                       | Active  | Open-source media server (secondary) |
| **Emby**           | `emby`                                                           | Offline | Legacy media server — not running    |
| **Tautulli**       | `tautulli`                                                       | Active  | Plex analytics and monitoring        |
| **Immich**         | `immich_server`, `immich_machine_learning`, `immich_postgres`, `immich_redis` | Active | Photo & video library       |
| **Audiobookshelf** | `audiobookshelf`                                                 | Active  | Audiobooks and podcasts              |
| **Seerr**          | `seerr`                                                          | Active  | Media request management             |
| **Stash**          | `stash`, `stash-vr`                                              | Active  | Adult media library + VR frontend    |
| **Threadfin**      | `threadfin`                                                      | Active  | IPTV M3U proxy for Plex/Jellyfin     |

---
*Last Updated: 2026-06-16*

# 01-plex.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Plex
  tags: [plex, media, streaming]
---

## Overview

Plex Media Server is the primary media server for the homelab, serving movies,
TV shows, and other content to Plex clients on the local network and remotely.
It uses Plex's own account-based authentication — no Authentik ForwardAuth is
applied. GeoBlock is active on the external route.

A second Plex instance running on another device (`192.168.1.186`) is routed via
Traefik's file provider as `plex2.jeeves5454.ddns.net`.

## Access

| Type      | URL                                     | Notes                                       |
|-----------|-----------------------------------------|---------------------------------------------|
| External  | `https://plex.jeeves5454.ddns.net`      | GeoBlock (CA/US/IN), no Authentik — Plex account auth |
| Direct    | `http://192.168.1.85:32400`             | LAN direct access                           |

No `*.home.local` internal Traefik route — Plex is accessed externally or by direct
IP on the LAN.

## Configuration

**Image:** `lscr.io/linuxserver/plex:latest`
**Compose project:** Standalone (managed via Portainer)

### Ports

| Port    | Protocol | Purpose                               |
|---------|----------|---------------------------------------|
| `32400` | TCP      | Plex Media Server API and web UI      |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.plex.rule: Host(`plex.jeeves5454.ddns.net`)
traefik.http.routers.plex.entrypoints: websecure
traefik.http.routers.plex.tls.certresolver: letsencrypt
traefik.http.routers.plex.middlewares: plex-geoblock@file,plex-headers
traefik.http.middlewares.plex-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.plex.loadbalancer.server.port: 32400
```

The `plex-headers` middleware injects `X-Forwarded-Proto: https` — required for
Plex to generate correct redirect and callback URLs when behind a reverse proxy.

### Environment Variables

| Variable        | Value                                    | Purpose                              |
|-----------------|------------------------------------------|--------------------------------------|
| `PUID`          | `1000`                                   | Run as user ID 1000                  |
| `PGID`          | `1000`                                   | Run as group ID 1000                 |
| `TZ`            | `America/Toronto`                        | Timezone                             |
| `VERSION`       | `docker`                                 | Use the latest Plex from Docker Hub  |
| `ADVERTISE_IP`  | `https://plex.jeeves5454.ddns.net:443`   | External URL Plex advertises to clients |

`ADVERTISE_IP` must match the externally reachable URL for remote streaming to work
correctly when behind Traefik.

## Volumes / Bind Mounts

| Host Path                              | Container Path  | Purpose                          |
|----------------------------------------|-----------------|----------------------------------|
| `/home/jeeves/docker/plex/config`      | `/config`       | Plex database, metadata, settings |
| `/mnt/Multimedia`                      | `/Multimedia`   | All media files (NFS from UnRAID) |
| `/home/jeeves/docker`                  | `/docker`       | Utility bind (admin access)      |

## Networks

| Network       | Purpose                              |
|---------------|--------------------------------------|
| `traefik-net` | Exposes Plex via Traefik external route |

## Dependencies

- NFS mount `/mnt/Multimedia` must be healthy — if UnRAID is down, Plex will show
  libraries as unavailable
- Internet connectivity for metadata scraping and Plex account authentication

## Notes / Gotchas

- Plex uses its own authentication (Plex.tv account). Authentik ForwardAuth is not
  applied to the external route because it would break the Plex app auth flow.
- `VERSION=docker` always pulls the latest Plex release on container start — pin to a
  specific build number (e.g. `VERSION=1.41.x.xxxx-xxxxxxxxx`) for stability.
- Plex transcoding temp files are written to `/tmp` inside the container. For heavy
  transcoding workloads, consider adding a bind mount for the transcode directory to
  a fast local path.
- Tautulli connects to Plex via the direct API at `http://plex:32400` over the
  `traefik-net` bridge.

---
*Last Updated: 2026-06-16*

# 02-jellyfin.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Jellyfin
  tags: [jellyfin, media, streaming, open-source]
---

## Overview

Jellyfin is a free and open-source media server used as a secondary streaming
solution alongside Plex. It serves TV shows and movies from the same UnRAID NFS
share and is accessible on the LAN only.

## Access

| Type      | URL                              | Notes                               |
|-----------|----------------------------------|-------------------------------------|
| Internal  | `https://jellyfin.home.local`    | LAN access via Step-CA TLS          |
| Direct    | `http://192.168.1.85:8096`       | LAN direct access                   |

No external (internet-facing) route.

## Configuration

**Image:** `jellyfin/jellyfin:latest`
**Compose project:** Standalone (managed via Portainer)

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `8096` | TCP      | Jellyfin web UI and API (host-bound) |

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.jellyfin.rule: Host(`jellyfin.home.local`)
traefik.http.routers.jellyfin.entrypoints: websecure
traefik.http.routers.jellyfin.tls: "true"
traefik.http.routers.jellyfin.tls.certresolver: step-ca
traefik.http.services.jellyfin.loadbalancer.server.port: 8096
```

### Environment Variables

| Variable  | Value             | Purpose       |
|-----------|-------------------|---------------|
| `PUID`    | `1000`            | User ID       |
| `PGID`    | `1000`            | Group ID      |
| `TZ`      | `America/Toronto` | Timezone      |

## Volumes / Bind Mounts

| Host Path                           | Container Path   | Purpose                            |
|-------------------------------------|------------------|------------------------------------|
| `/home/jeeves/docker/jellyfin/config` | `/config`      | Jellyfin database and settings     |
| `/home/jeeves/docker/jellyfin/cache`  | `/cache`       | Thumbnail and transcode cache      |
| `/mnt/Multimedia/TV`                | `/media/tv:ro`   | TV series (NFS from UnRAID, read-only) |
| `/mnt/Multimedia/Movies`            | `/media/movies:ro` | Movies (NFS from UnRAID, read-only) |

## Networks

| Network       | Purpose                          |
|---------------|----------------------------------|
| `traefik-net` | Exposes Jellyfin via Traefik     |

## Dependencies

- NFS mount `/mnt/Multimedia` must be healthy
- Step-CA for `jellyfin.home.local` TLS certificate

## Notes / Gotchas

- Media mounts are read-only (`:ro`) — Jellyfin cannot modify or delete source files.
- Jellyfin and Plex both mount the same NFS directories. Metadata and library scans
  are independent between the two servers.
- Hardware transcoding is available on Jellyfin but not explicitly configured — the
  `jellyfin/jellyfin:latest` image supports it via FFmpeg with Intel/NVIDIA hooks.
  Configure in Admin → Dashboard → Playback if needed.
- Port `8096` is also used by Emby (currently offline). Only one can be host-bound
  at a time — ensure Emby is stopped before starting Jellyfin and vice versa.

---
*Last Updated: 2026-06-16*

# 03-emby.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Emby (Offline)
  tags: [emby, media, offline]
---

## Overview

> **Status: OFFLINE** — The Emby container is present on Centerpoint but is not
> currently running. It has been superseded by Jellyfin for open-source media serving
> needs. The container and its configuration are retained.

Emby was previously used as an alternative media server. Its configuration and
data remain intact and the container can be restarted if needed, subject to the
port `8096` conflict with Jellyfin (see Notes).

## Access

| Type      | URL                           | Notes                              |
|-----------|-------------------------------|------------------------------------|
| Internal  | `https://emby.home.local`     | Traefik labels present — not routed while offline |

## Configuration

**Image:** `emby/embyserver:latest`
**Status:** `exited` (container stopped)

### Traefik Labels (inactive while container is stopped)

```yaml
traefik.enable: "true"
traefik.http.routers.emby.rule: Host(`emby.home.local`)
traefik.http.routers.emby.entrypoints: websecure
traefik.http.routers.emby.tls.certresolver: step-ca
traefik.http.services.emby.loadbalancer.server.port: 8096
```

## Volumes / Bind Mounts

| Host Path                           | Container Path | Purpose                          |
|-------------------------------------|----------------|----------------------------------|
| `/home/jeeves/docker/emby/config`   | `/config`      | Emby database and library config |
| `/mnt/Multimedia/ST`                | `/data`        | Media files (same NFS as Stash)  |

## Notes / Gotchas

- Emby and Jellyfin both use port `8096` internally. Only one can be host-bound at
  a time — starting Emby while Jellyfin is running will fail with a port conflict.
- If reactivating Emby, stop Jellyfin first or remap Emby to a different host port.
- The `emby/embyserver:latest` image tag pulls the latest release — verify
  compatibility with the existing config before restarting after a long pause.

---
*Last Updated: 2026-06-16*

# 04-tautulli.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Tautulli
  tags: [tautulli, plex, monitoring, analytics]
---

## Overview

Tautulli is the monitoring and analytics companion for Plex Media Server. It tracks
play history, user activity, and library statistics, and can send notifications
(email, Telegram, etc.) on media events such as new content additions, playback
starts, or user logins.

## Access

| Type      | URL                               | Notes                              |
|-----------|-----------------------------------|------------------------------------|
| Internal  | `https://tautulli.home.local`     | LAN access via Step-CA TLS         |

No external route — LAN and Tailscale access only.

## Configuration

**Image:** `lscr.io/linuxserver/tautulli:latest`
**Compose project:** Standalone (managed via Portainer)

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.tautulli.rule: Host(`tautulli.home.local`)
traefik.http.routers.tautulli.entrypoints: websecure
traefik.http.routers.tautulli.tls.certresolver: step-ca
traefik.http.services.tautulli.loadbalancer.server.port: 8181
```

### Environment Variables

| Variable  | Value             | Purpose   |
|-----------|-------------------|-----------|
| `PUID`    | `1000`            | User ID   |
| `PGID`    | `1000`            | Group ID  |
| `TZ`      | `America/Toronto` | Timezone  |

## Volumes / Bind Mounts

| Host Path                                    | Container Path | Purpose                             |
|----------------------------------------------|----------------|-------------------------------------|
| `/home/jeeves/docker/Tautulli/local_tautulli` | `/config`     | Tautulli database and configuration |

## Networks

| Network       | Purpose                           |
|---------------|-----------------------------------|
| `traefik-net` | Exposes Tautulli UI; also reaches Plex container |

## Dependencies

- Plex Media Server — Tautulli connects to the Plex API to retrieve playback data.
  Configure the Plex connection in Tautulli Settings → Plex Media Server using the
  container hostname `plex` and port `32400`.

## Notes / Gotchas

- Tautulli's database (`tautulli.db`) contains full play history. Back it up before
  upgrades.
- Notification agents (Telegram, email, etc.) and their credentials are stored in
  the Tautulli database. If the `/config` bind mount is lost, all notification
  integrations must be reconfigured.

---
*Last Updated: 2026-06-16*

# 05-immich.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Immich
  tags: [immich, photos, media, cuda, gpu, self-hosted]
---

## Overview

Immich is the self-hosted photo and video library for the homelab — a local
alternative to Google Photos. It provides automatic mobile backup, face recognition,
smart search, and album management. The machine learning container runs on the RTX
5080 via CUDA for accelerated facial recognition and CLIP-based smart search.

Photo storage lives on the UnRAID NAS NFS mount at `/mnt/Photos`.

## Access

| Type      | URL                                 | Notes                                                 |
|-----------|-------------------------------------|-------------------------------------------------------|
| Internal  | `https://photos.home.local`         | LAN access via Step-CA TLS                            |
| External  | `https://photos.jeevesconsults.ca`  | GeoBlock (CA/US/IN) + CrowdSec — no Authentik ForwardAuth |

Immich uses its own user authentication — Authentik ForwardAuth is not applied
because it would break the mobile app OAuth flow.

## Containers in This Stack

| Container                 | Image                                                              | GPU | Role                              |
|---------------------------|--------------------------------------------------------------------|-----|-----------------------------------|
| `immich_server`           | `ghcr.io/immich-app/immich-server:release`                         | No  | Main API and web server           |
| `immich_machine_learning` | `ghcr.io/immich-app/immich-machine-learning:release-cuda`          | Yes | Face recognition + CLIP search    |
| `immich_postgres`         | `ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0`  | No  | PostgreSQL with pgvecto.rs        |
| `immich_redis`            | `valkey/valkey:8-bookworm`                                         | No  | Job queue and cache               |

## Configuration

**Compose file:** `/home/jeeves/docker/immich/docker-compose.yml`
**Compose project:** `immich`

### Ports

| Port   | Protocol | Purpose                              |
|--------|----------|--------------------------------------|
| `2283` | TCP      | Immich web UI and API (host-bound)   |

### Traefik Labels

```yaml
# External route
traefik.http.routers.immich.rule: Host(`photos.jeevesconsults.ca`)
traefik.http.routers.immich.entrypoints: websecure
traefik.http.routers.immich.tls.certresolver: letsencrypt
traefik.http.routers.immich.middlewares: plex-geoblock@file,crowdsec-bouncer@file,immich-headers
traefik.http.middlewares.immich-headers.headers.customrequestheaders.X-Forwarded-Proto: https

# Internal route
traefik.http.routers.immich-internal.rule: Host(`photos.home.local`)
traefik.http.routers.immich-internal.entrypoints: websecure
traefik.http.routers.immich-internal.tls.certresolver: step-ca

traefik.http.services.immich.loadbalancer.server.port: 2283
```

### Key Environment Variables (`.env` file)

| Variable               | Value / Notes                                          |
|------------------------|--------------------------------------------------------|
| `UPLOAD_LOCATION`      | `/mnt/Photos/immich-library`                           |
| `DB_HOSTNAME`          | `immich_postgres`                                      |
| `DB_USERNAME`          | `postgres`                                             |
| `DB_PASSWORD`          | **REDACTED**                                           |
| `DB_DATABASE_NAME`     | `immich`                                               |
| `REDIS_HOSTNAME`       | `immich_redis`                                         |
| `TZ`                   | `America/Toronto`                                      |
| `IMMICH_VERSION`       | `release` (pinned to latest stable)                    |

## Volumes / Bind Mounts

| Host Path / Volume                        | Container Path              | Purpose                              |
|-------------------------------------------|-----------------------------|--------------------------------------|
| `/mnt/Photos/immich-library`              | `/data`                     | Primary upload library (NFS)         |
| `/mnt/Photos/Plex`                        | `/mnt/Photos/Plex:rw`       | Plex photo library (external library)|
| `/mnt/Photos/Google_Photos`               | `/mnt/Photos/Google_Photos:rw` | Google Photos import folder       |
| `/etc/localtime`                          | `/etc/localtime:ro`         | Host timezone sync                   |
| `model-cache` (named volume)              | `/cache`                    | ML model weight cache (machine learning container) |
| `${DB_DATA_LOCATION}` (from .env)         | `/var/lib/postgresql/data`  | PostgreSQL data                      |

### Sub-section: Machine Learning (CUDA)

`immich_machine_learning` runs with `runtime: nvidia`, giving it access to the RTX
5080 for:

- **Face detection and recognition** — identifies and clusters faces across the library
- **CLIP embeddings** — powers smart search ("photos of dogs at the beach")

| Environment Variable         | Value | Purpose                              |
|------------------------------|-------|--------------------------------------|
| `NVIDIA_VISIBLE_DEVICES`     | `all` | GPU access                           |
| `NVIDIA_DRIVER_CAPABILITIES` | `compute,utility` | CUDA compute caps        |
| `MACHINE_LEARNING_DEVICE_ID` | `0`   | Use GPU device 0                     |

ML model files are cached in the `model-cache` named Docker volume. Models are
downloaded from HuggingFace on first use and cached for subsequent runs.

### Sub-section: PostgreSQL (pgvecto.rs)

Immich uses a custom PostgreSQL 14 image with the `pgvecto.rs` and `pgvectors`
extensions pre-installed. These extensions power the vector similarity search that
underlies CLIP smart search and face clustering.

```
ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
```

The `shm_size: 128mb` allocation is required for PostgreSQL's shared memory.

### Sub-section: Redis / Valkey

`immich_redis` uses Valkey (the Redis fork) as the job queue and cache backend.
It handles background job scheduling for ML processing, thumbnail generation, and
library scans.

## Dependencies

- NFS mounts `/mnt/Photos` (UnRAID) must be healthy
- `immich_postgres` → `immich_redis` → `immich_server` startup order
- NVIDIA container runtime for `immich_machine_learning`

## Notes / Gotchas

- Immich does not use Authentik ForwardAuth — it has built-in multi-user auth.
  The mobile app connects directly to `https://photos.jeevesconsults.ca`.
- The `X-Forwarded-Proto: https` header (`immich-headers` middleware) is required
  for Immich to generate correct share links and OAuth callbacks.
- The PostgreSQL image is pinned to a specific digest — do not change it
  arbitrarily. Immich releases are tied to specific database schema versions.
- Library scan and ML job processing can be CPU/GPU intensive. Schedule large
  library scans during off-peak hours via Admin → Jobs.
- External libraries (Plex photos, Google Photos) are read-only imports. Changes
  to files in these directories are picked up on the next library scan.

---
*Last Updated: 2026-06-16*

# 06-audiobookshelf.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Audiobookshelf
  tags: [audiobookshelf, audiobooks, podcasts, media]
---

## Overview

Audiobookshelf is a self-hosted audiobook and podcast server. It manages and streams
audiobook and podcast content from the UnRAID NFS share, tracks listening progress
across devices, and supports mobile apps for on-the-go listening.

A notable configuration detail: the Step-CA root certificate is injected into the
container's trust store so that Audiobookshelf can make HTTPS calls to internal
`*.home.local` services (e.g. for metadata lookups or integrations).

## Access

| Type      | URL                                     | Notes                                       |
|-----------|-----------------------------------------|---------------------------------------------|
| External  | `https://audio.jeeves5454.ddns.net`     | GeoBlock (CA/US/IN) + CrowdSec — no Authentik |

No `*.home.local` internal Traefik route — accessible externally or via direct LAN
IP. Audiobookshelf uses its own account-based authentication.

## Configuration

**Image:** `ghcr.io/advplyr/audiobookshelf:latest`
**Compose project:** Standalone (managed via Portainer)

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.audio.rule: Host(`audio.jeeves5454.ddns.net`)
traefik.http.routers.audio.entrypoints: websecure
traefik.http.routers.audio.tls.certresolver: letsencrypt
traefik.http.routers.audio.middlewares: plex-geoblock@file,crowdsec-bouncer@file,audio-headers
traefik.http.middlewares.audio-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.audio.loadbalancer.server.port: 80
```

## Volumes / Bind Mounts

| Host Path                                        | Container Path                                 | Purpose                              |
|--------------------------------------------------|------------------------------------------------|--------------------------------------|
| `/home/jeeves/docker/audiobookshelf/config`      | `/config`                                      | App database and settings            |
| `/home/jeeves/docker/audiobookshelf/metadata`    | `/metadata`                                    | Cover art and cached metadata        |
| `/mnt/Multimedia/Audio/Audio_Books`              | `/audiobooks`                                  | Audiobook files (NFS from UnRAID)    |
| `/mnt/Multimedia/Audio/podcasts`                 | `/podcasts`                                    | Podcast episode files (NFS from UnRAID) |
| `/home/jeeves/docker/step-ca/config/certs/root_ca.crt` | `/usr/local/share/ca-certificates/step-ca.crt:ro` | Step-CA root cert trust injection |

The Step-CA root certificate is bind-mounted into the container's CA trust directory,
allowing Audiobookshelf to trust `*.home.local` TLS certificates when making outbound
HTTPS requests to internal services.

## Networks

| Network       | Purpose                              |
|---------------|--------------------------------------|
| `traefik-net` | Exposes the Audiobookshelf UI        |

## Dependencies

- NFS mount `/mnt/Multimedia/Audio` must be healthy for content to be accessible
- Step-CA root certificate at `/home/jeeves/docker/step-ca/config/certs/root_ca.crt`
  must exist for the bind mount to succeed

## Notes / Gotchas

- Audiobookshelf has its own user authentication — no Authentik ForwardAuth is
  applied. Manage users in the Admin panel.
- The `X-Forwarded-Proto: https` middleware is required for Audiobookshelf to
  generate correct callback and share URLs when behind Traefik.
- Podcast episode downloads are stored in `/mnt/Multimedia/Audio/podcasts`. Ensure
  UnRAID has sufficient free space for automatic podcast downloads.
- If Step-CA root cert is updated (e.g. after CA renewal), the container must be
  restarted to pick up the new cert from the bind mount.
- Listening progress is stored in the SQLite database under `/config`. Back up this
  directory before upgrades to preserve progress data.

---
*Last Updated: 2026-06-16*

# 07-seerr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Seerr
  tags: [seerr, overseerr, media-requests, plex, jellyfin]
---

## Overview

Seerr is a media request management application — a maintained fork of Overseerr.
It provides a user-friendly interface for requesting movies and TV shows, which
are then forwarded to the *arr stack (Radarr, Sonarr) for automated download and
delivery to Plex and Jellyfin. It also surfaces Plex availability status so users
can see what is already in the library before requesting.

## Access

| Type      | URL                                   | Notes                                           |
|-----------|---------------------------------------|-------------------------------------------------|
| Internal  | `https://seerr.home.local`            | LAN access via Step-CA TLS                      |
| External  | `https://seerr.jeeves5454.ddns.net`   | Authentik SSO + GeoBlock + CrowdSec             |

## Configuration

**Image:** `ghcr.io/seerr-team/seerr:latest`
**Compose project:** `arr` stack (managed via Portainer alongside the *arr services)

### Traefik Labels

```yaml
# External route
traefik.http.routers.seerr-external.rule: Host(`seerr.jeeves5454.ddns.net`)
traefik.http.routers.seerr-external.entrypoints: websecure
traefik.http.routers.seerr-external.tls.certresolver: letsencrypt
traefik.http.routers.seerr-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.seerr-external.service: seerr-svc

# Internal route
traefik.http.routers.seerr-internal.rule: Host(`seerr.home.local`)
traefik.http.routers.seerr-internal.entrypoints: websecure
traefik.http.routers.seerr-internal.tls.certresolver: step-ca
traefik.http.routers.seerr-internal.service: seerr-svc

traefik.http.services.seerr-svc.loadbalancer.server.port: 5055
```

### Environment Variables

| Variable    | Value             | Purpose              |
|-------------|-------------------|----------------------|
| `PORT`      | `5055`            | Application port     |
| `LOG_LEVEL` | `debug`           | Logging verbosity    |
| `TZ`        | `America/Toronto` | Timezone             |
| `NODE_ENV`  | `production`      | Runtime environment  |

## Volumes / Bind Mounts

| Host Path                          | Container Path  | Purpose                           |
|------------------------------------|-----------------|-----------------------------------|
| `/home/jeeves/docker/arr/Seer/config` | `/app/config` | Seerr database and configuration  |

## Networks

| Network          | Purpose                                              |
|------------------|------------------------------------------------------|
| `traefik-net`    | Exposes Seerr UI                                     |
| `media-network`  | Internal network shared with *arr stack services (Sonarr, Radarr, etc.) |

The `media-network` attachment allows Seerr to communicate directly with Sonarr,
Radarr, and Plex by container hostname without going through Traefik.

## Dependencies

- Plex Media Server — for library availability checks (connect via API)
- Sonarr, Radarr — to forward TV and movie requests
- Authentik for SSO on the external route

## Notes / Gotchas

- Seerr is a fork of Overseerr — configuration and API are compatible but issue
  tracking and releases are at `github.com/seerr-team/seerr`.
- User permissions and notification settings are configured inside the Seerr UI.
  When Authentik is used for ForwardAuth, Seerr should be configured to use
  "Sign-in with Plex" or local accounts for user identity — not OIDC directly.
- Seerr's config database at `/app/config` contains API keys for Plex, Sonarr, and
  Radarr. Back it up before upgrades.

---
*Last Updated: 2026-06-16*

# 09-threadfin.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media & Entertainment
  page: Threadfin
  tags: [threadfin, iptv, m3u, xmltv, plex, jellyfin]
---

## Overview

Threadfin is an M3U proxy and IPTV middleware that translates IPTV streams into a
format consumable by Plex DVR and Jellyfin Live TV. It manages M3U playlists, XMLTV
guide data, and presents a virtual tuner device to media servers via HDHR (HDHomeRun)
emulation.

## Access

| Type    | URL / Endpoint                  | Notes                                  |
|---------|---------------------------------|----------------------------------------|
| Web UI  | `http://192.168.1.85:34400`     | Direct LAN access — no Traefik route   |
| HDHR    | `http://192.168.1.85:34400`     | HDHomeRun device emulation endpoint    |

No Traefik route is configured for Threadfin — Plex and Jellyfin connect to it via
direct IP and port on the LAN.

## Configuration

**Image:** `fyb3roptik/threadfin:latest`
**Compose project:** Standalone (managed via Portainer)

### Ports

| Port    | Protocol | Purpose                               |
|---------|----------|---------------------------------------|
| `34400` | TCP      | Threadfin web UI and HDHR endpoint (host-bound) |

### Environment Variables

| Variable                   | Value             | Purpose                          |
|----------------------------|-------------------|----------------------------------|
| `TZ`                       | `America/Toronto` | Timezone                         |
| `THREADFIN_PORT`           | `34400`           | Listen port                      |
| `THREADFIN_BRANCH`         | `main`            | Update channel                   |
| `THREADFIN_BIND_IP_ADDRESS`| `0.0.0.0`         | Bind to all interfaces           |
| `THREADFIN_DEBUG`          | `0`               | Debug logging off                |

## Volumes / Bind Mounts

| Host Path                               | Container Path           | Purpose                            |
|-----------------------------------------|--------------------------|------------------------------------|
| `/home/jeeves/docker/threadfin/config`  | `/home/threadfin/conf`   | M3U playlists, XMLTV data, settings |

## Networks

| Network       | Purpose                                              |
|---------------|------------------------------------------------------|
| `traefik-net` | Present (container is on traefik-net but no labels)  |

Threadfin is on `traefik-net` for network reachability but has no Traefik labels —
it is accessed by direct IP.

## Plex / Jellyfin Integration

**Plex:**
1. In Plex Settings → Live TV & DVR → Set Up Plex Tuner
2. Enter the Threadfin HDHR URL: `http://192.168.1.85:34400/device.xml`
3. Plex discovers the virtual tuner and available channels

**Jellyfin:**
1. Admin Dashboard → Live TV → Add Tuner Device
2. Select HDHomeRun and enter `http://192.168.1.85:34400`

## Dependencies

- M3U source URL (IPTV provider) configured in Threadfin UI
- XMLTV guide source configured in Threadfin UI
- Plex or Jellyfin must be able to reach `192.168.1.85:34400` on the LAN

## Notes / Gotchas

- M3U playlists and XMLTV guide data are refreshed on a schedule configured in the
  Threadfin UI. If the IPTV provider URL changes, update it in Threadfin settings —
  Plex and Jellyfin do not need to be reconfigured.
- Threadfin does not store or proxy video streams itself; it redirects the media
  server directly to the IPTV stream URL. Buffering and quality are determined by
  the stream source.
- Port `34400` is non-standard. Ensure no firewall rules block LAN-to-LAN traffic
  on this port between Plex/Jellyfin and Centerpoint.

---
*Last Updated: 2026-06-16*

# h05-media-management

# 00-chapter-intro.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Chapter Introduction
  tags: [media-management, arr, sonarr, radarr, prowlarr, nzbget]
---

## Overview

This chapter documents the automated media acquisition and management stack —
commonly referred to as the *arr ecosystem. These services work together to
monitor, search, download, rename, and organise media content automatically,
then deliver it to Plex and Jellyfin.

## Download Pipeline

```
Seerr (request) ──► Sonarr / Radarr / Whisparr / LazyLibrarian
                         │
                         ▼
                    Prowlarr (indexer search)
                         │
                         ▼
                    NZBGet (Usenet download)
                         │
                         ▼
          /media/jeeves/1TB_Vol2/downloads  (Ceph OSD — temp)
                         │
                    Post-processing
                         │
                         ▼
            /mnt/Multimedia  (NFS → UnRAID — permanent)
                         │
                    Plex / Jellyfin
```

Downloads land temporarily on the Ceph OSD volume (`nvme2n1`, mounted at
`/media/jeeves/1TB_Vol2`) for high-speed write performance. After post-processing,
completed media is moved/hardlinked to the UnRAID NFS share at `/mnt/Multimedia`
for permanent storage.

## Shared Networks

All services in this chapter are on two Docker networks:

| Network          | Purpose                                                    |
|------------------|------------------------------------------------------------|
| `traefik-net`    | Web UI access via Traefik                                  |
| `media-network`  | Internal service-to-service communication (no Traefik hop) |

`media-network` allows *arr services to reach each other and NZBGet directly by
container hostname without going through Traefik.

## Services in This Chapter

| Service               | Container          | Status  | Purpose                                   |
|-----------------------|--------------------|---------|-------------------------------------------|
| **Sonarr**            | `sonarr`           | Active  | TV show monitoring and management         |
| **Radarr**            | `radarr`           | Active  | Movie monitoring and management           |
| **Prowlarr**          | `prowlarr`         | Active  | Indexer aggregator for all *arr apps      |
| **Bazarr**            | `bazarr`           | Active  | Subtitle management                       |
| **NZBGet**            | `nzbget`           | Active  | Usenet download client                    |
| **Whisparr**          | `whisparr`         | Active  | Adult content management (Sonarr fork)    |
| **LazyLibrarian**     | `lazylibrarian`    | Active  | Book and magazine management              |
| **Mylar3**            | `mylar3`           | Active  | Comics management                         |
| **Audiobookrequest**  | `audiobookrequest` | Active  | Audiobook request portal                  |
| **Profilarr**         | `profilarr`        | Active  | Quality profile manager for *arr apps     |
| **Dispatcharr**       | `dispatcharr`      | Active  | IPTV channel and stream dispatcher        |
| **Pulsarr**           | `pulsarr`          | Active  | Watchlist automation and notifications    |
| **FlareSolverr**      | `flaresolverr`     | Offline | Cloudflare bypass proxy for Prowlarr      |

---
*Last Updated: 2026-06-16*

# 01-sonarr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Sonarr
  tags: [sonarr, tv, arr, media-management]
---

## Overview

Sonarr monitors RSS feeds and indexers (via Prowlarr) for new TV show episodes,
automatically searches for and downloads them via NZBGet, then renames and
organises the files into the media library on UnRAID.

## Access

| Type      | URL                                    | Notes                                           |
|-----------|----------------------------------------|-------------------------------------------------|
| Internal  | `https://sonarr.home.local`            | LAN access via Step-CA TLS                      |
| External  | `https://sonarr.jeeves5454.ddns.net`   | Authentik SSO + GeoBlock + CrowdSec             |

## Configuration

**Image:** `lscr.io/linuxserver/sonarr:latest`
**Compose project:** `arr` stack (managed via Portainer)

### Traefik Labels

```yaml
# External route
traefik.http.routers.sonarr-external.rule: Host(`sonarr.jeeves5454.ddns.net`)
traefik.http.routers.sonarr-external.entrypoints: websecure
traefik.http.routers.sonarr-external.tls.certresolver: letsencrypt
traefik.http.routers.sonarr-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.sonarr-external.service: sonarr-svc

# Internal route
traefik.http.routers.sonarr-internal.rule: Host(`sonarr.home.local`)
traefik.http.routers.sonarr-internal.entrypoints: websecure
traefik.http.routers.sonarr-internal.tls.certresolver: step-ca
traefik.http.routers.sonarr-internal.service: sonarr-svc

traefik.http.services.sonarr-svc.loadbalancer.server.port: 8989
```

## Volumes / Bind Mounts

| Host Path                              | Container Path  | Purpose                                      |
|----------------------------------------|-----------------|----------------------------------------------|
| `/home/jeeves/docker/arr/Sonarr/config`| `/config`       | Sonarr database, settings, and series index  |
| `/media/jeeves/1TB_Vol2/downloads`     | `/downloads`    | NZBGet download staging (Ceph OSD)           |
| `/mnt/Multimedia`                      | `/media`        | Final media library (NFS from UnRAID)        |

Both `/downloads` and `/media` must be in the same container path namespace so
Sonarr can hardlink completed downloads rather than copy them — essential for
atomic moves between the download staging area and the media library.

## Integration Points

| Service     | Connection Method                         | Purpose                     |
|-------------|-------------------------------------------|-----------------------------|
| Prowlarr    | API (`http://prowlarr:9696`) via `media-network` | Indexer search       |
| NZBGet      | API (`http://nzbget:6789`) via `media-network`   | Download client      |
| Bazarr      | API (Bazarr polls Sonarr)                 | Subtitle fetching           |
| Pulsarr     | Sonarr webhook / API                      | Watchlist sync              |
| Seerr       | API                                       | Receives show requests      |

## Notes / Gotchas

- Sonarr's root folder for TV series must point to `/media/TV` (or equivalent)
  inside the container, which maps to `/mnt/Multimedia/TV` on the host via NFS.
- Quality profiles are managed centrally via Profilarr and pushed to Sonarr via
  its API — do not manually edit quality profiles in Sonarr if Profilarr is active.
- API key is stored in `/config/config.xml` — required to configure Prowlarr,
  Bazarr, Seerr, and Pulsarr integrations.

---
*Last Updated: 2026-06-16*

# 02-radarr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Radarr
  tags: [radarr, movies, arr, media-management]
---

## Overview

Radarr is the movie counterpart to Sonarr. It monitors for new and existing movie
releases, searches indexers via Prowlarr, downloads via NZBGet, and moves completed
movies into the media library on UnRAID.

## Access

| Type      | URL                                    | Notes                                           |
|-----------|----------------------------------------|-------------------------------------------------|
| Internal  | `https://radarr.home.local`            | LAN access via Step-CA TLS                      |
| External  | `https://radarr.jeeves5454.ddns.net`   | Authentik SSO + GeoBlock + CrowdSec             |

## Configuration

**Image:** `lscr.io/linuxserver/radarr:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
# External route
traefik.http.routers.radarr-external.rule: Host(`radarr.jeeves5454.ddns.net`)
traefik.http.routers.radarr-external.entrypoints: websecure
traefik.http.routers.radarr-external.tls.certresolver: letsencrypt
traefik.http.routers.radarr-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.radarr-external.service: radarr-svc

# Internal route
traefik.http.routers.radarr-internal.rule: Host(`radarr.home.local`)
traefik.http.routers.radarr-internal.entrypoints: websecure
traefik.http.routers.radarr-internal.tls.certresolver: step-ca
traefik.http.routers.radarr-internal.service: radarr-svc

traefik.http.services.radarr-svc.loadbalancer.server.port: 7878
```

## Volumes / Bind Mounts

| Host Path                              | Container Path  | Purpose                                |
|----------------------------------------|-----------------|----------------------------------------|
| `/home/jeeves/docker/arr/Radarr/config`| `/config`       | Radarr database and settings           |
| `/media/jeeves/1TB_Vol2/downloads`     | `/downloads`    | NZBGet download staging (Ceph OSD)     |
| `/mnt/Multimedia`                      | `/media`        | Final media library (NFS from UnRAID)  |

## Integration Points

| Service  | Connection Method                              | Purpose                  |
|----------|------------------------------------------------|--------------------------|
| Prowlarr | API (`http://prowlarr:9696`) via `media-network` | Indexer search         |
| NZBGet   | API (`http://nzbget:6789`) via `media-network`   | Download client        |
| Bazarr   | API (Bazarr polls Radarr)                      | Subtitle fetching        |
| Pulsarr  | Radarr webhook / API                           | Watchlist sync           |
| Seerr    | API                                            | Receives movie requests  |

## Notes / Gotchas

- Radarr root folder must be set to the path inside the container that maps to
  `/mnt/Multimedia/Movies` on the host.
- Hardlinking between `/downloads` and `/media` requires both paths to be on the
  same filesystem. The current setup has downloads on Ceph OSD and the final library
  on NFS — hardlinking is **not** possible across these. Radarr performs a copy+delete
  instead. This is slower but correct.
- Quality profiles are managed via Profilarr — avoid manual edits in Radarr UI.

---
*Last Updated: 2026-06-16*

# 03-prowlarr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Prowlarr
  tags: [prowlarr, indexer, arr, media-management]
---

## Overview

Prowlarr is the indexer aggregation layer for the *arr stack. It manages all Usenet
and torrent indexer connections in one place and syncs them automatically to Sonarr,
Radarr, Whisparr, and LazyLibrarian — eliminating the need to configure indexers
individually in each application.

## Access

| Type      | URL                             | Notes                              |
|-----------|---------------------------------|------------------------------------|
| Internal  | `https://prowlarr.home.local`   | LAN access via Step-CA TLS         |

No external route — internal management only.

## Configuration

**Image:** `lscr.io/linuxserver/prowlarr:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.prowlarr.rule: Host(`prowlarr.home.local`)
traefik.http.routers.prowlarr.entrypoints: websecure
traefik.http.routers.prowlarr.tls.certresolver: step-ca
traefik.http.services.prowlarr.loadbalancer.server.port: 9696
```

## Volumes / Bind Mounts

| Host Path                                | Container Path | Purpose                     |
|------------------------------------------|----------------|-----------------------------|
| `/home/jeeves/docker/arr/Prowlarr/config`| `/config`      | Indexer definitions and settings |

## Integration Points

Prowlarr pushes its configured indexers to downstream *arr apps via their APIs.
Each app is added in Prowlarr Settings → Apps:

| App              | API Endpoint (`media-network`)   |
|------------------|----------------------------------|
| Sonarr           | `http://sonarr:8989`             |
| Radarr           | `http://radarr:7878`             |
| Whisparr         | `http://whisparr:6969`           |
| LazyLibrarian    | `http://lazylibrarian:5299`      |

When an indexer is added or updated in Prowlarr, it is automatically propagated to
all connected apps. No manual indexer configuration in individual *arr apps is
needed.

## Notes / Gotchas

- FlareSolverr (currently offline) is used by Prowlarr to bypass Cloudflare-protected
  indexers. When FlareSolverr is down, any Cloudflare-protected indexer will fail.
  Configure the FlareSolverr proxy URL in Prowlarr Settings → Indexers → Proxies.
- Prowlarr's API key is required when adding Prowlarr as the indexer source in each
  *arr application. Find it in Prowlarr Settings → General → Security.
- Indexer categories must be correctly mapped in Prowlarr for each app (e.g. TV
  categories for Sonarr, movie categories for Radarr) — misconfigured categories
  result in wrong content being returned in searches.

---
*Last Updated: 2026-06-16*

# 04-bazarr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Bazarr
  tags: [bazarr, subtitles, arr, media-management]
---

## Overview

Bazarr automatically downloads subtitles for movies and TV shows managed by Radarr
and Sonarr. It monitors both libraries and fetches subtitles from configured
providers (OpenSubtitles, Subscene, etc.) whenever a new file is added or an
existing file is missing subtitles.

## Access

| Type      | URL                           | Notes                              |
|-----------|-------------------------------|------------------------------------|
| Internal  | `https://bazarr.home.local`   | LAN access via Step-CA TLS         |

No external route — internal management only.

## Configuration

**Image:** `lscr.io/linuxserver/bazarr:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.bazarr.rule: Host(`bazarr.home.local`)
traefik.http.routers.bazarr.entrypoints: websecure
traefik.http.routers.bazarr.tls.certresolver: step-ca
traefik.http.services.bazarr.loadbalancer.server.port: 6767
```

## Volumes / Bind Mounts

| Host Path                              | Container Path | Purpose                          |
|----------------------------------------|----------------|----------------------------------|
| `/home/jeeves/docker/arr/Bazarr/config`| `/config`      | Bazarr database and settings     |
| `/mnt/Multimedia`                      | `/media`       | Media library for subtitle placement (NFS from UnRAID) |

The `/media` mount mirrors the Sonarr/Radarr mount so Bazarr can write subtitle
files directly alongside the video files in the library.

## Integration Points

| Service  | Connection                                   | Purpose               |
|----------|----------------------------------------------|-----------------------|
| Sonarr   | API (`http://sonarr:8989`) via `media-network` | TV library sync     |
| Radarr   | API (`http://radarr:7878`) via `media-network` | Movie library sync  |

## Notes / Gotchas

- Subtitle provider API keys (OpenSubtitles, etc.) are stored in Bazarr's settings.
  These are rate-limited — avoid aggressive subtitle searches on large libraries.
- Bazarr writes subtitle `.srt` or `.ass` files next to the video files in
  `/mnt/Multimedia`. Plex and Jellyfin pick these up automatically on the next
  library scan.
- If Sonarr or Radarr is restarted, Bazarr may take a few minutes to re-sync its
  library view via the API.

---
*Last Updated: 2026-06-16*

# 05-nzbget.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: NZBGet
  tags: [nzbget, usenet, download-client, arr, media-management]
---

## Overview

NZBGet is the Usenet download client for the homelab. It receives download jobs
from Sonarr, Radarr, Whisparr, and LazyLibrarian, downloads from configured Usenet
providers, unpacks archives, and notifies the requesting *arr application when
complete.

Downloads are staged to the Ceph OSD volume (`1TB_Vol2`) for high-speed local
writes before being processed and moved to UnRAID.

## Access

| Type      | URL                                  | Notes                                           |
|-----------|--------------------------------------|-------------------------------------------------|
| Internal  | `https://nzbget.home.local`          | LAN access via Step-CA TLS                      |
| External  | `https://nzb.jeeves5454.ddns.net`    | Authentik SSO + GeoBlock + CrowdSec             |

## Configuration

**Image:** `lscr.io/linuxserver/nzbget:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
# External route
traefik.http.routers.nzbget-external.rule: Host(`nzb.jeeves5454.ddns.net`)
traefik.http.routers.nzbget-external.entrypoints: websecure
traefik.http.routers.nzbget-external.tls.certresolver: letsencrypt
traefik.http.routers.nzbget-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.nzbget-external.service: nzbget-svc

# Internal route
traefik.http.routers.nzbget-internal.rule: Host(`nzbget.home.local`)
traefik.http.routers.nzbget-internal.entrypoints: websecure
traefik.http.routers.nzbget-internal.tls.certresolver: step-ca
traefik.http.routers.nzbget-internal.service: nzbget-svc

traefik.http.services.nzbget-svc.loadbalancer.server.port: 6789
```

## Volumes / Bind Mounts

| Host Path                              | Container Path  | Purpose                                          |
|----------------------------------------|-----------------|--------------------------------------------------|
| `/home/jeeves/docker/arr/NZBGet/config`| `/config`       | NZBGet settings and queue database               |
| `/media/jeeves/1TB_Vol2/downloads`     | `/downloads`    | Download staging directory (Ceph OSD)            |
| `/mnt/Multimedia`                      | `/media`        | Media library (used by post-process scripts)     |

### Download Directory Layout

NZBGet organises downloads under `/downloads` with category subdirectories:

```
/media/jeeves/1TB_Vol2/downloads/
├── usenet/
│   ├── intermediate/    ← In-progress downloads
│   └── complete/
│       ├── tv/          ← Completed TV episodes → Sonarr picks up
│       ├── movies/      ← Completed movies → Radarr picks up
│       ├── books/       ← Completed books → LazyLibrarian picks up
│       └── adult/       ← Completed adult content → Whisparr picks up
```

## Notes / Gotchas

- Usenet provider credentials (server address, port, username, password) are stored
  in NZBGet's settings — these are **REDACTED** in all documentation.
- NZBGet's built-in web UI has its own username/password in addition to the Authentik
  ForwardAuth layer on the external route. Both must be configured.
- The Ceph OSD download volume provides fast local NVMe performance for downloads
  before they are moved to the slower NFS path. Ensure adequate free space is
  maintained on `1TB_Vol2`.
- Each *arr app connects to NZBGet via `http://nzbget:6789` on the `media-network`
  using the NZBGet API credentials configured in the *arr download client settings.

---
*Last Updated: 2026-06-16*

# 06-whisparr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Whisparr
  tags: [whisparr, arr, media-management, adult-content]
---

## Overview

Whisparr is a Sonarr fork purpose-built for managing adult content. It integrates
with Prowlarr for indexer searches and NZBGet for downloads, following the same
*arr workflow as Sonarr but with metadata sources appropriate for its content type.
Downloaded content is delivered to the same media volume that Stash monitors.

## Access

| Type      | URL                              | Notes                              |
|-----------|----------------------------------|------------------------------------|
| Internal  | `https://whisparr.home.local`    | LAN access via Step-CA TLS         |

No external route — internal management only.

## Configuration

**Image:** `ghcr.io/hotio/whisparr:v3`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.whisparr.rule: Host(`whisparr.home.local`)
traefik.http.routers.whisparr.entrypoints: websecure
traefik.http.routers.whisparr.tls.certresolver: step-ca
traefik.http.services.whisparr.loadbalancer.server.port: 6969
```

## Volumes / Bind Mounts

| Host Path                               | Container Path | Purpose                               |
|-----------------------------------------|----------------|---------------------------------------|
| `/home/jeeves/docker/arr/Whisparr/config`| `/config`    | Whisparr database and settings        |
| `/media/jeeves/1TB_Vol2/downloads`      | `/downloads`   | NZBGet download staging (Ceph OSD)    |
| `/mnt/Multimedia`                       | `/data`        | Media library including adult content (NFS from UnRAID) |

## Integration Points

| Service   | Connection Method                              | Purpose            |
|-----------|------------------------------------------------|--------------------|
| Prowlarr  | API (`http://prowlarr:9696`) via `media-network` | Indexer search   |
| NZBGet    | API (`http://nzbget:6789`) via `media-network`   | Download client  |

## Notes / Gotchas

- The `hotio/whisparr:v3` image is a community-maintained build. Monitor the
  repository for updates as this is not an official Servarr project.
- Whisparr's library root folder should point to the same path that Stash monitors
  (`/mnt/Multimedia/ST`) so content is automatically picked up by Stash after import.
- Prowlarr indexer categories for adult content must be configured correctly in
  Prowlarr and mapped to Whisparr in the Apps settings.

---
*Last Updated: 2026-06-16*

# 07-lazylibrarian.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: LazyLibrarian
  tags: [lazylibrarian, books, ebooks, magazines, arr, media-management]
---

## Overview

LazyLibrarian manages ebook, audiobook, and magazine acquisition. It monitors
authors and publications, searches for releases via Prowlarr, downloads via NZBGet,
and organises the completed files into the books library on UnRAID. Despite its
internal name, it is accessed externally via the `readarr.jeevesconsults.ca` domain.

## Access

| Type      | URL                                        | Notes                                           |
|-----------|--------------------------------------------|-------------------------------------------------|
| Internal  | `https://readarr.home.local`               | LAN access via Step-CA TLS                      |
| External  | `https://readarr.jeevesconsults.ca`        | Authentik SSO + GeoBlock + CrowdSec             |

> The external domain uses `readarr.jeevesconsults.ca` (not `lazylibrarian.*`).

## Configuration

**Image:** `lscr.io/linuxserver/lazylibrarian:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
# External route
traefik.http.routers.lazylibrarian-external.rule: Host(`readarr.jeevesconsults.ca`)
traefik.http.routers.lazylibrarian-external.entrypoints: websecure
traefik.http.routers.lazylibrarian-external.tls.certresolver: letsencrypt
traefik.http.routers.lazylibrarian-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.lazylibrarian-external.service: lazylibrarian-svc

# Internal route
traefik.http.routers.lazylibrarian-internal.rule: Host(`readarr.home.local`)
traefik.http.routers.lazylibrarian-internal.entrypoints: websecure
traefik.http.routers.lazylibrarian-internal.tls.certresolver: step-ca
traefik.http.routers.lazylibrarian-internal.service: lazylibrarian-svc

traefik.http.services.lazylibrarian-svc.loadbalancer.server.port: 5299
```

## Volumes / Bind Mounts

| Host Path                                                    | Container Path | Purpose                          |
|--------------------------------------------------------------|----------------|----------------------------------|
| `/home/jeeves/docker/arr/lazylibrarian/config`               | `/config`      | LazyLibrarian database and settings |
| `/media/jeeves/1TB_Vol2/downloads/usenet/complete/books`     | `/downloads`   | Completed book downloads (Ceph OSD) |
| `/mnt/Multimedia/Books/Lazylibrarian`                        | `/books`       | Final book library (NFS from UnRAID) |

## Integration Points

| Service  | Connection Method                              | Purpose             |
|----------|------------------------------------------------|---------------------|
| Prowlarr | API (`http://prowlarr:9696`) via `media-network` | Indexer search    |
| NZBGet   | API (`http://nzbget:6789`) via `media-network`   | Download client   |

## Notes / Gotchas

- LazyLibrarian covers ebooks, audiobook metadata management (separate from
  Audiobookshelf's podcast/streaming role), and magazines. Configure each
  content type under its own library section within LazyLibrarian.
- The external domain (`readarr.jeevesconsults.ca`) was chosen for cleaner branding
  — update DNS and Traefik labels if the domain ever changes.
- Calibre integration can be enabled in LazyLibrarian settings if a Calibre server
  is available on the network for ebook format conversion.

---
*Last Updated: 2026-06-16*

# 08-mylar3.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Mylar3
  tags: [mylar3, comics, cbz, arr, media-management]
---

## Overview

Mylar3 is the automated comic book downloader and manager for the homelab. It
monitors comic series, searches for new issues via Usenet indexers (through NZBGet),
and organises downloaded CBZ/CBR files into the comics library on UnRAID.

## Access

| Type      | URL                           | Notes                              |
|-----------|-------------------------------|------------------------------------|
| Internal  | `https://mylar.home.local`    | LAN access via Step-CA TLS         |

No external route — internal management only.

## Configuration

**Image:** `lscr.io/linuxserver/mylar3:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.mylar3.rule: Host(`mylar.home.local`)
traefik.http.routers.mylar3.entrypoints: websecure
traefik.http.routers.mylar3.tls.certresolver: step-ca
traefik.http.services.mylar3.loadbalancer.server.port: 8090
```

## Volumes / Bind Mounts

| Host Path                                                | Container Path | Purpose                           |
|----------------------------------------------------------|----------------|-----------------------------------|
| `/home/jeeves/docker/arr/mylar/config`                   | `/config`      | Mylar3 database and settings      |
| `/media/jeeves/1TB_Vol2/downloads/usenet/complete/books` | `/downloads`   | Completed downloads (Ceph OSD, shared with books) |
| `/mnt/Multimedia/Books/Comics`                           | `/comics`      | Comics library (NFS from UnRAID)  |

## Integration Points

| Service  | Connection Method                              | Purpose             |
|----------|------------------------------------------------|---------------------|
| NZBGet   | API (`http://nzbget:6789`) via `media-network` | Download client     |

> Mylar3 connects directly to NZBGet rather than through Prowlarr. Indexers are
> configured directly in Mylar3 settings.

## Notes / Gotchas

- Mylar3 uses ComicVine as its metadata source — a free API key from
  `comicvine.gamespot.com` is required and stored in the Mylar3 config.
- The `/downloads` path is shared with LazyLibrarian (both point to the books
  completion directory). Ensure Mylar3's category in NZBGet is distinct to avoid
  processing conflicts.
- Mylar3's database (`mylar.db`) stores the full comic watchlist and download
  history. Back it up before upgrades.

---
*Last Updated: 2026-06-16*

# 09-audiobookrequest.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Audiobookrequest
  tags: [audiobookrequest, audiobooks, requests, media-management]
---

## Overview

Audiobookrequest is a self-hosted audiobook request portal — similar in concept to
Seerr but for audiobooks. Users can search for and request audiobooks, which are
then queued for download via the configured acquisition tools (LazyLibrarian).

## Access

| Type      | URL                                    | Notes                              |
|-----------|----------------------------------------|------------------------------------|
| Internal  | `https://audiobookseer.home.local`     | LAN access via Step-CA TLS         |

No external route — LAN and Tailscale access only.

## Configuration

**Image:** `markbeep/audiobookrequest:1`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.audiobookrequest.rule: Host(`audiobookseer.home.local`)
traefik.http.routers.audiobookrequest.entrypoints: websecure
traefik.http.routers.audiobookrequest.tls.certresolver: step-ca
traefik.http.services.audiobookrequest.loadbalancer.server.port: 8000
```

### Key Environment Variables

| Variable                    | Value   | Purpose                              |
|-----------------------------|---------|--------------------------------------|
| `TZ`                        | `America/Toronto` | Timezone                   |
| `ABR_APP__PORT`             | `8000`  | Application listen port              |
| `ABR_APP__FORCE_LOGIN_TYPE` | `forms` | Forces form-based login              |
| `ABR_APP__VERSION`          | `1.10.5`| Application version                  |

`ABR_APP__FORCE_LOGIN_TYPE=forms` disables any SSO/header-based login and enforces
the standard username/password login form.

## Volumes / Bind Mounts

| Host Path                                    | Container Path | Purpose                          |
|----------------------------------------------|----------------|----------------------------------|
| `/home/jeeves/docker/arr/audiobookrequest/config` | `/config` | Request database and settings   |

## Notes / Gotchas

- User accounts and request history are stored in the `/config` bind mount.
- Audiobookrequest is pinned to image tag `1` — check for newer tagged releases
  before upgrading.
- Integration with LazyLibrarian (for fulfilling requests) must be configured
  in Audiobookrequest settings with the LazyLibrarian API endpoint and key.

---
*Last Updated: 2026-06-16*

# 10-profilarr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Profilarr
  tags: [profilarr, quality-profiles, arr, media-management]
---

## Overview

Profilarr is a quality profile and custom format manager for the *arr stack. It
maintains a centralised set of quality profiles and custom format definitions and
syncs them across Sonarr, Radarr, and other *arr apps via their APIs — ensuring
consistent quality standards across all services without manual duplication.

## Access

| Type      | URL                               | Notes                              |
|-----------|-----------------------------------|------------------------------------|
| Internal  | `https://profilarr.home.local`    | LAN access via Step-CA TLS         |

No external route — internal management only.

## Configuration

**Image:** `santiagosayshey/profilarr:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.profilarr.rule: Host(`profilarr.home.local`)
traefik.http.routers.profilarr.entrypoints: websecure
traefik.http.routers.profilarr.tls.certresolver: step-ca
traefik.http.services.profilarr.loadbalancer.server.port: 6868
```

### Environment Variables

| Variable | Value             | Purpose   |
|----------|-------------------|-----------|
| `TZ`     | `America/Toronto` | Timezone  |

## Volumes / Bind Mounts

| Host Path                                | Container Path | Purpose                            |
|------------------------------------------|----------------|------------------------------------|
| `/home/jeeves/docker/arr/profilarr/config`| `/config`     | Profile definitions and sync state |

## Notes / Gotchas

- Quality profiles in Sonarr and Radarr should not be edited manually when Profilarr
  is managing them — changes will be overwritten on the next sync.
- Profilarr can import community-maintained profile packs (e.g. TRaSH Guides
  profiles) and push them to all connected *arr apps.
- API keys for each connected *arr application must be configured in Profilarr
  settings.

---
*Last Updated: 2026-06-16*

# 11-dispatcharr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Dispatcharr
  tags: [dispatcharr, iptv, m3u, media-management]
---

## Overview

Dispatcharr is an IPTV playlist and stream management tool. It organises M3U
channel lists, manages IPTV stream sources, and acts as a companion to Threadfin —
providing a more structured interface for managing channel groups, EPG mappings,
and stream prioritisation before they are published to Plex and Jellyfin via
Threadfin.

Dispatcharr runs in **all-in-one (`aio`) mode**, bundling its application server,
Celery task worker, and Redis instance into a single container.

## Access

| Type      | URL                                 | Notes                              |
|-----------|-------------------------------------|------------------------------------|
| Internal  | `https://dispatcharr.home.local`    | LAN access via Step-CA TLS         |

No external route — internal management only.

## Configuration

**Image:** `ghcr.io/dispatcharr/dispatcharr:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
traefik.enable: "true"
traefik.http.routers.dispatcharr.rule: Host(`dispatcharr.home.local`)
traefik.http.routers.dispatcharr.entrypoints: websecure
traefik.http.routers.dispatcharr.tls.certresolver: step-ca
traefik.http.services.dispatcharr.loadbalancer.server.port: 9191
```

### Key Environment Variables

| Variable                | Value              | Purpose                               |
|-------------------------|--------------------|---------------------------------------|
| `DISPATCHARR_ENV`       | `aio`              | All-in-one mode (app + worker + Redis) |
| `DISPATCHARR_LOG_LEVEL` | `info`             | Logging verbosity                     |
| `REDIS_HOST`            | `localhost`        | Internal Redis (bundled in AIO mode)  |
| `CELERY_BROKER_URL`     | `redis://localhost:6379/0` | Celery task queue            |

## Volumes / Bind Mounts

| Host Path                                    | Container Path | Purpose                      |
|----------------------------------------------|----------------|------------------------------|
| `/home/jeeves/docker/arr/dispatcharr/data`   | `/data`        | Dispatcharr database and config |

## Notes / Gotchas

- In `aio` mode, Redis runs inside the container and does not persist state between
  restarts beyond what is stored in the `/data` bind mount. Task queue state is lost
  on container restart.
- Dispatcharr integrates with Threadfin as the downstream consumer of its managed
  M3U playlists. Configure the Dispatcharr output URL in Threadfin as the M3U
  source.
- `ghcr.io/dispatcharr/dispatcharr:latest` is an actively developed project —
  check release notes before pulling updates as breaking changes may affect the
  channel database schema.

---
*Last Updated: 2026-06-16*

# 12-pulsarr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: Pulsarr
  tags: [pulsarr, watchlist, notifications, arr, media-management]
---

## Overview

Pulsarr is a watchlist automation and notification bridge for the *arr stack. It
monitors Plex and Jellyfin watchlists, automatically adds requested movies and TV
shows to Radarr and Sonarr, and sends notifications when content is downloaded and
available. It also integrates with TMDB for metadata-enriched notifications.

## Access

| Type      | URL                                      | Notes                                           |
|-----------|------------------------------------------|-------------------------------------------------|
| Internal  | `https://pulsarr.home.local`             | LAN access via Step-CA TLS                      |
| External  | `https://pulsarr.jeeves5454.ddns.net`    | Authentik SSO + GeoBlock + CrowdSec             |

## Configuration

**Image:** `lakker/pulsarr:latest`
**Compose project:** `arr` stack

### Traefik Labels

```yaml
# External route
traefik.http.routers.pulsarr-external.rule: Host(`pulsarr.jeeves5454.ddns.net`)
traefik.http.routers.pulsarr-external.entrypoints: websecure
traefik.http.routers.pulsarr-external.tls.certresolver: letsencrypt
traefik.http.routers.pulsarr-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.pulsarr-external.service: pulsarr-svc

# Internal route
traefik.http.routers.pulsarr-internal.rule: Host(`pulsarr.home.local`)
traefik.http.routers.pulsarr-internal.entrypoints: websecure
traefik.http.routers.pulsarr-internal.tls.certresolver: step-ca
traefik.http.routers.pulsarr-internal.service: pulsarr-svc

traefik.http.services.pulsarr-svc.loadbalancer.server.port: 3003
```

### Key Environment Variables

| Variable      | Value             | Purpose                                      |
|---------------|-------------------|----------------------------------------------|
| `TZ`          | `America/Toronto` | Timezone                                     |
| `port`        | `3003`            | Application listen port                      |
| `tmdbApiKey`  | **REDACTED**      | TMDB API key for metadata and notification enrichment |

## Volumes / Bind Mounts

| Host Path                               | Container Path | Purpose                            |
|-----------------------------------------|----------------|------------------------------------|
| `/home/jeeves/docker/arr/pulsarr/config`| `/app/data`    | Pulsarr database and configuration |

## Integration Points

| Service  | Purpose                                                  |
|----------|----------------------------------------------------------|
| Sonarr   | Adds TV shows from watchlists; receives completion events |
| Radarr   | Adds movies from watchlists; receives completion events   |
| Plex     | Reads watchlists from Plex accounts                      |
| TMDB     | Fetches poster art and metadata for notifications         |

## Notes / Gotchas

- The TMDB API key (`tmdbApiKey`) is visible in the container environment — treat
  it as a secret. Rotate at `themoviedb.org` if exposed.
- Pulsarr's watchlist sync requires Plex account API tokens configured in the
  Pulsarr UI settings.
- Pulsarr is healthy-checked (`(healthy)` status in `docker ps`) — the healthcheck
  endpoint is configured internally.
- Notification channels (Discord, Telegram, etc.) are configured in Pulsarr settings
  and stored in the `/app/data` bind mount.

---
*Last Updated: 2026-06-16*

# 13-flaresolverr.md

kstack:
  book: Centerpoint Home Lab
  chapter: Media Management
  page: FlareSolverr (Offline)
  tags: [flaresolverr, cloudflare, prowlarr, offline, media-management]
---

## Overview

> **Status: OFFLINE** — The FlareSolverr container is present but currently not
> running (exited 8 weeks ago). It can be restarted when Cloudflare-protected
> indexers need to be accessed via Prowlarr.

FlareSolverr is a proxy server that bypasses Cloudflare's anti-bot protection for
web-based indexers. Prowlarr routes requests for Cloudflare-protected indexers
through FlareSolverr, which uses a headless browser session to solve the challenge
and return the page content.

## Access

FlareSolverr has no web UI. It is accessed internally by Prowlarr via its HTTP API
at `http://flaresolverr:8191`.

## Configuration

**Image:** `ghcr.io/flaresolverr/flaresolverr:latest`
**Status:** `exited`

### Prowlarr Integration

Once running, configure in Prowlarr:

1. Settings → Indexers → Add Proxy
2. Type: **FlareSolverr**
3. Host: `http://flaresolverr:8191`
4. Tag: Apply the tag to any Cloudflare-protected indexer

## Volumes / Bind Mounts

| Host Path                           | Container Path | Purpose              |
|-------------------------------------|----------------|----------------------|
| `/home/jeeves/docker/flaresolver`   | `/config`      | FlareSolverr config  |

## Notes / Gotchas

- FlareSolverr uses a headless Chromium browser. It consumes significant CPU and
  RAM during challenge solving — resource spikes are normal.
- Cloudflare regularly updates its bot detection. FlareSolverr may stop working
  after Cloudflare updates and require an image update.
- When FlareSolverr is offline, any Prowlarr indexer tagged with the FlareSolverr
  proxy will fail silently on search — no error surfaced in Sonarr/Radarr. Check
  Prowlarr indexer status if searches return no results.
- To restart: `docker start flaresolverr` or redeploy via Portainer.

---
*Last Updated: 2026-06-16*

# h06-documents-organization

# 00-chapter-intro.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Chapter Introduction
  tags: [documents, organization, paperless, bookstack, productivity]
---

## Overview

This chapter covers personal productivity, knowledge management, and life-organisation
services. These range from document archival and wiki systems through to recipe
management, home inventory, plant care, and fitness tracking.

## Services in This Chapter

| Service        | Container(s)                                                              | Purpose                                      |
|----------------|---------------------------------------------------------------------------|----------------------------------------------|
| **Paperless-NGX** | `paperless-webserver-1`, `paperless-db-1`, `paperless-broker-1`, `paperless_gotenberg`, `paperless_tika` | Document management and archival |
| **BookStack**  | `bookstack`, `bookstack-db`                                               | Self-hosted wiki and documentation           |
| **Karakeep**   | `karakeep-web`                                                            | Bookmarks and read-it-later                  |
| **Memos**      | `memos`                                                                   | Quick notes and journal                      |
| **Booklore**   | `booklore`, `booklore-db`                                                 | Personal book library and reading tracker    |
| **Mealie**     | `mealie`, `mealieaddons`                                                  | Recipe management and meal planning          |
| **Homebox**    | `homebox`                                                                 | Home inventory management                    |
| **Medikeep**   | `medikeep`, `medikeep-db`                                                 | Personal medical records tracker             |
| **LubeLogger** | `lubelogger`, `lubelogger-db`                                             | Vehicle maintenance log                      |
| **HortusFox**  | `hortusfox`, `hortusfox-db`, `hortusfox-cron`                            | Plant care and garden management             |
| **LinkStack**  | `linkstack`                                                               | Public link-in-bio / personal landing page   |
| **Reitti**     | `reitti-app`, `reitti-postgis`, `reitti-redis`, `reitti-rabbitmq`, `reitti-tiles`, `reitti-photon` | Activity and fitness tracking |
| **Swarm-Reitti Bridge** | `swarm-reitti-bridge`                                            | Custom Foursquare→Reitti check-in sync service |

---
*Last Updated: 2026-06-17*

# 01-paperless-ngx.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Paperless-NGX
  tags: [paperless, documents, ocr, archive, productivity]
---

## Overview

Paperless-NGX is the centralised document management and archival system. It
ingests scanned documents and PDFs via a watched consume directory and email,
applies OCR, tags, correspondents, and document types, then stores the processed
results in a structured archive. Gmail OAuth is configured for email ingestion.
SSO is provided via Authentik OpenID Connect.

## Access

| Type     | URL                                          | Auth                   |
|----------|----------------------------------------------|------------------------|
| External | `https://paperless.jeeves5454.ddns.net`      | Authentik OIDC + GeoBlock + CrowdSec |
| Internal | `https://paperless.home.local`               | Step-CA TLS (internal) |

Authentication uses Authentik OIDC (`openid_connect` Django allauth provider).
Regular local login is kept enabled (`PAPERLESS_DISABLE_REGULAR_LOGIN=false`).
Auto-signup for new OIDC users is disabled — accounts must be pre-created.

## Containers

Five containers in this stack:

| Container             | Image                                          | Role              |
|-----------------------|------------------------------------------------|-------------------|
| `paperless-webserver-1` | `ghcr.io/paperless-ngx/paperless-ngx:latest` | Web UI + workers  |
| `paperless-db-1`      | `postgres:16`                                  | Primary database  |
| `paperless-broker-1`  | `redis:7`                                      | Celery task queue |
| `paperless_gotenberg` | `gotenberg/gotenberg:8.27`                     | DOCX→PDF conversion |
| `paperless_tika`      | `apache/tika:latest`                           | Content extraction|

### paperless-webserver-1

Main application container running both the Django web server and Celery workers.

**Key environment variables:**

| Variable                          | Value / Notes                                                   |
|-----------------------------------|-----------------------------------------------------------------|
| `PAPERLESS_URL`                   | `https://paperless.jeeves5454.ddns.net`                         |
| `PAPERLESS_CSRF_TRUSTED_ORIGINS`  | `https://paperless.jeeves5454.ddns.net`                         |
| `PAPERLESS_OAUTH_CALLBACK_BASE_URL` | `https://paperless.jeeves5454.ddns.net`                       |
| `PAPERLESS_DBHOST`                | `db`                                                            |
| `PAPERLESS_REDIS`                 | `redis://broker:6379`                                           |
| `PAPERLESS_TIKA_ENABLED`          | `1`                                                             |
| `PAPERLESS_TIKA_ENDPOINT`         | `http://tika:9998`                                              |
| `PAPERLESS_TIKA_GOTENBERG_ENDPOINT` | `http://gotenberg:3000`                                       |
| `PAPERLESS_TASK_WORKERS`          | `2`                                                             |
| `PAPERLESS_THREADS_PER_WORKER`    | `2`                                                             |
| `PAPERLESS_TIME_ZONE`             | `America/Toronto`                                               |
| `PAPERLESS_OCR_LANGUAGE`          | `eng`                                                           |
| `PAPERLESS_CONSUMER_RECURSIVE`    | `true`                                                          |
| `PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS` | `true`                                                       |
| `PAPERLESS_CONSUMER_POLLING`      | `5` (seconds)                                                   |
| `PAPERLESS_FILENAME_FORMAT`       | `{{ created_year }}/{{ document_type }}/{{ created_year }}-{{ created_month }}-{{ created_day }}_{{ correspondent }}_{{ title }}` |
| `PAPERLESS_FILENAME_FORMAT_REMOVE_NONE` | `true`                                                  |
| `PAPERLESS_SOCIAL_AUTO_SIGNUP`    | `false`                                                         |
| `PAPERLESS_ACCOUNT_EMAIL_VERIFICATION` | `none`                                                   |
| `PAPERLESS_GMAIL_OAUTH_CLIENT_ID` | (visible — treat as semi-public)                               |
| `PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET` | **REDACTED**                                               |
| `PAPERLESS_APPS`                  | `allauth.socialaccount.providers.openid_connect`                |
| OIDC provider client secret       | **REDACTED** (inside `PAPERLESS_SOCIALACCOUNT_PROVIDERS`)       |

**Bind mounts:**

| Host Path                               | Container Path                        | Purpose               |
|-----------------------------------------|---------------------------------------|-----------------------|
| `/home/jeeves/docker/paperless/data`    | `/usr/src/paperless/data`             | Index and SQLite state|
| `/mnt/data/paperless/media`             | `/usr/src/paperless/media`            | Archived document files |
| `/mnt/data/paperless/consume`           | `/usr/src/paperless/consume`          | Watched consume folder |
| `/mnt/data/paperless/export`            | `/usr/src/paperless/export`           | Export output folder  |

> The `media`, `consume`, and `export` directories live on the NFS-equivalent
> Ceph volume at `/mnt/data/`, providing separation from the system drive.

### paperless-db-1

PostgreSQL 16 database backend.

**Bind mounts:**

| Host Path                               | Container Path             |
|-----------------------------------------|----------------------------|
| `/home/jeeves/docker/paperless/pgdata`  | `/var/lib/postgresql/data` |

### paperless-broker-1

Redis 7 for Celery task queue.

**Bind mounts:**

| Host Path                             | Container Path |
|---------------------------------------|----------------|
| `/home/jeeves/docker/paperless/redis` | `/data`        |

### paperless_gotenberg

Gotenberg 8.27 — converts Office documents (DOCX, XLSX, etc.) to PDF for
ingestion. No persistent volumes.

### paperless_tika

Apache Tika — extracts content and metadata from complex document formats.
No persistent volumes.

## Traefik Labels

```yaml
traefik.http.routers.paperless-external.rule: Host(`paperless.jeeves5454.ddns.net`)
traefik.http.routers.paperless-external.entrypoints: websecure
traefik.http.routers.paperless-external.tls.certresolver: letsencrypt
traefik.http.routers.paperless-external.middlewares: plex-geoblock@file,crowdsec-bouncer@file

traefik.http.routers.paperless-internal.rule: Host(`paperless.home.local`)
traefik.http.routers.paperless-internal.entrypoints: websecure
traefik.http.routers.paperless-internal.tls.certresolver: step-ca
```

## Notes / Gotchas

- Secrets in `PAPERLESS_SOCIALACCOUNT_PROVIDERS` (OIDC client secret) and
  `PAPERLESS_GMAIL_OAUTH_CLIENT_SECRET` are in plaintext in the Portainer-managed
  compose environment — rotate if the Portainer env is ever exposed.
- The consume directory polling interval is 5 seconds. Drop files into
  `/mnt/data/paperless/consume` (or a subdirectory — subdirectory names become
  tags automatically via `PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS`).
- Tika and Gotenberg must be running for complex document formats to process;
  plain-text PDFs work without them.
- The `PAPERLESS_LOGOUT_REDIRECT_URL` is set to the Authentik end-session
  endpoint — clicking Log Out in Paperless also terminates the Authentik session.

---
*Last Updated: 2026-06-17*

# 02-bookstack.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: BookStack
  tags: [bookstack, wiki, documentation, mariadb, oidc]
---

## Overview

BookStack is the self-hosted personal knowledge base and documentation wiki —
the platform this very documentation is written in. It organises content into
Shelves → Books → Chapters → Pages and provides a full Markdown editor. Access
is protected by Authentik OIDC on the external route. On the internal route,
authentication still passes through BookStack's own OIDC flow (no Traefik
ForwardAuth bypass), so the Authentik session is always required.

## Access

| Type     | URL                                     | Auth                             |
|----------|-----------------------------------------|----------------------------------|
| External | `https://wiki.jeeves5454.ddns.net`      | Authentik OIDC + GeoBlock + CrowdSec |
| Internal | `https://wiki.home.local`               | Authentik OIDC (Step-CA TLS)    |

BookStack uses its own OIDC integration rather than Traefik ForwardAuth. The
external Traefik route applies GeoBlock and CrowdSec but **not**
`authentik-auth@docker` — BookStack manages the OIDC redirect itself.

## Containers

| Container      | Image             | Role             |
|----------------|-------------------|------------------|
| `bookstack`    | `lscr.io/linuxserver/bookstack:latest` | Web application |
| `bookstack-db` | `mariadb:10.11`   | MariaDB database |

### bookstack (application)

**Key environment variables:**

| Variable                  | Value / Notes                                                  |
|---------------------------|----------------------------------------------------------------|
| `APP_URL`                 | `https://wiki.jeeves5454.ddns.net`                             |
| `AUTH_METHOD`             | `oidc`                                                         |
| `AUTH_AUTO_INITIATE`      | `true` — skips BookStack login page, redirects to Authentik    |
| `OIDC_NAME`               | `Authentik`                                                    |
| `OIDC_ISSUER`             | `https://auth.jeevesconsults.ca/application/o/book-stack-website-s/` |
| `OIDC_ISSUER_DISCOVER`    | `true`                                                         |
| `OIDC_CLIENT_ID`          | `QjpMMmpDqNCQIM75np6gGyMkN9Y569AtyNVhfJvx`                     |
| `OIDC_CLIENT_SECRET`      | **REDACTED**                                                   |
| `OIDC_EXTERNAL_ID_CLAIM`  | `email`                                                        |
| `OIDC_DISPLAY_NAME_CLAIMS`| `name`                                                         |
| `OIDC_FETCH_AVATAR`       | `false`                                                        |
| `OIDC_END_SESSION_ENDPOINT` | `false`                                                      |
| `DB_HOST`                 | `bookstack-db`                                                 |
| `DB_DATABASE`             | `bookstack`                                                    |
| `DB_USERNAME`             | `bookstack`                                                    |
| `DB_PASSWORD`             | **REDACTED**                                                   |
| `MAIL_HOST`               | `smtp.gmail.com`                                               |
| `MAIL_PORT`               | `587`                                                          |
| `MAIL_USERNAME`           | `jeeves5454@gmail.com`                                         |
| `MAIL_ENCRYPTION`         | `TLS`                                                          |
| `MAIL_FROM`               | `noreply@jeevesconsults.ca`                                    |
| `PUID` / `PGID`           | `1000`                                                         |
| `TZ`                      | `America/Toronto`                                              |

**Bind mounts:**

| Host Path                              | Container Path | Purpose                    |
|----------------------------------------|----------------|----------------------------|
| `/home/jeeves/docker/bookstack/config` | `/config`      | App config, attachments, uploads |
| `/home/jeeves/docker/bookstack/public` | `/public`      | Public web assets          |

### bookstack-db (MariaDB 10.11)

**Bind mounts:**

| Host Path                           | Container Path     |
|-------------------------------------|--------------------|
| `/home/jeeves/docker/bookstack/db`  | `/var/lib/mysql`   |

## Traefik Labels

```yaml
traefik.http.routers.bookstack-ext.rule: Host(`wiki.jeeves5454.ddns.net`)
traefik.http.routers.bookstack-ext.entrypoints: websecure
traefik.http.routers.bookstack-ext.tls.certresolver: letsencrypt
traefik.http.routers.bookstack-ext.middlewares: plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.bookstack-ext.service: bookstack-svc

traefik.http.routers.bookstack-int.rule: Host(`wiki.home.local`)
traefik.http.routers.bookstack-int.entrypoints: websecure
traefik.http.routers.bookstack-int.tls.certresolver: step-ca
traefik.http.routers.bookstack-int.service: bookstack-svc

traefik.http.services.bookstack-svc.loadbalancer.server.port: 80
```

## Notes / Gotchas

- `APP_URL` is set to the external URL even though both internal and external
  routes exist. This is intentional — it is used for OIDC redirect URIs and
  email links, which must be reachable externally.
- `AUTH_AUTO_INITIATE: true` means anyone who hits the URL is immediately
  redirected to Authentik. There is no BookStack login form shown unless OIDC
  fails. To bypass OIDC in an emergency, use `?prevent_auto_init=true` appended
  to the URL.
- For an existing user to link their Authentik account, go to Admin → Users →
  select user → set **External Authentication ID** to their email address.
- The LSIO image uses `DB_USERNAME`, not `DB_USER`. Using `DB_USER` silently
  falls back to no authentication and causes confusing login failures.
- `APP_KEY` (Laravel application key) is stored in the `/config` directory and
  persists across container restarts. Do not delete the config bind mount or the
  key must be regenerated and all sessions will be invalidated.

---
*Last Updated: 2026-06-17*

# 03-karakeep.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Karakeep
  tags: [karakeep, bookmarks, read-later, productivity]
---

## Overview

Karakeep is a self-hosted bookmark manager and read-it-later service. It crawls
saved URLs for their full text and screenshots, indexes them via Meilisearch,
and supports OCR on images (`ocr_langs=eng`). Video download is enabled
(yt-dlp backend, no size limit). SMTP is configured for email-to-save via Gmail.
New signups are disabled — account management is manual.

## Access

| Type     | URL                                        | Auth                        |
|----------|--------------------------------------------|-----------------------------|
| External | `https://bookmark.jeevesconsults.ca`       | GeoBlock + CrowdSec (own auth) |

External-only — no internal `home.local` route configured.

## Configuration

**Image:** `ghcr.io/karakeep-app/karakeep:latest`

### Key Environment Variables

| Variable                              | Value / Notes                                  |
|---------------------------------------|------------------------------------------------|
| `NEXTAUTH_URL`                        | `https://bookmark.jeevesconsults.ca`           |
| `NEXTAUTH_URL_INTERNAL`               | `http://karakeep-web:3000`                     |
| `NEXTAUTH_TRUST_HOST`                 | `true`                                         |
| `NEXTAUTH_SECRET`                     | **REDACTED**                                   |
| `DATA_DIR`                            | `/data`                                        |
| `ASSETS_DIR`                          | `/assets`                                      |
| `MEILI_ADDR`                          | `http://meilisearch:7700`                      |
| `MEILI_MASTER_KEY`                    | **REDACTED**                                   |
| `BROWSER_WEB_URL`                     | `http://chrome:9222` (headless Chromium)       |
| `OCR_LANGS`                           | `eng`                                          |
| `OCR_CONFIDENCE_THRESHOLD`            | `75`                                           |
| `CRAWLER_VIDEO_DOWNLOAD`              | `true`                                         |
| `CRAWLER_VIDEO_DOWNLOAD_MAX_SIZE`     | `-1` (unlimited)                               |
| `CRAWLER_VIDEO_DOWNLOAD_TIMEOUT_SEC`  | `7200`                                         |
| `INFERENCE_ENABLE_AUTO_SUMMARIZATION` | `true`                                         |
| `MAX_ASSET_SIZE_MB`                   | `50`                                           |
| `DISABLE_SIGNUPS`                     | `true`                                         |
| `EMAIL_VERIFICATION_REQUIRED`         | `false`                                        |
| `SMTP_HOST`                           | `smtp.gmail.com`                               |
| `SMTP_PORT`                           | `587`                                          |
| `SMTP_SECURE`                         | `true`                                         |
| `SMTP_USER`                           | `jeeves5454@gmail.com`                         |
| `SMTP_FROM`                           | `jeeves5454@gmail.com`                         |
| `SMTP_PASSWORD`                       | **REDACTED**                                   |

### Traefik Labels

```yaml
traefik.http.routers.karakeep.rule: Host(`bookmark.jeevesconsults.ca`)
traefik.http.routers.karakeep.entrypoints: websecure
traefik.http.routers.karakeep.tls.certresolver: letsencrypt
traefik.http.routers.karakeep.middlewares: plex-geoblock@file,crowdsec-bouncer@file,karakeep-headers
traefik.http.middlewares.karakeep-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.karakeep.loadbalancer.server.port: 3000
```

## Volumes / Bind Mounts

| Host Path                             | Container Path | Purpose               |
|---------------------------------------|----------------|-----------------------|
| `/home/jeeves/docker/karakeep/data`   | `/data`        | Database and app data |
| `/mnt/data/karakeep`                  | `/assets`      | Saved assets (screenshots, videos) |

## Stack Companions

Karakeep requires additional sidecar containers (Meilisearch, Headless Chrome)
in the same compose network to function. These are not independently accessible.

## Notes / Gotchas

- The `X-Forwarded-Proto: https` middleware is required for Karakeep (Next.js)
  to trust the proxy and generate correct redirect URIs — without it, HTTPS
  callbacks will fail.
- Video downloads can consume significant storage on `/mnt/data/karakeep`. The
  timeout is 2 hours, which accommodates long-form content.
- Meilisearch index data is stored in `/data` — this must be backed up alongside
  the app database for search to remain functional after a restore.
- `DISABLE_SIGNUPS: true` — new accounts must be created manually in the admin UI.

---
*Last Updated: 2026-06-17*

# 04-memos.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Memos
  tags: [memos, notes, journal, productivity]
---

## Overview

Memos is a lightweight, self-hosted note-taking and micro-journaling app.
It provides a simple Twitter/Mastodon-style feed of notes, supports Markdown,
tags, and public/private visibility per memo. Internal-only access — no external
route is configured.

## Access

| Type     | URL                          | Auth                       |
|----------|------------------------------|----------------------------|
| Internal | `https://memos.home.local`   | Memos own auth (Step-CA TLS) |

No external route — accessible only on the LAN.

## Configuration

**Image:** `neosmemo/memos:stable`

### Environment Variables

| Variable              | Value                             | Purpose             |
|-----------------------|-----------------------------------|---------------------|
| `MEMOS_INSTANCE_URL`  | `https://memos.home.local`        | Canonical URL       |
| `MEMOS_PORT`          | `5230`                            | Listen port         |
| `TZ`                  | `America/Toronto`                 | Timezone            |

### Traefik Labels

```yaml
traefik.http.routers.memos.rule: Host(`memos.home.local`)
traefik.http.routers.memos.entrypoints: websecure
traefik.http.routers.memos.tls.certresolver: step-ca
traefik.http.services.memos.loadbalancer.server.port: 5230
```

## Volumes / Bind Mounts

| Host Path                          | Container Path     | Purpose             |
|------------------------------------|--------------------|---------------------|
| `/home/jeeves/docker/memos/data`   | `/var/opt/memos`   | Database and assets |

Memos uses SQLite stored in `/var/opt/memos`. No external database dependency.

## Notes / Gotchas

- `neosmemo/memos:stable` tracks the stable release tag. Upgrading the image
  will migrate the SQLite database automatically on first startup.
- There is no Authentik integration on this service — Memos manages its own
  accounts internally.
- Memos supports public content sharing via short links; ensure private memos
  are set to private if personal content is being stored.

---
*Last Updated: 2026-06-17*

# 05-booklore.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Booklore
  tags: [booklore, books, library, mariadb, step-ca]
---

## Overview

Booklore is a personal e-book library manager. It indexes books from the NFS
Multimedia share (read-only) and a local bookdrop directory for new additions.
The app is a Java/Spring Boot application (JDK 25 with Shenandoah GC) backed by
MariaDB. It uses its own internal authentication — no Authentik ForwardAuth or
OIDC. The Step-CA root certificate is bind-mounted into the system trust store
to allow Booklore to make HTTPS calls to internal `*.home.local` services.

## Access

| Type     | URL                                      | Auth                              |
|----------|------------------------------------------|-----------------------------------|
| External | `https://booklore.jeevesconsults.ca`     | GeoBlock + CrowdSec (own auth)    |
| Internal | `https://booklore.home.local`            | Own auth (Step-CA TLS)            |

## Containers

| Container    | Image                            | Role             |
|--------------|----------------------------------|------------------|
| `booklore`   | `grimmory/grimmory:latest`       | Web application  |
| `booklore-db`| `lscr.io/linuxserver/mariadb:latest` | MariaDB database |

### booklore (application)

**Key environment variables:**

| Variable            | Value / Notes                                     |
|---------------------|---------------------------------------------------|
| `DATABASE_USERNAME` | `booklore`                                        |
| `DATABASE_PASSWORD` | **REDACTED**                                      |
| `DATABASE_URL`      | `jdbc:mariadb://booklore-db:3306/booklore`        |
| `BOOKLORE_PORT`     | `6060`                                            |
| `DISK_TYPE`         | `NETWORK` (books from NFS mount)                  |
| `USER_ID` / `GROUP_ID` | `1000`                                         |
| `TZ`                | `America/Toronto`                                 |
| `APP_VERSION`       | `v3.2.0`                                          |

**Runtime:** Java 25 (Temurin JDK 25.0.3) with Shenandoah GC.  
JVM tuning: max 60% RAM, Shenandoah compact heuristics, 256MB metaspace cap.

**Bind mounts:**

| Host Path                                                   | Container Path                                  | Purpose                   |
|-------------------------------------------------------------|-------------------------------------------------|---------------------------|
| `/mnt/Multimedia/Books`                                     | `/books`                                        | Book library (read-only via NFS) |
| `/home/jeeves/docker/booklore/data`                         | `/app/data`                                     | App state and metadata    |
| `/home/jeeves/docker/booklore/bookdrop`                     | `/bookdrop`                                     | Drop zone for new books   |
| `/home/jeeves/docker/step-ca/config/certs/root_ca.crt`     | `/usr/local/share/ca-certificates/step-ca.crt` | Step-CA root trust injection |

### booklore-db (MariaDB LSIO)

**Image:** `lscr.io/linuxserver/mariadb:latest`

| Variable       | Value      |
|----------------|------------|
| `MYSQL_DATABASE` | `booklore` |
| `MYSQL_USER`   | `booklore` |
| `MYSQL_PASSWORD` | **REDACTED** |
| `PUID` / `PGID`| `1000`     |

**Bind mounts:**

| Host Path                              | Container Path |
|----------------------------------------|----------------|
| `/home/jeeves/docker/booklore/mariadb` | `/config`      |

## Traefik Labels

```yaml
traefik.http.routers.booklore-external.rule: Host(`booklore.jeevesconsults.ca`)
traefik.http.routers.booklore-external.entrypoints: websecure
traefik.http.routers.booklore-external.tls.certresolver: letsencrypt
traefik.http.routers.booklore-external.middlewares: plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.booklore-external.service: booklore-svc

traefik.http.routers.booklore-internal.rule: Host(`booklore.home.local`)
traefik.http.routers.booklore-internal.entrypoints: websecure
traefik.http.routers.booklore-internal.tls.certresolver: step-ca
traefik.http.routers.booklore-internal.service: booklore-svc

traefik.http.services.booklore-svc.loadbalancer.server.port: 6060
```

## Notes / Gotchas

- The Step-CA root certificate bind mount (`root_ca.crt` → `/usr/local/share/ca-certificates/`) makes the container's Java runtime trust internal TLS certificates. This is required if Booklore makes any HTTPS requests to `*.home.local` services.
- `/mnt/Multimedia/Books` is the NFS share from UnRAID — it must be mounted before
  Booklore starts or the library scan will fail silently. Check `df -h /mnt/Multimedia`
  if the library appears empty.
- Drop new e-books (EPUB, PDF) into `/home/jeeves/docker/booklore/bookdrop` for
  automatic library import.
- `grimmory/grimmory:latest` is the container image name for the Booklore project.

---
*Last Updated: 2026-06-17*

# 06-mealie.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Mealie
  tags: [mealie, recipes, meal-planning, oidc, authentik]
---

## Overview

Mealie is a self-hosted recipe manager and meal planner. It imports recipes from
URLs, supports manual entry, and provides a weekly meal plan interface. OIDC
authentication is via Authentik, with auto-redirect enabled. The `mealieaddons`
sidecar (Mealie Addons by razziel89) provides enhanced recipe retrieval, PDF
export via Pandoc, and image embedding.

## Access

| Type     | URL                                     | Auth                          |
|----------|-----------------------------------------|-------------------------------|
| External | `https://recipe.jeevesconsults.ca`      | Authentik OIDC + GeoBlock + CrowdSec |
| Internal | `https://mealie.home.local`             | Authentik OIDC (Step-CA TLS) |

OIDC auto-redirect is enabled (`OIDC_AUTO_REDIRECT: true`) — the Mealie login
form is bypassed and Authentik is shown directly.

## Containers

| Container      | Image                                    | Role                          |
|----------------|------------------------------------------|-------------------------------|
| `mealie`       | `ghcr.io/mealie-recipes/mealie:latest`   | Core application              |
| `mealieaddons` | `ghcr.io/razziel89/mealie-addons:latest` | Enhanced retrieval and export |

### mealie (application)

**Key environment variables:**

| Variable                      | Value / Notes                                                         |
|-------------------------------|-----------------------------------------------------------------------|
| `BASE_URL`                    | `https://recipe.jeevesconsults.ca`                                    |
| `OIDC_AUTH_ENABLED`           | `true`                                                                |
| `OIDC_AUTO_REDIRECT`          | `true`                                                                |
| `OIDC_PROVIDER_NAME`          | `Authentik`                                                           |
| `OIDC_CONFIGURATION_URL`      | `https://auth.jeevesconsults.ca/application/o/mealie/.well-known/openid-configuration` |
| `OIDC_CLIENT_ID`              | `cjTjv6CLnu0pT7qkkthDTjXmaVPYdxMfOKW9d5Wy`                           |
| `OIDC_CLIENT_SECRET`          | **REDACTED**                                                          |
| `OIDC_USER_GROUP`             | `family-friends`                                                      |
| `OIDC_ADMIN_GROUP`            | `admins`                                                              |
| `OIDC_SIGNUP_ENABLED`         | `false`                                                               |
| `OIDC_REMEMBER_ME`            | `true`                                                                |
| `ALLOW_SIGNUP`                | `false`                                                               |
| `SMTP_HOST`                   | `smtp.gmail.com`                                                      |
| `SMTP_PORT`                   | `587`                                                                 |
| `SMTP_AUTH_STRATEGY`          | `TLS`                                                                 |
| `SMTP_USER`                   | `jeeves5454@gmail.com`                                                |
| `SMTP_PASSWORD`               | **REDACTED**                                                          |
| `SMTP_FROM_EMAIL`             | `jeeves5454@gmail.com`                                                |
| `PUID` / `PGID`               | `1000`                                                                |
| `TZ`                          | `America/Toronto`                                                     |

**Bind mounts:**

| Host Path                          | Container Path | Purpose             |
|------------------------------------|----------------|---------------------|
| `/home/jeeves/docker/mealie/data`  | `/app/data`    | Database and assets |

### mealieaddons

Mealie Addons provides enhanced recipe scraping, Pandoc-based PDF/EPUB export,
and image embedding for recipes.

**Key environment variables:**

| Variable              | Value / Notes                             |
|-----------------------|-------------------------------------------|
| `MEALIE_BASE_URL`     | `https://recipe.jeevesconsults.ca`        |
| `MEALIE_RETRIEVAL_URL` | `http://mealie:9000`                     |
| `MA_SELF_URL`         | `http://localhost:9000`                   |
| `MA_LISTEN_INTERFACE` | `:9000`                                   |
| `MA_IMAGE_ACTION`     | `embed`                                   |
| `MA_TIMEOUT_SECS`     | `60`                                      |
| `MA_RETRIEVAL_LIMIT`  | `5`                                       |
| `GIN_MODE`            | `release`                                 |
| `PANDOC_FLAGS`        | `--epub-title-page=false`                 |

No bind mounts — stateless.

**Traefik route:**

```yaml
traefik.http.routers.mealieaddons.rule: Host(`mealieaddons.home.local`)
traefik.http.routers.mealieaddons.entrypoints: websecure
traefik.http.routers.mealieaddons.tls.certresolver: step-ca
traefik.http.services.mealieaddons.loadbalancer.server.port: 9000
```

## Traefik Labels (mealie)

```yaml
traefik.http.routers.mealie-ext.rule: Host(`recipe.jeevesconsults.ca`)
traefik.http.routers.mealie-ext.entrypoints: websecure
traefik.http.routers.mealie-ext.tls.certresolver: letsencrypt
traefik.http.routers.mealie-ext.middlewares: plex-geoblock@file,crowdsec-bouncer@file,mealie-headers
traefik.http.routers.mealie-ext.service: mealie-svc

traefik.http.routers.mealie-int.rule: Host(`mealie.home.local`)
traefik.http.routers.mealie-int.entrypoints: websecure
traefik.http.routers.mealie-int.tls.certresolver: step-ca
traefik.http.routers.mealie-int.service: mealie-svc

traefik.http.middlewares.mealie-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.mealie-svc.loadbalancer.server.port: 9000
```

## Notes / Gotchas

- `OIDC_USER_GROUP: family-friends` limits OIDC login to members of that Authentik
  group. Users not in this group can be denied access even with valid Authentik
  credentials. Manage group membership in Authentik admin.
- The `X-Forwarded-Proto: https` header middleware is required for Mealie to
  generate correct OIDC redirect URIs. Without it, HTTPS URLs will not form
  correctly and the OIDC flow will fail.
- Mealie uses SQLite by default when no external database is configured. The
  database file lives in `/app/data`. Back up this directory before upgrades.
- `mealieaddons` is accessible at `mealieaddons.home.local` — this is the URL
  to configure in Mealie settings for the enhanced importer.

---
*Last Updated: 2026-06-17*

# 07-homebox.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Homebox
  tags: [homebox, inventory, home-management, authentik]
---

## Overview

Homebox is a home inventory management app for tracking household items,
their locations, warranties, and purchase records. The external route uses
Authentik ForwardAuth for SSO. The internal route applies `X-Forwarded-Proto`
only (Homebox manages its own session internally). Signups are disabled;
registration is not allowed.

## Access

| Type     | URL                                       | Auth                                     |
|----------|-------------------------------------------|------------------------------------------|
| External | `https://homebox.jeeves5454.ddns.net`     | Authentik ForwardAuth + GeoBlock + CrowdSec |
| Internal | `https://homebox.home.local`              | Homebox own session (Step-CA TLS)        |

## Configuration

**Image:** `ghcr.io/sysadminsmedia/homebox:latest`

### Key Environment Variables

| Variable                       | Value / Notes                     |
|--------------------------------|-----------------------------------|
| `HBOX_MODE`                    | `production`                      |
| `HBOX_LOG_LEVEL`               | `info`                            |
| `HBOX_LOG_FORMAT`              | `text`                            |
| `HBOX_WEB_MAX_UPLOAD_SIZE`     | `10` (MB)                         |
| `HBOX_OPTIONS_ALLOW_REGISTRATION` | `false`                        |
| `HBOX_OPTIONS_ALLOW_ANALYTICS` | `false`                           |
| `HBOX_STORAGE_PREFIX_PATH`     | `data`                            |
| `TZ`                           | `America/Toronto`                 |

### Traefik Labels

```yaml
traefik.http.routers.homebox-ext.rule: Host(`homebox.jeeves5454.ddns.net`)
traefik.http.routers.homebox-ext.entrypoints: websecure
traefik.http.routers.homebox-ext.tls.certresolver: letsencrypt
traefik.http.routers.homebox-ext.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file,homebox-headers
traefik.http.routers.homebox-ext.service: homebox-svc

traefik.http.routers.homebox-int.rule: Host(`homebox.home.local`)
traefik.http.routers.homebox-int.entrypoints: websecure
traefik.http.routers.homebox-int.tls.certresolver: step-ca
traefik.http.routers.homebox-int.middlewares: homebox-headers
traefik.http.routers.homebox-int.service: homebox-svc

traefik.http.middlewares.homebox-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.homebox-svc.loadbalancer.server.port: 7745
```

## Volumes / Bind Mounts

| Host Path                          | Container Path | Purpose                    |
|------------------------------------|----------------|----------------------------|
| `/home/jeeves/docker/homebox/data` | `/data`        | SQLite database and uploads|

Homebox uses SQLite (`homebox.db`) stored in the `/data` bind mount with WAL
journal mode and foreign key enforcement enabled.

## Notes / Gotchas

- The external route applies `authentik-auth@docker` ForwardAuth — all external
  access requires a valid Authentik session. The internal route skips ForwardAuth
  but applies the `X-Forwarded-Proto` header middleware (required for HTTPS-aware
  links and session cookies).
- `HBOX_OPTIONS_ALLOW_REGISTRATION: false` disables self-service account creation.
  New accounts must be created by an admin in the Homebox UI.
- Homebox stores photos and attachments in the `/data` bind mount alongside the
  SQLite database. Back up the entire directory before upgrades.

---
*Last Updated: 2026-06-17*

# 08-medikeep.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Medikeep
  tags: [medikeep, medical, health, postgres, authentik-sso]
---

## Overview

Medikeep is a personal medical records tracker. It stores health records,
prescriptions, appointments, and related documents. SSO is configured via
Authentik (native OIDC integration — not Traefik ForwardAuth). The external
route uses GeoBlock and CrowdSec; the internal route is TLS-only with no
additional auth middleware.

## Access

| Type     | URL                                       | Auth                              |
|----------|-------------------------------------------|-----------------------------------|
| External | `https://medical.jeeves5454.ddns.net`     | Authentik OIDC + GeoBlock + CrowdSec |
| Internal | `https://medikeep.home.local`             | Authentik OIDC (Step-CA TLS)     |

SSO is handled by Medikeep's built-in `SSO_ENABLED: true` — it redirects to
Authentik on login. Traefik does not apply ForwardAuth to this service.

## Containers

| Container     | Image                              | Role             |
|---------------|------------------------------------|------------------|
| `medikeep`    | `ghcr.io/afairgiant/medikeep:latest` | Web application |
| `medikeep-db` | `postgres:15.8-alpine`             | PostgreSQL database |

### medikeep (application)

**Key environment variables:**

| Variable              | Value / Notes                                             |
|-----------------------|-----------------------------------------------------------|
| `SSO_ENABLED`         | `true`                                                    |
| `SSO_PROVIDER_TYPE`   | `authentik`                                               |
| `SSO_ISSUER_URL`      | `https://auth.jeevesconsults.ca/application/o/medikeep/` |
| `SSO_CLIENT_ID`       | `EN44sdEMtf29bgTN077W48XsCSpi9bj1Wk0eypI1`               |
| `SSO_CLIENT_SECRET`   | **REDACTED**                                              |
| `SSO_REDIRECT_URI`    | `https://medical.jeeves5454.ddns.net/auth/sso/callback`   |
| `DB_HOST`             | `medikeep-db`                                             |
| `DB_PORT`             | `5432`                                                    |
| `DB_NAME`             | `medical_records`                                         |
| `DB_USER`             | `medapp`                                                  |
| `DB_PASSWORD`         | **REDACTED**                                              |
| `LOG_LEVEL`           | `DEBUG`                                                   |
| `LOG_ROTATION_METHOD` | `logrotate`                                               |
| `ENABLE_API_DOCS`     | `false`                                                   |
| `DEBUG`               | `false`                                                   |
| `PUID` / `PGID`       | `1000`                                                    |
| `TZ`                  | `America/Toronto`                                         |

**Bind mounts:**

| Host Path                                    | Container Path  | Purpose              |
|----------------------------------------------|-----------------|----------------------|
| `/home/jeeves/docker/medikeep/uploads`       | `/app/uploads`  | Document uploads     |
| `/home/jeeves/docker/medikeep/logs`          | `/app/logs`     | Application logs     |
| `/home/jeeves/docker/medikeep/backups`       | `/app/backups`  | Backup output        |

### medikeep-db (PostgreSQL 15.8)

**Image:** `postgres:15.8-alpine`

| Variable         | Value             |
|------------------|-------------------|
| `POSTGRES_DB`    | `medical_records` |
| `POSTGRES_USER`  | `medapp`          |
| `POSTGRES_PASSWORD` | **REDACTED**   |

**Bind mounts:**

| Host Path                                       | Container Path             |
|-------------------------------------------------|----------------------------|
| `/home/jeeves/docker/medikeep/postgres/data`    | `/var/lib/postgresql/data` |

## Traefik Labels

```yaml
traefik.http.routers.medikeep-ext.rule: Host(`medical.jeeves5454.ddns.net`)
traefik.http.routers.medikeep-ext.entrypoints: websecure
traefik.http.routers.medikeep-ext.tls.certresolver: letsencrypt
traefik.http.routers.medikeep-ext.middlewares: plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.medikeep-ext.service: medikeep-svc

traefik.http.routers.medikeep-int.rule: Host(`medikeep.home.local`)
traefik.http.routers.medikeep-int.entrypoints: websecure
traefik.http.routers.medikeep-int.tls.certresolver: step-ca
traefik.http.routers.medikeep-int.service: medikeep-svc

traefik.http.services.medikeep-svc.loadbalancer.server.port: 8000
```

## Notes / Gotchas

- `SSO_REDIRECT_URI` points to the external domain — this is the OAuth callback
  URL registered in Authentik. Even when accessing via the internal `home.local`
  URL, the OIDC callback will redirect through the external domain.
- Medikeep's `LOG_LEVEL` is set to `DEBUG` — logs may be verbose. Logs are
  accessible in the bind-mounted `/app/logs` directory.
- Contains sensitive personal medical data. Limit backup exposure and ensure
  the PostgreSQL data directory is included in regular backup schedules.

---
*Last Updated: 2026-06-17*

# 09-lubelogger.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: LubeLogger
  tags: [lubelogger, vehicles, maintenance, postgres]
---

## Overview

LubeLogger is a vehicle maintenance and fuel log tracker. It stores service
records, oil changes, tyre rotations, fuel fill-ups, and other vehicle events.
External-only route with GeoBlock and CrowdSec — no internal `home.local` route.
Authentication is LubeLogger's own built-in user system. Backend is PostgreSQL 16.

## Access

| Type     | URL                                       | Auth                        |
|----------|-------------------------------------------|-----------------------------|
| External | `https://logger.jeeves5454.ddns.net`      | GeoBlock + CrowdSec (own auth) |

No internal route configured.

## Containers

| Container       | Image                          | Role                |
|-----------------|--------------------------------|---------------------|
| `lubelogger`    | `ghcr.io/hargata/lubelogger:latest` | Web application  |
| `lubelogger-db` | `postgres:16`                  | PostgreSQL database |

### lubelogger (application)

**Image:** `ghcr.io/hargata/lubelogger:latest`  
**Runtime:** .NET 10.0.8 (ASP.NET Core)

**Key environment variables:**

| Variable               | Value / Notes                         |
|------------------------|---------------------------------------|
| `POSTGRES_CONNECTION`  | **REDACTED** (includes password)      |
| `ASPNETCORE_HTTP_PORTS`| `8080`                                |
| `LC_ALL` / `LANG`      | `en_US.UTF-8`                         |

**Bind mounts:**

| Host Path                               | Container Path                    | Purpose                       |
|-----------------------------------------|-----------------------------------|-------------------------------|
| `/home/jeeves/docker/lubelogger/data`   | `/App/data`                       | App data and uploads          |
| `/home/jeeves/docker/lubelogger/keys`   | `/root/.aspnet/DataProtection-Keys` | ASP.NET data protection keys |

### lubelogger-db (PostgreSQL 16)

**Image:** `postgres:16`

| Variable         | Value        |
|------------------|--------------|
| `POSTGRES_USER`  | `lubelogger` |
| `POSTGRES_DB`    | `lubelogger` |
| `POSTGRES_PASSWORD` | **REDACTED** |

**Bind mounts:**

| Host Path                              | Container Path             |
|----------------------------------------|----------------------------|
| `/home/jeeves/docker/lubelogger/db`    | `/var/lib/postgresql/data` |

## Traefik Labels

```yaml
traefik.http.routers.lubelogger.rule: Host(`logger.jeeves5454.ddns.net`)
traefik.http.routers.lubelogger.entrypoints: websecure
traefik.http.routers.lubelogger.tls.certresolver: letsencrypt
traefik.http.routers.lubelogger.middlewares: plex-geoblock@file,crowdsec-bouncer@file,lubelogger-headers
traefik.http.middlewares.lubelogger-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.docker.network: traefik-net
traefik.http.services.lubelogger.loadbalancer.server.port: 8080
```

## Notes / Gotchas

- `POSTGRES_CONNECTION` in the container environment contains the database
  password in plaintext — treat this as sensitive and avoid logging the
  environment of this container.
- The `DataProtection-Keys` bind mount (`/lubelogger/keys`) must persist across
  container restarts. If it is lost, all active user sessions will be
  invalidated and users will need to log in again.
- The `X-Forwarded-Proto: https` middleware is required for ASP.NET Core to
  correctly identify requests as HTTPS when behind a reverse proxy.

---
*Last Updated: 2026-06-17*

# 10-hortusfox.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: HortusFox
  tags: [hortusfox, plants, garden, mariadb]
---

## Overview

HortusFox is a plant care and garden management app. It tracks plants, watering
schedules, fertilisation, and overdue care tasks. A separate cron container
triggers scheduled task checks. Internal-only access via Step-CA TLS — no
external route. The workspace is named "Jeeves Garden".

## Access

| Type     | URL                              | Auth                            |
|----------|----------------------------------|---------------------------------|
| Internal | `https://hortusfox.home.local`   | HortusFox own auth (Step-CA TLS) |

No external route — LAN access only.

## Containers

| Container        | Image                                       | Role                 |
|------------------|---------------------------------------------|----------------------|
| `hortusfox`      | `ghcr.io/danielbrendel/hortusfox-web:latest`| Web application      |
| `hortusfox-db`   | `mariadb:11`                                | MariaDB database     |
| `hortusfox-cron` | `alpine:latest`                             | Scheduled task runner|

### hortusfox (application)

**Runtime:** PHP 8.3 on Apache

**Key environment variables:**

| Variable                   | Value / Notes                   |
|----------------------------|---------------------------------|
| `APP_WORKSPACE`            | `Jeeves Garden`                 |
| `APP_TIMEZONE`             | `America/Toronto`               |
| `APP_LANG`                 | `en`                            |
| `APP_DEBUG`                | `true`                          |
| `APP_ADMIN_EMAIL`          | `jeeves5454@gmail.com`          |
| `APP_OVERDUE_TASK_HOURS`   | `10`                            |
| `APP_ENABLE_HISTORY`       | `true`                          |
| `APP_HISTORY_NAME`         | `Garden Log`                    |
| `APP_ENABLE_SYSTEM_MESSAGES` | `true`                        |
| `APP_ENABLE_PHOTO_SHARE`   | `false`                         |
| `APP_ENABLE_CHAT`          | `false`                         |
| `APP_ENABLE_SCROLLER`      | `true`                          |
| `APP_CRON_PW`              | **REDACTED**                    |
| `DB_HOST`                  | `hortusfox-db`                  |
| `DB_PORT`                  | `3306`                          |
| `DB_DATABASE`              | `hortusfox`                     |
| `DB_USERNAME`              | `hortusfox`                     |
| `DB_CHARSET`               | `utf8mb4`                       |

**Bind mounts:**

| Host Path                                   | Container Path              | Purpose               |
|---------------------------------------------|-----------------------------|-----------------------|
| `/home/jeeves/docker/hortusfox/images`      | `/var/www/html/public/img`  | Plant images          |
| `/home/jeeves/docker/hortusfox/themes`      | `/var/www/html/public/themes` | Custom themes       |
| `/home/jeeves/docker/hortusfox/logs`        | `/var/www/html/app/logs`    | App logs              |
| `/home/jeeves/docker/hortusfox/migrations`  | `/var/www/html/app/migrations` | DB migration files |
| `/home/jeeves/docker/hortusfox/backup`      | `/var/www/html/public/backup` | Export/backup files |

### hortusfox-db (MariaDB 11)

**Image:** `mariadb:11` (MariaDB 11.8.6)

| Variable         | Value      |
|------------------|------------|
| `MYSQL_DATABASE` | `hortusfox`|
| `MYSQL_USER`     | `hortusfox`|
| `MYSQL_PASSWORD` | **REDACTED**|
| `MYSQL_ROOT_PASSWORD` | **REDACTED** |

**Bind mounts:**

| Host Path                              | Container Path       |
|----------------------------------------|----------------------|
| `/home/jeeves/docker/hortusfox/db`     | `/var/lib/mysql`     |

### hortusfox-cron (Alpine)

**Image:** `alpine:latest`

A lightweight Alpine container that runs periodic cron jobs to trigger
HortusFox's scheduled task processing (e.g., overdue task notifications).
It communicates with the main `hortusfox` container using the `APP_CRON_PW`
credential. No persistent volumes.

## Traefik Labels

```yaml
traefik.http.routers.hortusfox.rule: Host(`hortusfox.home.local`)
traefik.http.routers.hortusfox.entrypoints: websecure
traefik.http.routers.hortusfox.tls.certresolver: step-ca
traefik.http.services.hortusfox.loadbalancer.server.port: 80
```

## Notes / Gotchas

- `APP_CRON_PW` authenticates the cron container's requests to the HortusFox
  web app. This is set as a plaintext environment variable — treat it as a
  secret. It is REDACTED in documentation.
- `APP_DEBUG: true` means verbose PHP error output is enabled. Disable this
  if HortusFox ever becomes accessible externally.
- Plant images are stored in the `images` bind mount. This directory should be
  included in backups to preserve photo history.

---
*Last Updated: 2026-06-17*

# 11-linkstack.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: LinkStack
  tags: [linkstack, linktree, about-me, public, website]
---

## Overview

LinkStack is a self-hosted link-in-bio / personal landing page, serving as the
public-facing "about me" page and link hub for `jeevesconsults.ca`. It responds
to two separate domain names on a single Traefik route: the primary
`aboutme.jeevesconsults.ca` and the root `www.jeevesconsults.ca`.

## Access

| URL                                  | Auth          |
|--------------------------------------|---------------|
| `https://aboutme.jeevesconsults.ca`  | Public — no auth |
| `https://www.jeevesconsults.ca`      | Public — no auth |

Both hostnames resolve to the same LinkStack instance. No authentication
middleware — this is a public-facing page.

## Configuration

**Image:** `linkstackorg/linkstack:latest`

### Environment Variables

| Variable               | Value / Notes                        |
|------------------------|--------------------------------------|
| `HTTPS_SERVER_NAME`    | `aboutme.jeevesconsults.ca`          |
| `SERVER_ADMIN`         | `ask@jeevesconsults.ca`              |
| `PHP_MEMORY_LIMIT`     | `512M`                               |
| `UPLOAD_MAX_FILESIZE`  | `8M`                                 |
| `TZ`                   | `America/Toronto`                    |

### Traefik Labels

```yaml
traefik.http.routers.linkstack.rule: Host(`aboutme.jeevesconsults.ca`) || Host(`www.jeevesconsults.ca`)
traefik.http.routers.linkstack.entrypoints: websecure
traefik.http.routers.linkstack.tls.certresolver: letsencrypt
traefik.http.services.linkstack.loadbalancer.server.port: 80
```

> The `||` in the Traefik host rule matches either domain — both resolve to the
> same container and present the same LinkStack profile.

## Volumes / Bind Mounts

| Host Path                            | Container Path | Purpose                  |
|--------------------------------------|----------------|--------------------------|
| `/home/jeeves/docker/linkstack/data` | `/htdocs`      | App files, config, SQLite |

## Notes / Gotchas

- `HTTPS_SERVER_NAME` is set to `aboutme.jeevesconsults.ca`. Even though both
  domains are handled by Traefik, LinkStack internally uses this value for
  canonical URL generation. Links may contain this hostname.
- No GeoBlock or CrowdSec on this route — it is intentionally fully public.
  Monitor access logs if abuse is suspected.
- LinkStack stores its own SQLite database and assets under `/htdocs`. The
  entire `data/` bind mount must be backed up to preserve the profile content.
- Letsencrypt will issue certificates for both `aboutme.jeevesconsults.ca` and
  `www.jeevesconsults.ca` via the same Traefik router — both must resolve to
  Centerpoint's public IP in DNS for certificate issuance to succeed.

---
*Last Updated: 2026-06-17*

# 12-reitti.md

kstack:
  book: Centerpoint Home Lab
  chapter: Documents & Organization
  page: Reitti
  tags: [reitti, fitness, activity-tracking, postgis, rabbitmq, redis]
---

## Overview

Reitti is a self-hosted activity and fitness tracking platform. It records GPS
tracks, check-ins, and location history and displays them on interactive maps.
The platform is a 6-container stack: the core Java application, a PostGIS
spatial database, Redis cache, RabbitMQ message queue, a tile proxy for map
tiles, and Photon for geocoding.

External access is at `https://reitti.jeeves5454.ddns.net`. Check-in data from
Foursquare/Swarm is synced into Reitti via the separate
**swarm-reitti-bridge** service (see next page).

## Access

| Type     | URL                                    | Auth                  |
|----------|----------------------------------------|-----------------------|
| External | `https://reitti.jeeves5454.ddns.net`   | Reitti own auth + letsencrypt |

No internal `home.local` route — external-only.

## Containers

| Container         | Image                              | Role                          |
|-------------------|------------------------------------|-------------------------------|
| `reitti-app`      | `dedicatedcode/reitti:latest`      | Core Java application         |
| `reitti-postgis`  | `postgis/postgis:17-3.5-alpine`    | Spatial database (PostgreSQL 17 + PostGIS 3.5) |
| `reitti-redis`    | `redis:7-alpine`                   | Cache and session store       |
| `reitti-rabbitmq` | `rabbitmq:3-management-alpine`     | Message queue for async jobs  |
| `reitti-tiles`    | `nginx:alpine`                     | Map tile caching proxy        |
| `reitti-photon`   | `rtuszik/photon-docker:1.3.0`      | Local geocoding (Nominatim-based) |

### reitti-app

The main Spring Boot application serving the Reitti web UI and REST API. Ingests
location data via the OwnTracks-compatible endpoint used by swarm-reitti-bridge.

**Key environment variables:**

| Variable            | Value / Notes                                    |
|---------------------|--------------------------------------------------|
| `POSTGIS_HOST`      | `postgis`                                        |
| `POSTGIS_PORT`      | `5432`                                           |
| `POSTGIS_DB`        | `reittidb`                                       |
| `POSTGIS_USER`      | `reitti`                                         |
| `POSTGIS_PASSWORD`  | **REDACTED**                                     |
| `REDIS_HOST`        | `redis`                                          |
| `REDIS_PORT`        | `6379`                                           |
| `RABBITMQ_HOST`     | `rabbitmq`                                       |
| `RABBITMQ_PORT`     | `5672`                                           |
| `RABBITMQ_USER`     | `reitti`                                         |
| `RABBITMQ_PASSWORD` | **REDACTED**                                     |
| `PHOTON_BASE_URL`   | `http://photon:2322`                             |

**Volumes:**

| Volume / Mount                           | Container Path | Purpose                  |
|------------------------------------------|----------------|--------------------------|
| Docker volume `reitti_reitti-data`       | `/data`        | Application data and uploads |

**Traefik labels:**

```yaml
traefik.http.routers.reitti.rule: Host(`reitti.jeeves5454.ddns.net`)
traefik.http.routers.reitti.entrypoints: websecure
traefik.http.routers.reitti.tls.certresolver: letsencrypt
traefik.http.services.reitti.loadbalancer.server.port: 8080
```

### reitti-postgis

PostgreSQL 17 with PostGIS 3.5 spatial extension. Stores all GPS track data,
waypoints, and geospatial indices.

**Image:** `postgis/postgis:17-3.5-alpine`

| Variable            | Value      |
|---------------------|------------|
| `POSTGRES_USER`     | `reitti`   |
| `POSTGRES_DB`       | `reittidb` |
| `POSTGRES_PASSWORD` | **REDACTED** |

**Volumes:**

| Volume                        | Container Path             |
|-------------------------------|----------------------------|
| `reitti_postgis-data` (named) | `/var/lib/postgresql/data` |

### reitti-redis

Redis 7 (Alpine) — used for caching and session management.

**Image:** `redis:7-alpine`

**Volumes:**

| Volume                    | Container Path |
|---------------------------|----------------|
| `reitti_redis-data` (named) | `/data`      |

No authentication configured — network-isolated to the Reitti internal stack
network.

### reitti-rabbitmq

RabbitMQ 3 with management plugin. Handles async processing of imported tracks
and location events.

**Image:** `rabbitmq:3-management-alpine`

| Variable                | Value    |
|-------------------------|----------|
| `RABBITMQ_DEFAULT_USER` | `reitti` |
| `RABBITMQ_DEFAULT_PASS` | **REDACTED** |

**Volumes:**

| Volume                        | Container Path      |
|-------------------------------|---------------------|
| `reitti_rabbitmq-data` (named)| `/var/lib/rabbitmq` |

RabbitMQ management UI is available internally on port 15672 (not exposed via
Traefik).

### reitti-tiles

NGINX-based map tile proxy/cache. Serves map tiles from upstream tile providers
with local caching to reduce external requests.

**Image:** `nginx:alpine` (NGINX 1.29.8)

| Variable          | Value  |
|-------------------|--------|
| `NGINX_CACHE_SIZE`| `1g`   |

No persistent volumes — tile cache is in-memory. No external Traefik route.

### reitti-photon

Local geocoding service using the Photon engine (Nominatim/OSM-based).
Configured for the `ca` (Canada) region with parallel update strategy.

**Image:** `rtuszik/photon-docker:1.3.0`

| Variable          | Value      |
|-------------------|------------|
| `REGION`          | `ca`       |
| `UPDATE_STRATEGY` | `PARALLEL` |

**Volumes:**

| Volume                       | Container Path  |
|------------------------------|-----------------|
| `reitti_photon-data` (named) | `/photon/data`  |

The photon data volume holds the downloaded Nominatim extract for the CA region.
It can be large (several GB); allow time for the initial data download on first
start.

## Data Flow

```
GPS device / OwnTracks → Reitti OwnTracks ingest API
Foursquare/Swarm check-ins → swarm-reitti-bridge → Reitti OwnTracks ingest API
                                      ↓
                              reitti-app (Spring Boot)
                                      ↓
                           RabbitMQ (async processing)
                                      ↓
                           PostGIS (spatial storage)
                                      ↓
                        Redis (cache) + Photon (geocoding)
                                      ↓
                              Web UI / Map tiles
```

## Notes / Gotchas

- All named Docker volumes (`reitti_reitti-data`, `reitti_postgis-data`,
  `reitti_redis-data`, `reitti_rabbitmq-data`, `reitti_photon-data`) must be
  included in any backup strategy — none are bind-mounted to host paths.
- PostGIS 17 requires spatial extensions to be enabled in the database on first
  run — `dedicatedcode/reitti:latest` handles this automatically on startup.
- The Photon geocoding data download is region-specific (`REGION: ca`). If
  geocoding stops working after a restart, check that the photon data volume is
  intact and the photon container started successfully.
- RabbitMQ management UI (port 15672) is available inside the Docker network but
  is not exposed externally. Access via `docker exec` or Portainer console if
  needed.
- Check-in data from Foursquare/Swarm flows through **swarm-reitti-bridge** (see
  next page). Reitti itself has no Foursquare integration — the bridge
  translates and delivers data via the OwnTracks ingestion endpoint.

---
*Last Updated: 2026-06-17*

# Swarm-Reitti Bridge

## Overview

**Swarm-Reitti Bridge** (v1.1.0) is a custom-built Node.js service that automatically
syncs Foursquare/Swarm check-ins into two downstream services:

- **Reitti** — self-hosted location history tracker, receiving check-ins in OwnTracks format
- **AdventureLog** — self-hosted travel journal, receiving check-ins as Locations, Visits,
  and World Travel region marks

It was built specifically for this homelab as a bridge between the Foursquare API and
these self-hosted services — keeping all location history under local control.

The service runs at `https://swarm.jeeves5454.ddns.net` and is externally reachable so
the OAuth callback from Foursquare can complete.

> **This service was built in this homelab environment.** Its source code lives at
> `/home/jeeves/docker/code-server/projects/swarm-reitti-bridge/` and the production
> deployment at `/home/jeeves/docker/swarmreitti/swarm-reitti-bridge/`.
> The container image (`swarm-reitti-bridge`) is built locally via `docker compose build`
> and is not published to any registry.

---

## Background: Why Polling Mode?

This bridge was originally designed to use Foursquare **push notifications** —
Foursquare would POST to the bridge's `/push` endpoint on every check-in,
enabling near-instant syncing. That architecture worked correctly.

In **late 2024 / early 2025**, Foursquare changed their API pricing and moved
push notifications behind a paid credit system. Attempting to subscribe to push
notifications returns:

```json
{ "errorType": "credits_exhausted", "code": 402 }
```

The bridge was updated to **polling mode** as the solution. Instead of waiting
for Foursquare to push, it actively polls the Foursquare API every 15 minutes
for new check-ins.

| Aspect               | Push (legacy, broken)          | Polling (current)           |
|----------------------|--------------------------------|-----------------------------|
| Trigger              | Foursquare → Bridge (instant)  | Bridge → Foursquare (timer) |
| Latency              | < 1 second                     | Up to 15 minutes            |
| API credits required | Yes (paid)                     | No (free tier)              |
| API calls/day        | ~0 idle                        | 96 (10% of free quota)      |
| Cost                 | Requires payment               | $0.00                       |

The 15-minute maximum latency is entirely acceptable for personal location history tracking.

---

## How It Works

### High-Level Flow

```
User checks in on Swarm
         ↓
Foursquare API stores check-in
         ↓
Bridge polls every 15 minutes (or immediately on container start)
GET /v2/users/self/checkins?oauth_token=...
         ↓
Bridge identifies new check-ins (deduplication via lastCheckinIds)
         ↓
┌────────────────────────────────────────────────────────┐
│  For each new check-in (oldest-first):                  │
│                                                         │
│  1. Convert to OwnTracks format                         │
│     POST to Reitti ingest API                           │
│     Authorization: Bearer <REITTI_API_TOKEN>            │
│                    ↓                                    │
│             Check-in on Reitti map                      │
│                                                         │
│  2. (if AdventureLog configured)                        │
│     Find or create AL Category (Foursquare → emoji)     │
│     Find or create AL Location (dedup by name + coords) │
│     Create AL Visit (dedup: one per location per day)   │
│     Mark AL World Travel region as visited              │
│                    ↓                                    │
│         Location + Visit in AdventureLog                │
│         Region marked on world map                      │
└────────────────────────────────────────────────────────┘
         ↓
State saved to disk (lastCheckinId, lastCheckinTimestamp)
```

AdventureLog failures are **non-fatal** — Reitti sync completes regardless.

### Deduplication

The bridge tracks `lastCheckinIds` per user — a map of `userId → most recently
processed check-in ID`. On each poll:

1. **Quick check** — fetches the single most recent check-in from Foursquare. If its ID
   matches `lastCheckinIds[userId]`, the poll exits immediately (already up to date).
2. **Gap fetch** — if different, fetches all check-ins within a 2-week window (paginated,
   50 per page), sorted oldest-first so Reitti receives events in chronological order.

### Startup Behaviour

On container start, the bridge immediately polls for any users already stored in
`state.json` — it does not wait for the first 15-minute interval. This ensures
check-ins that occurred while the container was down are processed within seconds
of restart.

### State Persistence

Three state objects are saved to `/app/data/state.json`:

| Key                     | Contents                                         |
|-------------------------|--------------------------------------------------|
| `userTokens`            | OAuth access tokens per userId (from Foursquare) |
| `lastCheckinTimestamps` | Last seen check-in Unix timestamp per userId     |
| `lastCheckinIds`        | Last processed check-in ID per userId            |

Writes are atomic (write to `.tmp`, then rename) to avoid corruption on crash.

> **Permission note:** `state.json` is owned by root (written inside the container).
> Use `sudo` when editing on the host.

---

## Data Mapping

### Reitti (OwnTracks Format)

| OwnTracks Field | Source                                           | Notes                                              |
|-----------------|--------------------------------------------------|----------------------------------------------------|
| `_type`         | Hardcoded: `"location"`                          | OwnTracks type identifier                          |
| `lat`           | `checkin.venue.location.lat`                     | Venue latitude                                     |
| `lon`           | `checkin.venue.location.lng`                     | Venue longitude (`lng` → `lon`)                    |
| `tst`           | `checkin.createdAt`                              | Unix timestamp of check-in                         |
| `tid`           | First 2 chars of username, uppercased            | OwnTracks tracker ID                               |
| `acc`           | Hardcoded: `10`                                  | Accuracy in metres                                 |
| `alt`           | Hardcoded: `0`                                   | Altitude (not available from Foursquare)           |
| `batt`          | Hardcoded: `100`                                 | Battery (not applicable)                           |
| `vel`           | Hardcoded: `0`                                   | Velocity (not applicable)                          |
| `t`             | Hardcoded: `"c"`                                 | OwnTracks trigger type: check-in                   |
| `desc`          | `checkin.venue.name` (max 200 chars)             | Venue name                                         |
| `addr`          | Address + City + State + Country (max 300 chars) | Formatted address string                           |

```http
POST <REITTI_API_URL>
Authorization: Bearer <REITTI_API_TOKEN>
Content-Type: application/json
```

### AdventureLog Integration

#### Category Mapping

- The **primary** Foursquare category maps to an AdventureLog category (created if absent)
- Category icons are resolved from a static Foursquare → emoji lookup table
  (`adventurelog-categories.js`); unknown categories fall back to `🌍`
- **Secondary** Foursquare categories become AdventureLog `tags`

#### Location Deduplication

Locations are matched by `venue name (lowercase) + coordinates rounded to 2 decimal places`
(≈1.1 km tolerance). If a match exists, it is reused without modification. If not, a new
location is created. Coordinates sent to AdventureLog are rounded to 6 decimal places
(AdventureLog enforces a 9-total-digit limit on lat/lng fields).

#### Visit Deduplication

One visit is created per location per local calendar day. The local date is derived using
the venue's timezone (resolved from lat/lng via `geo-tz`). A check-in shout and any
companion names (`with` array) are combined into the visit notes field.

#### World Travel (Region Marking)

After each visit is created, the bridge resolves the check-in's country and state/province
to an ISO 3166-2 region code via AdventureLog's `GET /api/regions` list, then POSTs to
`/api/visitedregion`. Already-visited regions are cached per poll cycle and skipped silently.
A small country name map handles known Foursquare → AdventureLog naming mismatches
(e.g. `México` → `Mexico`). Unresolvable regions are logged as warnings.

> **Note:** Creating Locations and Visits via the API does **not** automatically update
> the World Travel world map — these are separate systems in AdventureLog. The bridge
> handles both independently.

---

## OAuth Setup

Foursquare OAuth 2.0 is used to grant the bridge permission to read check-in data.
This is a one-time setup that persists in the state file.

### Initial Setup Steps

1. **Visit the auth URL:**
   ```
   https://swarm.jeeves5454.ddns.net/auth
   ```

2. **The bridge redirects to Foursquare** with the configured `client_id` and `redirect_uri`.

3. **Authenticate** with your Foursquare account and authorise the app.

4. **Foursquare redirects back** to `https://swarm.jeeves5454.ddns.net/callback` with an
   authorisation code.

5. **The bridge exchanges** the code for an access token, saves it to state, and begins
   polling immediately.

> **Important:** Re-authenticating via `/auth` resets the sync baseline to the most recent
> check-in at that moment — older unsynced check-ins will be skipped. Only re-authenticate
> if starting fresh or after a token expiry. If you need to reprocess past check-ins, patch
> `state.json` directly (see Operations below).

### Foursquare App Configuration

| Field          | Value                                        |
|----------------|----------------------------------------------|
| Redirect URI   | `https://swarm.jeeves5454.ddns.net/callback` |
| Push API URL   | (not required — polling mode)                |

---

## Access and Endpoints

**External URL:** `https://swarm.jeeves5454.ddns.net`
**Certificate:** Let's Encrypt (letsencrypt resolver)
**Port:** 3000 (internal)

### API Endpoints

| Method | Path        | Purpose                                                        |
|--------|-------------|----------------------------------------------------------------|
| `GET`  | `/`         | Status dashboard — service cards, recent check-ins, recent errors |
| `GET`  | `/auth`     | Start Foursquare OAuth flow (rate-limited: 10/15 min)         |
| `GET`  | `/callback` | OAuth callback (Foursquare redirects here)                    |
| `GET`  | `/health`   | JSON health — `ok`, per-service status, last sync times        |
| `POST` | `/push`     | Legacy webhook endpoint (inactive in polling mode)             |

The `/health` endpoint returns structured JSON including an `ok: true/false` field
suitable for Uptime Kuma's JSON Query monitor:

```json
{
  "ok": true,
  "status": "healthy",
  "connectedUsers": 1,
  "reitti": { "configured": true, "lastSuccessAt": "...", "lastSuccessVenue": "..." },
  "adventurelog": { "configured": true, "lastSuccessAt": "...", "lastSuccessVenue": "..." },
  "lastPollAt": "..."
}
```

---

## Configuration

### Environment Variables

| Variable                   | Value / Notes                                                |
|----------------------------|--------------------------------------------------------------|
| `FOURSQUARE_CLIENT_ID`     | **REDACTED** (Foursquare developer console)                  |
| `FOURSQUARE_CLIENT_SECRET` | **REDACTED**                                                 |
| `FOURSQUARE_REDIRECT_URI`  | `https://swarm.jeeves5454.ddns.net/callback`                 |
| `REITTI_API_URL`           | `https://reitti.jeeves5454.ddns.net/api/v1/ingest/owntracks` |
| `REITTI_API_TOKEN`         | **REDACTED** (Reitti API bearer token)                       |
| `PUSH_SECRET`              | **REDACTED** (session signing secret)                        |
| `ADVENTURELOG_API_URL`     | `https://travel.jeeves5454.ddns.net`                         |
| `ADVENTURELOG_API_KEY`     | **REDACTED** (AdventureLog API key)                          |
| `UPTIME_KUMA_PUSH_URL`     | **REDACTED** (Uptime Kuma push monitor URL, no query params) |
| `POLLING_INTERVAL_MINUTES` | `15` (default)                                               |
| `PORT`                     | `3000`                                                       |
| `NODE_ENV`                 | `production`                                                 |

Omitting both `ADVENTURELOG_API_URL` and `ADVENTURELOG_API_KEY` disables AdventureLog
sync entirely. Omitting `UPTIME_KUMA_PUSH_URL` disables push heartbeats.

### Traefik Labels

```yaml
traefik.http.routers.swarm-bridge.rule: Host(`swarm.jeeves5454.ddns.net`)
traefik.http.routers.swarm-bridge.entrypoints: websecure
traefik.http.routers.swarm-bridge.tls.certresolver: letsencrypt
traefik.http.services.swarm-bridge.loadbalancer.server.port: 3000
```

### Volumes / Bind Mounts

| Host Path                                                 | Container Path | Purpose                                      |
|-----------------------------------------------------------|----------------|----------------------------------------------|
| `/home/jeeves/docker/swarmreitti/swarm-reitti-bridge/data` | `/app/data`   | State file (`state.json`) persisted across restarts |

---

## Monitoring (Uptime Kuma)

Three complementary monitors are recommended:

| Monitor Type       | Target                                                      | What it catches                   |
|--------------------|-------------------------------------------------------------|-----------------------------------|
| Docker Container   | Container name `swarm-reitti-bridge`                        | Container crash / OOM kill        |
| HTTP(s)            | `https://swarm.jeeves5454.ddns.net/health`                  | App unresponsive                  |
| HTTP(s) JSON Query | `https://swarm.jeeves5454.ddns.net/health` → `$.ok`         | Reitti/AL sync errors, no users   |
| Push               | Uptime Kuma push monitor URL                                | Poll loop stopped, token expired  |

The push monitor receives `?status=up` after every successful poll and
`?status=down&msg=<reason>` on failure. Configure the heartbeat interval to
`POLLING_INTERVAL_MINUTES + 2` minutes (e.g. 17 minutes for a 15-minute poll interval).

---

## Logging

The bridge uses structured JSON logging to stdout (Dozzle-compatible):

```json
{"ts":"2026-06-23T12:00:00.000Z","level":"INFO","cat":"POLL","msg":"Scheduled poll","userCount":1}
{"ts":"2026-06-23T12:00:01.000Z","level":"INFO","cat":"CHECKIN","msg":"Sent to Reitti","venue":"Tim Hortons"}
{"ts":"2026-06-23T12:00:02.000Z","level":"INFO","cat":"AL","msg":"Location created","venue":"Tim Hortons","id":"..."}
{"ts":"2026-06-23T12:00:02.000Z","level":"INFO","cat":"AL","msg":"Visit created","locationId":"...","date":"2026-06-23"}
{"ts":"2026-06-23T12:00:02.000Z","level":"INFO","cat":"AL","msg":"Region marked as visited","regionId":"CA-ON"}
{"ts":"2026-06-23T12:00:03.000Z","level":"INFO","cat":"STATE","msg":"State saved","usersCount":1}
```

Log categories: `SERVER`, `POLL`, `CHECKIN`, `AL`, `AUTH`, `STATE`.

`WARN` and `ERROR` entries are also captured in an in-memory ring buffer (last 20) and
displayed on the status dashboard at `/`.

View live logs:

```bash
docker logs -f swarm-reitti-bridge
```

---

## Operations and Management

### Verify the Bridge is Running

```bash
# Container status
docker ps | grep swarm-reitti-bridge

# Health check (includes ok field and per-service status)
curl -s https://swarm.jeeves5454.ddns.net/health | jq .

# Status dashboard
open https://swarm.jeeves5454.ddns.net/
```

### Re-authenticate with Foursquare

If the OAuth token expires or becomes invalid (bridge logs show `Token expired` WARN):

1. Visit `https://swarm.jeeves5454.ddns.net/auth`
2. Log in and authorise the app
3. The bridge saves the new token and polls immediately

> Do **not** re-authenticate to retry past check-ins — it will move the sync baseline
> forward and those check-ins will be permanently skipped.

### Reprocess Past Check-ins

To force the bridge to reprocess a specific check-in (e.g. after fixing an AL sync error):

```bash
cd /home/jeeves/docker/swarmreitti/swarm-reitti-bridge

# View current state
sudo cat data/state.json

# Roll back to just before a specific check-in Unix timestamp
sudo python3 -c "
import json
with open('data/state.json') as f:
    s = json.load(f)
s['lastCheckinIds']['<userId>'] = None
s['lastCheckinTimestamps']['<userId>'] = <tst - 1>
with open('data/state.json', 'w') as f:
    json.dump(s, f, indent=2)
print('Done')
"

docker compose restart swarm-reitti-bridge
```

The startup poll will immediately pick up the check-in on restart.

### Adjust Polling Interval

Edit `POLLING_INTERVAL_MINUTES` in the compose environment and rebuild:

| Interval | API calls/day | Use case                    |
|----------|--------------|-----------------------------|
| `5`      | 288          | More responsive (30% quota) |
| `15`     | 96           | **Recommended** (10% quota) |
| `30`     | 48           | Most conservative (5% quota)|

### Rebuild the Container

```bash
cd /home/jeeves/docker/swarmreitti/swarm-reitti-bridge
docker compose up -d --build
docker logs swarm-reitti-bridge --follow
```

---

## Troubleshooting

### Check-ins not appearing in Reitti

1. Check the status dashboard at `/` — the Reitti card shows last push time and any error message
2. Check logs:
   ```bash
   docker logs swarm-reitti-bridge | grep -E '"cat":"CHECKIN"|"level":"ERROR"'
   ```
3. Verify token is connected: `curl -s https://swarm.jeeves5454.ddns.net/health | jq .connectedUsers`
   — if `0`, re-authenticate via `/auth`
4. Test Reitti API directly:
   ```bash
   curl -s -o /dev/null -w "%{http_code}" -X POST \
     https://reitti.jeeves5454.ddns.net/api/v1/ingest/owntracks \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{"_type":"location","lat":43.65,"lon":-79.37,"tst":1782088106}'
   ```
   Expected: `200`. If `401`, the token in `.env` is wrong.

### Check-ins not appearing in AdventureLog

1. Check the status dashboard `/` — the AdventureLog card shows last sync and any error
2. Verify AdventureLog API key is valid:
   ```bash
   curl -s "https://travel.jeeves5454.ddns.net/api/locations?limit=1" \
     -H "X-API-Key: <key>"
   ```
   Expected: JSON with `count` field. If `403`, the key is wrong.
3. Common error — **trailing slash causes 308 redirect with lost body**: the bridge strips
   trailing slashes automatically; if seeing 400s verify the AL URL has no trailing slash
   in `ADVENTURELOG_API_URL`

### World Travel region not updating

- The bridge only marks regions when `venue.location.state` and `venue.location.country`
  are both present in the Foursquare data. Venues with missing location data are skipped.
- If a region resolves but the country/state name doesn't match AdventureLog's English
  names, a `WARN` log is emitted: `Could not resolve region`. Add a mapping to
  `FOURSQUARE_COUNTRY_MAP` in `index.js` if needed.
- `GET /api/visitedregion` can confirm which regions are already marked.

### OAuth callback fails (redirect URI mismatch)

- Ensure `FOURSQUARE_REDIRECT_URI` in env exactly matches the redirect URI registered in
  the Foursquare developer console: `https://swarm.jeeves5454.ddns.net/callback`

### Bridge container exits immediately

- Check logs: `docker logs swarm-reitti-bridge`
- Most common cause: missing required environment variable. The app logs all missing vars
  and exits with code 1.

### State file corruption

```bash
sudo rm /home/jeeves/docker/swarmreitti/swarm-reitti-bridge/data/state.json
docker restart swarm-reitti-bridge
# Then re-authenticate via /auth
```

---

## Source Code

```
/home/jeeves/docker/code-server/projects/swarm-reitti-bridge/   ← development
/home/jeeves/docker/swarmreitti/swarm-reitti-bridge/            ← production deployment
├── index.js                    ← Main application
├── adventurelog-categories.js  ← Foursquare category → emoji icon map
├── Dockerfile
├── docker-compose.yml
├── package.json                ← version 1.1.0
├── tests/
│   ├── integration.test.js
│   └── unit.test.js
└── data/
    └── state.json              ← Runtime state (OAuth tokens, last check-in IDs)
```

---

*Last Updated: 2026-06-23*

# AdventureLog - Travel Helper

## Overview

Travel Helper is a Python-based sidecar service for AdventureLog. It runs on a
weekly schedule and automatically links Locations (based on their Visits) to
Collections, populates day-by-day itinerary entries, and adds region-level
Locations as Trip Context. It is idempotent — re-running it never creates
duplicates.

It also exposes a status web page at `https://travel-helper.home.local` showing
last run time, run statistics, live log output, and a manual trigger button.

---

## Why It Exists

AdventureLog Collections support itinerary entries and linked Locations, but
provides no automatic way to connect existing Visits to a Collection by date
range. Without this service, every Collection must be linked manually, location
by location.

Travel Helper solves this by scanning all Collections that have a `start_date`
and `end_date`, finding every Location that has a Visit falling within that
range, and wiring everything together automatically.

---

## What It Does Each Run

1. **Loads** all regions, locations (with embedded visits), and collections from
   AdventureLog.
2. **For each Collection with a date range:**
   - Finds all Locations whose Visit dates (converted to local timezone) fall
     within the collection's start–end range.
   - Links those Locations to the Collection (`PATCH /api/locations/{id}`).
   - Adds a day-level itinerary entry per visit date
     (`POST /api/itineraries`, `is_global: false`).
   - Resolves the region for each matched Location (from cache → AdventureLog
     `region` field → Nominatim reverse geocode fallback).
   - Finds or creates a region-level Location (centroid of the region).
   - Links that region Location to the Collection and adds it as Trip Context
     (`POST /api/itineraries`, `is_global: true`).
3. **Persists** the region ↔ location ID cache to `/data/region_cache.json` so
   Nominatim is only called for locations not yet resolved.

---

## Access

| Type     | URL                               |
|----------|-----------------------------------|
| Internal | `https://travel-helper.home.local` |
| External | Not exposed                        |

The status page auto-refreshes every 60 seconds when idle, every 10 seconds
while a sync is running.

---

## Configuration

**Image:** `travel-helper:latest` (built locally — see Maintenance)  
**Source:** `/home/jeeves/docker/adventurelog/travel-helper/`  
**Bind mount:** `/home/jeeves/docker/adventurelog/travel-helper/data` → `/data`

Key environment variables (set in Portainer stack environment table):

| Variable                | Value / Notes                                                                 |
|-------------------------|-------------------------------------------------------------------------------|
| `ADVENTURELOG_API_URLS` | Comma-separated, tried left-to-right. First reachable URL wins.               |
|                         | `http://adventurelog-web:3000,https://travel.home.local,https://<external>`  |
| `ADVENTURELOG_API_KEY`  | Set in Portainer — never hardcode. Rotate here when key changes.              |
| `NOMINATIM_USER_AGENT`  | Must include a contact email per Nominatim usage policy.                      |
| `NOMINATIM_DELAY`       | Seconds between Nominatim requests. Default `3.0`. Do not lower.             |
| `CACHE_FILE`            | `/data/region_cache.json`                                                     |
| `CRON_DAY`              | Day of week for scheduled run. Default `mon`.                                 |
| `CRON_HOUR`             | UTC hour for scheduled run. Default `3`.                                      |
| `STATUS_PORT`           | Internal port for Flask status page. Default `80`.                            |

**Networks:**
- `adventurelog-internal` — direct container-to-container access to AdventureLog
- `traefik-net` — exposes the status page through Traefik

**Traefik labels:**
```yaml
- "traefik.enable=true"
- "traefik.http.routers.travel-helper-int.rule=Host(`travel-helper.home.local`)"
- "traefik.http.routers.travel-helper-int.entrypoints=websecure"
- "traefik.http.routers.travel-helper-int.tls=true"
- "traefik.http.routers.travel-helper-int.tls.certresolver=step-ca"
- "traefik.http.services.travel-helper.loadbalancer.server.port=80"
```

---

## Dependencies

| Service              | Role                                                               |
|----------------------|--------------------------------------------------------------------|
| `adventurelog-web`   | AdventureLog Next.js frontend (port 3000) — primary API target    |
| `adventurelog-server`| Django backend — reached indirectly via Next.js proxy              |
| Nominatim (external) | Reverse geocoding for region resolution. Rate-limited at 1 req/3s |
| Traefik              | Routes `travel-helper.home.local` to the status page              |

---

## URL Resolution (Internal-First)

On each run, Travel Helper probes `ADVENTURELOG_API_URLS` in order and uses
the first URL that returns HTTP 200, 201, 401, or 403. This means:

1. `http://adventurelog-web:3000` is tried first (direct, never leaves host)
2. `https://travel.home.local` is tried second (through Traefik internally)
3. The external URL is a last resort only

The resolved URL is shown on the status page. If the internal network is
healthy, you should always see `http://adventurelog-web:3000` displayed there.

---

## Nominatim Usage

Nominatim is only called when a Location's region cannot be resolved from:

1. The in-memory cache (populated from `region_cache.json` on startup)
2. The `region` field already set on the AdventureLog Location record

On first run, most locations are resolved from the existing `geocode_cache.json`
(generated during the original Swarm import) without hitting Nominatim at all.
Subsequent runs only call Nominatim for newly imported locations.

Requests use `accept-language=en` and `zoom=10` to return English region names
at state/province level. Rate limiting: 3-second base delay with exponential
backoff (3s → 12s → 48s) on HTTP 429.

---

## Gotchas

- **No trailing slashes on AdventureLog API URLs.** AdventureLog returns
  `308 Permanent Redirect` for URLs ending in `/`. HTTP clients drop the POST
  body on 308 redirects, causing silent `400 Bad Request` failures. All
  endpoints in this service omit trailing slashes.

- **`adventurelog-web` listens on port 3000**, not 8000. Port 8000 is exposed
  by `adventurelog-server` (Django), but API calls must go through the Next.js
  frontend which proxies `/api/` to the backend.

- **Collections must have both `start_date` and `end_date`** to be processed.
  Collections with only one date or no dates are silently skipped.

- **Visit dates are converted to local timezone before matching.** A visit
  at 11 pm in Toronto is June 22 locally but June 23 in UTC. The visit's IANA
  `timezone` field is used for this conversion. If the field is absent, UTC is
  used as a fallback.

- **`loc_collections` is maintained in-memory across collections in one run.**
  This prevents overwriting a location's collection list when it matches
  multiple collections in the same run.

- **The `itinerary` content_type for Location is `24`.** This is an internal
  AdventureLog FK constant. If a future AdventureLog update changes this, the
  constant `LOCATION_CONTENT_TYPE = 24` in `main.py` must be updated.

- **API key expiry.** The AdventureLog API key can be rotated in AdventureLog
  under User Settings → API Tokens. Update it in Portainer's environment
  variable table for the AdventureLog stack — never in any file.

---

## Status Page Features

Available at `https://travel-helper.home.local`:

| Section            | Contents                                                    |
|--------------------|-------------------------------------------------------------|
| Service Status     | Running/Idle badge, resolved AdventureLog URL, last/next run times |
| Last Run Results   | Collections matched, locations linked, itinerary entries, Trip Context entries, error count |
| Errors — Last Run  | Timestamped list of all ERROR-level log entries from the last run |
| Live Log           | Last 80 log lines, newest first, auto-refreshes             |
| Run Now button     | Triggers an immediate sync in a background thread           |

`GET /health` returns a JSON summary suitable for uptime monitoring.

---

## Maintenance

### Rebuilding the image after code changes

```bash
docker build -t travel-helper:latest /home/jeeves/docker/adventurelog/travel-helper
```

Then redeploy the AdventureLog stack in Portainer. Portainer uses the
pre-built `travel-helper:latest` image — it does not build from source.

### Rotating the API key

1. Generate a new token in AdventureLog → User Settings → API Tokens
2. Update `ADVENTURELOG_API_KEY` in the AdventureLog stack's environment
   variable table in Portainer
3. Redeploy the stack

### Clearing the region cache

Delete `/home/jeeves/docker/adventurelog/travel-helper/data/region_cache.json`.
The next run will rebuild it from AdventureLog region fields and Nominatim
(expect the run to take longer and make Nominatim calls).

### Adjusting the schedule

Change `CRON_DAY` and `CRON_HOUR` in Portainer environment variables and
redeploy. Times are UTC.

### Checking logs outside the status page

```bash
docker logs travel-helper --tail 100 -f
```

---

## How It Was Built

Travel Helper was developed iteratively during a session in June 2026 as a
companion to the Swarm → AdventureLog bulk import (`import_swarm.py`). The
original import created ~7,000 Locations and ~10,000 Visits but left
Collections unlinked. Key design decisions:

- **Python + APScheduler** for the weekly cron, matching the import toolchain
- **Flask** for the status page, styled to match the Swarm-Reitti Bridge dashboard
- **Internal-first URL probing** to avoid unnecessary external traffic; the
  service lives on `adventurelog-internal` so direct container access is always
  preferred
- **Full re-scan on every run** (idempotent) rather than incremental sync, to
  avoid state management complexity
- **`region_cache.json` pre-seeded** from the Swarm import's `geocode_cache.json`
  so that ~95% of locations resolve without any Nominatim calls on first run

---

## Last Updated

2026-06-24

# h07-finance

# 00-chapter-intro.md

kstack:
  book: Centerpoint Home Lab
  chapter: Finance
  page: Chapter Introduction
  tags: [finance, subscriptions, expenses, investments, productivity]
---

## Overview

This chapter covers personal finance and expense-tracking services. These tools
handle subscription management, shared expense splitting, and investment
portfolio tracking.

## Services in This Chapter

| Service        | Container(s)                                                  | Purpose                              |
|----------------|---------------------------------------------------------------|--------------------------------------|
| **Wallos**     | `wallos`                                                      | Subscription and recurring expense tracker |
| **SplitPro**   | `splitpro`, `splitpro-db`                                     | Shared expense splitting (Splitwise-alternative) |
| **Ghostfolio** | `ghostfolio`, `ghostfolio-postgres`, `ghostfolio-redis`       | Open-source wealth management and portfolio tracker |

---
*Last Updated: 2026-06-17*

# 01-wallos.md

kstack:
  book: Centerpoint Home Lab
  chapter: Finance
  page: Wallos
  tags: [wallos, subscriptions, finance, sqlite]
---

## Overview

Wallos is a self-hosted subscription and recurring payment tracker. It tracks
monthly, annual, and custom-interval subscriptions, calculates total spending,
and provides a visual dashboard of recurring costs. Data is stored in a SQLite
database. Authentication is Wallos' own built-in user system — no Authentik
integration. External-only route.

## Access

| Type     | URL                                     | Auth                          |
|----------|-----------------------------------------|-------------------------------|
| External | `https://wallos.jeevesconsults.ca`      | GeoBlock + CrowdSec (own auth) |

No internal `home.local` route configured.

## Configuration

**Image:** `bellamy/wallos:latest`  
**Runtime:** PHP 8.3

### Traefik Labels

```yaml
traefik.http.routers.wallos.rule: Host(`wallos.jeevesconsults.ca`)
traefik.http.routers.wallos.entrypoints: websecure
traefik.http.routers.wallos.tls.certresolver: letsencrypt
traefik.http.routers.wallos.middlewares: plex-geoblock@file,crowdsec-bouncer@file,wallos-headers
traefik.http.middlewares.wallos-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.wallos.loadbalancer.server.port: 80
```

## Volumes / Bind Mounts

| Host Path                                | Container Path                       | Purpose                |
|------------------------------------------|--------------------------------------|------------------------|
| `/home/jeeves/docker/wallos/db`          | `/var/www/html/db`                   | SQLite database        |
| `/home/jeeves/docker/wallos/logos`       | `/var/www/html/images/uploads/logos` | Subscription logos     |

Wallos stores all data in a SQLite file within the `db` bind mount. No external
database dependency.

## Notes / Gotchas

- Wallos manages its own user accounts — no SSO or Authentik integration.
  Credentials are stored in the SQLite database.
- The `X-Forwarded-Proto: https` middleware is required for PHP to detect HTTPS
  correctly when behind a reverse proxy.
- Service logos are persisted in the `logos` bind mount. Both `db/` and `logos/`
  must be backed up together to fully restore the instance.
- No currency exchange rate API key is required by default — Wallos supports
  manual rate entry.

---
*Last Updated: 2026-06-17*

# 02-splitpro.md

kstack:
  book: Centerpoint Home Lab
  chapter: Finance
  page: SplitPro
  tags: [splitpro, expenses, splitting, authentik, postgres, step-ca]
---

## Overview

SplitPro is a self-hosted shared expense splitting app — an open-source
alternative to Splitwise. It tracks group expenses, calculates balances, and
supports multi-currency via the Frankfurter exchange rate provider. Authentik
OIDC provides authentication. Email invites are handled via Gmail SMTP. The
Step-CA root certificate is injected so SplitPro can make HTTPS calls to
internal `*.home.local` services. External-only route.

## Access

| Type     | URL                                        | Auth                          |
|----------|--------------------------------------------|-------------------------------|
| External | `https://splitpro.jeevesconsults.ca`       | Authentik OIDC + GeoBlock + CrowdSec |

SplitPro handles its own OIDC redirect to Authentik — Traefik does not apply
`authentik-auth@docker` ForwardAuth. Authentication is managed natively by the
Next.js `next-auth` library.

## Containers

| Container     | Image                             | Role                |
|---------------|-----------------------------------|---------------------|
| `splitpro`    | `ossapps/splitpro:latest`         | Web application     |
| `splitpro-db` | `ossapps/postgres:17.7-trixie`    | PostgreSQL 17 database |

### splitpro (application)

**Runtime:** Node.js 22 (Next.js)

**Key environment variables:**

| Variable                         | Value / Notes                                                    |
|----------------------------------|------------------------------------------------------------------|
| `NEXTAUTH_URL`                   | `https://splitpro.jeevesconsults.ca`                             |
| `NEXTAUTH_SECRET`                | **REDACTED**                                                     |
| `AUTHENTIK_ID`                   | `xA8pjyG7s4LsnmRKp9Wn9H9v8oUpawBAMvmEF9tT`                      |
| `AUTHENTIK_SECRET`               | **REDACTED**                                                     |
| `AUTHENTIK_ISSUER`               | `https://auth.jeevesconsults.ca/application/o/splitpro`          |
| `OIDC_ALLOW_DANGEROUS_EMAIL_LINKING` | `1`                                                          |
| `DATABASE_URL`                   | **REDACTED** (includes PostgreSQL password)                      |
| `POSTGRES_USER`                  | `splitpro`                                                       |
| `POSTGRES_DB`                    | `splitpro`                                                       |
| `POSTGRES_PORT`                  | `5432`                                                           |
| `EMAIL_SERVER_HOST`              | `smtp.gmail.com`                                                 |
| `EMAIL_SERVER_PORT`              | `587`                                                            |
| `EMAIL_SERVER_USER`              | `jeeves5454@gmail.com`                                           |
| `EMAIL_SERVER_PASSWORD`          | **REDACTED**                                                     |
| `FROM_EMAIL`                     | `splitpro@jeevesconsults.ca`                                     |
| `ENABLE_SENDING_INVITES`         | `true`                                                           |
| `DISABLE_EMAIL_SIGNUP`           | `false`                                                          |
| `CURRENCY_RATE_PROVIDER`         | `frankfurter`                                                    |
| `DEFAULT_HOMEPAGE`               | `/balances`                                                      |
| `CACHE_RETENTION_INTERVAL`       | `2 days`                                                         |
| `CLEAR_CACHE_CRON_RULE`          | `0 2 * * 0` (Sunday 2am)                                         |
| `UPLOAD_MAX_FILE_SIZE_MB`        | `10`                                                             |
| `NODE_EXTRA_CA_CERTS`            | `/etc/ssl/certs/step-ca-root.crt`                                |
| `PORT`                           | `3000`                                                           |

**Bind mounts:**

| Host Path                                                    | Container Path                  | Purpose                    |
|--------------------------------------------------------------|---------------------------------|----------------------------|
| `/home/jeeves/docker/splitpro/uploads`                       | `/app/uploads`                  | Receipt and document uploads |
| `/home/jeeves/docker/step-ca/config/certs/root_ca.crt`      | `/etc/ssl/certs/step-ca-root.crt` | Step-CA root trust injection |

### splitpro-db (PostgreSQL 17)

**Image:** `ossapps/postgres:17.7-trixie`

A PostgreSQL 17 image published by the SplitPro project (based on the
official `postgres:17` Debian image).

| Variable         | Value       |
|------------------|-------------|
| `POSTGRES_USER`  | `splitpro`  |
| `POSTGRES_DB`    | `splitpro`  |
| `POSTGRES_PASSWORD` | **REDACTED** |
| `POSTGRES_PORT`  | `5432`      |

**Bind mounts:**

| Host Path                           | Container Path             |
|-------------------------------------|----------------------------|
| `/home/jeeves/docker/splitpro/db`   | `/var/lib/postgresql/data` |

## Traefik Labels

```yaml
traefik.http.routers.splitpro-external.rule: Host(`splitpro.jeevesconsults.ca`)
traefik.http.routers.splitpro-external.entrypoints: websecure
traefik.http.routers.splitpro-external.tls.certresolver: letsencrypt
traefik.http.routers.splitpro-external.middlewares: plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.splitpro-external.service: splitpro-svc
traefik.http.services.splitpro-svc.loadbalancer.server.port: 3000
```

## Notes / Gotchas

- `OIDC_ALLOW_DANGEROUS_EMAIL_LINKING: 1` allows Authentik OIDC logins to
  link to existing accounts by matching email address. Required if users were
  created before OIDC was enabled, or if local signup is still active.
- `NODE_EXTRA_CA_CERTS` injects the Step-CA root certificate into Node.js' TLS
  trust chain. This is required if SplitPro makes any HTTPS calls to internal
  `*.home.local` endpoints (e.g., if integrated with other internal services).
- `DATABASE_URL` in the container environment contains the PostgreSQL password
  in plaintext — treat this as sensitive.
- Currency rates are fetched from `api.frankfurter.app` (public, no API key
  required).

---
*Last Updated: 2026-06-17*

# 03-ghostfolio.md

kstack:
  book: Centerpoint Home Lab
  chapter: Finance
  page: Ghostfolio
  tags: [ghostfolio, investments, portfolio, finance, postgres, redis]
---

## Overview

Ghostfolio is an open-source wealth management and investment portfolio tracker.
It aggregates holdings across accounts, tracks performance, and visualises
asset allocation. Internal-only access via Step-CA TLS — no external route is
configured. Ghostfolio manages its own user authentication. The stack is three
containers: the application, a PostgreSQL 15 database, and Redis for caching.

## Access

| Type     | URL                               | Auth                               |
|----------|-----------------------------------|------------------------------------|
| Internal | `https://ghostfolio.home.local`   | Ghostfolio own auth (Step-CA TLS)  |

No external route — accessible only on the LAN.

## Containers

| Container           | Image                      | Role                  |
|---------------------|----------------------------|-----------------------|
| `ghostfolio`        | `ghostfolio/ghostfolio:latest` | Web application    |
| `ghostfolio-postgres` | `postgres:15-alpine`     | PostgreSQL 15 database|
| `ghostfolio-redis`  | `redis:alpine`             | Cache and session store|

### ghostfolio (application)

**Runtime:** Node.js 22

**Key environment variables:**

| Variable          | Value / Notes                      |
|-------------------|------------------------------------|
| `DATABASE_URL`    | **REDACTED** (PostgreSQL connection string with password) |
| `REDIS_HOST`      | `ghostfolio-redis`                 |
| `REDIS_PORT`      | `6379`                             |
| `ACCESS_TOKEN_SALT` | **REDACTED**                     |
| `JWT_SECRET_KEY`  | **REDACTED**                       |
| `NODE_ENV`        | `production`                       |
| `TZ`              | `America/Toronto`                  |

No bind mounts — application state is stored entirely in PostgreSQL.

### ghostfolio-postgres (PostgreSQL 15)

**Image:** `postgres:15-alpine`

| Variable         | Value           |
|------------------|-----------------|
| `POSTGRES_DB`    | `ghostfoliodb`  |
| `POSTGRES_USER`  | `ghostfoliouser`|
| `POSTGRES_PASSWORD` | **REDACTED** |
| `TZ`             | `America/Toronto` |

**Bind mounts:**

| Host Path                                      | Container Path             |
|------------------------------------------------|----------------------------|
| `/home/jeeves/docker/ghostfolio/postgres_data` | `/var/lib/postgresql/data` |

### ghostfolio-redis

**Image:** `redis:alpine`

No authentication configured — network-isolated to the Ghostfolio internal
stack network.

**Bind mounts:**

| Host Path                                   | Container Path |
|---------------------------------------------|----------------|
| `/home/jeeves/docker/ghostfolio/redis_data` | `/data`        |

## Traefik Labels

```yaml
traefik.http.routers.ghostfolio.rule: Host(`ghostfolio.home.local`)
traefik.http.routers.ghostfolio.entrypoints: websecure
traefik.http.routers.ghostfolio.tls.certresolver: step-ca
traefik.http.services.ghostfolio.loadbalancer.server.port: 3333
```

## Notes / Gotchas

- `ACCESS_TOKEN_SALT` and `JWT_SECRET_KEY` are critical secrets. If lost, all
  existing sessions and API tokens will be invalidated and cannot be recovered
  without reconfiguring Ghostfolio.
- Ghostfolio fetches market data from various external providers (Yahoo Finance,
  Coingecko, etc.). Market data API rate limits may apply depending on configured
  data sources.
- Portfolio data import supports CSV and JSON formats compatible with common
  brokerages. Check the Ghostfolio docs for the required format.
- Redis stores only ephemeral cache — data is not critical and does not need to
  be backed up. The `postgres_data` bind mount is the primary backup target.
- Port 3333 is the default Ghostfolio application port.

---
*Last Updated: 2026-06-17*

# h08-utilities

# 00-chapter-intro.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: Chapter Introduction
  tags: [utilities, tools, remote-access, file-management]
---

## Overview

This chapter covers general-purpose utility services — remote access, file
management, offline content, productivity tooling, and infrastructure support
containers.

## Services in This Chapter

| Service           | Container(s)                                        | Purpose                                |
|-------------------|-----------------------------------------------------|----------------------------------------|
| **Guacamole**     | `guacamole`, `guacd`, `guacamole_db`                | Clientless remote desktop gateway      |
| **FileBrowser**   | `filebrowser`                                       | Web-based file manager                 |
| **Kiwix**         | `kiwix`                                             | Offline Wikipedia and ZIM content      |
| **Draw.io**       | `drawio`                                            | Self-hosted diagram editor             |
| **IT Tools**      | `it-tools`                                          | Developer and IT utility collection    |
| **PairDrop**      | `pairdrop`                                          | LAN file sharing (AirDrop-like)        |
| **ConvertX**      | `convertx`                                          | File format converter                  |
| **MinusPod**      | `minuspod`                                          | AI-powered podcast manager and transcriber |
| **Docker Proxy**  | `dockerproxy`                                       | Read-only Docker socket proxy          |

---
*Last Updated: 2026-06-17*

# 01-guacamole.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: Apache Guacamole
  tags: [guacamole, remote-desktop, rdp, vnc, ssh, authentik, postgres]
---

## Overview

Apache Guacamole is a clientless remote desktop gateway. It provides browser-based
access to RDP, VNC, SSH, and Telnet sessions with no client software required.
Authentication is via Authentik OpenID Connect (native Guacamole extension — not
Traefik ForwardAuth). The stack is three containers: the web application, the
`guacd` native protocol daemon, and PostgreSQL 15 for connection and user data.

## Access

| Type     | URL                                    | Auth                              |
|----------|----------------------------------------|-----------------------------------|
| External | `https://guac.jeeves5454.ddns.net`     | Authentik OIDC + GeoBlock + CrowdSec |
| Internal | `https://guac.home.local`              | Authentik OIDC (Step-CA TLS)      |

Guacamole uses its built-in `openid` extension with `EXTENSION_PRIORITY=openid`
to handle the Authentik OIDC flow directly. Traefik does not apply
`authentik-auth@docker` ForwardAuth.

## Containers

| Container       | Image                          | Role                            |
|-----------------|--------------------------------|---------------------------------|
| `guacamole`     | `guacamole/guacamole:latest`   | Web application (Tomcat/Java)   |
| `guacd`         | `guacamole/guacd:latest`       | Native protocol daemon (RDP/VNC/SSH) |
| `guacamole_db`  | `postgres:15-alpine`           | PostgreSQL database             |

### guacamole (application)

**Runtime:** Apache Tomcat / Java. `WEBAPP_CONTEXT=ROOT` serves the app at `/`
rather than `/guacamole`.

**Key environment variables:**

| Variable                       | Value / Notes                                                       |
|--------------------------------|---------------------------------------------------------------------|
| `GUACD_HOSTNAME`               | `guacd`                                                             |
| `POSTGRESQL_HOSTNAME`          | `postgres` (internal alias for `guacamole_db`)                      |
| `POSTGRESQL_DATABASE`          | `guacamole_db`                                                      |
| `POSTGRESQL_USER`              | `guacamole_user`                                                     |
| `POSTGRESQL_PASSWORD`          | **REDACTED**                                                        |
| `WEBAPP_CONTEXT`               | `ROOT`                                                              |
| `EXTENSION_PRIORITY`           | `openid`                                                            |
| `OPENID_ISSUER`                | `https://auth.jeevesconsults.ca/application/o/guacamole/`           |
| `OPENID_CLIENT_ID`             | `A1l7KFyrugknbC8jtFjPxm520XkcNVjGMfTFtJMt`                         |
| `OPENID_CLIENT_SECRET`         | **REDACTED**                                                        |
| `OPENID_REDIRECT_URI`          | `https://guac.jeeves5454.ddns.net/`                                 |
| `OPENID_AUTHORIZATION_ENDPOINT`| `https://auth.jeevesconsults.ca/application/o/authorize/`           |
| `OPENID_JWKS_ENDPOINT`         | `https://auth.jeevesconsults.ca/application/o/guacamole/jwks/`      |
| `OPENID_SCOPE`                 | `openid email profile`                                              |
| `OPENID_USERNAME_CLAIM_TYPE`   | `preferred_username`                                                |

No bind mounts — app state is stored in PostgreSQL.

### guacd

The native protocol daemon that handles the actual RDP, VNC, SSH, and Telnet
protocol sessions. Guacamole app proxies all protocol traffic through guacd.

**Bind mounts:**

| Host Path                                   | Container Path | Purpose                     |
|---------------------------------------------|----------------|-----------------------------|
| `/home/jeeves/docker/guacamole/drive`        | `/drive`       | Virtual drive for file transfer |
| `/home/jeeves/docker/guacamole/record`       | `/record`      | Session recordings          |

### guacamole_db (PostgreSQL 15)

**Image:** `postgres:15-alpine`

| Variable            | Value          |
|---------------------|----------------|
| `POSTGRES_DB`       | `guacamole_db` |
| `POSTGRES_USER`     | `guacamole_user`|
| `POSTGRES_PASSWORD` | **REDACTED**   |
| `PGDATA`            | `/var/lib/postgresql/data/guacamole` |

**Bind mounts:**

| Host Path                                    | Container Path             | Purpose            |
|----------------------------------------------|----------------------------|--------------------|
| `/home/jeeves/docker/guacamole/db-data`      | `/var/lib/postgresql/data` | Database files     |
| `/home/jeeves/docker/guacamole/init`         | `/docker-entrypoint-initdb.d` | Init SQL scripts|

## Traefik Labels

```yaml
traefik.http.routers.guacamole-external.rule: Host(`guac.jeeves5454.ddns.net`)
traefik.http.routers.guacamole-external.entrypoints: websecure
traefik.http.routers.guacamole-external.tls.certresolver: letsencrypt
traefik.http.routers.guacamole-external.middlewares: plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.guacamole-external.service: guac-svc

traefik.http.routers.guacamole-internal.rule: Host(`guac.home.local`)
traefik.http.routers.guacamole-internal.entrypoints: websecure
traefik.http.routers.guacamole-internal.tls.certresolver: step-ca
traefik.http.routers.guacamole-internal.service: guac-svc

traefik.http.services.guac-svc.loadbalancer.server.port: 8080
traefik.docker.network: traefik-net
```

## Notes / Gotchas

- `EXTENSION_PRIORITY=openid` means Guacamole's OpenID extension takes
  precedence over any local user database. If Authentik is down, Guacamole
  login is unavailable — there is no local admin fallback while OpenID is
  primary.
- `OPENID_REDIRECT_URI` must exactly match the redirect URI registered in
  Authentik. It points to the **external** domain (`guac.jeeves5454.ddns.net`)
  even when accessing internally — Authentik sends the browser there after
  authentication.
- `WEBAPP_CONTEXT=ROOT` removes the `/guacamole` path prefix. The app responds
  directly at `/`. Without this, Traefik would need to strip the prefix or all
  URLs would be `/guacamole/...`.
- Session recordings are stored in the `record` bind mount. Manage disk usage
  if many long sessions are recorded.
- The `init` bind mount contains the SQL schema initialisation scripts for the
  database. These run once on first start. Do not remove them if the container
  has not yet initialised.

---
*Last Updated: 2026-06-17*

# 02-filebrowser.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: FileBrowser
  tags: [filebrowser, files, storage, authentik]
---

## Overview

FileBrowser is a web-based file manager that provides browsing, uploading,
downloading, and editing of files across multiple mounted host paths. The
external route is protected by Authentik ForwardAuth. The internal route requires
no additional middleware. Several significant host directories are exposed.

## Access

| Type     | URL                                      | Auth                                     |
|----------|------------------------------------------|------------------------------------------|
| External | `https://files.jeeves5454.ddns.net`      | Authentik ForwardAuth + GeoBlock + CrowdSec |
| Internal | `https://files.home.local`               | FileBrowser own auth (Step-CA TLS)       |

## Configuration

**Image:** `filebrowser/filebrowser:latest`

### Traefik Labels

```yaml
traefik.http.routers.filebrowser-external.rule: Host(`files.jeeves5454.ddns.net`)
traefik.http.routers.filebrowser-external.entrypoints: websecure
traefik.http.routers.filebrowser-external.tls.certresolver: letsencrypt
traefik.http.routers.filebrowser-external.middlewares: authentik-auth@docker,plex-geoblock@file,crowdsec-bouncer@file
traefik.http.routers.filebrowser-external.service: filebrowser-svc

traefik.http.routers.filebrowser-internal.rule: Host(`files.home.local`)
traefik.http.routers.filebrowser-internal.entrypoints: websecure
traefik.http.routers.filebrowser-internal.tls.certresolver: step-ca
traefik.http.routers.filebrowser-internal.service: filebrowser-svc

traefik.http.services.filebrowser-svc.loadbalancer.server.port: 80
```

## Volumes / Bind Mounts

| Host Path                             | Container Path     | Purpose                              |
|---------------------------------------|--------------------|--------------------------------------|
| `/home/jeeves/docker/filebrowser/config`   | `/config`     | FileBrowser config file              |
| `/home/jeeves/docker/filebrowser/database` | `/database`   | FileBrowser database (SQLite)        |
| `/home/jeeves`                        | `/srv/home`        | Jeeves home directory                |
| `/media/jeeves/1TB_Vol2/downloads`    | `/srv/downloads`   | Download staging area (Ceph OSD)     |
| `/mnt`                                | `/srv/mnt`         | NFS mounts (Multimedia, Photos, data)|

> FileBrowser provides access to the full home directory, download staging area,
> and all NFS mounts. Treat external access with appropriate caution.

## Notes / Gotchas

- Authentik ForwardAuth is applied on the external route — external access
  requires a valid Authentik session before FileBrowser's own login is shown.
  FileBrowser still maintains its own user database internally.
- `/mnt` is exposed as `/srv/mnt` — this includes all NFS shares from UnRAID
  (`/mnt/Multimedia`, `/mnt/Photos`, `/mnt/data`). Changes made via
  FileBrowser are live on the NFS shares.
- The SQLite database in `/database` stores FileBrowser users, permissions, and
  settings. Back it up alongside `/config`.
- `PUID=1000 / GID=1000` — FileBrowser runs as the `jeeves` user, so file
  operations respect the same ownership as files on disk.

---
*Last Updated: 2026-06-17*

# 03-kiwix.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: Kiwix
  tags: [kiwix, offline, wikipedia, zim, authentik]
---

## Overview

Kiwix serves offline ZIM-format content — primarily Wikipedia snapshots and
other reference material — accessible without an internet connection. ZIM files
are stored on the Ceph OSD volume. The external route is gated behind Authentik
ForwardAuth. The internal route has no additional auth middleware.

## Access

| Type     | URL                                     | Auth                                     |
|----------|-----------------------------------------|------------------------------------------|
| External | `https://kiwix.jeeves5454.ddns.net`     | Authentik ForwardAuth + GeoBlock + CrowdSec |
| Internal | `https://kiwix.home.local`              | No additional auth (Step-CA TLS)         |

## Configuration

**Image:** `ghcr.io/kiwix/kiwix-serve:latest`

### Traefik Labels

```yaml
traefik.http.routers.kiwix-external.rule: Host(`kiwix.jeeves5454.ddns.net`)
traefik.http.routers.kiwix-external.entrypoints: websecure
traefik.http.routers.kiwix-external.tls.certresolver: letsencrypt
traefik.http.routers.kiwix-external.middlewares: plex-geoblock@file,crowdsec-bouncer@file,authentik-auth@docker
traefik.http.routers.kiwix-external.service: kiwix-svc

traefik.http.routers.kiwix-internal.rule: Host(`kiwix.home.local`)
traefik.http.routers.kiwix-internal.entrypoints: websecure
traefik.http.routers.kiwix-internal.tls.certresolver: step-ca
traefik.http.routers.kiwix-internal.service: kiwix-svc

traefik.http.services.kiwix-svc.loadbalancer.server.port: 8080
```

## Volumes / Bind Mounts

| Host Path                             | Container Path | Purpose         |
|---------------------------------------|----------------|-----------------|
| `/media/jeeves/1TB_Vol1/Kiwix_Images` | `/data`        | ZIM content files |

ZIM files are stored on the second Ceph OSD volume (`nvme2n1`,
`/media/jeeves/1TB_Vol1`). kiwix-serve auto-discovers all `.zim` files in
`/data` on startup and presents them in the library UI.

## Notes / Gotchas

- ZIM files can be very large (Wikipedia full English is ~100GB). Ensure
  adequate space on the Ceph volume before downloading new content.
- New ZIM files can be added by placing them in
  `/media/jeeves/1TB_Vol1/Kiwix_Images`. A container restart is required for
  kiwix-serve to discover them.
- Kiwix has no built-in authentication — the internal route is unprotected on
  the LAN. The external route relies entirely on Authentik ForwardAuth.
- ZIM files can be downloaded from `library.kiwix.org` and transferred to the
  Ceph volume via FileBrowser or SCP.

---
*Last Updated: 2026-06-17*

# 04-drawio.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: Draw.io
  tags: [drawio, diagrams, productivity]
---

## Overview

Draw.io (now diagrams.net) is a self-hosted diagramming application for
creating network diagrams, flowcharts, architecture diagrams, and more. This
instance is stateless — diagrams are saved locally by the browser or to
connected storage (Google Drive, OneDrive, etc.), not on the server. External-only
access with GeoBlock and CrowdSec.

## Access

| Type     | URL                                      | Auth                        |
|----------|------------------------------------------|-----------------------------|
| External | `https://drawio.jeeves5454.ddns.net`     | GeoBlock + CrowdSec (no auth) |

No authentication middleware — Draw.io is accessible to anyone who bypasses
GeoBlock. The app itself has no user accounts.

## Configuration

**Image:** `jgraph/drawio:latest`  
**Runtime:** Java 11 / Apache Tomcat

### Key Environment Variables

| Variable              | Value / Notes                          |
|-----------------------|----------------------------------------|
| `DRAWIO_BASE_URL`     | `https://drawio.jeeves5454.ddns.net`   |
| `PUBLIC_DNS`          | `drawio.jeeves5454.ddns.net`           |
| `DRAWIO_GOOGLE_CLIENT_ID` | `jeeves5454@gmail.com` (Google integration) |
| `LETS_ENCRYPT_ENABLED`| `false` (TLS handled by Traefik)       |

### Traefik Labels

```yaml
traefik.http.routers.drawio.rule: Host(`drawio.jeeves5454.ddns.net`)
traefik.http.routers.drawio.entrypoints: websecure
traefik.http.routers.drawio.tls.certresolver: letsencrypt
traefik.http.routers.drawio.middlewares: plex-geoblock@file,crowdsec-bouncer@file,drawio-headers
traefik.http.middlewares.drawio-headers.headers.customrequestheaders.X-Forwarded-Proto: https
traefik.http.services.drawio.loadbalancer.server.port: 8080
```

## Volumes / Bind Mounts

None — Draw.io is completely stateless. All diagram data is stored client-side
(browser localStorage or connected cloud storage).

## Notes / Gotchas

- `LETS_ENCRYPT_ENABLED=false` is correct — Traefik handles TLS termination.
  The Draw.io container itself serves plain HTTP on port 8080.
- The `X-Forwarded-Proto: https` header is required so Draw.io generates correct
  HTTPS URLs for embedded resources and exports.
- Diagrams are not stored on the server. To preserve diagrams, save them to a
  local file (`.drawio` / `.xml`) or configure a cloud storage backend in the
  Draw.io UI.
- `DRAWIO_GOOGLE_CLIENT_ID` enables the Google Drive integration in the file
  picker. Users must authorise their own Google account in the app.

---
*Last Updated: 2026-06-17*

# 05-it-tools.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: IT Tools
  tags: [it-tools, developer-tools, utilities]
---

## Overview

IT Tools is a collection of handy online developer and IT utilities — including
JWT decoders, UUID generators, hash generators, cron expression parsers, base64
encoders, regex testers, and many more. It is a static React app served by
NGINX. Internal-only access. No configuration, no authentication, no persistent
state.

## Access

| Type     | URL                            | Auth          |
|----------|--------------------------------|---------------|
| Internal | `https://ittools.home.local`   | None (LAN only) |

## Configuration

**Image:** `corentinth/it-tools:latest`  
**Runtime:** NGINX 1.26 (static SPA)

### Traefik Labels

```yaml
traefik.http.routers.ittools.rule: Host(`ittools.home.local`)
traefik.http.routers.ittools.entrypoints: websecure
traefik.http.routers.ittools.tls.certresolver: step-ca
traefik.http.services.ittools.loadbalancer.server.port: 80
```

## Volumes / Bind Mounts

None — fully stateless static application.

## Notes / Gotchas

- No data leaves the browser — all tools run client-side in JavaScript. Safe
  to use for sensitive values (passwords, tokens, keys) that should not be sent
  to external services.
- The tool list expands with each release of the `corentinth/it-tools` image.
  Pull the latest tag to get new tools.

---
*Last Updated: 2026-06-17*

# 06-pairdrop.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: PairDrop
  tags: [pairdrop, file-sharing, webrtc, lan]
---

## Overview

PairDrop is a local network file sharing app inspired by Apple AirDrop. It
uses WebRTC for peer-to-peer file transfers between devices on the same LAN
session. No accounts, no file storage on the server — files transfer directly
between browsers. Internal-only access.

## Access

| Type     | URL                             | Auth          |
|----------|---------------------------------|---------------|
| Internal | `https://pairdrop.home.local`   | None (LAN only) |

## Configuration

**Image:** `lscr.io/linuxserver/pairdrop:latest`

### Environment Variables

| Variable       | Value             | Purpose                          |
|----------------|-------------------|----------------------------------|
| `TZ`           | `America/Toronto` | Timezone                         |
| `PUID`         | `1000`            | User ID                          |
| `PGID`         | `1000`            | Group ID                         |
| `WS_FALLBACK`  | `false`           | WebSocket fallback (disabled)    |
| `RATE_LIMIT`   | `false`           | Rate limiting (disabled for LAN) |
| `RTC_CONFIG`   | `false`           | External STUN/TURN (not needed on LAN) |
| `DEBUG_MODE`   | `false`           | Debug logging                    |

### Traefik Labels

```yaml
traefik.http.routers.pairdrop.rule: Host(`pairdrop.home.local`)
traefik.http.routers.pairdrop.entrypoints: websecure
traefik.http.routers.pairdrop.tls.certresolver: step-ca
traefik.http.services.pairdrop.loadbalancer.server.port: 3000
```

## Volumes / Bind Mounts

None — fully stateless. Files are transferred peer-to-peer and never stored on
the server.

## Notes / Gotchas

- PairDrop discovers peers in the same "room" — by default, all users behind the
  same NAT/gateway appear to each other. On a home LAN this means all devices
  on the network can see each other.
- `RTC_CONFIG=false` is correct for LAN use — no external STUN/TURN server is
  needed when all peers are on the same network.
- For Tailscale peers to use PairDrop, they would need a TURN server since
  Tailscale creates separate subnets. Current config is LAN-only.

---
*Last Updated: 2026-06-17*

# 07-convertx.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: ConvertX
  tags: [convertx, file-conversion, utilities]
---

## Overview

ConvertX is a self-hosted file format conversion tool. It supports converting
between video, audio, image, document, and other file formats using a web
interface. Internal-only access. Account registration is enabled, allowing any
LAN user to create an account.

## Access

| Type     | URL                               | Auth                           |
|----------|-----------------------------------|--------------------------------|
| Internal | `https://convertx.home.local`     | ConvertX own auth (Step-CA TLS) |

## Configuration

**Image:** `ghcr.io/c4illin/convertx`

### Environment Variables

| Variable                     | Value  | Purpose                                      |
|------------------------------|--------|----------------------------------------------|
| `HTTP_ALLOWED`               | `true` | Allows HTTP connections (Traefik terminates TLS) |
| `ACCOUNT_REGISTRATION`       | `true` | Any LAN user can self-register               |
| `QTWEBENGINE_CHROMIUM_FLAGS` | `--no-sandbox` | Required for headless Chromium in container |

### Traefik Labels

```yaml
traefik.http.routers.convertx.rule: Host(`convertx.home.local`)
traefik.http.routers.convertx.entrypoints: websecure
traefik.http.routers.convertx.tls.certresolver: step-ca
traefik.http.services.convertx.loadbalancer.server.port: 3000
```

## Volumes / Bind Mounts

| Host Path                     | Container Path | Purpose              |
|-------------------------------|----------------|----------------------|
| `/data/compose/43/data`       | `/app/data`    | App data and SQLite  |

> The data directory is a Portainer-managed compose path
> (`/data/compose/<stack-id>/data`) — this stack is managed entirely via
> Portainer with no local compose file. The path is determined by the Portainer
> stack ID (43).

## Notes / Gotchas

- `ACCOUNT_REGISTRATION=true` allows any user on the LAN to self-register.
  Disable this if access should be restricted to a single admin account.
- Converted files are stored temporarily in the app data directory and deleted
  after download. Do not use this path for long-term storage.
- `--no-sandbox` is a Chromium security flag required when running in a
  container (no user namespace isolation). This is standard for containerised
  headless browser setups.
- ConvertX uses FFmpeg, ImageMagick, and other converters internally — available
  formats depend on the image build.

---
*Last Updated: 2026-06-17*

# 08-minuspod.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: MinusPod
  tags: [minuspod, podcasts, transcription, whisper, ai, cuda]
---

## Overview

MinusPod is an AI-powered podcast manager and transcriber. It manages podcast
feeds, downloads episodes, and transcribes audio using a local Whisper model.
Summaries and notes are generated via an LLM (Qwen3:14b) through the Ollama
API. The container runs with NVIDIA GPU access (RTX 5080) for accelerated
Whisper inference. Internal-only access. Episode data is retained for 180 days.

## Access

| Type     | URL                               | Auth                               |
|----------|-----------------------------------|------------------------------------|
| Internal | `https://minuspod.home.local`     | MinusPod own auth (Step-CA TLS)    |

No external route — LAN access only.

## Configuration

**Image:** `ttlequals0/minuspod:2.1.9`  
**Runtime:** CUDA 12.9 / NVIDIA runtime (RTX 5080)

### Key Environment Variables

| Variable                    | Value / Notes                              |
|-----------------------------|--------------------------------------------|
| `BASE_URL`                  | `https://minuspod.home.local`              |
| `WHISPER_BACKEND`           | `local`                                    |
| `WHISPER_DEVICE`            | `cuda`                                     |
| `WHISPER_MODEL`             | `medium`                                   |
| `LLM_PROVIDER`              | `ollama`                                   |
| `OPENAI_BASE_URL`           | `https://ollama.home.local/v1`             |
| `OPENAI_MODEL`              | `qwen3:14b`                                |
| `OPENAI_API_KEY`            | **REDACTED**                               |
| `RETENTION_PERIOD`          | `4320` hours (180 days)                    |
| `MINUSPOD_TRUSTED_PROXY_COUNT` | `1`                                     |
| `NVIDIA_VISIBLE_DEVICES`    | `all`                                      |
| `NVIDIA_DRIVER_CAPABILITIES`| `compute,utility`                          |

### Traefik Labels

```yaml
traefik.http.routers.minuspod.rule: Host(`minuspod.home.local`)
traefik.http.routers.minuspod.entrypoints: websecure
traefik.http.routers.minuspod.tls.certresolver: step-ca
traefik.http.services.minuspod.loadbalancer.server.port: 8000
```

## Volumes / Bind Mounts

| Host Path                                     | Container Path | Purpose                       |
|-----------------------------------------------|----------------|-------------------------------|
| `/home/jeeves/dockers/minuspod/data`          | `/app/data`    | Episode database and downloads|

> Note: The data directory is under `/home/jeeves/dockers/` (not `docker/`) —
> this is the actual bind mount path as confirmed by `docker inspect`.

## AI Integration

MinusPod uses two AI components:

| Component    | Model         | Transport                              | Purpose                |
|--------------|---------------|----------------------------------------|------------------------|
| Whisper      | `medium`      | Local CUDA inference (RTX 5080)        | Audio transcription    |
| Qwen3:14b    | `qwen3:14b`   | Ollama API at `ollama.home.local/v1`   | Summaries and notes    |

`OPENAI_BASE_URL` points to the internal Ollama instance (which exposes an
OpenAI-compatible API). The `OPENAI_MODEL` is `qwen3:14b` — this model must be
present in Ollama. Verify with `docker exec ollama ollama list`.

## Notes / Gotchas

- The Whisper `medium` model on CUDA provides a good balance of speed and
  accuracy. Upgrade to `large-v3` if accuracy is more important than speed
  (uses more VRAM). The RTX 5080 has 16GB GDDR7 — ample for either.
- `RETENTION_PERIOD=4320` hours = 180 days. Episodes older than this are
  automatically removed. Adjust if long-term episode storage is needed.
- `MINUSPOD_TRUSTED_PROXY_COUNT=1` tells MinusPod to trust one level of proxy
  headers (Traefik) for correct IP and protocol detection.
- The bind mount path is `/home/jeeves/dockers/minuspod/data` (note: `dockers`,
  not `docker`). This is different from the convention used by most other stacks.
- Ensure `qwen3:14b` is loaded in Ollama before enabling LLM summaries:
  ```bash
  docker exec ollama ollama pull qwen3:14b
  ```

---
*Last Updated: 2026-06-17*

# 09-docker-proxy.md

kstack:
  book: Centerpoint Home Lab
  chapter: Utilities
  page: Docker Socket Proxy
  tags: [dockerproxy, security, docker, infrastructure]
---

## Overview

The Docker Socket Proxy (`dockerproxy`) provides a read-only, filtered proxy to
the Docker daemon socket. It exposes a limited subset of the Docker API over TCP,
allowing containers like Homepage to query running container data without
requiring direct access to `/var/run/docker.sock`. No Traefik route — internal
use only.

## Configuration

**Image:** `tecnativa/docker-socket-proxy`

### Environment Variables (API Permission Flags)

| Variable       | Value | Permission granted                    |
|----------------|-------|---------------------------------------|
| `CONTAINERS`   | `1`   | Read container list and inspect data  |
| `EVENTS`       | `1`   | Subscribe to Docker events stream     |
| `POST`         | `0`   | **Disabled** — no write operations    |
| `ALLOW_START`  | `0`   | Cannot start containers               |
| `ALLOW_STOP`   | `0`   | Cannot stop containers                |
| `ALLOW_RESTARTS` | `0` | Cannot restart containers            |
| `AUTH`         | `0`   | No auth endpoint access               |
| `BUILD`        | `0`   | No build operations                   |
| `COMMIT`       | `0`   | No commit operations                  |
| `CONFIGS`      | `0`   | No config access                      |
| `DISTRIBUTION` | `0`   | No distribution endpoint access       |

All write operations are disabled. The proxy grants read-only container metadata
access only.

## Volumes / Bind Mounts

| Host Path                  | Container Path             | Purpose                    |
|----------------------------|----------------------------|----------------------------|
| `/var/run/docker.sock`     | `/var/run/docker.sock`     | Docker socket (read-only proxy source) |

## Consumer

The primary consumer is **Homepage** (`homepage.home.local`), which connects to
`dockerproxy:2375` to discover running containers and display live service
status widgets. This avoids mounting the Docker socket directly into Homepage.

## Notes / Gotchas

- The proxy listens on TCP port 2375 (standard Docker API port) inside the
  Docker network — it is not exposed on any host port or Traefik route.
- Allowing only `CONTAINERS=1` and `EVENTS=1` means Homepage can list containers
  and receive real-time status events, but cannot perform any management actions.
- If Homepage shows containers as offline or fails to discover services, verify
  `dockerproxy` is running and on the same Docker network as Homepage.
- The `tecnativa/docker-socket-proxy` image is the community standard for this
  pattern. It is based on HAProxy 3.x and routes allowed API paths only.

---
*Last Updated: 2026-06-17*