SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-28

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 sẽ xây dựng gì

Một stack arr 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 giao tiếp với nhau qua mạng Compose bằng service name và dùng chung một cây thư mục trên host. Phần 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, nên phần lớn nội dung của guide này tập trung vào việc đó.

Stack này không tự tìm content cho bạn. Prowlarr lưu các indexer mà 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, trước tiên hãy đọc phần cơ bản về Docker Compose cho VPS. Bài này giả định docker compose version đã in ra kết quả trên server của bạn.

Khi Sonarr xử lý xong một file đã download, nó import file đó vào thư viện. Nếu thư mục download và thư mục thư viện nằm trên cùng filesystem, thao tác import sẽ tạo một 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 mất thời gian. Torrent tiếp tục seeding từ tên cũ, trong khi media server đọc file bằng 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 giữa chúng. Sonarr sẽ chuyển sang copy file. 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 đọc ghi. Log import cũng ghi nhận hardlink thất bại và file đã được copy thay thế. 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 lỗi. Bên trong container, bind mount tạo ra một ranh giới filesystem. Mount /mnt/data/torrents vào /downloads/mnt/data/media vào /tv. Dù cả hai thư mục nằm trên cùng một disk của host, Sonarr vẫn thấy chúng là hai mount riêng biệt và từ chối tạo link 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/tv sẽ làm mất khả năng tạo hardlink.

Cách sửa là chỉ 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. Mọi path mà chúng sử dụng đều phải là thư mục nằm 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 PUIDPGID 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à MoviesShows 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/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

4 thành phần trong tệp này thực sự có tác dụng.

${DATA_ROOT}:/data giống hệt nhau trong 3 container có truy cập media. Prowlarr không cần nó 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 rule firewall riêng 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 khiến người dùng bất ngờ. Xem giải thích tại lý do Docker publish port vượt qua ufw.

Port 6881 được publish trên mọi interface một cách có chủ đích. Đây là port lắng nghe torrent và phải có thể truy cập để nhận kết nối từ peer. Cho phép port này bằng sudo ufw allow 6881. Nếu chưa quen với command đó, hãy đọc kiến thức cơ bản về firewall ufw cho VPS.

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 chúng 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ả 4 service đều nên đọc running. Tính đến tháng 7 năm 2026, các image này được publish trên lscr.io và tag latest theo bản stable hiện tại. Nếu muốn việc upgrade là một quyết định có chủ đích thay vì một bất ngờ, hãy pin một version tag cụ thể.

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

Vì các cổng chỉ bind trên loopback nên hiện chưa có gì được public. 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 browser sẽ truy cập Sonarr trên server. Để truy cập lâu dài, đặt stack phía sau Traefik với certificate TLS cho nhiều app, hoặc truy cập server qua WireGuard VPN do bạn tự host. Không ứng dụng nào trong số này nên nằm trực tiếp trên public Internet và chỉ được bảo vệ bằng trang đăng nhập riêng của ứng dụng. Nếu chọn reverse proxy và muốn dùng một account cho cả 4 giao diện thay vì phải quản lý 4 lần đăng nhập riêng, Authentik cung cấp single sign-on tự host để Traefik thực thi xác thực này trên mọi request bằng forward auth.

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. Đọc mật khẩu, sau đó đổi mật khẩu trong web interface:

docker compose logs qbittorrent | grep -i password

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

Đặ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 chứa các file tải chưa hoàn tất trong cùng cây thư mục, chẳng hạn /data/torrents/incomplete. Một file tải xong ở bất kỳ vị trí nào bên ngoài /data sẽ không thể được tạo hard link vào thư viện.

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. Đường dẫn trên host /mnt/data/media/Shows sẽ 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 trên cùng một network có service DNS nội bộ (domain name system). Không dùng localhost ở đây: bên trong container Sonarr, localhost là Sonarr.

Để trống Remote Path Mappings. 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à ứng dụng arr có thể truy cập. Khi dùng chung một mount /data, cả 2 container đã dùng cùng một hệ thống đường dẫn. Đây là lý do thứ 2 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 các định nghĩa indexer vào những ứ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 một ứ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 này với Radarr tại http://radarr:7878.

Kết quả màu đỏ với thông báo connection was refused hầu như luôn có nghĩa là sai tên service hoặc thiếu tiền tố http://. Xác nhận tên này phân giải được 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 truyền mạng hoạt động bình thường. Lỗi name resolution chứng minh tên service bị sai.

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

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, số thứ hai là link count. File được tạo hardlink sẽ có cùng inode ở cả hai vị trí và link count là 2. Nếu có hai inode khác nhau, mỗi inode có link count là 1, Sonarr đã copy file; log import sẽ cho biết hardlink thất bại.

