In the previous post, we covered the architecture and how all the pieces fit together. Now it’s time to get our hands dirty.
This post is the engine room of your automated media pipeline:
- qBittorrent: The muscle (downloads files).
- Jackett: The translator (standardizes search results).
- Radarr: The brain (orchestrates everything).
By the end, you’ll have a working pipeline that can search, download, and organize movies automatically.

Prerequisites
Before we start, make sure you have:
- A running Kubernetes cluster (in my case, K3s)
- kubectl configured
- A storage solution for app configs (we’ll use Longhorn)
- NFS storage for media files (or any ReadWriteMany storage)
- Basic understanding of Kubernetes concepts (Pods, Services, PVCs)
The Golden Rule: Shared Storage (don’t skip this)
The #1 cause of failure in *arr stacks is inconsistent Path Mapping. If Radarr and qBittorrent do not see the exact same files at the exact same internal mount paths, imports will fail or result in slow, space-wasting file copies across virtual drives.
The Goal: Atomic Moves (Instant Imports).
To achieve this, apps must share underlying storage and mount it identically.
The Strategy: We will use shared, ReadWriteMany (RWX) PVCs mounted at the same locations across containers:
/downloads: The staging area shared between qBittorrent (write) and Radarr (read/write)./movies: The final library destination shared between Radarr (write) and Jellyfin (read).
👉 Keep these paths consistent and you will save hours of debugging.
Project Structure

We will use Kustomize to keep manifests clean and modular. This structure makes debugging easy: if Radarr breaks, you know exactly which folder to check.
media-server/├── namespace.yaml├── storage/│ ├── nfs-downloads-pv-pvc.yaml│ ├── nfs-movies-pv-pvc.yaml├── qbitt/│ ├── kustomization.yaml│ ├── qbitt-config-pvc.yaml│ ├── qbitt-sts.yaml│ └── qbitt-svc.yaml├── jackett/│ ├── kustomization.yaml│ ├── jackett-config-pvc.yaml│ ├── jackett-deploy.yaml│ └── jackett-svc.yaml└── radarr/ ├── kustomization.yaml ├── radarr-config-pvc.yaml ├── radarr-sts.yaml └── radarr-svc.yaml
Step 1: Bootstrap Namespace & Storage
First, create the namespace and the shared volumes for your media.
1. Create Namespace
# namespace.yamlapiVersion: v1kind: Namespacemetadata: name: media
kubectl apply -f namespace.yaml
2. Create Storage (Pick ONE option)
Option A: NFS (Recommended for NAS users) Replace *192.168.1.xx* with your NAS IP.
# storage/nfs-downloads-pv-pvc.yamlapiVersion: v1kind: PersistentVolumemetadata: name: media-downloadsspec: capacity: storage: 200Gi accessModes: - ReadWriteMany # Multiple pods can write nfs: path: /volume1/downloads # Your NFS export path server: 192.168.1.xx # Your NAS IP persistentVolumeReclaimPolicy: Retain storageClassName: ""---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: media-downloads namespace: mediaspec: accessModes: - ReadWriteMany resources: requests: storage: 200Gi volumeName: media-downloads storageClassName: ""
# storage/nfs-movies-pv-pvc.yamlapiVersion: v1kind: PersistentVolumemetadata: name: media-moviesspec: capacity: storage: 500Gi accessModes: - ReadWriteMany nfs: path: /volume1/movies server: 192.168.1.xx persistentVolumeReclaimPolicy: Retain storageClassName: ""---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: media-movies namespace: mediaspec: accessModes: - ReadWriteMany resources: requests: storage: 500Gi volumeName: media-movies storageClassName: ""
kubectl apply -f storage/
Option B: Longhorn (Local Cluster Storage)
If you don’t have a NAS, you can use Longhorn with ReadWriteMany (requires Longhorn RWX support):
# storage/longhorn-downloads-pvc.yamlapiVersion: v1kind: PersistentVolumeClaimmetadata: name: media-downloads namespace: mediaspec: accessModes: - ReadWriteMany storageClassName: longhorn resources: requests: storage: 200Gi---# storage/longhorn-movies-pvc.yamlapiVersion: v1kind: PersistentVolumeClaimmetadata: name: media-movies namespace: mediaspec: accessModes: - ReadWriteMany storageClassName: longhorn resources: requests: storage: 500Gi
kubectl apply -f storage/
Step 2: Deploy qBittorrent
qBittorrent is the download client in this stack. It is the “muscle” that handles the actual downloading of files based on instructions from Radarr. It manages the torrent queue, seeding, and overall download process.
qBittorrent needs a dedicated PVC for its configuration and access to the shared media-downloads PVC.

