Todo homelab tem aquele serviço que a família usa de verdade. No meu é o mediacenter: um servidor com Jellyfin e a turma dos *arr (Radarr, Sonarr, Lidarr, Prowlarr, Bazarr), organizando filmes, séries e música sozinho.
A primeira versão rodava em Docker Compose, e ela continua no GitHub em rmmarconi/rmediacenter. Hoje a máquina roda tudo nativamente no Debian 13, com pacotes, releases oficiais e units do systemd, em cima de um volume LVM que junta três discos. Este post conta as duas coisas: as decisões de projeto que valem para qualquer uma das versões e o que aprendi escrevendo um instalador nativo que dá para rodar de novo sem medo.
Antes de tudo: este post fala de configuração de software. BitTorrent é um protocolo com muitos usos legítimos (distribuições Linux, conteúdo em domínio público, seus próprios arquivos). O que você baixa, e se tem o direito de baixar, é responsabilidade sua e depende da lei do seu país. Nada aqui traz mídia, indexadores ou trackers.
O que roda na máquina
| Serviço | Para que serve | Como é instalado |
|---|---|---|
| Jellyfin | O servidor de mídia: a “Netflix de casa” | Repositório APT oficial, com jellyfin-ffmpeg7 |
| qBittorrent | Cliente de download | qbittorrent-nox, direto do Debian |
| Radarr / Sonarr / Lidarr | Automação de filmes, séries e música | Tarball oficial self-contained (.NET) |
| Prowlarr | Gerencia os indexadores e distribui para os outros | Tarball oficial |
| Bazarr | Legendas automáticas | Zip da release num virtualenv Python próprio |
| Jellyseerr / Seerr | Portal de pedidos para a família | Compilado do código-fonte, com Node 22 e pnpm |
| FlareSolverr | Resolve o desafio do Cloudflare para indexadores | Bundle oficial com Chromium, ouvindo só em 127.0.0.1 |
O proxy reverso (Caddy) roda em outra máquina, e o mediacenter só serve as aplicações.
Por que saí do Docker
A versão em containers funciona bem, e continua sendo a que eu recomendo para quem está começando. Mas, para uma máquina dedicada só a isso, o Debian facilita:
- o Jellyfin mantém um repositório APT oficial, com o próprio ffmpeg, e o
qbittorrent-noxestá no arquivo base do Debian. Nada precisa ser compilado, com exceção do Jellyseerr; - a transcodificação por hardware não depende de passar
/dev/dripara dentro de container nem de acertar o GID do gruporender; - logs, restart e dependências ficam todos no systemd e no
journalctl, como qualquer outro serviço do sistema; - o isolamento continua existindo, só que feito pelo próprio systemd (veja mais abaixo).
O preço é ter que escrever um instalador decente. É dele que trata metade deste post.
A decisão que a maioria dos guias erra: um ponto de montagem só
Esta vale para Docker e para instalação nativa. Downloads e biblioteca ficam no mesmo sistema de arquivos, como subpastas de /data:
/data/ um sistema de arquivos só
├── torrents/ o qBittorrent escreve aqui
│ ├── movies/ tv/ music/ incomplete/
└── media/ o Jellyfin lê aqui
├── movies/ tv/ music/
Quando o Radarr importa um filme, ele cria um hardlink de /data/torrents/movies/... para /data/media/movies/...: dois nomes para o mesmo arquivo no disco. O torrent continua semeando, a biblioteca fica organizada, e o filme de 40 GB ocupa 40 GB, não 80. A importação é instantânea, porque mover dentro do mesmo sistema de arquivos é só trocar metadados.
A pegadinha: hardlink não atravessa sistema de arquivos. Se torrents e media estiverem em discos, partições ou volumes diferentes, os *arr passam a copiar em silêncio. Nenhum erro aparece, só o disco enchendo. A referência para tudo isso é o TRaSH Guides.
E quando você tem três discos de tamanhos diferentes?
Era o meu caso: um de 500 GB, um de 1 TB e um de 2 TB. Montar cada um numa pasta quebraria os hardlinks. A saída foi o LVM: os três discos viram physical volumes de um mesmo volume group, e um único logical volume linear de 3,2 TB vira o /data, com um ext4 só. O sistema operacional fica num NVMe à parte.
NAME MAJ:MIN SIZE TYPE MOUNTPOINTS
sda 8:0 465,8G disk
└─sda1 8:1 465,8G part
└─data_vg-data_lv 254:0 3,2T lvm /data
sdb 8:16 931,5G disk
└─sdb1 8:17 931,5G part
└─data_vg-data_lv 254:0 3,2T lvm /data
sdc 8:32 1,8T disk
└─sdc1 8:33 1,8T part
└─data_vg-data_lv 254:0 3,2T lvm /data
nvme0n1 259:0 238,5G disk
├─nvme0n1p1 259:1 976M part /boot/efi
└─nvme0n1p2 259:2 105,1G part /
A receita, para discos novos e vazios (os comandos apagam o que houver neles):
sudo pvcreate /dev/sda1 /dev/sdb1 /dev/sdc1
sudo vgcreate data_vg /dev/sda1 /dev/sdb1 /dev/sdc1
sudo lvcreate -l 100%FREE -n data_lv data_vg
sudo mkfs.ext4 -m 0 -L data /dev/data_vg/data_lv # -m 0: sem reserva de root num disco só de mídia
echo 'LABEL=data /data ext4 defaults,noatime 0 2' | sudo tee -a /etc/fstab
sudo mkdir -p /data && sudo mount /data
O bônus é que crescer é trivial: chegou um disco novo, basta um vgextend e um lvextend -r, e o /data aumenta sem desmontar nada.
O trade-off, sem enfeite: um LV linear não tem redundância nenhuma. Se qualquer um dos três discos morrer, o sistema de arquivos inteiro fica comprometido, e não só a parte que estava naquele disco. Para mídia que dá para recuperar, eu aceito o risco. O que não dá para perder (bancos de dados, configurações, histórico) fica fora do /data, no NVMe, e tem backup. Se a sua mídia for insubstituível, use RAID (mdadm, ZFS) em vez de LVM linear.
Um usuário só, e o setgid fazendo o papel do PUID/PGID
Nas imagens da LinuxServer.io, a mágica que evita “permission denied” é todo mundo usar o mesmo PUID/PGID com UMASK=002. Na instalação nativa, reproduzi o mesmo contrato:
- um usuário de sistema
mediacenter, sem shell, é dono de tudo e roda todos os serviços; - cada unit do systemd usa
UMask=0002, então os arquivos saem com664e os diretórios com775; - os diretórios de
/datatêm o bit setgid (chmod 2775), para que tudo o que for criado ali herde o grupomediacenter, não importa quem criou; - o Jellyfin, que o pacote instala com usuário próprio, entra no grupo
mediacenter. Ele só precisa ler a biblioteca.
O estado das aplicações fica em /var/lib/mediacenter, no NVMe, e os binários em /opt/mediacenter. O backup que importa é o do primeiro.
Isolamento sem container: o systemd faz o trabalho
Sair do Docker não significa rodar tudo solto. Cada serviço ganha uma unit gerada pelo instalador, com o hardening que o systemd oferece de graça:
[Service]
Type=simple
User=mediacenter
Group=mediacenter
UMask=0002
ExecStart=/opt/mediacenter/Radarr/Radarr -nobrowser -data=/var/lib/mediacenter/radarr
Restart=on-failure
NoNewPrivileges=true # nada de sudo/setuid a partir daqui
PrivateTmp=true # /tmp próprio
ProtectHome=true # /home invisível
ProtectSystem=full # /usr, /boot e /etc somente leitura
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
ReadWritePaths=/data /var/lib/mediacenter # só isso é gravável
Se um dos apps for comprometido, ele consegue estragar a mídia e o próprio estado, mas não o sistema. Para conferir o quanto cada serviço está exposto, o systemd dá uma nota: systemd-analyze security radarr.
Um instalador que dá para rodar de novo
O install.sh instala tudo, mas a ideia principal é que ele seja idempotente: rodar de novo é o jeito de atualizar. Algumas escolhas que fizeram diferença:
- Versões resolvidas na hora. A tag mais recente de cada projeto vem de
git ls-remote --tags, e a URL do binário vem da API de releases do GitHub. A versão instalada fica gravada em/opt/mediacenter/.versions/. Se já estiver na última versão, o componente é pulado. - Cada componente isolado. Se o Jellyseerr falhar na compilação, o Bazarr ainda é instalado. No fim, sai um resumo com OK, WARN ou FAIL por componente.
- Testa antes de instalar. Confere se
torrents/emedia/estão no mesmo sistema de arquivos (stat -c %d), faz um hardlink de verdade comlne avisa se o/datanão é um ponto de montagem próprio, o que significaria mídia enchendo o disco do sistema. - Modos seguros:
--dry-runresolve as versões sem mudar nada, e--only radarrmexe num componente só.
As pegadinhas de shell script que apareceram no caminho
- O apt traduz a própria saída. Num sistema em pt_BR, o “Candidate:” do
apt-cache policyvira “Candidato:”, e qualquergrepnele quebra. Solução:export LC_ALL=Cno topo e, sempre que possível, olhar só o código de saída (apt-cache show pacote). - O Debian renomeia bibliotecas a cada release. O .NET precisa da libicu, que já foi
libicu72,74e76. Em vez de manter uma lista que envelhece, o script pergunta ao apt qual existe:apt-cache pkgnames libicu | grep -E '^libicu[0-9]+$' | sort -V | tail -1. O mesmo vale paralibasound2, que viroulibasound2t64. - Repositório que dá 404 quebra todo apt seguinte. Um Debian recém-lançado pode ainda não ter pacotes no repositório do Jellyfin. O script testa se a suite existe (
trixie, depoisbookworm) antes de adicioná-la, e remove o.sourcesse a atualização falhar. trap ... RETURNcomset -ué uma armadilha. O trap de RETURN também dispara quando a função que chamou retorna, e aí a variável local do diretório temporário já saiu de escopo. Comset -u, isso aborta o script inteiro. A limpeza agora é explícita, em cada caminho de saída.- Firewall: SSH primeiro, sempre. O script lê a porta do
sshd_confige libera o SSH antes doufw enable. Configurar firewall remotamente na ordem errada é um jeito clássico de perder o acesso ao servidor.
Proxy em outra máquina, e o firewall como porteiro
O Caddy, com HTTPS pela CA interna (tls internal), roda em outro host da rede. O instalador gera um Caddyfile.for-proxy já apontando para o IP do mediacenter, pronto para copiar para o proxy. Cada serviço vira https://<nome>.media.lan, e um wildcard *.media.lan no DNS local (no Pi-hole, por exemplo) resolve todos de uma vez.
jellyfin.media.lan {
import common
reverse_proxy 10.1.1.100:8096 {
flush_interval -1 # não bufferiza: é streaming de arquivo grande
}
}
Como o proxy está em outra máquina, os serviços precisam ouvir em 0.0.0.0, e quem limita a exposição é o firewall. Por isso existe o --proxy-ip: com ele, o ufw só aceita conexões nas portas das interfaces web vindas do proxy.
Confissão: escrevendo este post, conferi o meu próprio servidor e as portas 8096, 8080, 7878 e as outras estavam liberadas para “Anywhere”. Eu tinha rodado o instalador sem o --proxy-ip. A correção é rodar de novo com a flag. Isso não quebra nada, porque ele pula o que já está instalado.
A ordem da configuração importa
- qBittorrent: a senha temporária está em
journalctl -u qbittorrent. Configure para salvar em/data/torrents, crie as categoriasmovies,tvemusic, defina uma senha de verdade e um limite de seed com ação de pausar. Atrás do proxy, desmarque o Enable Host header validation. - Prowlarr: cadastre os indexadores uma vez e conecte o Radarr, o Sonarr e o Lidarr. Ele empurra os indexadores para os três. O FlareSolverr fica em
http://127.0.0.1:8191, porque roda na mesma máquina e só ouve localmente. - Radarr e Sonarr: pasta raiz em
/data/media/moviese/data/media/tv, com Use Hardlinks instead of Copy ligado. Para perfis de qualidade e nomes, siga o TRaSH Guides. - Lidarr: ligue o Rename Tracks, que vem desligado, e restrinja o perfil de qualidade, senão ele fica “melhorando” a biblioteca para sempre.
- Jellyfin: bibliotecas em
/data/media/*. - Jellyseerr: login com a conta do Jellyfin e conexão com o Radarr e o Sonarr. É o único serviço que faz sentido compartilhar com outras pessoas.
- Bazarr: idiomas (português, com inglês de fallback) e vários provedores. Nenhum path mapping é necessário, porque os caminhos são os mesmos para todos os serviços.
Depois da primeira importação, prove que o hardlink funcionou: o número de links (segunda coluna) tem que ser 2, com o mesmo inode nos dois caminhos.
ls -li /data/torrents/movies/*/*.mkv /data/media/movies/*/*.mkv
Troubleshooting: as que me pegaram
FlareSolverr reiniciando sem parar: “Could not find Xvfb”
O FlareSolverr usa um Chromium de verdade para passar pelo desafio do Cloudflare, e ele precisa de um display, mesmo num servidor sem monitor. No bundle nativo, esse display é o Xvfb (um X virtual), que não vem junto. O sintoma é o serviço em loop de activating com Failed to execute script 'flaresolverr' no journal. A correção é instalar o pacote:
sudo apt install -y xvfb
sudo systemctl restart flaresolverr
journalctl -u flaresolverr -n 20
qBittorrent respondendo só “Unauthorized”, sem tela de login
Esse texto é o corpo de um 403 numa chamada /api/v2/..., não a tela de login recusando a senha. Descubra de onde vem com curl -si http://127.0.0.1:8080/ | head -n 20:
- Se voltar
200 OKcom o HTML do login, o problema é o navegador: a interface é uma SPA, e uma cópia em cache mais um cookieSIDexpirado chamam a API com uma sessão morta. Teste numa janela anônima e depois limpe os dados do site. - Se voltar 403 também, o problema é o servidor. Até você definir a sua senha, o qBittorrent gera uma temporária nova a cada start. Se o log disser “banned”, as tentativas erradas bloquearam o seu IP por uma hora; um restart do serviço resolve.
Jellyseerr: compilado do código-fonte (e hoje, Seerr)
É o único componente compilado do código-fonte. Ele precisa de Node 22 (o Debian traz uma versão mais antiga, então o instalador usa o repositório da NodeSource), leva alguns minutos e usa algo em torno de 2 GB de RAM no pnpm build. Numa máquina com pouca memória, vale fechar o resto antes de rodar o --only jellyseerr. No meu servidor, o portal de pedidos hoje roda como seerr.service, o sucessor do Jellyseerr. Se você migrar também, desabilite a unit antiga para as duas não brigarem pela porta 5055.
Mais algumas, em uma linha cada
- Os *arr copiando em vez de linkar:
torrentsemediaestão em sistemas de arquivos diferentes. O teste do instalador existe exatamente para isso. - “Permission denied” na importação: algum arquivo ficou com outro dono. Rode
sudo chown -R mediacenter:mediacenter /datae confira o setgid dos diretórios. - Torrent parado em 0 B/s: a porta 6881 não está encaminhada no roteador.
- Jellyfin não vê arquivos novos: adicione uma conexão em Settings → Connect → Emby/Jellyfin no Radarr e no Sonarr.
No meu homelab
O mediacenter é uma máquina física separada do Proxmox, na VLAN de servidores. Por ser independente, ele ganhou um papel extra: é ele que recebe em tempo real os logs do Proxmox. E foi comparando os dois que descobri que os travamentos do Proxmox tinham a ver com a energia elétrica, porque as duas máquinas caíam no mesmo minuto. Essa história está no post sobre observabilidade do homelab.
Resumo
- Um sistema de arquivos só para downloads e biblioteca: hardlinks funcionam e as importações são instantâneas. Com vários discos, o LVM resolve, mas sem redundância.
- Um usuário só, com
umask 002e setgid, é o equivalente nativo do PUID/PGID dos containers. - O systemd isola bem:
ProtectSystem,NoNewPrivilegeseReadWritePathscustam três linhas. - Instalador idempotente, que testa antes de instalar e é rodado de novo para atualizar.
- Backup do estado, não da mídia, e nada exposto na internet sem VPN ou autenticação na frente.
A versão em Docker Compose, com o compose comentado, o Caddyfile e o bootstrap.sh, está em github.com/rmmarconi/rmediacenter.
Referências
- TRaSH Guides: hardlinks, estrutura de pastas e perfis de qualidade
- Servarr Wiki: documentação oficial do Radarr, Sonarr, Lidarr e Prowlarr
- Jellyfin: instalação em Linux
- systemd.exec: todas as opções de sandbox usadas nas units
- ArchWiki: LVM, a melhor explicação de LVM que conheço