Automated torrent cleanup for Transmission. Docker container with web dashboard, cron scheduler, and
3.2K
Automated cleanup for one or more Transmissionβ instances. Removes torrents that have been seeding (or are stopped) for longer than a configurable number of days, optionally enforcing a minimum upload ratio before deletion. Ships as a Docker container with a built-in web dashboard, scheduled runs, and multi-channel notifications.
Images are built automatically for linux/amd64 and linux/arm64 on every push to main or the cleanup-history test branch (tagged dev) and on every release tag (tagged latest + version number). Current feature version: 1.1.0.
| Registry | Image |
|---|---|
| GitHub Container Registry | ghcr.io/gioxx/transmissioncleaner:latest |
| Docker Hub | gfsolone/transmissioncleaner:latest |
For pre-release testing, use ghcr.io/gioxx/transmissioncleaner:dev or
gfsolone/transmissioncleaner:dev.
# GHCR
docker pull ghcr.io/gioxx/transmissioncleaner:latest
# Docker Hub
docker pull gfsolone/transmissioncleaner:latest
50 7 * * 3 (every Wednesday at 07:50)/api/status and /api/torrents for scripting or external integrations/api/status/config/cleanups.db and searchable from /history; dry runs are excluded.eml notifications with python scripts/import_history.py --db /config/cleanups.db /path/to/emails9091)No Python installation required on the host.
git clone https://github.com/gioxx/TransmissionCleaner.git
cd TransmissionCleaner
cp stack.env.example stack.env
cp config/servers.json.example config/servers.json
cp .env.example .env
Edit config/servers.json with your Transmission server(s) β the file supports // and /* */ comments:
[
{
"name": "Home",
"host": "192.168.1.10",
"port": 9091,
"user": "admin",
"password": "yourpassword"
}
]
Then set your cleanup rules and notification settings in stack.env.
docker compose up -d
The pre-built image is pulled automatically from GHCR. Open http://your-host:8080β β done.
To build from source instead, uncomment
build: .indocker-compose.ymland rundocker compose up -d --build.
The repository ships a ready-to-use docker-compose.yml that pulls the pre-built image from GHCR. Copy the examples, fill in your values and run:
cp stack.env.example stack.env
cp config/servers.json.example config/servers.json
cp .env.example .env # only if you need a port other than 8080
# edit the three files, then:
docker compose up -d
The config/ directory is mounted as a volume, so servers.json persists across container restarts and updates.
It also contains cleanups.db, the SQLite database with the cleanup history. Include this file in backups.
Full docker-compose.yml for reference:
services:
transmission-cleaner:
image: ghcr.io/gioxx/transmissioncleaner:latest
# build: . # uncomment to build from source instead
container_name: transmission-cleaner
ports:
- "${HOST_PORT:-8080}:8080" # set HOST_PORT in .env
volumes:
- ./config:/config # servers.json lives here
env_file:
- stack.env
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/status')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
If you prefer a one-liner, build the image first and then run it:
# Run using the pre-built GHCR image
docker run -d \
--name transmission-cleaner \
--restart unless-stopped \
-p 8080:8080 \
-v "$(pwd)/config:/config" \
--env-file stack.env \
ghcr.io/gioxx/transmissioncleaner:latest
For a quick single-server test without a config file, use the inline fallback:
docker run -d \
--name transmission-cleaner \
--restart unless-stopped \
-p 8080:8080 \
-e TRANSMISSION_SERVERS='[{"name":"Home","host":"192.168.1.10","port":9091,"user":"admin","password":"s3cr3t"}]' \
-e DAYS_TO_WAIT=10 \
-e CLEANUP_SCHEDULE="50 7 * * 3" \
-e TZ="Europe/Rome" \
transmission-cleaner:latest
transmission-cleaner.docker-compose.yml content below.stack.env.example directly.services:
transmission-cleaner:
image: transmission-cleaner:latest
build:
context: https://github.com/gioxx/TransmissionCleaner.git
container_name: transmission-cleaner
ports:
- "8080:8080"
volumes:
- /your/host/path/config:/config # put servers.json here
environment:
SERVERS_FILE: "/config/servers.json"
DAYS_TO_WAIT: "10"
MIN_RATIO: "0"
CLEANUP_SCHEDULE: "50 7 * * 3"
TZ: "Europe/Rome"
DRY_RUN: "false"
NOTIFY_ALWAYS: "false"
TELEGRAM_ENABLED: "false"
TELEGRAM_BOT_TOKEN: ""
TELEGRAM_CHAT_ID: ""
RESEND_ENABLED: "false"
RESEND_API_KEY: ""
RESEND_FROM: "[email protected]"
RESEND_TO: "[email protected]"
SMTP_ENABLED: "false"
SMTP_HOST: "localhost"
SMTP_PORT: "25"
SMTP_FROM: "[email protected]"
SMTP_TO: "[email protected]"
SMTP_TLS: "false"
restart: unless-stopped
Portainer tip β use the Secrets feature or Portainer's environment variable editor to avoid putting passwords in plain text inside the stack definition. For
servers.json, bind-mount a directory from the host (e.g./opt/transmission-cleaner/config) so credentials stay on the host filesystem and survive stack redeployments.
All configuration is done via environment variables (in your local stack.env, passed with --env-file, or set individually with -e). Use stack.env.example as the starting template.
Servers are configured via a JSON file mounted into the container. This is the recommended approach as it supports comments and multi-line formatting.
| Variable | Default | Description |
|---|---|---|
SERVERS_FILE | /config/servers.json | Path to the servers config file inside the container |
config/servers.json format (supports // and /* */ comments):
[
{
// Display name shown in the web UI and notifications
"name": "S1",
// Hostname or IP address of the Transmission instance
"host": "192.168.1.10",
// RPC port β optional, default 9091
"port": 9091,
// Leave empty if authentication is disabled
"user": "admin",
"password": "secret"
},
{
"name": "S2",
"host": "nas.local",
"port": 9091,
"user": "admin",
"password": "secret"
}
]
Copy the provided example to get started:
cp config/servers.json.example config/servers.json
Fallback β inline env var
If SERVERS_FILE does not exist, the app falls back to the TRANSMISSION_SERVERS environment variable. Useful for quick tests or single-server setups that don't need a mounted file:
TRANSMISSION_SERVERS=[{"name":"Home","host":"192.168.1.10","port":9091,"user":"admin","password":"secret"}]
| Variable | Default | Description |
|---|---|---|
DAYS_TO_WAIT | 10 | Delete torrents that have been seeding/stopped for at least this many days |
MIN_RATIO | 0 | Minimum upload ratio required before deletion. 0 disables this check |
CLEANUP_SCHEDULE | 50 7 * * 3 | Cron expression for the automatic run |
TZ | UTC | IANA timezone (e.g. Europe/Rome) the cron schedule and displayed times are evaluated in. Without this, the container defaults to UTC and CLEANUP_SCHEDULE/"Next run" will silently be off from your local time |
DRY_RUN | false | If true, log what would be deleted without actually deleting anything |
Deletion criteria (all must be true):
seeding or stoppedDAYS_TO_WAITMIN_RATIO > 0: upload ratio β₯ MIN_RATIOCron schedule examples:
| Expression | Meaning |
|---|---|
50 7 * * 3 | Every Wednesday at 07:50 (default) |
0 3 * * * | Every day at 03:00 |
0 6 * * 1 | Every Monday at 06:00 |
0 */6 * * * | Every 6 hours |
30 22 * * * | Every day at 22:30 |
All three channels can be active simultaneously. By default, notifications are sent only when at least one torrent is deleted or an error occurs. Set NOTIFY_ALWAYS=true to receive a report on every run.
| Variable | Default | Description |
|---|---|---|
NOTIFY_ALWAYS | false | Send notifications even when nothing was deleted |
| Variable | Default | Description |
|---|---|---|
TELEGRAM_ENABLED | false | Enable Telegram notifications |
TELEGRAM_BOT_TOKEN | β | Bot token from @BotFatherβ |
TELEGRAM_CHAT_ID | β | Target chat/channel ID |
How to get your credentials:
/newbot and follow the prompts. Copy the token it gives you β TELEGRAM_BOT_TOKEN.TELEGRAM_CHAT_ID.https://api.telegram.org/bot<TOKEN>/getUpdates after sending a message β look for "chat":{"id": ...}.TELEGRAM_ENABLED=true
TELEGRAM_BOT_TOKEN=123456789:AABBCCDDeeffGGHHiijjKKLLmmNNoopp
TELEGRAM_CHAT_ID=-100123456789
Resendβ is a developer-friendly transactional email API with a generous free tier (3 000 emails/month).
| Variable | Default | Description |
|---|---|---|
RESEND_ENABLED | false | Enable Resend notifications |
RESEND_API_KEY | β | API key from the Resend dashboard |
RESEND_FROM | β | Sender address (must be a verified domain in Resend) |
RESEND_TO | β | Recipient address |
RESEND_ENABLED=true
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[email protected]
[email protected]
Use your own mail server, a relay, or a provider like Gmail, Fastmail, Mailgun, etc.
| Variable | Default | Description |
|---|---|---|
SMTP_ENABLED | false | Enable SMTP notifications |
SMTP_HOST | localhost | SMTP server hostname |
SMTP_PORT | 25 | SMTP port |
SMTP_USER | β | Username (leave empty if not required) |
SMTP_PASSWORD | β | Password |
SMTP_FROM | β | Sender address |
SMTP_TO | β | Recipient address |
SMTP_TLS | false | true = implicit TLS / SMTPS (port 465). false = plain or STARTTLS |
Gmail example (App Password required β enable 2FA first):
SMTP_ENABLED=true
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
[email protected]
SMTP_PASSWORD=xxxx-xxxx-xxxx-xxxx
[email protected]
[email protected]
SMTP_TLS=true
Local relay / Postfix (no auth):
SMTP_ENABLED=true
SMTP_HOST=192.168.1.1
SMTP_PORT=25
[email protected]
[email protected]
SMTP_TLS=false
The host port is configured via .env (not stack.env), because Docker Compose reads .env automatically for variable substitution in the compose file β env_file: only passes variables into the container and cannot affect port mapping.
cp .env.example .env
# then edit .env:
HOST_PORT=8081
| File | Variable | Default | Description |
|---|---|---|---|
.env | HOST_PORT | 8080 | Port exposed on the host |
The dashboard is served at http://your-host:8080 and auto-refreshes every 60 seconds.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β π§Ή Transmission Cleaner Next run: 05h 42m 18s β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β [βΆ Run cleanup now] [π Dry run] [βΊ Refresh] β
ββββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββ¬βββββββββββββ€
β Servers β Torrents β To removeβ Keeping β β
β 2 β 47 β 8 β 39 β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βοΈ Settings β
β Retain: 10 days Β· Min ratio: disabled β
β Schedule: 0 6 * * * Β· Notifications: Telegram, SMTP β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β π‘ S1 (192.168.1.10) Connected 47 torrents β
β ββββββββββββββββββββββ¬βββββββββ¬βββββββ¬βββββ¬ββββββ¬βββββββ
β β Name β Status β Size β AgeβRatioβ ββ
β β Ubuntu 24.04 LTS β seedingβ 4 GB β 15dβ1.52 βπ Delββ
β β Fedora 41 Workstat β seedingβ 2 GB β 3dβ0.21 ββKeepββ
β ββββββββββββββββββββββ΄βββββββββ΄βββββββ΄βββββ΄ββββββ΄βββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β π Last run log β
β Transmission Cleaner β 2025-06-15 06:00:01 β
β === S1 (192.168.1.10) === β
β Removed: Ubuntu 24.04 LTS | age: 15d | ratio: 1.52 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Rows highlighted in red will be deleted on the next automatic run (or immediately if you click Run cleanup now).
| Button | Action |
|---|---|
| Run cleanup now | Executes the full cleanup immediately and sends notifications |
| Dry run | Fetches torrents and marks what would be deleted β no actual deletions, no notifications |
| Refresh | Reloads the page and queries all servers again |
The history importer reads plain-text Gmail .eml exports matching the old
Torrent deletion log subject. It imports the email date, complete message
body, server/torrent details, and deletion count into the same persistent
SQLite history. Imported entries are marked imported and are deduplicated by
the email Message-ID (or a file hash when that header is unavailable).
The importer is a command-line utility and does not send notifications. It can
read one file or every .eml file in a directory:
# From a source checkout, using a local database
python scripts/import_history.py \
--db /config/cleanups.db \
/path/to/exported-emails
# Preview the files without writing history
python scripts/import_history.py \
--dry-run \
--db /config/cleanups.db \
"/path/to/Torrent deletion log.eml"
For a running Docker Compose deployment, copy the exports into the mounted config directory and run the importer inside the container:
mkdir -p config/import
cp /path/to/*.eml config/import/
docker compose exec transmission-cleaner \
python scripts/import_history.py \
--db /config/cleanups.db \
/config/import
The importer creates the database if necessary. Existing history is retained;
running the same import again reports duplicates and does not create new rows.
Dry runs and imported legacy records are separate concepts: dry runs are never
stored, while legacy emails are stored with the imported trigger.
| Endpoint | Method | Description |
|---|---|---|
/ | GET | Web dashboard (HTML) |
/run | POST | Trigger cleanup (?dry=1 for dry run) |
/api/status | GET | Scheduler and last-run metadata (JSON) |
/api/torrents | GET | Full torrent list per server (JSON) |
/history | GET | Searchable cleanup history (HTML) |
/api/history | GET | Paginated cleanup history search (JSON) |
GET /api/status example:
{
"next_run": "2025-06-16T06:00:00+02:00",
"last_run": "2025-06-15T06:00:01.234567",
"last_deleted": 3,
"last_errors": 0,
"schedule": "0 6 * * *",
"dry_run": false
}
GET /api/torrents example:
[
{
"server": "S1",
"connected": true,
"error": "",
"torrents": [
{
"name": "Ubuntu 24.04 LTS",
"status": "seeding",
"size": "4.00 GB",
"age_days": 15,
"ratio": 1.52,
"progress": 100.0,
"will_delete": true
}
]
}
]
Trigger a cleanup from the command line:
curl -X POST http://your-host:8080/run
# Dry run:
curl -X POST "http://your-host:8080/run?dry=1"
git pull
docker compose up -d --build
Docker Compose will rebuild the image and recreate the container with zero downtime for the scheduler (the new container starts before the old one stops).
host in TRANSMISSION_SERVERS is reachable from inside the container.
docker exec transmission-cleaner python -c \
"import socket; socket.create_connection(('192.168.1.10', 9091), 3)"
localhost or 127.0.0.1 (which resolves to the container itself)./start to it.curl "https://api.telegram.org/bot<TOKEN>/sendMessage" \
-d chat_id=<CHAT_ID> \
-d text="test"
TRANSMISSION_SERVERS parsing errorThe value must be valid JSON. Verify it with:
echo '$TRANSMISSION_SERVERS' | python3 -m json.tool
Common mistakes: single quotes inside the JSON string, trailing commas, missing brackets.
docker logs -f transmission-cleaner
Content type
Image
Digest
sha256:108947b38β¦
Size
58.6 MB
Last updated
3 months ago
docker pull gfsolone/transmissioncleaner