This Ansible role provisions a standard Red Hat Enterprise Linux 9 (RHEL 9) system as a secure, efficient and lightweight peer-to-peer (P2P) seedbox, running qBittorrent.
The configuration prioritizes security and simplicity, utilizing integrated tools such as SELinux and firewalld. To maintain a minimal system footprint, it is configured for zero-logging operation and no shell history. Among others (mkbrr, tqm, netronome, sizechecker etc.), the role incorporates Autobrr for modern automated downloads and cross-seed for enhanced seeding.
Please be aware that the absence of persistent logs may complicate troubleshooting, though the ephemeral journal should be sufficient for most diagnostics. This project is an enhanced fork of my zero_footprint_rutorrent_seedbox repository, simplified and adapted for qBittorrent (lot of lessons learned!). Contributions via pull requests are welcome.
- A clean installation of RHEL 9 (CentOS 9 Stream should also work, but is not always tested).
- Pre-configured, passwordless Ansible access with
sudoprivileges. The luckylittle/ansible-role-create-user role may be used to establish this access. - Access via password should also be in place (mainly due to single-user vsftpd) - e.g.
sudo passwd <user>.
- SSH Key Authentication is mandatory: This role will disable password-based SSH access by setting
PasswordAuthentication no. You must configure SSH key-pair authentication BEFORE execution to avoid being completely locked out of the system. - Firewall IP whitelisting: To ensure you can access the system after the firewall is enabled, you must add your client IP address(es) to the
ipv4_whitelistvariable. Failure to do so will result in a system lockout as well.
Default variables are:
set_google_dns- iftrue, it will add Google DNS servers to the primary interface. Defaults to true.set_timezone- change the time zone of the server, defaults to Australia/Sydney.sysctl_tunables- on/off for various tuning options in sysctl.yml. Default is on.
Note: Lot of the tasks rely on remote_user / ansible_user variable (user who logs in to the remote machine via Ansible). For example, it creates directory structure under that user.
qbt_port- what port should qBittorrent listen on. Default is 55442.
ftp_port- what port should vsftpd listen on. Default is 55443.pasv_port_range- what port range should be used for FTP PASV, by default this is 64000-64321.single_user- whentrueonly one FTP user will be used and it is the same username who runs this playbook.⚠️ Whenfalse, this file is used, update accordingly⚠️ This is now true by default.
ipv4_whitelist- what IP addresses should be used in the firewalld zone for access to services. Default whitelisted is arbitrary addressX.X.X.X.⚠️ You need to change it to your own⚠️
Example: 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12 123.222.11.111
require_reboot- does the machine require reboot after the playbook is finished. It is recommended & default to be true.
Role variables are also tunable, but it is not recommended to change them unless you know what you are doing.
- Ansible core v
2.16.14 ansible-galaxy collection install -r requirements.yml
[seedbox]
123.124.125.126---
- hosts: seedbox
name: Playbook for zero_footprint_qbittorrent_seedbox role
roles:
- "luckylittle.zero_footprint_qbittorrent_seedbox"| OS | Version 0.1.1 | Version 0.1.2 |
|---|---|---|
| 9.6 (Plow) | ✅ | ✅ |
On a brand new Red Hat Enterprise Linux release 9.6 (Plow) on AWS (t3.medium - 2 vCPU, 4GiB RAM), it took 13m 59s. The following versions were installed during the last RHEL9 test:
| Package name | Package version |
|---|---|
| autobrr | 1.65.0 |
| bash | 5.1.8-9.el9.x86_64 |
| cross-seed | 6.13.2 |
| curl | 7.76.1-31.el9_6.1.x86_64 |
| firewalld | 1.3.4-9.el9_5.noarch |
| libdb-utils | 5.3.28-57.el9_6.x86_64 |
| mkbrr | 1.15.0 |
| netronome | 0.6.0 |
| NetworkManager | 1.52.0-5.el9_6.x86_64 |
| nvm | 0.40.3 |
| openssh | 8.7p1-45.el9.x86_64 |
| qBittorrent | 5.1.2 |
| sizechecker | 1.4.0 |
| tar | 1.34-7.el9.x86_64 |
| tqm | 1.16.0 |
| traceroute | 2.1.1-1.el9.x86_64 |
| tuned | 2.25.1-2.el9_6.noarch |
| vnstat | 2.9-2.el9.x86_64 |
| vsftpd | 3.0.5-6.el9.x86_64 |
| wget | 1.21.1-8.el9_4.x86_64 |
The following Terraform can be used to create necessary infrastructure (based on RHEL9.X on AWS):
# Configure the AWS Provider
provider "aws" {
region = "ap-southeast-2"
}
# Variable
variable "key_name" {
type = string
default = "ec2-pair"
description = "AWS Key-pair"
}
# Find latest RHEL 9 AMI
data "aws_ami" "rhel9" {
most_recent = true
owners = ["309956199498"] # Red Hat's AWS account ID
filter {
name = "name"
values = ["RHEL-9*"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
}
# Create a security group
resource "aws_security_group" "rhel9_sg" {
name = "rhel9_sg"
description = "Security group for RHEL 9 EC2 seedbox instance"
tags = {
Name = "RHEL9-SecurityGroup"
}
}
resource "aws_vpc_security_group_ingress_rule" "allow_all" {
security_group_id = aws_security_group.rhel9_sg.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
description = "Generally a bad practice, but we need to test firewalld functionality"
tags = {
Name = "allow_all"
}
}
resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_ipv4" {
security_group_id = aws_security_group.rhel9_sg.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1" # semantically equivalent to all ports
}
resource "aws_vpc_security_group_egress_rule" "allow_all_traffic_ipv6" {
security_group_id = aws_security_group.rhel9_sg.id
cidr_ipv6 = "::/0"
ip_protocol = "-1" # semantically equivalent to all ports
}
# Create an EC2 instance
resource "aws_instance" "rhel_instance" {
ami = data.aws_ami.rhel9.id
instance_type = "t3.medium"
vpc_security_group_ids = [aws_security_group.rhel9_sg.id]
key_name = var.key_name # Replace with your key pair name
root_block_device {
volume_size = 15
volume_type = "gp3"
encrypted = true
tags = {
Name = "RHEL-9-Seedbox"
}
}
ebs_block_device {
device_name = "/dev/sdb"
volume_size = 15
volume_type = "gp3"
encrypted = true
delete_on_termination = true
tags = {
Name = "RHEL-9-Seedbox"
}
}
user_data = <<EOF
#!/bin/bash
# Log all output for debugging
exec > >(tee /var/log/user-data.log) 2>&1
echo "Starting user data script at $(date)"
# Wait for the EBS volume to be available
echo "Waiting for EBS volume to be available..."
while [ ! -e /dev/nvme1n1 ]; do
echo "Waiting for /dev/nvme1n1..."
sleep 5
done
echo "EBS volume /dev/nvme1n1 is available"
# Create partition on the EBS volume
echo "Creating partition on /dev/nvme1n1..."
(
echo n # Add a new partition
echo p # Primary partition
echo 1 # Partition number
echo # First sector (Accept default: 1)
echo # Last sector (Accept default: varies)
echo w # Write changes
) | fdisk /dev/nvme1n1
# Wait a moment for the partition to be recognized
sleep 5
# Format the partition with XFS
echo "Formatting /dev/nvme1n1p1 with XFS..."
mkfs.xfs /dev/nvme1n1p1
# Get the UUID of the new partition
echo "Getting UUID of the partition..."
UUID=$(blkid -s UUID -o value /dev/nvme1n1p1)
echo "UUID: $UUID"
# Add entry to /etc/fstab
echo "Adding entry to /etc/fstab..."
echo "UUID=$UUID /home xfs defaults 0 0" >> /etc/fstab
# Create a temporary mount point to preserve existing home data
echo "Creating temporary mount point..."
mkdir -p /mnt/temp_home
# Mount the new volume temporarily
mount /dev/nvme1n1p1 /mnt/temp_home
# Copy existing /home contents to the new volume (if any)
if [ "$(ls -A /home 2>/dev/null)" ]; then
echo "Copying existing /home contents to new volume..."
cp -arv /home/* /mnt/temp_home/
fi
# Unmount the temporary mount
umount /mnt/temp_home
rmdir /mnt/temp_home
# Mount the new volume to /home
echo "Mounting new volume to /home..."
mount -av
# Reload systemd daemon
systemctl daemon-reload
# Verify the mount
echo "Verifying mount..."
df -h /home
mount | grep /home
# Restore default SELinux security contexts
restorecon -Rv /home/
echo "User data script completed successfully at $(date)"
# Optional: Create a marker file to indicate completion
touch /var/log/user-data-complete
EOF
tags = {
Name = "RHEL-9-Seedbox"
Environment = "Dev"
}
}
# Output the instance details
output "instance_id" {
value = aws_instance.rhel_instance.id
}
output "instance_public_ip" {
value = aws_instance.rhel_instance.public_ip
}
output "instance_dns" {
value = aws_instance.rhel_instance.public_dns
}To test:
# Create a testing EC2 machine
cd tests/
terraform init; terraform apply -var=key_name=<NAME_OF_THE_EXISTING_KEY_PAIR_IN_AWS> -auto-approve
# Insert the EC2 machine's public IP to the Ansible inventory
terraform output -raw instance_public_ip > inventory
# Make necessary symlink for testing playbook
mkdir roles/; ln -s ../../ roles/luckylittle.zero_footprint_qbittorrent_seedbox
# Run the test
time ansible-playbook -i inventory -u ec2-user test.yml
# To destroy the EC2 machine afterwards
# terraform destroy -auto-approveAfter you successfully apply this role, you should be able to see a similar output and access the following services:
"----------------------------------------------------"
"Autobrr URL:"
"http://123.124.125.126:7474/onboard"
"----------------------------------------------------"
"Autobrr Healthz URL:"
"http://123.124.125.126:7474/api/healthz/liveness"
"----------------------------------------------------"
"qBt WebUI:"
"http://123.124.125.126:8080"
"----------------------------------------------------"
"Netronome URL:",
"http://123.124.125.126:7575",
"----------------------------------------------------"
"vsFTPd URL:"
"ftps://123.124.125.126:55443"
"----------------------------------------------------"MIT
luckylittle.zero_footprint_qbittorrent_seedbox
Lucian Maly <[email protected]>
Last update: Fri 29 Aug 2025 00:02:49 UTC