The Manifests
1. PVC & Service
# qbitt/qbitt-config-pvc.yamlapiVersion: v1kind: PersistentVolumeClaimmetadata: name: qbitt-config namespace: mediaspec: accessModes: - ReadWriteOnce storageClassName: longhorn # or your default class resources: requests: storage: 1Gi---# qbitt/qbitt-svc.yamlapiVersion: v1kind: Servicemetadata: name: qbitt namespace: mediaspec: type: ClusterIP selector: app: qbitt ports: - name: web port: 80 targetPort: 8080 - name: torrent-tcp port: 6881 targetPort: 6881
2. StatefulSet
# qbitt/qbitt-sts.yamlapiVersion: apps/v1kind: StatefulSetmetadata: name: qbitt namespace: mediaspec: serviceName: qbitt replicas: 1 selector: matchLabels: app: qbitt template: metadata: labels: app: qbitt spec: containers: - name: qbitt image: linuxserver/qbittorrent:latest env: - name: PUID value: "1000" - name: PGID value: "1000" - name: WEBUI_PORT value: "8080" ports: - containerPort: 8080 name: web - containerPort: 6881 name: torrent-tcp - containerPort: 6881 protocol: UDP name: torrent-udp volumeMounts: - name: config mountPath: /config - name: downloads mountPath: /downloads resources: requests: memory: "512Mi" cpu: "100m" limits: memory: "2Gi" cpu: "1000m" livenessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 60 periodSeconds: 30 readinessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 30 periodSeconds: 10 volumes: - name: config persistentVolumeClaim: claimName: qbitt-config - name: downloads persistentVolumeClaim: claimName: media-downloads
Deploy: kubectl apply -k qbitt/
Configuration
- Forward port:
kubectl port-forward -n media svc/qbitt 8080:80 - Log in: “admin” is the username and check logs for temp password (
kubectl logs -n media sts/qbitt). - Critical: Set “Default Save Path” to
/downloads. - Keep incomplete torrents in:
/dowloads/incomplete

Step 3: Deploy Jackett
Jackett acts as the “translator” in our pipeline. It functions as a proxy between Radarr and your torrent indexers. It translates Radarr’s standardized queries into searches across multiple different torrent sites and presents the results back to Radarr in a unified format.

The Manifests
1. PVC & Service
# jackett/jackett-config-pvc.yamlapiVersion: v1kind: PersistentVolumeClaimmetadata: name: jackett-config namespace: mediaspec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 1Gi---# jackett/jackett-svc.yamlapiVersion: v1kind: Servicemetadata: name: jackett namespace: mediaspec: selector: app: jackett ports: - port: 80 targetPort: 9117
2. Deployment
# jackett/jackett-deploy.yamlapiVersion: apps/v1kind: Deploymentmetadata: name: jackett namespace: mediaspec: replicas: 1 selector: matchLabels: app: jackett template: metadata: labels: app: jackett spec: containers: - name: jackett image: linuxserver/jackett:latest env: - name: PUID value: "1000" - name: PGID value: "1000" - name: TZ value: "Europe/Madrid" ports: - containerPort: 9117 volumeMounts: - name: config mountPath: /config resources: requests: memory: "128Mi" cpu: "50m" limits: memory: "512Mi" cpu: "500m" livenessProbe: tcpSocket: port: 9117 initialDelaySeconds: 60 periodSeconds: 30 readinessProbe: tcpSocket: port: 9117 initialDelaySeconds: 30 periodSeconds: 10 volumes: - name: config persistentVolumeClaim: claimName: jackett-config
Deploy: kubectl apply -k jackett/
Configuration
- Access UI:
kubectl port-forward -n media svc/jackett 9117:80 - Add a few indexers (e.g., 1337x, RARBG).
- Copy the API Key from the top right corner. You need this for Radarr.
💡 Tip: Start with 2–3 indexers. More isn’t always better and can slow down searches.
Step 4: Deploy Radarr
Radarr acts as the “brain” and orchestrator of the entire media pipeline. It manages your movie watchlist, monitors for upcoming releases, and instructs Jackett to find sources. Once found, Radarr sends download instructions to qBittorrent and finally imports, renames, and organizes the completed file into your permanent library.
👉 Note: Radarr mounts both shared volumes so it can perform atomic moves of files from the staging area (/downloads) to the final library (/movies).

