What this builds
A storage node running qBittorrent as an unprivileged service: incomplete data written to the NVMe tier, completed data moved to the fifty-terabyte bulk tier, everything owned by a shared group so that a second account can read it over SFTP without any chmod archaeology, and a web interface reachable only through an authenticated proxy.
The obvious approach is to install the package, click through the interface and get on with your day. That works for a fortnight, until the first file arrives with the wrong group and your sync user cannot read it, and by then there are forty thousand files with the wrong group.
Before you start
- An S-50: fifty terabytes of bulk behind a one-terabyte NVMe tier, on a ten-gigabit port. Traffic is unmetered under fair use, and the acceptable use policy is short and specific about what may cross it. Distribution rights are your problem, not a footnote.
- Debian 13, root, and a hostname such as
box.example.com.
1. Users, groups, layout
apt update && apt install -y qbittorrent-nox nginx apache2-utils acl
groupadd -g 3000 media
useradd -r -g media -u 3000 -d /srv/qbt -m -s /usr/sbin/nologin qbt
useradd -m -G media syncTwo accounts, one group. The daemon runs as qbt and never logs in; sync is the account you pull files with and can write nothing. Now the directories, and the two flags that make this work permanently:
install -d -o qbt -g media -m 2775 /srv/nvme/incomplete
install -d -o qbt -g media -m 2775 /srv/bulk/complete
setfacl -d -m g:media:rwx /srv/bulk/complete
setfacl -d -m g:media:rwx /srv/nvme/incompleteMode 2775 sets the setgid bit, so every file created inside inherits the media group instead of the creating user’s primary group. The default ACL then grants that group read and write on new entries regardless of the process umask. Together they mean the permission question is answered once rather than after every download.
On an S-50 the bulk tier and the NVMe are separate block devices. Confirm which is which before you commit to the paths:
lsblk -o NAME,SIZE,ROTA,MOUNTPOINT2. The daemon
Start it once so it writes a configuration file, then stop it and edit that file. Fighting the interface for settings that live in a text file is a waste of an afternoon.
sudo -u qbt qbittorrent-nox --webui-port=8080 &
sleep 5 && pkill -u qbt qbittorrent-noxEdit /srv/qbt/.config/qBittorrent/qBittorrent.conf so the [BitTorrent] and [Preferences] sections contain:
[BitTorrent]
Session\DefaultSavePath=/srv/bulk/complete
Session\TempPath=/srv/nvme/incomplete
Session\TempPathEnabled=true
Session\Port=51413
Session\DiskCacheSize=2048
Session\MaxConnections=800
Session\MaxUploads=40
Session\GlobalMaxSeedingMinutes=-1
Session\Preallocation=true
[Preferences]
WebUI\Address=127.0.0.1
WebUI\Port=8080
WebUI\LocalHostAuth=false
WebUI\CSRFProtection=true
Downloads\UseIncompleteExtension=trueStaging on NVMe is the whole trick. Torrent writes are small and scattered, which is the worst possible pattern for the bulk tier; the NVMe absorbs them, and the completed file moves across as one sequential copy. Preallocation stops fragmentation on the way in.
3. A unit that cannot wander
/etc/systemd/system/qbittorrent.service:
[Unit]
Description=qBittorrent
After=network-online.target
Wants=network-online.target
[Service]
User=qbt
Group=media
UMask=0002
ExecStart=/usr/bin/qbittorrent-nox
Restart=on-failure
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadWritePaths=/srv/qbt /srv/nvme/incomplete /srv/bulk/complete
LimitNOFILE=65535
[Install]
WantedBy=multi-user.targetUMask=0002 and Group=media are what keep new files group-writable. Without the first, setgid gives you the right group on files nobody in that group can write, which is a subtle and irritating half-success.
systemctl daemon-reload && systemctl enable --now qbittorrent4. Kernel and firewall
Ten gigabits with several hundred peers needs bigger socket buffers and a connection table that will not fill:
cat > /etc/sysctl.d/70-seedbox.conf <<EOF
net.core.rmem_max = 33554432
net.core.wmem_max = 33554432
net.ipv4.tcp_rmem = 4096 87380 33554432
net.ipv4.tcp_wmem = 4096 65536 33554432
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.netfilter.nf_conntrack_max = 524288
EOF
sysctl --systemFirewall: the torrent port open on both families, SSH and HTTPS open, the web interface bound to loopback and therefore needing nothing.
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
iif lo accept
ip6 nexthdr icmpv6 accept
ip protocol icmp accept
tcp dport { 22, 443 } accept
tcp dport 51413 accept
udp dport 51413 accept
}
}5. The interface, behind a proxy
certbot certonly --standalone -d box.example.com
htpasswd -c /etc/nginx/qbt.htpasswd youserver {
listen 443 ssl;
server_name box.example.com;
ssl_certificate /etc/letsencrypt/live/box.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/box.example.com/privkey.pem;
client_max_body_size 100m;
location / {
auth_basic "closed";
auth_basic_user_file /etc/nginx/qbt.htpasswd;
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-Host $http_host;
proxy_http_version 1.1;
}
}Verify it
Use something with a large, well-seeded swarm and an unambiguous licence. A current Debian installer image is the traditional choice and saturates a ten-gigabit port more convincingly than anything else you can legally test with.
Add the torrent through the interface, then watch the staging tier fill:
watch -n2 "du -sh /srv/nvme/incomplete /srv/bulk/complete"While it runs, confirm the port is genuinely reachable rather than merely open in your own firewall, since an unreachable port produces a seedbox that downloads acceptably and seeds to nobody:
ss -tn state established sport = :51413 | wc -lIncoming connections on that port mean peers found you. Zero, with plenty of outgoing connections, means only you are dialling out.
When the download completes, check the three things this build exists for:
ls -l /srv/nvme/incomplete
ls -ln /srv/bulk/complete
sudo -u sync cat /srv/bulk/complete/*.iso > /dev/null && echo "sync can read"The staging directory is empty, the completed files show group 3000 with group read and write, and the sync account can read them without any manual permission fixing. That third command is the one that proves the setgid and ACL work paid off.
Finally, prove the interface is not exposed:
curl -m5 http://box.example.com:8080/ ; echo "exit $?"
curl -sI https://box.example.com/ | head -1The first should fail on connection, the second should return 401. Anything else and the address binding in step two did not take.
Afterwards
Set a global seeding ratio or a share-time limit unless you intend to seed everything forever, because fifty terabytes fills faster than anyone expects. If a complaint ever reaches us about traffic from your instance, the process is described on the abuse page: it is forwarded to you, and we do not go looking through your disk.