Đồng thời, hãy theo dõi dung lượng đĩa. df -h /mnt/data hầu như không thay đổi khi import, vì hardlink chỉ thêm một tên 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. Dùng ls -ln /mnt/data/media để kiểm tra owner id có khớp với PUID của bạn không. Lưu ý rằng thư mục phải có quyền execute trước khi container có thể truy cập vào đó.

Các file hiển thị owner là root có nghĩa là container đã khởi động trước khi thư mục trên host tồn tại, nên Docker đã tạo thư mục đó với quyền root. Dừng stack, chown thư mục rồi khởi động lại.

Nếu xóa một torrent khỏi qBittorrent và thấy file trong library cũng biến mất, import có thể đã tạo một bản copy rồi xóa bản đó sau đó. Cũng có thể bạn đã xóa data thay vì chỉ xóa torrent entry. Với hardlink thực sự, xóa một tên vẫn giữ nguyên tên còn lại vì data chỉ được giải phóng khi link count giảm về 0.

Nếu disk đầy nhanh hơn nhiều so với lượng media bạn đã thêm, đây là trường hợp copy gây tốn dung lượng nhất. 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 poll indexer, ghi vào một database SQLite nhỏ và đổi tên file. Một server có 2 GB RAM có thể chạy thoải mái cả 4 container. Tải lớn thường đến từ nơi khác. Download client có thể làm bão hòa I/O của disk khi xử lý torrent lớn, còn media server transcoding video trên cùng máy sẽ chiếm CPU. Hãy đặt media trên volume có throughput thực tế tốt, đồng thời giới hạn bandwidth của download client nếu server còn phải chạy những tác vụ khác mà bạn quan tâm. Hãy dự trù riêng cho các dịch vụ đó, thay vì mặc định rằng server vẫn còn đủ headroom: một workspace AFFiNE tự host là thêm 4 container cùng một database phía sau, và trên máy 2 GB, nó có thể chiếm phần lớn memory. Không phải dịch vụ bổ sung nào cũng tốn nhiều tài nguyên như vậy: một dịch vụ đơn nhiệm như một trình theo dõi bài tập openGym tự host có thể chạy chung trên máy mà không gặp vấn đề, miễn là bạn cấp TLS riêng cho nó và biết file database nằm ở đâu trước khi tin cậy nó lưu cả một năm lịch sử tập luyện. Bất kỳ hệ thống nào gồm web application, database Postgres và background worker queue đều gần với mức tiêu thụ của AFFiNE hơn, vì vậy hãy quyết định một help desk hỗ trợ Chatwoot tự host nên chạy trên server này hay trên máy riêng trước khi bạn phát hiện giới hạn giữa chừng lúc 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 chứ không phải mức trung bình sẽ va chạm với quá trình import: nếu bạn đang cân nhắc một OneCLI tự host cấp sandbox riêng cho agent của từng người, hãy đối chiếu các thông số sizing do dự án công bố với lượng tài nguyên thực sự còn trống khi qBittorrent đang chạy hết công suất, không phải với số liệu mà free -h hiển thị trên một máy đang idle.

FAQ

Vì theo góc nhìn của container, source và destination nằm trên các filesystem khác nhau. Hai bind mount riêng biệt, chẳng hạn /downloads/tv, được xem là hai filesystem ngay cả khi cả hai đều nằm trên một disk của host. Hãy mount cùng một thư mục cha vào mọi container tại /data, rồi đặt thư mục download và thư viện bên trong đó. Khi đó có thể tạo link. Xác nhận kết quả bằng stat -c '%i %h %n' trên cả hai file: chúng có cùng inode và số link là 2.

Tôi nên dùng PUID và PGID nào?

Dùng numeric ID 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 -uid -g. Trên Ubuntu VPS mới, cả hai 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ể ghi 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 của các file hiện có bằng chown -R.

Tôi 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 mỗi port được publish vào 127.0.0.1 trong file Compose. Sau đó truy cập các interface thông qua SSH tunnel, VPN hoặc reverse proxy có TLS termination (bảo mật tầng truyền tải) và cơ chế xác thực riêng. Publish trực tiếp nguy hiểm hơn bạn nghĩ, vì Docker tự chèn các firewall rule riêng. Một rule deny của ufw sẽ không chặn được lưu lượng đó.

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

Image LinuxServer.io ghi password tạm thời của 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, hệ thống sẽ tạo password tạm thời mới cho đến khi bạn đặt password riêng.

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

Có, và đó chính là mục đích của layout này. Mount /mnt/data/media vào media server tại /media. Khi đó các thư viện của nó nằm ở /media/Movies/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 PUIDPGID để nó có thể đọc các file do arr stack ghi.

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