SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor

Docker Compose chạy Prowlarr, Sonarr, Radarr, qBittorrent

Chạy Prowlarr, Sonarr, Radarr và qBittorrent trên VPS bằng một file Docker Compose, dùng chung PUID, PGID và volume để hardlink hoạt động đúng.

Bạn đang xây dựng gì

Một arr stack chạy bằng Docker Compose gồm 4 container để quản lý thư viện media: Prowlarr quản lý cấu hình indexer, Sonarr quản lý series, Radarr quản lý phim và qBittorrent làm download client. Các container này giao tiếp với nhau qua Compose network bằng service name và cùng dùng một cây thư mục trên host. Việc cài đặt khá ngắn. Yếu tố quyết định stack có hoạt động ổn định trong nhiều năm hay khiến bạn phải xử lý lỗi mỗi tuần là cách bố trí volume. Vì vậy, phần lớn nội dung của guide này tập trung vào việc đó.

Stack không tự tìm content cho bạn. Prowlarr lưu các indexer bạn thêm vào, còn việc sử dụng indexer nào là quyết định và trách nhiệm pháp lý của bạn. Guide này tập trung vào phần hạ tầng: user, path, permission, container networking và các bước kiểm tra để xác nhận hệ thống hoạt động.

Nếu bạn chưa từng viết file Compose, hãy đọc phần cơ bản về Docker Compose cho VPS trước. Bài viết này giả định docker compose version đã in ra kết quả trên server của bạn. Hãy dùng Docker Engine 28.0.0 hoặc mới hơn: các release cũ cho phép host trong cùng một local network segment truy cập những port được publish vào localhost.

Khi Sonarr xử lý xong một bản tải xuống, nó import file vào thư viện. Nếu thư mục tải xuống và thư mục thư viện nằm trên cùng filesystem, đồng thời hardlink được bật, Sonarr sẽ thử tạo hardlink: một tên thứ hai trỏ đến cùng dữ liệu trên disk. Thao tác này không tốn thêm dung lượng và gần như không tốn thời gian. Torrent tiếp tục seed từ tên cũ, còn media server đọc tên mới.

Nếu hai thư mục nằm trên các filesystem khác nhau, kernel không thể tạo hardlink đó. Sonarr sẽ chuyển sang copy. Một season 40 GB lúc này chiếm 80 GB disk và mất vài phút cho thao tác input/output. Sonarr ghi log lỗi hardlink do khác filesystem ở mức Trace, còn các exception khác liên quan đến hardlink ở mức Debug. Vì vậy, log thông thường không đảm bảo sẽ có thông báo lỗi. Trên VPS có giới hạn dung lượng disk cố định, đây là nguyên nhân khiến nhiều người hết dung lượng chỉ sau một tuần.

Đây là điểm dễ mắc bẫy. Bên trong container, bind mount là một ranh giới filesystem. Mount /mnt/data/torrents vào /downloads và /mnt/data/media vào /tv. Dù cả hai cùng nằm trên một disk của host, Sonarr vẫn thấy đây là hai mount riêng và không thể tạo hardlink giữa chúng. Tài liệu chính thức của image LinuxServer.io nói rõ điều này: dùng các path riêng biệt /downloads và /tv sẽ làm mất khả năng tạo hardlink.

Cách sửa là dùng một mount. Mọi container truy cập media đều dùng cùng một volume duy nhất, /mnt/data:/data, và mọi path chúng sử dụng đều là thư mục bên trong volume đó. Một mount point, một filesystem, hardlink hoạt động bình thường.

Tạo user, group và các thư mục

Container ghi file bằng numeric user ID do PUID và PGID thiết lập. Dùng chính account của bạn để có thể đọc và chỉnh sửa các file đó qua SSH mà không cần sudo.

id -u
id -g

Cả hai lệnh thường in ra 1000 trên một Ubuntu VPS mới. Bây giờ tạo cây thư mục. Đặt cây này trên disk chứa media và giữ toàn bộ cây trên cùng một disk.

sudo mkdir -p /mnt/data/torrents/movies /mnt/data/torrents/tv
sudo mkdir -p /mnt/data/media/Movies /mnt/data/media/Shows
sudo chown -R 1000:1000 /mnt/data
sudo chmod -R 775 /mnt/data

Kiểm tra để chắc chắn đây thực sự là một filesystem trước khi tiếp tục:

df --output=source,target /mnt/data/torrents /mnt/data/media

