Skip to content

Configuration

chodeus edited this page May 21, 2026 · 21 revisions

Configuration

CHUB reads a single YAML file on startup: config.yml, kept under ${CONFIG_DIR} (inside the Docker container, that's /config/config.yml).

The easiest way to edit is the web UI — every Settings page writes back through a validated API. If you hand-edit the file:

  • Keep permissions at 0600. It contains API keys.
  • CHUB revalidates the whole file on startup. If validation fails, the container log tells you which field is wrong and CHUB refuses to start.

Per-module config lives on the Modules page, next to each module's description. This page covers the top-level blocks only.

Jump to:


🧭 At a glance — top-level layout

general:           {}   # global toggles
auth:              {}   # admin user (managed by the UI)
instances:         {}   # Radarr / Sonarr / Lidarr / Plex connections
schedule:          {}   # when each module runs
notifications:     {}   # Discord / Email / Apprise per module
user_interface:    {}   # theme

# one section per module — see the Modules page for fields
poster_renamerr:   {}
border_replacerr:  {}
poster_cleanarr:   {}
labelarr:          {}
jduparr:           {}
nohl:              {}
unmatched_assets:  {}
upgradinatorr:     {}
renameinatorr:     {}
health_checkarr:   {}
nestarr:           {}
sync_gdrive:       {}
plex_maintenance:  {}

Anything you omit falls back to safe defaults.


📝 Full example config.yml

A complete, working example with every top-level block populated and each module enabled minimally. Copy this as a starting point and trim or extend as needed.

general:
  log_level: info
  update_notifications: false
  max_logs: 9
  webhook_initial_delay: 30
  webhook_retry_delay: 60
  webhook_max_retries: 3
  webhook_secret: ""               # set to require shared secret on inbound webhooks
  duplicate_exclude_groups: []

auth:
  username: admin                  # managed by the UI; leave the rest to CHUB
  password_hash: ""
  jwt_secret: ""
  token_expiry_hours: 24

instances:
  radarr:
    radarr_main:
      url: http://radarr:7878
      api: <radarr-api-key>
      enabled: true
  sonarr:
    sonarr_main:
      url: http://sonarr:8989
      api: <sonarr-api-key>
      enabled: true
  lidarr:
    lidarr_main:
      url: http://lidarr:8686
      api: <lidarr-api-key>
      enabled: true
  plex:
    plex_main:
      url: http://plex:32400
      api: <x-plex-token>
      enabled: true

schedule:
  poster_renamerr:
    type: cron
    expression: "0 */4 * * *"      # every 4 hours
  jduparr:
    type: interval
    minutes: 720                   # every 12 hours
  upgradinatorr:
    type: cron
    expression: "15 3 * * *"       # daily at 03:15

notifications:
  main:
    discord:
      enabled: true
      webhook: https://discord.com/api/webhooks/...
  health_checkarr:
    email:
      enabled: true
      from: chub@example.com
      to: [you@example.com]
      smtp_host: smtp.example.com
      smtp_port: 587
      username: chub
      password: <smtp-password>

user_interface:
  theme: dark                      # light | dark

# --- Modules ---
# See the Modules page for every field. Examples below are a minimal subset.

poster_renamerr:
  dry_run: false
  action_type: hardlink
  asset_folders: true
  source_dirs: [/kometa]
  destination_dir: /posters
  instances:
    - radarr_main
    - sonarr_main
    - plex_main:
        library_names: ["Movies", "TV Shows"]
        add_posters: true

border_replacerr:
  dry_run: false
  source_dirs: [/posters]
  destination_dir: /posters
  border_width: 26                  # color-mode only; ignored when a holiday's borders are set
  skip: false                       # holiday-only mode: when true, only runs on active-holiday days
  border_colors: ["#ff7300"]
  holidays:
    - name: 🎃 Halloween
      schedule: "range(10/01-10/31)"
      colors: ["#FF6600", "#000000"]
    - name: 🎄 Christmas             # image-mode — composites bundled PNGs instead of flat color
      schedule: "range(12/01-12/26)"
      colors: ["#C8102E", "#00843D"] # fallback if a border PNG is missing
      borders: [v1, v2, v3, v4, v5] # cycles per poster; resolution checks /config/borders/christmas/ first

poster_cleanarr:
  mode: report                         # bloat-image mode
  plex_path: "/plex-config/Library/Application Support/Plex Media Server/Metadata"
  overlays_only: false                 # true: only sweep Kometa-tagged overlays; preserve custom uploads
  instances: [plex_main, radarr_main, sonarr_main]  # Plex needed for bloat; add *arr for orphan-asset comparison
  orphan_assets_enabled: false         # opt in to the title-scan asset cleanup
  orphan_assets_mode: report           # report | move | remove
  asset_dirs: []                       # directories to scan for orphan assets
  include_collections: true

labelarr:
  mappings:
    - app_instance: sonarr_main
      labels: [watched, favorite]
      plex_instances:
        - instance: plex_main
          library_names: ["TV Shows"]

jduparr:
  hash_database: /config/jduparr.db
  source_dirs: [/media/movies, /media/tv]

nohl:
  searches: 10
  source_dirs:
    - { path: /media/movies, mode: movie }
    - { path: /media/tv,     mode: series }
  instances: [radarr_main, sonarr_main]

unmatched_assets:
  instances: [radarr_main, sonarr_main, plex_main]

upgradinatorr:
  instances_list:
    - instance: radarr_main
      count: 10
      tag_name: chub-upgradinatorr
      ignore_tag: ignore
      search_mode: upgrade
    - instance: sonarr_main
      count: 5
      count_mode: season_album         # series_artist (default) | season_album
      tag_name: chub-upgradinatorr
      ignore_tag: ignore
      search_mode: upgrade

renameinatorr:
  rename_folders: true
  count: 100
  tag_name: chub-renameinatorr
  instances: [radarr_main, sonarr_main]

health_checkarr:
  report_only: false
  instances: [radarr_main, sonarr_main, lidarr_main]

nestarr:
  library_mappings:
    - arr_instance: radarr_main
      plex_instances:
        - { instance: plex_main, library_names: [Movies] }

sync_gdrive:
  gdrive_sa_location: /config/gdrive-sa.json
  gdrive_list:
    - id: "<google-drive-folder-id>"
      location: /posters/gdrive-pull

⚙️ general

Global toggles. All editable from Settings → General.

general:
  log_level: info              # debug | info | warning | error
  update_notifications: false  # banner when a new CHUB release is out
  max_logs: 9                  # rotated log files kept per module
  webhook_initial_delay: 30    # seconds to wait after an inbound webhook before acting
  webhook_retry_delay: 60      # seconds between retries
  webhook_max_retries: 3
  webhook_secret: ""           # empty = unauthenticated; set to require a shared secret
  duplicate_exclude_groups: [] # duplicate group IDs the UI should hide

If webhook_secret is set, every inbound webhook must send X-Webhook-Secret: <secret> (or ?secret=<secret>). See Webhooks.


🔐 auth

Managed by the web UI. Don't edit unless you're resetting things.

auth:
  username: admin
  password_hash: "$2b$12$..."
  jwt_secret: "<random>"
  token_expiry_hours: 24

To reset the admin password, see First Run → Resetting the admin password.


🔌 instances

Your Radarr, Sonarr, Lidarr, and Plex connections. The key under each service is the name you'll reference elsewhere in config.yml (radarr_main, sonarr_4k, etc.).

instances:
  radarr:
    radarr_main:
      url: http://radarr:7878
      api: <api_key>
      enabled: true
  sonarr:
    sonarr_main:
      url: http://sonarr:8989
      api: <api_key>
  lidarr:
    lidarr_main:
      url: http://lidarr:8686
      api: <api_key>
  plex:
    plex_main:
      url: http://plex:32400
      api: <x_plex_token>

Plex: the api field is the X-Plex-Token, not a Plex login. Finding your token.

About URLs: CHUB refuses to connect to cloud-metadata addresses or unrouteable ranges as a safety check. Use a normal IP or hostname your container can resolve — http://radarr:7878 works if CHUB and Radarr share a Docker network.

Testing: in Settings → Instances, each row has a Test button. Run it after adding or editing an entry.


schedule

One entry per module that should run on a schedule. Anything not listed here is manual-only (triggered by you from the dashboard, or by a webhook).

schedule:
  poster_renamerr:
    type: cron
    expression: "0 */4 * * *"   # every 4 hours
  jduparr:
    type: interval
    minutes: 720                # every 12 hours

type is either cron (with expression) or interval (with minutes). Settings → Schedule has a form that writes this for you.

A built-in system health probe runs every 6 hours on its own; you don't configure it.


📣 notifications

One entry per module that should send notifications, plus an optional main entry for global notifications.

notifications:
  main:
    discord:
      enabled: true
      webhook: https://discord.com/api/webhooks/...
  poster_renamerr:
    discord:
      enabled: true
      webhook: https://discord.com/api/webhooks/...
      mention_role: "123456789"
  upgradinatorr:
    apprise:
      enabled: true
      url: "discord://..."
  health_checkarr:
    email:
      enabled: true
      from: chub@example.com
      to: [you@example.com]
      smtp_host: smtp.example.com
      smtp_port: 587
      username: chub
      password: <smtp_password>

Discord, Email, and Apprise are supported. Apprise's URL format covers dozens of other services — see the Apprise README for the catalog.


🎨 user_interface

user_interface:
  theme: dark    # light | dark

Server-wide default. Each browser also remembers its own choice, so toggling the theme in the header sticks on that device.


🔒 Secret handling

When CHUB returns your config to the UI, it replaces these fields with ******** so they don't leak into browser storage or screenshots:

  • api, api_key
  • access_token, refresh_token, token, client_secret
  • password_hash, jwt_secret, webhook_secret

When you save config back through the UI, any field still equal to ******** is kept as-is — so editing non-sensitive fields won't wipe your API keys.


🔄 Auto-migration from older config formats

If I drop in a config.yml written for an older format of this tool, CHUB detects the legacy shape on load and migrates it in place — no hand-editing needed.

How it works

On startup, CHUB scans the file for shape signals that the current schema never produces (a top-level main: or discord: section, certain renamed-or-removed keys, or value types that have since changed). If any are present:

  1. The original file is copied to config.yml.legacy-<ISO-timestamp>.yml in the same directory — preserved untouched, forever.
  2. The migration rules run against the parsed YAML in memory.
  3. The migrated YAML is written back to config.yml.
  4. Each rule that fired is printed to the container log so I can see exactly what changed.

If the file already matches the current schema, none of this happens — chub loads it normally.

What gets migrated

Kind Example
Section moves main.log_levelgeneral.log_level; main.themeuser_interface.theme; main.update_notificationsgeneral.update_notifications; the now-empty main: section is dropped
Section removals Top-level discord: (per-module notifications cover it)
Field renames unmatched_assets.ignore_root_foldersignore_folders; renameinatorr.ignore_tagignore_tags; poster_cleanarr.source_dirsasset_dirs; labelarr.mappings[].plex_instances[].plex_instanceinstance
Field removals poster_renamerr.incremental_border_replacerr, unmatched_assets.source_dirs, poster_cleanarr.ignore_media (warnings logged — those features no longer exist)
Type conversions poster_cleanarr.dry_run: boolmode: str (true"report", false"remove"); border_replacerr.holidays: dictlist[{name, schedule, colors[], borders[]}]

Verifying paths after migration

The migrator only rewrites the shape of the YAML — it can't know what's actually on the host filesystem. After the first boot, I check that every path-valued field still points at a real location inside CHUB's container. Volume mounts often differ between setups, so a path that worked under the previous tool may now point at nothing.

Fields to spot-check:

  • sync_gdrive.gdrive_sa_location — the Google Drive service-account JSON file
  • sync_gdrive.gdrive_list[].location — the per-drive destination directories
  • poster_renamerr.source_dirs[] + destination_dir
  • border_replacerr.source_dirs[] + destination_dir
  • poster_cleanarr.asset_dirs[]
  • poster_cleanarr.plex_path and plex_maintenance.plex_path
  • nohl.source_dirs[].path (or string form)
  • jduparr.hash_database and jduparr.source_dirs[]

The migration log prints a one-line reminder about this. The web UI's Settings pages also flag empty / unreadable paths as you save.

Restoring the original

The config.yml.legacy-<timestamp>.yml file is the unmodified original. To roll back: stop CHUB, copy the backup over config.yml, restart with the older tool — or hand-fix anything I want kept and restart CHUB.

Re-runs are no-ops

The migrator is idempotent. If I edit config.yml later and restart, no legacy signals will be present (they were all rewritten on the first run), so the migration step is skipped entirely.


🚨 If CHUB won't start

The container log prints which field failed validation. Either fix the field or remove the bad section and restart — CHUB falls back to defaults for anything missing.

docker compose logs chub

See Troubleshooting for common failures.

Clone this wiki locally