Skip to main content

Configuration Profiles

Seven opinionated, known-good configurations. Find the one that matches your deployment, apply it, and move on.

Each profile is a starting point, not a straitjacket — but each is built from what has actually worked (and what has actually broken) on real deployments.

Purpose

To skip the trial and error. Most tuning questions have a right answer once you know how big the deployment is and what it is for.

Choosing a profile

ProfileForThe one thing that matters
DefaultEvaluating, first installGet the secrets right
Small home serverA Pi, a NAS, a mini-PCKeep it lean; rTorrent is fine here
Large media libraryHundreds of torrents, thousands of itemsUse qBittorrent, not rTorrent
High availabilityIt must not go downExternal Postgres + real backups
EnterpriseMany users, audit requirementsRBAC + 2FA + audit review
PerformanceIt works but it's slowTrigram indexes + Postgres tuning
SecurityInternet-facingPublish nothing you don't have to

Default

The shipped configuration. Sensible, safe, and unopinionated — this is what you get from cp .env.example .env.

Use it when: you are installing for the first time or evaluating.

# --- Required. The stack REFUSES to start without these. -------------------
POSTGRES_PASSWORD=<strong ALPHANUMERIC password>
ADMIN_PASSWORD=<strong password>

# Generate each separately with: openssl rand -base64 48
JWT_ACCESS_SECRET=<48+ random chars>
JWT_REFRESH_SECRET=<48+ random chars>
ENCRYPTION_KEY=<48+ random chars — MUST DIFFER from JWT_ACCESS_SECRET>

# --- Sensible defaults ----------------------------------------------------
NODE_ENV=production
PORT=4000
FRONTEND_PORT=8080
CORS_ORIGIN=http://localhost:8080

POSTGRES_USER=ultratorrent
POSTGRES_DB=ultratorrent
REDIS_HOST=redis
REDIS_PORT=6379

JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL_DAYS=30

FILE_MANAGER_ROOTS=/downloads
SSRF_ALLOW_HOSTS=prowlarr

ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@ultratorrent.local

PUID=1000
PGID=1000
TZ=Etc/UTC
docker compose --profile qbittorrent up -d --build
docker compose exec backend npx prisma db seed
The four rules that stop the backend from booting
  1. JWT_ACCESS_SECRET and ENCRYPTION_KEY must each be 32+ chars.
  2. They must be different from each other.
  3. Neither may be a dev-* / change-me default.
  4. POSTGRES_PASSWORD must be alphanumeric (it is embedded in a URL).

These are enforced. If the backend starts in production, you satisfied them.

Checklist

  • All three secrets generated with openssl rand -base64 48
  • ENCRYPTION_KEYJWT_ACCESS_SECRET
  • Seeded admin password changed after first login
  • .env backed up off the host

Small home server

A Raspberry Pi 4/5, a Synology/QNAP NAS, an old laptop, a mini-PC. Modest CPU, 2–4 GB RAM, a handful of users.

Use it when: under ~50 active torrents and a library of a few hundred items.

# Start from Default, then:

# The NAS admin UI usually owns 8080.
FRONTEND_PORT=8123
CORS_ORIGIN=http://nas.local:8123

# Own the media as the user that already owns it (e.g. Plex).
# Find it with: id plex
PUID=1000
PGID=1000
TZ=America/New_York

# Keep the file-manager boundary tight.
FILE_MANAGER_ROOTS=/downloads

# Longer sessions — it's a trusted LAN, and re-logins are annoying.
JWT_ACCESS_TTL=30m
JWT_REFRESH_TTL_DAYS=60

Engine: the bundled rTorrent is genuinely fine at this size. Its crash bug is load-driven and effectively invisible below ~100 torrents (a 7-torrent host went zero crashes over the same period a 752-torrent host crashed 44 times).

docker compose --profile rtorrent up -d --build

Skip the IMDb catalogue unless you need missing-episode detection. It is 8.9M rows and it is optional. If you do import it, budget the RAM and let the trigram indexes finish building before you scan.

NAS gotchas that will cost you an hour
  • Port 8080 is usually taken. Set FRONTEND_PORT. Do not try to remap it with a Compose override — Compose appends ports, so the original mapping survives and still conflicts.
  • Synology DSM strips SETUID/SETGID from the container's default capabilities, which breaks rTorrent's privilege drop and makes it run as root (so downloads land root-owned). The shipped Compose file re-adds them with cap_add: ["SETUID", "SETGID"]. Keep that line.
  • Media owned by another app? Don't chown it. Set PUID/PGID to that user (id plex) so downloads are written as them.