Cả hai dòng phải hiển thị cùng một source device. Hai device khác nhau nghĩa là hardlink sẽ không bao giờ hoạt động, bất kể bạn thiết lập gì trong container config.

Các thư mục library được đặt tên là Movies và Shows có chủ ý. Nếu bạn đã chạy Jellyfin làm media server, hãy mount /mnt/data/media vào Jellyfin dưới dạng /media; khi đó các library sẽ nằm tại /media/Movies và /media/Shows, đúng vị trí mà guide đó sử dụng.

Tệp environment

Giữ các giá trị thay đổi theo từng server trong .env, cạnh file Compose.

mkdir -p ~/arr && cd ~/arr

Tạo ~/arr/.env:

PUID=1000
PGID=1000
TZ=Etc/UTC
DATA_ROOT=/mnt/data

Đặt TZ thành zone của bạn, chẳng hạn Europe/Berlin. Các ứng dụng arr lên lịch tác vụ và ghi timestamp vào log theo zone đó, nên giá trị sai sẽ khiến toàn bộ log về sau khó đọc.

Tệp Compose

Viết ~/arr/docker-compose.yml:

services:
  prowlarr:
    image: lscr.io/linuxserver/prowlarr:latest
    container_name: prowlarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ./config/prowlarr:/config
    ports:
      - 127.0.0.1:9696:9696
    restart: unless-stopped

  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ./config/sonarr:/config
      - ${DATA_ROOT}:/data
    ports:
      - 127.0.0.1:8989:8989
    restart: unless-stopped

  radarr:
    image: lscr.io/linuxserver/radarr:latest
    container_name: radarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
    volumes:
      - ./config/radarr:/config
      - ${DATA_ROOT}:/data
    ports:
      - 127.0.0.1:7878:7878
    restart: unless-stopped

  qbittorrent:
    image: lscr.io/linuxserver/qbittorrent:latest
    container_name: qbittorrent
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - TZ=${TZ}
      - WEBUI_PORT=8080
      - TORRENTING_PORT=6881
    volumes:
      - ./config/qbittorrent:/config
      - ${DATA_ROOT}:/data
    ports:
      - 127.0.0.1:8080:8080
      - 6881:6881
      - 6881:6881/udp
    stop_grace_period: "10s"
    restart: unless-stopped

Bốn phần trong tệp này thực sự đảm nhiệm công việc.

${DATA_ROOT}:/data giống hệt nhau trong ba container có truy cập media. Prowlarr không cần phần này vì Prowlarr không bao giờ mở tệp media.

Mọi web port đều bind vào 127.0.0.1, nên Docker chỉ publish chúng trên địa chỉ loopback. Một 8989:8989 thông thường sẽ publish port trên mọi interface, và các firewall rule của Docker sẽ chuyển thẳng lưu lượng đó qua rule deny của ufw. Hành vi này thường xuyên gây bất ngờ và được giải thích trong vì sao Docker publish port đi thẳng qua ufw.

Port 6881 được publish trên mọi interface có chủ đích. Đây là port lắng nghe của torrent và phải có thể truy cập để peer kết nối vào. Hành vi tương tự của Docker cũng áp dụng ở đây, nên bạn không cần rule ufw cho port này; thêm rule cũng không thay đổi gì. Tài liệu Docker, được kiểm tra vào ngày November 8, 2026, cho biết lưu lượng đến một port đã publish được định tuyến trước khi đến các chain mà ufw sử dụng, nên sudo ufw allow 6881 không có tác dụng với port này. Docker publish port đó trên mọi interface của host ngay khi container khởi động, và một deny của ufw cũng không đóng được port này. Nếu cần giới hạn những ai có thể kết nối đến port này, tài liệu Docker hướng dẫn dùng rule trong chain DOCKER-USER khi dùng backend iptables mặc định; backend nftables thì yêu cầu rule trong một nftables table riêng. Hãy dùng ufw cho các service không chạy trong Docker, chẳng hạn SSH, và đọc kiến thức cơ bản về firewall ufw cho VPS nếu bạn chưa quen với công cụ này.

Các thư mục config được tách riêng cho từng ứng dụng, chỉ volume media được dùng chung. Hãy tạo các thư mục này trước lần khởi động đầu tiên để chúng thuộc về user của bạn thay vì root:

mkdir -p ~/arr/config/prowlarr ~/arr/config/sonarr ~/arr/config/radarr ~/arr/config/qbittorrent
docker compose up -d
docker compose ps