The Manifests
1. PVC & Service
# radarr/radarr-config-pvc.yamlapiVersion: v1kind: PersistentVolumeClaimmetadata: name: radarr-config namespace: mediaspec: accessModes: - ReadWriteOnce resources: requests: storage: 2Gi---# radarr/radarr-svc.yamlapiVersion: v1kind: Servicemetadata: name: radarr namespace: mediaspec: selector: app: radarr ports: - name: web port: 80 targetPort: 7878
2. StatefulSet
# radarr/radarr-sts.yamlapiVersion: apps/v1kind: StatefulSetmetadata: name: radarr namespace: mediaspec: serviceName: radarr replicas: 1 selector: matchLabels: app: radarr template: metadata: labels: app: radarr spec: containers: - name: radarr image: linuxserver/radarr:latest env: - name: PUID value: "1000" - name: PGID value: "1000" - name: TZ value: "Europe/Madrid" ports: - containerPort: 7878 volumeMounts: - name: config mountPath: /config - name: movies mountPath: /movies - name: downloads mountPath: /downloads resources: requests: memory: "256Mi" cpu: "100m" limits: memory: "1Gi" cpu: "1000m" startupProbe: httpGet: path: /ping port: 7878 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 30 livenessProbe: httpGet: path: /ping port: 7878 periodSeconds: 30 readinessProbe: httpGet: path: /ping port: 7878 periodSeconds: 10 volumes: - name: config persistentVolumeClaim: claimName: radarr-config - name: movies persistentVolumeClaim: claimName: media-movies - name: downloads persistentVolumeClaim: claimName: media-downloads
Deploy: kubectl apply -k radarr/
Step 5: Wire Everything Together
Now we connect the components using internal Kubernetes DNS.
Access Radarr (http://localhost:7878 via port-forward).
5.1 Add Download Client (qBittorrent)
- Settings → Download Clients → + → qBittorrent
- Name: qBittorrent
- Host:
qbitt.media.svc.cluster.local(This is the K8s Service name) - Port:
80(Service port) - Credentials: Admin / (Your Password)
- Test & Save.

💡 We use the Kubernetes DNS name `qbitt.media.svc.cluster.local` so Radarr can reach qBittorrent within the cluster.
5.2 Add Indexers (via Jackett)
- Settings → Indexers → + → Torznab
- URL:
[http://jackett.media.svc.cluster.local/api/v2.0/indexers/YOUR\_INDEXER/results/torznab/](http://jackett.media.svc.cluster.local/api/v2.0/indexers/YOUR_INDEXER/results/torznab/) - API Key: Paste from Jackett.
- Test & Save.

💡 Finding the Torznab URL: In Jackett, click the ”Copy Torznab Feed” button next to each indexer. Replace the host with the Kubernetes service name.
5.3 Set Root Folder

- Settings → Media Management → Add Root Folder
- Select
/movies.
5.4 Set Up Quality Profiles (Optional but Recommended)

- Go to Settings → Profiles
- Edit the default profile or create a new one.
- Select which qualities you want (1080p, 4K, etc.).
- Set upgrade rules.
Step 6: Test the Pipeline
Let’s verify everything works:
- Add a Movie: Go to Radarr, search for a movie, set the path to
/movies, and click "Add". - Verify Search: Check Radarr’s “History” or “Search” tab to see it querying Jackett.
- Verify Download: Check qBittorrent; the torrent should appear in the
/downloadsfolder. - Verify Import: Once finished, the file should disappear from the qBittorrent queue and appear in your
/moviesfolder (and Radarr library).
Troubleshooting
“No indexers available”
- Verify Jackett is running: `kubectl get pods -n media -l app=jackett`
- Check the Jackett URL in Radarr uses the Kubernetes service name
- Verify the API key is correct
“Download client unavailable”
- Verify qBittorrent is running: `kubectl get pods -n media -l app=qbitt`
- Check the host is `qbitt.media.svc.cluster.local` (not localhost)
- Verify username/password
“Import failed: file not found”
This is the path mismatch problem. Verify:
- qBittorrent saves to `/downloads`
- Radarr has `/downloads` mounted
- Both use the same PVC for downloads
Check with:
# In qBittorrent podkubectl exec -n media sts/qbitt - ls -la /downloads# In Radarr podkubectl exec -n media sts/radarr - ls -la /downloads
Both should show the same files.
“Connection refused” errors
Check if pods are in different nodes and can reach each other:
# From Radarr, test connection to qBittorrentkubectl exec -n media sts/radarr - curl -v http://qbitt.media.svc.cluster.local
Verify Your Deployment
# Check all pods are runningkubectl get pods -n media# Expected output:# NAME READY STATUS RESTARTS AGE# qbitt-0 1/1 Running 0 10m# jackett-xxxxxxxxx-xxxxx 1/1 Running 0 10m# radarr-0 1/1 Running 0 10m# Check serviceskubectl get svc -n media# Expected output:# NAME TYPE CLUSTER-IP PORT(S)# qbitt ClusterIP 10.43.x.x 80/TCP# jackett ClusterIP 10.43.x.x 80/TCP# radarr ClusterIP 10.43.x.x 80/TCP
What’s Next
In Part 2, we’ll add the user-facing components:
- Jellyfin: to stream your movies
- Jellyseerr: so users can request content
- Bazarr: for automatic subtitles
See you in the next post! 🚀
Thanks for reading!
💡 Want more hands‑on tips about Kubernetes, Cloud, and DevOps?
👉 Follow me here on Medium and let’s connect on LinkedIn!