Checklist

  • FRONTEND_PORT set to something free
  • PUID/PGID match whoever should own the media
  • cap_add: ["SETUID","SETGID"] still present (Synology)
  • rTorrent profile enabled
  • IMDb catalogue skipped, or imported deliberately

Large media library

Hundreds to thousands of torrents. Thousands of media items. The IMDb catalogue imported. This is where the interesting failures live.

Use it when: you are past ~100 active torrents, or ~2,000 media items.

The one decision that matters: do not use rTorrent

# Start from Default, then:
QBITTORRENT_PORT=8081
docker compose --profile qbittorrent up -d
docker compose logs qbittorrent | grep -i password # first-run temp password

Register it under Infrastructure → Engines (kind qBittorrent, base URL http://qbittorrent:8080).

rTorrent 0.9.8 has an unfixable, load-driven crash bug

internal_error: priority_queue_insert(...) fires during tracker-announce scheduling. It has no fix in the 0.9.8 lineage, and it scales with your torrent count. Real measurements from two hosts running the identical build:

TorrentsCrashes
70
75244 in 4 days (~10/day)

qBittorrent handles thousands of torrents comfortably. Move before you feel it, not after.

If the qBittorrent connection test fails with 401: disable Enable Host header validation under Options → Web UI (the backend connects by the service name qbittorrent, which qBittorrent does not trust by default).

The second decision: every indexer needs a minSeeders

This is not a nicety. The per-indexer seeder filter only applies when the column is set — so an indexer with no minSeeders hands you 0-seeder releases, and:

A 0-seeder magnet can never fetch its metadata — yet the engine counts it as an active download the whole time it tries.

The real outcome was an engine holding 1,137 torrents and moving 0 bytes: 1,114 of them had zero seeders, and with max_active_downloads: 100, exactly 88 metaDL + 12 stalledDL = 100 slots were permanently held by torrents that would never finish. The 1,034 healthy ones sat queued behind them.

Set minSeeders on every indexer, and enable the parking queue (it ships disabled — it pauses dead torrents so they stop holding slots, then periodically force-starts them to re-check whether seeders appeared).

The third decision: trigram indexes

With the 8.9M-row IMDb catalogue, ILIKE lookups without GIN trigram indexes take 47.8 seconds each and will starve Postgres until scans never complete. With them: 180 ms.

Current builds build these automatically at runtime. Verify they are valid:

SELECT c.relname, i.indisvalid
FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid
WHERE c.relname LIKE '%trgm%'; -- all must be `t`

See Performance.

Checklist

  • qBittorrent, not rTorrent
  • minSeeders set on every indexer
  • Parking queue enabled
  • All three trigram indexes exist and are valid
  • Postgres has 4 GB+ and random_page_cost lowered for SSD
  • Scans are not run concurrently with the IMDb import

High availability

Downtime is unacceptable. Note honestly: UltraTorrent is a single-instance application — the backend is not designed to be horizontally scaled behind a load balancer (job bodies run in-process; a second instance would duplicate scheduled work). HA here means fast, reliable recovery, not active-active.

Use it when: you need a hard recovery-time objective.

# Point at an EXTERNAL, managed, replicated Postgres — the single most
# valuable HA change you can make.
DATABASE_URL=postgresql://ultratorrent:PASSWORD@postgres.internal:5432/ultratorrent?schema=public

# Redis can also be external, but it holds no durable state — losing it is cheap.
REDIS_HOST=redis.internal
REDIS_PORT=6379

The HA architecture

The app host becomes cattle: if it dies, you rebuild it from the repo, restore .env, point it at the same database, and you are back.

What actually buys you availability

Do thisWhy
External, replicated Postgres with point-in-time recoveryIt is the only irreplaceable component
.env in a secret storeWithout ENCRYPTION_KEY, a database restore is half a restore
Media on a NAS/RAID, not container-localDecouples your media from the app host
restart: unless-stopped on everythingAlready the default. It is what makes rTorrent's crashes survivable
Monitor /api/system/ready, not just /live/live says the process exists; /ready says its dependencies are usable
A rehearsed restore drillYour RTO is a guess until you have measured it
Alert on RestartCountCatches a crash-loop that docker compose ps hides

Health probes

# Liveness — is the process alive? (this is what the container healthcheck uses)
curl -f http://localhost:8080/api/system/live || echo DOWN

# Readiness — are its dependencies usable?
curl -f http://localhost:8080/api/system/ready || echo NOT_READY
Not yet verified

Running UltraTorrent behind a load balancer with multiple backend replicas has not been validated, and the in-process job model means scheduled work would very likely be duplicated across instances. Treat multi-replica as unsupported until proven otherwise.

Checklist

  • Postgres is external, replicated, with PITR
  • .env is in a secret store, not only on the host
  • Media lives on redundant storage
  • /api/system/ready is monitored and alerts
  • RestartCount is monitored
  • The restore drill has been run and timed (that is your real RTO)
  • Only one backend instance runs

Enterprise

Many users, real roles, an audit requirement.

Use it when: UltraTorrent is shared beyond a household.

# Start from Default + Security, then:

# Shorter sessions.
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL_DAYS=7

# The exact production origin. Never `*`.
CORS_ORIGIN=https://ultratorrent.corp.example.com

# Narrow the hard file boundary to exactly what the engine writes.
FILE_MANAGER_ROOTS=/downloads/media

# Trust only the indexers you actually run.
SSRF_ALLOW_HOSTS=prowlarr

Roles, applied

Assign the least role that does the job:

PersonRoleWatch out for
YouSUPER_ADMINThe only role that can grant SUPER_ADMIN. Give it to as few people as possible.
A co-adminADMINISTRATOREverything except system.manage.
Someone who manages their own mediaPOWER_USER⚠️ Includes all files.*delete, bulk actions and cleanup included.
An ordinary userUSERRead-only files. Safe.
A dashboard viewerREAD_ONLYView only.
POWER_USER can delete your files

It holds every files.* permission, including files.delete and files.cleanup. If that is not what you intend, build a custom role. Note also that torrents.delete_data (removes data from disk) is a separate permission from torrents.delete — grant them independently.

The platform enforces the escalation guards for you: only a SUPER_ADMIN may grant SUPER_ADMIN, no user may edit their own roles, deactivating a user revokes their refresh tokens immediately, and bulk actions require the same permission as their dedicated route — so a viewer cannot smuggle a destructive operation through /torrents/bulk.

Mandatory practices

  • 2FA on every admin account. Enrolment is confirmed, not blind — a user must prove possession of a valid code before it activates, so nobody locks themselves out by accident. Save the 10 single-use recovery codes.
  • Review the audit log monthly. It records failed logins with the attempted username, every destructive action, role changes, and settings changes — and it now names the media each row targeted instead of showing an opaque id.
  • API keys, not shared passwords, for machine access.

Checklist

  • Every user has the least role that works
  • 2FA enrolled on all admin accounts; recovery codes stored
  • POWER_USER grants reviewed (they can delete files)
  • Audit log reviewed on a schedule
  • CORS_ORIGIN is the exact production origin
  • FILE_MANAGER_ROOTS is as narrow as possible

Performance

Everything works — it is just slow. See Performance for the full treatment; this is the config layer.

# docker-compose.override.yml
services:
postgres:
command:
- postgres
- -c
- shared_buffers=1GB
- -c
- work_mem=32MB
- -c
- maintenance_work_mem=512MB # makes index builds much faster
- -c
- effective_cache_size=3GB
- -c
- random_page_cost=1.1 # you are on SSD; the 4.0 default assumes spinning disk

Then, in order of impact:

  1. Verify the trigram indexes exist and are indisvalid = true. This is worth ~265× on IMDb title lookups (47.8 s → 180 ms). An INVALID index is worse than none — the planner ignores it, but its name exists, so IF NOT EXISTS skips the rebuild forever.
  2. ANALYZE after any large import. A stale plan is a slow plan.
  3. random_page_cost=1.1 — the highest-leverage single line. The 4.0 default biases the planner against index scans, which is wrong on SSD.
  4. Do not run a library scan during the IMDb import. They fight.
  5. Move to qBittorrent if you have many torrents.
docker compose exec postgres psql -U ultratorrent -d ultratorrent -c "
EXPLAIN ANALYZE SELECT * FROM imdb_titles WHERE \"primaryTitle\" ILIKE 'Silo';"
# Want: Bitmap Index Scan. Do NOT want: Seq Scan.

Checklist

  • Trigram indexes present and valid
  • EXPLAIN shows a Bitmap Index Scan
  • random_page_cost lowered for SSD
  • ANALYZE run recently
  • Postgres has enough shared_buffers

Security

Internet-facing, or simply paranoid. Pairs with Security.

NODE_ENV=production

# The EXACT origin. Not `*`. Not localhost.
CORS_ORIGIN=https://ultratorrent.example.com

# Short-lived everything.
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL_DAYS=7

# The narrowest possible hard boundary.
FILE_MANAGER_ROOTS=/downloads/media

# Full SSRF protection: set EMPTY if you use no private-IP indexer.
# If you use the bundled Prowlarr, you MUST keep it listed.
SSRF_ALLOW_HOSTS=prowlarr

Publish nothing you do not have to

# docker-compose.override.yml — un-publish the companion ports.
services:
qbittorrent:
ports: !reset [] # reach it via the internal network only
prowlarr:
ports: !reset []
The engine control surface is unauthenticated

rTorrent's SCGI/XML-RPC interface gives full control of the client, including command execution (it runs rm during delete-with-data). The shipped Compose file correctly keeps it on expose (internal only) — never publish it. The same caution applies to the qBittorrent Web API: if you published its port to fetch the first-run password, unpublish it afterwards.

TLS at the edge

docker compose --profile proxy up -d

Replace the :80 site label in deploy/Caddyfile with your domain for automatic Let's Encrypt HTTPS. See TLS.

The honest recommendation

Put it on a VPN instead. WireGuard or Tailscale gives you remote access with a fraction of the attack surface. UltraTorrent moves, deletes and executes against files. There is very little upside to exposing it publicly.

Checklist

  • TLS terminates at a reverse proxy
  • CORS_ORIGIN is the exact production origin
  • rTorrent / FlareSolverr are not published
  • qBittorrent / Prowlarr ports un-published (or firewalled)
  • 2FA on every account that can log in
  • FILE_MANAGER_ROOTS narrowed
  • SSRF_ALLOW_HOSTS lists only trusted indexers
  • chmod 600 .env
  • Audit log reviewed regularly

Comparison

DefaultSmallLargeHAEnterprisePerformanceSecurity
EngineqBittorrentrTorrent OKqBittorrentqBittorrentqBittorrentqBittorrentqBittorrent
IMDb catalogueoptionalskipyesyesyesyesoptional
Trigram indexesauton/acriticalcriticalcriticalcriticalauto
Postgresbundledbundledbundled + tunedexternalbundledtunedbundled
JWT_ACCESS_TTL15m30m15m15m15m15m15m
JWT_REFRESH_TTL_DAYS306030307307
2FArecommendedoptionalrecommendedrequiredrequiredrequired
Published ports808080808080 + 8081via LBvia proxy80/443 only
minSeedersset itset itmandatorymandatorymandatorymandatoryset it
Parking queueoffoffononononoff

Troubleshooting

SymptomLikely profile mismatch
Engine keeps restartingYou are on Small settings at Large scale → switch to qBittorrent
Scans never finishMissing trigram indexes → Performance
Nothing downloads, all queuedNo minSeedersLarge
Backend won't bootSecrets → Default
Downloads owned by rootPUID/PGID or missing cap_addSmall

Full detail in Troubleshooting.

Tips

  • Profiles compose. Large + Security is a perfectly normal combination.
  • Pick your engine before you grow, not after. Migrating engines at 800 torrents is a chore; starting on the right one is free.
  • The minSeeders rule is the cheapest insurance in this document. One unset column brought a 1,137-torrent host to zero bytes per second.

FAQ

Can I switch profiles later? Yes. They are just settings. Switching engines means re-adding torrents to the new engine, so do it early.

Which profile for a Raspberry Pi? Small home server. Skip the IMDb catalogue.

I have 300 torrents. Small or Large? Large. rTorrent's crash rate is already climbing at that count.

Do I need Prowlarr? No. It is an optional companion that manages Torznab indexer definitions for you. UltraTorrent boots and works fine without it. See Prowlarr.

Can I run multiple backend instances? Not supported. Job bodies run in-process, so a second instance would duplicate scheduled work. See High availability.

See also