Cả bốn service đều nên đọc running. Tính đến ngày November 8, 2026, các image này được publish trên lscr.io và tag latest theo bản stable hiện tại, vì vậy hãy pin một version tag cụ thể nếu bạn muốn việc nâng cấp là quyết định có chủ ý thay vì một bất ngờ.

Truy cập an toàn vào các giao diện web

Các giao diện web chỉ bind vào loopback. Hãy forward chúng qua SSH từ máy của bạn:

ssh -L 9696:127.0.0.1:9696 -L 8989:127.0.0.1:8989 \
    -L 7878:127.0.0.1:7878 -L 8080:127.0.0.1:8080 you@your-server

Bây giờ http://127.0.0.1:8989 trong trình duyệt sẽ truy cập Sonarr trên server. Để truy cập lâu dài, hãy đặt stack phía sau Traefik với chứng chỉ TLS cho nhiều ứng dụng, hoặc truy cập server qua VPN WireGuard tự host. Tuy nhiên, VPN không tự truy cập được các cổng này. Tài liệu Docker, được kiểm tra vào ngày 8 tháng 11, 2026, cho biết với Docker Engine 28.0.0 trở lên và cấu hình bridge mặc định, chỉ Docker host mới truy cập được cổng được publish trên địa chỉ localhost. Để dùng VPN, bạn có thể tiếp tục chạy SSH tunnel qua VPN, hoặc đổi binding trong file Compose từ 127.0.0.1 sang địa chỉ riêng của server trên giao diện VPN. Không ứng dụng nào trong số này nên nằm trên public Internet chỉ với trang login riêng của nó ở phía trước. Nếu chọn reverse proxy và muốn dùng một account cho cả bốn giao diện thay vì phải quản lý bốn login riêng của từng ứng dụng, Authentik cung cấp single sign-on tự host để Traefik có thể áp dụng cho mọi request bằng forward auth. Sonarr và Radarr có hai điểm cần lưu ý riêng khi chạy phía sau proxy. Vì vậy, trước khi route chúng qua Traefik, hãy đọc cách chạy Sonarr và Radarr phía sau reverse proxy để biết các mục Allowed Hosts giúp ngăn lỗi Invalid Hostname và lý do /api phải bỏ qua login.

qBittorrent tạo một mật khẩu administrator ngẫu nhiên ở lần khởi động đầu tiên và ghi mật khẩu đó vào log của container. Hãy đọc mật khẩu, rồi đổi mật khẩu trong giao diện web:

docker compose logs qbittorrent | grep -i password

Nếu bỏ qua bước đổi mật khẩu, mỗi lần restart sẽ tạo một mật khẩu ngẫu nhiên mới, và bạn sẽ phải xem log lại sau mỗi lần như vậy.

Đặt đường dẫn bên trong từng ứng dụng

Trong qBittorrent, mở Options, sau đó mở Downloads và đặt đường dẫn lưu mặc định thành /data/torrents. Giữ thư mục tải xuống chưa hoàn tất bên trong cùng cây thư mục, chẳng hạn như /data/torrents/incomplete. Tệp tải xuống hoàn tất ở bất kỳ vị trí nào bên ngoài /data đều không thể được hardlink vào thư viện bằng Compose file này.

Trong Sonarr, mở Settings, sau đó mở Media Management và thêm root folder /data/media/Shows. Trong Radarr, root folder là /data/media/Movies. Đây là các đường dẫn bên trong container. Host path /mnt/data/media/Shows bị từ chối vì thư mục đó không tồn tại theo góc nhìn của container.

Trong cả Sonarr và Radarr, mở Settings, sau đó mở Download Clients và thêm qBittorrent. Host là qbittorrent và port là 8080. Tên service hoạt động như hostname vì Compose đặt cả 4 container vào cùng một network có service DNS (domain name system) nội bộ. Không dùng localhost ở đây: bên trong Sonarr container, localhost là Sonarr.

Để Remote Path Mappings trống. Tính năng này dùng để chuyển đổi đường dẫn mà download client báo cáo thành đường dẫn mà arr application có thể nhìn thấy. Với một mount /data được dùng chung, cả hai container đã sử dụng cùng một đường dẫn, đây là lý do thứ hai khiến cách bố trí này đáng thực hiện.

Kết nối Prowlarr với Sonarr và Radarr

Prowlarr đẩy định nghĩa indexer vào các ứng dụng khác, nên bạn chỉ cần cấu hình mỗi indexer một lần thay vì hai lần. Prowlarr cần API (application programming interface) key từ từng ứng dụng.

