Docker Compose 部署 Prowlarr、Sonarr、Radarr 和
用一个 Docker Compose 文件在 VPS 上运行 Prowlarr、Sonarr、Radarr 和 qBittorrent,统一 PUID、PGID 与卷布局,避免硬链接失效后复制文件。
构建内容
Docker Compose arr stack 由 4 个容器组成,用于管理媒体库:Prowlarr 管理索引器设置,Sonarr 管理剧集,Radarr 管理电影,qBittorrent 作为下载客户端。它们通过 Compose 网络中的服务名相互通信,并共享主机上的同一目录树。安装过程很短。决定该 stack 能否长期稳定运行、还是每周都需要处理问题的关键,是卷的布局,因此本指南的大部分内容都围绕这一点展开。
该 stack 不会替您查找内容。Prowlarr 中保存的是您添加的索引器,使用哪些索引器由您自行决定,相关法律责任也由您承担。本指南介绍基础配置:用户、路径、权限、容器网络,以及用于确认系统正常运行的检查方法。
如果您从未编写过 Compose 文件,请先阅读 VPS 的 Docker Compose 基础知识。本文章假定 docker compose version 已经能在您的服务器上输出内容。
硬链接为何会失效,以及这为何是关键
Sonarr 完成下载后,会将文件导入媒体库。如果下载目录和媒体库目录位于同一文件系统中,导入操作会创建硬链接:为磁盘上的同一份数据添加第二个名称。该操作不占用额外空间,也几乎不需要时间。Torrent 继续通过旧名称做种,媒体服务器则通过新名称读取文件。
如果两个目录位于不同文件系统中,内核无法创建该链接。Sonarr 会退回到复制操作。此时,一个 40 GB 的季度文件会占用 80 GB 磁盘空间,并产生数分钟的输入输出开销;导入日志也会记录硬链接失败,随后改为复制文件。在磁盘配额固定的 VPS 上,这可能导致一周内耗尽空间。
问题在于,容器内的 bind mount 是一道文件系统边界。将 /mnt/data/torrents 挂载为 /downloads,并将 /mnt/data/media 挂载为 /tv;即使它们位于同一台主机的同一块磁盘上,Sonarr 仍会将其视为两个独立挂载点,并拒绝跨挂载点创建硬链接。LinuxServer.io 的官方镜像文档明确说明了这一点:使用分开的 /downloads 和 /tv 路径会失去创建硬链接的能力。
解决方法是只使用一个挂载点。所有访问媒体文件的容器都使用同一个卷 /mnt/data:/data,并将它们使用的每个路径都设置为该卷中的目录。一个挂载点对应一个文件系统,硬链接即可正常工作。
创建用户、组和目录
容器会使用由 PUID 和 PGID 设置的数字用户 ID 写入文件。请使用您自己的账户,这样无需 sudo,即可通过 SSH 读取和编辑这些文件。
id -u
id -g在全新的 Ubuntu VPS 上,这两个命令通常都会输出 1000。现在创建目录树。将其放在存储媒体文件的磁盘上,并确保整个目录树位于同一块磁盘上。
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继续之前,确认这些目录确实位于同一文件系统中:
df --output=source,target /mnt/data/torrents /mnt/data/media两行必须显示相同的源设备。不同的设备意味着无论如何设置容器配置,硬链接都无法工作。
库目录特意命名为 Movies 和 Shows。如果您已经将 Jellyfin 用作媒体服务器,请将 /mnt/data/media 挂载到 Jellyfin 中的 /media,这样其媒体库就会位于 /media/Movies 和 /media/Shows,与该指南中的路径完全一致。
环境文件
将每台服务器上不同的值保存在 .env 中,并放在 Compose 文件旁边。
mkdir -p ~/arr && cd ~/arr写入 ~/arr/.env:
PUID=1000
PGID=1000
TZ=Etc/UTC
DATA_ROOT=/mnt/data将 TZ 设置为您自己的时区,例如 Europe/Berlin。arr 应用会按该时区调度任务,并为日志行添加时间戳。值错误会导致后续所有日志难以理解。
Compose 文件
编写 ~/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 项配置发挥了实际作用。
${DATA_ROOT}:/data 在访问媒体文件的 3 个容器中保持一致。Prowlarr 不需要该配置,因为 Prowlarr 从不打开媒体文件。
所有 Web 端口都绑定到 127.0.0.1,因此 Docker 只会在环回地址上发布这些端口。直接使用 8989:8989 会将端口发布到所有网络接口,Docker 自身的防火墙规则还会让流量直接绕过 ufw 的 deny 规则。这种行为经常令人困惑,详见为什么 Docker 会让端口直接绕过 ufw。
有意将端口 6881 发布到所有网络接口。这是 torrent 监听端口,必须能够接收传入的对等连接。使用 sudo ufw allow 6881 放行该端口。如果不熟悉此命令,请阅读VPS 的 ufw 防火墙基础。
每个应用都使用独立的配置目录,只有媒体卷是共享的。首次启动前创建这些目录,使其归当前用户所有,而不是归 root 所有:
mkdir -p ~/arr/config/prowlarr ~/arr/config/sonarr ~/arr/config/radarr ~/arr/config/qbittorrent
docker compose up -d
docker compose ps4 个服务都应读取 running。截至 2026 年 7 月,这些镜像发布在 lscr.io 上,latest 标签跟随当前稳定版本。如果希望升级由您主动决定,而不是意外发生,请改用固定的版本标签。
安全访问 Web 界面
由于这些端口只监听 loopback,目前还没有任何服务暴露到公网。请从您自己的计算机通过 SSH 转发这些端口:
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现在,您在浏览器中访问 http://127.0.0.1:8989,即可打开服务器上的 Sonarr。若要长期访问,请将整个服务栈置于带有多个应用 TLS 证书的 Traefik之后,或通过自行托管的 WireGuard VPN访问服务器。这些应用都不应只依靠自身的登录页面直接暴露在公网中。如果选择反向代理,并且希望四个界面共用一个账户,而不是分别管理四个应用的登录账户,Authentik 可提供自行托管的单点登录,Traefik 可通过 forward auth 对每个请求强制执行该认证。
qBittorrent 首次启动时会生成随机管理员密码,并将其输出到容器日志中。读取该密码,然后在 Web 界面中修改:
docker compose logs qbittorrent | grep -i password如果跳过修改,每次重启都会生成新的随机密码,您每次都必须重新查看日志。
设置每个应用中的路径
在 qBittorrent 中打开 Options,然后打开 Downloads,将默认保存路径设置为 /data/torrents。将未完成下载文件夹放在同一目录树中,例如 /data/torrents/incomplete。如果下载完成时位于 /data 之外,就无法为其在媒体库中创建硬链接。
在 Sonarr 中打开 Settings,然后打开 Media Management,并添加根文件夹 /data/media/Shows。在 Radarr 中,根文件夹为 /data/media/Movies。这些路径位于容器内部。主机路径 /mnt/data/media/Shows 会被拒绝,因为从容器的角度看,该目录不存在。
在 Sonarr 和 Radarr 中,打开 Settings,然后打开 Download Clients,并添加 qBittorrent。主机为 qbittorrent,端口为 8080。Compose 会通过内部 DNS(域名系统)服务将这4个容器连接到同一网络,因此可以使用服务名作为主机名。此处不要使用 localhost:在 Sonarr 容器中,localhost 指向 Sonarr。
将 Remote Path Mappings 留空。此功能用于将下载客户端报告的路径转换为 arr 应用可以访问的路径。两个容器共用同一个 /data 挂载后,它们已经对所有路径保持一致;这也是该布局值得采用的第二个原因。
将 Prowlarr 连接到 Sonarr 和 Radarr
Prowlarr 会将索引器定义推送到其他应用,因此只需配置一次索引器,不必重复配置。每个应用都需要提供一个 API(应用程序接口)密钥。
在 Sonarr 中打开 Settings,然后打开 General,复制 API key。在 Prowlarr 中打开 Settings,然后打开 Apps,添加 Sonarr 应用,并填写3个字段。Prowlarr Server 填写 http://prowlarr:9696。Sonarr Server 填写 http://sonarr:8989。API Key 填写刚才复制的值。点击 Test。绿色结果表示 Prowlarr 已通过 Compose 网络连接到 Sonarr。对 Radarr 重复上述操作,地址使用 http://radarr:7878。
如果红色结果显示连接被拒绝,几乎总是因为服务名称错误或缺少 http:// 前缀。确认可以在容器内解析该名称:
docker compose exec prowlarr curl -sS -o /dev/null -w '%{http_code}\n' http://sonarr:8989HTTP 状态码证明网络路径正常。名称解析错误证明服务名称不正确。
验证硬链接是否实际生效
在看到链接计数之前,不要认为配置已经生效。导入一个项目后,比较下载文件和媒体库文件:
stat -c '%i %h %n' /mnt/data/torrents/tv/*/*.mkv
stat -c '%i %h %n' /mnt/data/media/Shows/*/*/*.mkv第一个数字是 inode,第二个数字是链接计数。硬链接文件在两个位置应显示相同的 inode,链接计数为 2。如果两个 inode 不同,且各自的链接计数均为 1,则表示 Sonarr 复制了文件,导入日志会显示硬链接失败。
同时监控磁盘使用量。执行导入时,df -h /mnt/data 应基本不变,因为硬链接只会添加一个名称,不会复制数据。
实际会出现什么问题
导入时出现权限错误,表示容器的用户 ID 无法写入媒体库目录。错误信息是 Access to the path ... is denied。使用 ls -ln /mnt/data/media 检查所有者 ID 是否与 PUID 相匹配。还要注意,容器必须先拥有目录的执行权限,才能进入该目录。
如果文件显示由 root 所有,说明容器启动时宿主机目录尚不存在,因此 Docker 以 root 身份创建了该目录。停止整个服务栈,chown 该目录,然后重新启动。
从 qBittorrent 删除种子后,如果发现媒体库文件也消失,说明导入时执行的是复制,之后又删除了副本;或者你删除的是数据,而不是种子条目。使用真正的硬链接时,删除其中一个名称不会影响另一个名称,因为只有链接计数降为 0 时,数据才会被释放。
如果磁盘的增长速度超过你添加媒体的速度,说明复制问题已经造成了最严重的存储浪费。购买更多存储前,先运行上文的 stat 检查。
VPS 需要满足的条件
这 3 个 arr 应用都很轻量。它们会轮询索引器、写入小型 SQLite 数据库并重命名文件。一台配备 2 GB RAM 的服务器可以轻松运行全部 4 个容器。负载主要来自其他组件。下载客户端处理大型 torrent 时会占满磁盘输入输出;同一台服务器上的媒体服务器转码视频时则会占用 CPU。如果服务器还要运行其他重要服务,请将媒体文件放在具有实际吞吐能力的卷上,并为下载客户端设置带宽限制。应单独为这些其他服务预留资源,不要假设服务器一定有足够余量:自托管 AFFiNE 工作区还包括另外 4 个容器及其后端数据库,在 2 GB 服务器上,它会占用大部分内存。并非每个附加服务的资源开销都这么高:像自托管 openGym 训练记录器这样的单一用途服务可以与其他服务共享服务器,只要为它配置独立的 TLS,并在让它保存一整年的训练记录前确认数据库文件的位置。任何同时包含 Web 应用、Postgres 数据库和后台工作队列的服务,其资源需求都更接近 AFFiNE;因此,在导入任务运行到一半才发现资源上限之前,应先决定自托管 Chatwoot 客服台应该部署在这台服务器上,还是使用独立服务器。对于突发型工作负载,还需要更加谨慎,因为与导入任务发生资源冲突的是峰值负载,而不是平均负载:如果你正在考虑自托管 OneCLI,为每个人提供独立的沙箱化代理,请将其公布的配置需求与 qBittorrent 全速运行期间实际可用的资源进行比较,而不要与空闲服务器上 free -h 显示的数值比较。
FAQ
为什么 Sonarr 会复制文件,而不是创建硬链接?
因为从容器的角度看,源路径和目标路径位于不同的文件系统中。两个独立的 bind mount(例如 /downloads 和 /tv)会被视为两个文件系统,即使它们都来自宿主机上的同一块磁盘。请在每个容器中将同一个父目录挂载为 /data,并将下载目录和媒体库放在其中,这样才能创建链接。对两个文件运行 stat -c '%i %h %n' 进行确认:它们应具有相同的 inode,链接计数应为 2。
应使用哪些 PUID 和 PGID?
使用拥有媒体目录树的宿主机账户的数字 ID。可通过 id -u 和 id -g 获取该 ID。在全新的 Ubuntu VPS 上,两者通常都是 1000。堆栈中的每个容器都必须使用同一对值,否则一个应用创建的文件可能无法被另一个应用修改。修改这些值后,使用 docker compose up -d --force-recreate 重新创建容器,并使用 chown -R 修复现有文件的所有权。
是否需要将这些 Web 界面暴露到互联网?
不需要,也不应这样做。在 Compose 文件中,将每个发布的端口绑定到 127.0.0.1,然后通过 SSH 隧道、VPN 或反向代理访问这些界面。反向代理应终止 TLS(传输层安全协议)并添加自身的身份验证。直接发布端口的风险比看起来更高,因为 Docker 会自行插入防火墙规则,ufw 的 deny 规则无法阻止这类流量。
在哪里可以找到 qBittorrent 密码?
LinuxServer.io 镜像会在启动日志中为 admin 用户输出一个临时密码。运行 docker compose logs qbittorrent | grep -i password 读取该密码,然后在 Options 和 Web UI 中设置永久密码。在设置自定义密码之前,每次重启都会生成新的临时密码。
Jellyfin 可以使用相同的目录吗?
可以,这正是这种目录布局的目的。将 /mnt/data/media 挂载到媒体服务器中作为 /media。这样,其媒体库位于 /media/Movies 和 /media/Shows,而 Sonarr 和 Radarr 通过 /data/media 将文件写入相同目录。为媒体服务器设置相同的 PUID 和 PGID,使其能够读取 arr 堆栈写入的文件。