Cấu hình Docker Compose cho bộ stack arr trên VPS
Hướng dẫn thiết lập Prowlarr, Sonarr, Radarr và qBittorrent trong một file Docker Compose duy nhất. Cấu hình volume chuẩn để hỗ trợ hardlink và tránh lỗi quyền truy cập PUID PGID.
Bạn sẽ xây dựng những gì
Một stack Docker Compose arr gồm bốn container quản lý thư viện đa phương tiện: Prowlarr để thiết lập indexer, Sonarr cho phim bộ, Radarr cho phim lẻ và qBittorrent làm client tải xuống. Các container này giao tiếp với nhau qua mạng Compose bằng tên service và chia sẻ chung một cấu trúc thư mục trên host. Việc cài đặt rất nhanh chóng. Phần quyết định stack hoạt động ổn định trong nhiều năm hay gây lỗi hàng tuần chính là cách bố trí volume, vì vậy phần lớn hướng dẫn này tập trung vào vấn đề đó.
Stack này không tự tìm kiếm nội dung cho bạn. Prowlarr lưu trữ bất kỳ indexer nào bạn thêm vào, và việc bạn sử dụng indexer nào là quyết định cũng như trách nhiệm pháp lý của bạn. Hướng dẫn này bao gồm các phần kỹ thuật hạ tầng: người dùng, đường dẫn, quyền truy cập, mạng container và các bước kiểm tra để xác nhận hệ thống đang hoạt động.
Nếu bạn chưa từng viết file Compose, hãy đọc kiến thức cơ bản về Docker Compose cho VPS trước. Bài viết này giả định rằng docker compose version đã hiển thị kết quả trên server của bạn.
Tại sao hardlink bị lỗi và tại sao đây là vấn đề cốt lõi
Khi Sonarr hoàn tất quá trình tải xuống, nó sẽ nhập tệp vào thư viện của bạn. Nếu thư mục tải xuống và thư mục thư viện nằm trên cùng một filesystem, quá trình nhập sẽ là một hardlink: một tên thứ hai trỏ đến cùng một dữ liệu trên ổ đĩa. Nó không tốn thêm dung lượng và không mất thời gian. Torrent vẫn tiếp tục seeding từ tên cũ trong khi media server của bạn đọc từ 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 liên kết đó. Sonarr sẽ chuyển sang chế độ copy. Một season 40 GB giờ đây chiếm 80 GB dung lượng đĩa và mất vài phút để thực hiện input/output, và log nhập tệp sẽ ghi lại rằng hardlink đã thất bại và tệp đã được copy thay thế. Trên một VPS với dung lượng đĩa cố định, đây là cách khiến người dùng hết dung lượng chỉ trong một tuần.
Đây là cái bẫy. Bên trong một container, bind mount là một ranh giới filesystem. Mount /mnt/data/torrents thành /downloads và /mnt/data/media thành /tv, và mặc dù cả hai đều nằm trên một ổ đĩa host, Sonarr vẫn thấy hai mount riêng biệt và từ chối liên kết giữa chúng. Tài liệu của image LinuxServer.io chính thức đã nêu rõ điều này: việc sử dụng các đường dẫn /downloads và /tv riêng biệt sẽ làm mất khả năng tạo hardlink.
Giải pháp là sử dụng một mount duy nhất. Mọi container truy cập vào media đều nhận cùng một volume đơn lẻ, /mnt/data:/data, và mọi đường dẫn chúng sử dụng đều là một thư mục bên trong đó. Một điểm mount, một filesystem, hardlink hoạt động bình thường.
Tạo người dùng, nhóm và các thư mục
Các container ghi tệp dưới dạng một ID người dùng dạng số, được thiết lập bởi PUID và PGID. Hãy sử dụng tài khoản của chính bạn để bạn có thể đọc và chỉnh sửa các tệp đó qua SSH mà không cần sudo.
id -u
id -gCả hai thường hiển thị 1000 trên một VPS Ubuntu mới. Bây giờ hãy xây dựng cấu trúc thư mục. Hãy đặt nó trên bất kỳ ổ đĩa nào chứa dữ liệu của bạn và giữ toàn bộ cấu trúc đó trên cùng một ổ đĩa đó.
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/dataHãy kiểm tra xem đó có thực sự là một hệ thống tệp duy nhất hay không trước khi bạn tiếp tục:
df --output=source,target /mnt/data/torrents /mnt/data/mediaCả hai dòng phải hiển thị cùng một thiết bị nguồn. Hai thiết bị khác nhau đồng nghĩa với việc hardlink sẽ không bao giờ hoạt động, bất kể bạn thiết lập gì trong cấu hình container.
Các thư mục thư viện được đặt tên là Movies và Shows một cách có chủ đích. Nếu bạn đã chạy Jellyfin làm máy chủ media, hãy mount /mnt/data/media vào Jellyfin dưới dạng /media và các thư viện của nó sẽ nằm tại /media/Movies và /media/Shows, chính xác tại nơi mà hướng dẫn đó đặt chúng.
Tệp môi trường
Lưu các giá trị thay đổi theo từng server vào .env, đặt cạnh tệp Compose.
mkdir -p ~/arr && cd ~/arrViết ~/arr/.env:
PUID=1000
PGID=1000
TZ=Etc/UTC
DATA_ROOT=/mnt/dataThiết lập TZ theo múi giờ của bạn, ví dụ như Europe/Berlin. Các ứng dụng arr lập lịch tác vụ và đóng dấu thời gian cho các dòng log theo múi giờ đó, vì vậy giá trị sai sẽ gây khó hiểu cho mọi log sau này.
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-stoppedCó bốn thành phần trong tệp đó thực hiện công việc thực tế.
${DATA_ROOT}:/data giống hệt nhau trong ba container truy cập vào media. Prowlarr không có nó, vì Prowlarr không bao giờ mở tệp media.
Mọi cổng web đều được bind vào 127.0.0.1, vì vậy Docker chỉ publish nó trên địa chỉ loopback. Một 8989:8989 đơn thuần sẽ publish nó trên mọi interface, và các quy tắc firewall của chính Docker sẽ chuyển traffic đó đi thẳng qua quy tắc deny của ufw. Hành vi đó luôn gây bất ngờ cho người dùng, và nó được giải thích trong tại sao Docker publish cổng trực tiếp qua ufw.
Cổng 6881 được publish trên tất cả các interface một cách có chủ đích. Đó là cổng lắng nghe torrent, và nó phải có thể truy cập được cho các kết nối peer đến. Hãy cho phép nó bằng sudo ufw allow 6881, và đọc các kiến thức cơ bản về firewall ufw cho VPS nếu lệnh đó còn mới với bạn.
Các thư mục cấu hình được tách biệt cho từng ứng dụng, và chỉ có volume media là được chia sẻ. Hãy tạo chúng trước lần khởi chạy đầu tiên để chúng thuộc sở hữu của 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 psCả bốn dịch vụ đề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 sát bản phát hành ổn định hiện tại, vì vậy hãy ghim một tag phiên bản nếu bạn muốn việc nâng cấp là một quyết định chủ động thay vì một sự bất ngờ.
Truy cập giao diện web một cách an toàn
Vì các port đang nằm trên loopback, chưa có gì bị lộ ra ngoài. Hãy chuyển tiếp 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-serverBây giờ http://127.0.0.1:8989 trong trình duyệt của bạn sẽ truy cập được Sonarr trên server. Để truy cập vĩnh viễn, 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 một VPN WireGuard do chính bạn lưu trữ. Không ứng dụng nào trong số này nên nằm trên internet công cộng mà chỉ có trang đăng nhập của riêng chúng bảo vệ.
qBittorrent tạo một mật khẩu quản trị ngẫu nhiên trong lần khởi động đầu tiên và in nó vào log của container. Hãy đọc nó, sau đó thay đổi trong giao diện web:
docker compose logs qbittorrent | grep -i passwordNếu bạn bỏ qua việc thay đổi, một mật khẩu ngẫu nhiên mới sẽ được tạo mỗi khi khởi động lại, và bạn sẽ phải quay lại kiểm tra log mỗi lần.
Thiết lập đường dẫn bên trong mỗi ứng dụng
Trong qBittorrent, mở Options, sau đó chọn Downloads và thiết lập đường dẫn lưu mặc định thành /data/torrents. Hãy giữ thư mục incomplete-downloads bên trong cùng cây thư mục đó, ví dụ như /data/torrents/incomplete. Một tệp tải xuống hoàn tất ở bất kỳ đâu bên ngoài /data sẽ không thể được hardlink vào thư viện.
Trong Sonarr, mở Settings, sau đó chọn Media Management và thêm thư mục gốc /data/media/Shows. Trong Radarr, thư mục gốc 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 dưới góc nhìn của container.
Trong cả Sonarr và Radarr, mở Settings, sau đó chọn Download Clients và thêm qBittorrent. Host là qbittorrent và port là 8080. Tên dịch vụ hoạt động như một hostname vì Compose đặt cả bốn container trên cùng một mạng với dịch vụ DNS (domain name system) nội bộ. Đừng sử dụng localhost ở đây: bên trong container Sonarr, localhost chính là Sonarr.
Để trống phần Remote Path Mappings. Tính năng này tồn tại để chuyển đổi đường dẫn mà client tải xuống báo cáo thành đường dẫn mà ứng dụng arr có thể nhìn thấy. Với một mount /data được chia sẻ, cả hai container đã thống nhất về mọi đường dẫn, đây là lý do thứ hai khiến cấu trúc 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 các ứng dụng khác, vì vậy bạn chỉ cần cấu hình indexer một lần thay vì hai lần. Nó cần một API (application programming interface) key từ mỗi ứng dụng.
Trong Sonarr, mở Settings, sau đó chọn General và sao chép API key. Trong Prowlarr, mở Settings, sau đó chọn Apps, thêm một ứng dụng Sonarr và điền vào ba 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 lá cây nghĩa là Prowlarr đã kết nối được với Sonarr thông qua mạng Compose. Lặp lại với Radarr tại http://radarr:7878.
Kết quả màu đỏ thông báo kết nối bị từ chối hầu như luôn có nghĩa là sai tên dịch vụ hoặc thiếu tiền tố http://. Xác nhận tên dịch vụ 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:8989Mã trạng thái HTTP chứng minh đường dẫn mạng vẫn ổn. Lỗi phân giải tên chứng minh tên dịch vụ bị sai.
Chứng minh hardlink đang thực sự hoạt động
Đừng tin vào cấu hình cho đến khi bạn thấy số lượng link. 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/*/*/*.mkvSố đầu tiên là inode và số thứ hai là số lượng link. Một file được hardlink sẽ hiển thị cùng một inode ở cả hai vị trí và có số lượng link là 2. Hai inode khác nhau, mỗi cái có số lượng link là 1, nghĩa là Sonarr đã copy file đó và log import sẽ báo rằng hardlink thất bại.
Hãy theo dõi cả ổ đĩa nữa. df -h /mnt/data gần như không thay đổi khi quá trình import diễn ra, vì hardlink chỉ thêm một tên mới chứ không sao chép dữ liệu.
Những lỗi thực tế thường gặp
Lỗi quyền truy cập khi import nghĩa là user id của container không thể ghi vào thư mục thư viện. Thông báo lỗi là Access to the path ... is denied. Hãy kiểm tra bằng ls -ln /mnt/data/media để đảm bảo owner id khớp với PUID của bạn, và lưu ý rằng các thư mục cần có bit thực thi (execute bit) thì container mới có thể truy cập vào được.
Các tệp tin hiển thị quyền sở hữu là root 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 nó dưới quyền root. Hãy dừng stack, chown thư mục đó, rồi khởi động lại.
Việc xóa một torrent khỏi qBittorrent và thấy tệp tin trong thư viện biến mất nghĩa là quá trình import trước đó là một bản copy và sau đó đã bị xóa, hoặc bạn đã xóa dữ liệu thay vì chỉ xóa mục torrent. Với hardlink thực thụ, việc xóa một tên tệp sẽ không ảnh hưởng đến tên còn lại, vì dữ liệu chỉ được giải phóng khi số lượng liên kết (link count) về bằng 0.
Ổ đĩa đầy nhanh hơn dung lượng media bạn đã thêm vào là vấn đề copy ở mức độ tốn kém nhất. Hãy chạy kiểm tra stat như trên trước khi bạn mua thêm dung lượng lưu trữ.
Yêu cầu của stack này đối với VPS
Ba ứng dụng arr rất nhẹ. Chúng thực hiện poll các indexer, ghi vào một cơ sở dữ liệu SQLite nhỏ và đổi tên tệp. Một máy chủ với 2 GB RAM chạy thoải mái cả bốn container. Tải hệ thống đến từ các nguồn khác. Một download client sẽ làm bão hòa input và output của ổ đĩa khi tải các torrent lớn, và một media server thực hiện transcoding video trên cùng máy chủ sẽ chiếm dụng CPU. Hãy lưu trữ media trên một volume có throughput thực tế, và thiết lập giới hạn băng thông trên download client nếu máy chủ đang thực hiện bất kỳ tác vụ nào khác mà bạn quan tâm.
FAQ
Tại sao Sonarr sao chép tệp thay vì tạo hardlink?
Vì nguồn và đích nằm trên các filesystem khác nhau dưới góc nhìn của container. Hai bind mount riêng biệt, ví dụ như /downloads và /tv, được coi là hai filesystem ngay cả khi chúng cùng nằm trên một ổ đĩa host. Hãy mount một thư mục cha duy nhất làm /data vào mọi container, đặt thư mục tải xuống và thư viện bên trong đó, khi đó việc tạo link mới khả thi. Xác nhận kết quả bằng lệnh stat -c '%i %h %n' trên cả hai tệp: chúng phải có cùng inode và số lượng link là 2.
Tôi nên dùng PUID và PGID nào?
Hãy sử dụng ID số của tài khoản host đang sở hữu cây thư mục media, bạn có thể lấy thông tin này từ id -u và id -g. Trên một VPS Ubuntu mới cài đặt, giá trị này thường là 1000 cho cả hai. Mọi container trong stack phải sử dụng cùng một cặp giá trị, nếu không một ứng dụng sẽ ghi tệp mà ứng dụng khác không thể sửa đổi. Sau khi thay đổi các giá trị này, hãy tạo lại các container bằng docker compose up -d --force-recreate và sửa quyền cho các tệp hiện có bằng chown -R.
Tôi có cần mở các giao diện web này ra internet không?
Không, và bạn không nên làm vậy. Hãy bind mỗi port được publish vào 127.0.0.1 trong tệp Compose, sau đó truy cập các giao diện này thông qua SSH tunnel, VPN hoặc reverse proxy có khả năng kết thúc TLS (transport layer security) và thêm lớp xác thực riêng. Việc publish trực tiếp các port này nguy hiểm hơn bạn tưởng, vì Docker tự chèn các quy tắc firewall của riêng nó và quy tắc ufw deny sẽ không chặn được lưu lượng đó.
Tôi tìm mật khẩu qBittorrent ở đâu?
Image của LinuxServer.io sẽ in mật khẩu tạm thời cho người dùng admin trong log khởi động. Chạy docker compose logs qbittorrent | grep -i password để đọc mật khẩu đó, sau đó thiết lập mật khẩu vĩnh viễn trong phần Options và Web UI. Một mật khẩu tạm thời mới sẽ được tạo mỗi khi khởi động lại cho đến khi bạn tự thiết lập mật khẩu của riêng mình.
Jellyfin có thể sử dụng cùng các thư mục đó không?
Có, đó chính là mục đích của cấu trúc này. Mount /mnt/data/media vào media server của bạn dưới dạng /media, khi đó các thư viện của nó sẽ nằm tại /media/Movies và /media/Shows trong khi Sonarr và Radarr ghi vào cùng các thư mục đó thông qua /data/media. Hãy cấp cho media server cùng PUID và PGID để nó có thể đọc được những gì arr stack đã ghi.