Trong Sonarr, mở Settings, sau đó mở General và sao chép API key. Trong Prowlarr, mở Settings, sau đó mở Apps, thêm ứng dụng Sonarr và điền 3 trường. Prowlarr Server là http://prowlarr:9696. Sonarr Server là http://sonarr:8989. API Key là giá trị bạn đã sao chép. Nhấn Test. Kết quả màu xanh nghĩa là Prowlarr đã kết nối được với Sonarr qua Compose network. Lặp lại các bước với Radarr tại http://radarr:7878.

Kết quả màu đỏ với thông báo connection was refused nghĩa là kết nối không tới được service đang listening tại địa chỉ và port đã cấu hình. Xác nhận tên được phân giải từ bên trong container:

docker compose exec prowlarr curl -sS -o /dev/null -w '%{http_code}\n' http://sonarr:8989

Mã trạng thái HTTP chứng minh đường mạng hoạt động bình thường. Lỗi name resolution nghĩa là tên service không được phân giải; hãy kiểm tra chính tả tên và xác nhận cả hai container đang chạy trên cùng một Compose network.

Không nên tin cấu hình cho đến khi bạn kiểm tra link count. Sau khi một mục đã được import, hãy so sánh file đã tải xuống với file trong thư viện:

stat -c '%i %h %n' /mnt/data/torrents/tv/*/*.mkv
stat -c '%i %h %n' /mnt/data/media/Shows/*/*/*.mkv

Số đầu tiên là inode và số thứ hai là link count. Trên cùng một filesystem, hai file là hardlink của nhau sẽ có cùng inode và link count ít nhất là 2; các hardlink bổ sung sẽ làm tăng giá trị này. Inode khác nhau nghĩa là hai file không phải hardlink của nhau, nhưng không chứng minh được thao tác tạo hardlink đã thất bại. Sonarr cũng sẽ copy file mà không thử tạo hardlink nếu tùy chọn hardlink bị tắt. Lỗi tạo hardlink giữa các filesystem được ghi ở mức Trace, còn các exception khác liên quan đến hardlink được ghi ở mức Debug. Vì vậy, log import thông thường không đảm bảo sẽ có giải thích.

Đồng thời, hãy theo dõi dung lượng đĩa. df -h /mnt/data gần như không thay đổi khi import, vì hardlink chỉ thêm một tên mới và không tạo thêm dữ liệu.

Điều gì thực sự gây lỗi

Lỗi quyền khi import có nghĩa là user id của container không thể ghi vào thư mục library. Thông báo lỗi là Access to the path ... is denied. Kiểm tra bằng ls -ln /mnt/data/media để xác nhận owner id khớp với PUID, và nhớ rằng directory cần có quyền execute trước khi container có thể truy cập vào đó.

Directory thuộc về root có thể xuất hiện do bạn start container trước khi directory trên host tồn tại, vì Docker sẽ tạo các directory bind-mount còn thiếu. Stop stack, chown directory rồi start lại.

Nếu xóa một torrent trong qBittorrent rồi thấy file library cũng biến mất, hãy kiểm tra những path nào đã bị xóa; xóa riêng download không xóa bản copy hoặc hardlink riêng trong library. Với hardlink thực sự, xóa một tên vẫn giữ nguyên tên còn lại, vì dữ liệu chỉ được giải phóng khi link count giảm về 0.

Disk đầy nhanh hơn nhiều so với dung lượng media bạn đã thêm là dạng nghiêm trọng nhất của lỗi copy. Chạy kiểm tra stat ở trên trước khi mua thêm storage.

Stack này cần gì từ một VPS

Ba ứng dụng arr khá nhẹ. Chúng gửi truy vấn định kỳ đến các indexer, ghi vào một cơ sở dữ liệu SQLite nhỏ và đổi tên file. Tải lớn đến từ các thành phần khác. Download client có thể làm bão hòa I/O đĩa khi xử lý torrent lớn, còn media server transcode video trên cùng máy sẽ chiếm CPU. Hãy lưu media trên volume có throughput thực tế tốt và đặt giới hạn băng thông cho download client nếu server còn chạy các tác vụ khác mà bạn cần ưu tiên. Hãy dự trù riêng cho các tác vụ đó thay vì mặc định rằng server còn đủ tài nguyên: một workspace AFFiNE tự host cần thêm 4 container cùng một database phía sau, và AFFiNE khuyến nghị 2 GB RAM cho nhu cầu cơ bản và 4 GB cho tài liệu dài hơn 10,000 từ. Với một ứng dụng đơn chức năng như một trình theo dõi bài tập openGym tự host, hãy cấp cho nó TLS riêng và xác định rõ thư mục dữ liệu nằm ở đâu trước khi tin tưởng giao cho nó lịch sử tập luyện của cả năm. Bất kỳ hệ thống nào chạy web application, database Postgres và background worker queue đều gần phía AFFiNE của khoảng này hơn. Vì vậy, hãy quyết định một help desk hỗ trợ Chatwoot tự host có nên chạy trên server này hay trên một server riêng trước khi bạn chạm giới hạn giữa chừng khi import. Với workload tăng đột biến, cần thận trọng hơn nữa vì chính mức đỉnh, không phải mức trung bình, mới có thể xung đột với quá trình import: nếu đang cân nhắc một OneCLI tự host cấp sandbox riêng cho từng người dùng, hãy đối chiếu các thông số sizing đã công bố với lượng tài nguyên thực sự còn trống khi qBittorrent chạy hết công suất, thay vì đối chiếu với giá trị free -h trên một máy đang nhàn rỗi.

FAQ

Vì hardlink bị tắt hoặc thao tác tạo hardlink thất bại. Trường hợp này cũng xảy ra khi source và destination nằm trên các filesystem khác nhau theo góc nhìn của container. Hai bind mount riêng biệt, như /downloads và /tv, là hai mount point riêng ngay cả khi cùng trỏ đến một ổ đĩa trên host, và hardlink không thể vượt qua chúng. Hãy mount cùng một thư mục cha vào mọi container dưới dạng /data, đặt thư mục downloads và library bên trong đó, rồi thao tác tạo link sẽ thực hiện được. Xác nhận kết quả bằng stat -c '%i %h %n' trên cả hai file: nếu nằm trên cùng filesystem, chúng phải hiển thị cùng inode và link count ít nhất là 2.

Nên dùng PUID và PGID nào?

Dùng id dạng số của account trên host đang sở hữu cây thư mục media. Bạn lấy các giá trị này bằng id -u và id -g. Trên một Ubuntu VPS mới, cả hai giá trị thường là 1000. Mọi container trong stack phải dùng cùng một cặp giá trị. Nếu không, một ứng dụng có thể tạo file mà ứng dụng khác không thể sửa. Sau khi thay đổi các giá trị, hãy tạo lại các container bằng docker compose up -d --force-recreate và sửa quyền sở hữu của các file hiện có bằng chown -R.

Có cần expose các web interface này ra internet không?

Không, và bạn không nên làm vậy. Bind từng published port vào 127.0.0.1 trong file Compose, sau đó truy cập các interface thông qua SSH tunnel hoặc reverse proxy trên server. Reverse proxy sẽ thực hiện TLS termination (transport layer security) và thêm cơ chế xác thực riêng. Với Docker Engine 28.0.0 trở lên và cấu hình bridge mặc định, chỉ dùng VPN không thể truy cập port được publish trên 127.0.0.1, vì Docker chỉ làm cho port đó có thể truy cập từ chính host. Các bản Docker cũ hơn cho phép các host trên cùng segment mạng cục bộ truy cập những port được publish vào localhost. Nếu muốn truy cập trực tiếp qua VPN, hãy publish các port trên địa chỉ VPN của server. Publish trực tiếp trên public interface nguy hiểm hơn bạn nghĩ, vì Docker tự chèn các rule firewall và rule deny của ufw sẽ không chặn được lưu lượng đó.

Tìm password của qBittorrent ở đâu?

Image LinuxServer.io in một password tạm thời cho user admin vào startup log. Chạy docker compose logs qbittorrent | grep -i password để đọc password đó, sau đó đặt password cố định trong Options và Web UI. Mỗi lần restart, một password tạm thời mới sẽ được tạo cho đến khi bạn đặt password riêng.

Jellyfin có thể dùng cùng các thư mục không?

Có, đó chính là mục đích của layout này. Mount /mnt/data/media vào media server dưới dạng /media. Các library của media server nằm tại /media/Movies và /media/Shows, còn Sonarr và Radarr ghi vào chính các thư mục đó thông qua /data/media. Cấp cho media server cùng PUID và PGID để nó có thể đọc những file do arr stack tạo.

Bài viết này có hữu ích không?

#sonarr#radarr#prowlarr#docker-compose#tự lưu trữ