{"id":543,"date":"2026-02-11T13:49:12","date_gmt":"2026-02-11T13:49:12","guid":{"rendered":"https:\/\/hackingwithj.com\/?p=543"},"modified":"2026-02-11T13:49:12","modified_gmt":"2026-02-11T13:49:12","slug":"hardening-my-docker-media-stack-arr-jellyfin","status":"publish","type":"post","link":"https:\/\/hackingwithj.com\/?p=543","title":{"rendered":"Hardening My Docker Media Stack (ARR + Jellyfin)"},"content":{"rendered":"\n<p>After a busy PEN-100 training week, I finally had time to revisit something that\u2019s been bothering me in my homelab: My Docker media stack was functional \u2014 but not properly hardened. I run the typical *arr ecosystem:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Prowlarr<\/li>\n\n\n\n<li>Sonarr<\/li>\n\n\n\n<li>Radarr<\/li>\n\n\n\n<li>Bazarr<\/li>\n\n\n\n<li>NZBGet<\/li>\n\n\n\n<li>Jellyfin<\/li>\n\n\n\n<li>Jellyseerr<\/li>\n<\/ul>\n\n\n\n<p>It worked perfectly. Security-wise? It needed work. This post walks through how I hardened it, validated the changes, and tested my own setup \u2014 without pretending it\u2019s bulletproof.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Initial Problem<\/h2>\n\n\n\n<p>Originally, my containers were running with:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-e13f6a498e864773d92fff28748b3300\"><code>PUID=1000\nPGID=1000<\/code><\/pre>\n\n\n\n<p>On most Linux systems, UID 1000 is your first regular user account. Not root (UID 0), but still a user with broad access to personal files. Additionally:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Volumes were writable<\/li>\n\n\n\n<li>Default Docker capabilities were active<\/li>\n\n\n\n<li>No explicit resource limits were defined<\/li>\n<\/ul>\n\n\n\n<p>Functionality came first. Security was \u201cfuture me\u2019s problem.\u201d Time to fix that.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Threat Model (Be Realistic)<\/h2>\n\n\n\n<p>This system is:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Not publicly exposed<\/li>\n\n\n\n<li>Behind a Proxmox firewall (default DROP)<\/li>\n\n\n\n<li>Only accessible via Nginx Proxy Manager in a separate VLAN<\/li>\n\n\n\n<li>Docker API is not exposed<\/li>\n\n\n\n<li>No docker.sock mounted into containers<\/li>\n<\/ul>\n\n\n\n<p>So the realistic threat scenario is:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>An application-level compromise (e.g., RCE in Sonarr or Prowlarr)<\/p>\n<\/blockquote>\n\n\n\n<p>Not:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Internet-wide scanning<\/li>\n\n\n\n<li>Remote Docker daemon takeover<\/li>\n\n\n\n<li>Exposed privileged containers<\/li>\n<\/ul>\n\n\n\n<p>The goal is not \u201cperfect security.\u201d The goal is reducing blast radius.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1 \u2013 Dedicated Non-Root Service Account<\/h2>\n\n\n\n<p>  Instead of using my primary user (UID 1000), I created a dedicated service account:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-d0e6a56d0f294896acfdc064228a0d82\"><code>sudo groupadd -g 1100 mediacenter\nsudo useradd -u 1100 -g 1100 -s \/usr\/sbin\/nologin -M media-service<\/code><\/pre>\n\n\n\n<p>Why:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No interactive login<\/li>\n\n\n\n<li>No home directory<\/li>\n\n\n\n<li>Predictable UID\/GID mapping<\/li>\n\n\n\n<li>Isolation from my personal user account<\/li>\n<\/ul>\n\n\n\n<p>Then I reassigned ownership:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-e251372df21a2a2e12fa4365b68e2b1c\"><code>sudo chown -R 1100:1100 ~\/docker\nsudo chown -R 1100:1100 \/mnt\/downloads<\/code><\/pre>\n\n\n\n<p>All containers now run as:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-dc55dd50244eb6d7601f7ecc456cef7b\"><code>PUID=1100\nPGID=1100<\/code><\/pre>\n\n\n\n<p>Or explicitly:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-73289c9a6a13ae6e2a6f88d4c31512f0\"><code>user: 1100:1100<\/code><\/pre>\n\n\n\n<p>Impact: If a container is compromised, it can only access what UID 1100 owns \u2014 not my personal files, not system files. This reduces blast radius significantly.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2 \u2013 Network Isolation<\/h2>\n\n\n\n<p>I defined a user-defined bridge:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-d5f15d37b9e1b6713e1f19ef8defd722\"><code>networks:\n  media-net:\n    driver: bridge<\/code><\/pre>\n\n\n\n<p>All containers run inside this network.<\/p>\n\n\n\n<p>This isolates them from Docker\u2019s default bridge and allows internal DNS resolution (e.g., <code>curl http:\/\/sonarr:8989<\/code>).<\/p>\n\n\n\n<p>Important nuance:<\/p>\n\n\n\n<p>Containers on the same bridge can communicate freely.<br>This is acceptable for my homelab threat model.<\/p>\n\n\n\n<p>Full micro-segmentation would add complexity without significant security gain in this context.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3 \u2013 Enforcing No New Privileges<\/h2>\n\n\n\n<p>Every container includes:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-e9219725de6a91308e6d5d88df92020b\"><code>security_opt:\n  - no-new-privileges:true<\/code><\/pre>\n\n\n\n<p>What this does:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Prevents processes from gaining additional privileges<\/li>\n\n\n\n<li>Blocks setuid-based privilege escalation<\/li>\n\n\n\n<li>Stops privilege abuse inside the container<\/li>\n<\/ul>\n\n\n\n<p>This does not make container escape impossible. It limits in-container privilege escalation. It\u2019s a strong and low-cost control.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4 \u2013 Dropping Linux Capabilities<\/h2>\n\n\n\n<p>This was one of the biggest improvements. By default, Docker grants containers a reduced but still meaningful set of Linux capabilities. Even without <code>--privileged<\/code>, containers can:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Override file permission checks<\/li>\n\n\n\n<li>Use raw sockets<\/li>\n\n\n\n<li>Change file ownership<\/li>\n\n\n\n<li>Bind to low ports<\/li>\n\n\n\n<li>Modify certain kernel-level behaviors<\/li>\n<\/ul>\n\n\n\n<p>To reduce this attack surface, I explicitly dropped all capabilities:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-e0c043202fbc446e5db43887bc595d33\"><code>cap_drop:\n  - ALL<\/code><\/pre>\n\n\n\n<p>For example:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-8872f3833f1c0b6152a8c2e03c9157a2\"><code>sonarr:\n  image: ghcr.io\/linuxserver\/sonarr\n  environment:\n    - PUID=1100\n    - PGID=1100\n  security_opt:\n    - no-new-privileges:true\n  cap_drop:\n    - ALL<\/code><\/pre>\n\n\n\n<p>After redeploying, I verified:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-3fb23107bbdcf505ddd721565dfa8fa3\"><code>docker inspect sonarr --format '{{.HostConfig.CapDrop}}'\n&#91;ALL]<\/code><\/pre>\n\n\n\n<p>Now, even if an attacker gains root inside the container, kernel-level interaction is heavily restricted. This does not prevent container escape entirely \u2014 but it meaningfully reduces the attack surface.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 5 \u2013 Resource Limits (DoS Protection)<\/h2>\n\n\n\n<p>Before hardening, containers had unlimited access to system resources. A compromised container could:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Exhaust RAM<\/li>\n\n\n\n<li>Consume all CPU<\/li>\n\n\n\n<li>Fork bomb the host<\/li>\n<\/ul>\n\n\n\n<p>To mitigate this, I added:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-a147a4142d8cf1baaa1e34f2f089a27a\"><code>mem_limit: 512m\ncpus: 1.0\npids_limit: 200<\/code><\/pre>\n\n\n\n<p>For heavier services like Jellyfin:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-ef458cb2b550b9a7aaa6ca5cca55c5bb\"><code>mem_limit: 1g<\/code><\/pre>\n\n\n\n<p>Now, even if compromised, a container cannot easily take down the host. This improves resilience more than most people realize.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 6 \u2013 Read-Only Media Mounts<\/h2>\n\n\n\n<p>For Jellyfin:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-e37f4b7a47d47cda03e3b5ffed7dfb23\"><code>- \/mnt\/downloads\/data\/media:\/data\/media:ro<\/code><\/pre>\n\n\n\n<p>If Jellyfin is compromised:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Media cannot be modified<\/li>\n\n\n\n<li>Files cannot be deleted<\/li>\n\n\n\n<li>Ransomware-style attacks are prevented at the container level<\/li>\n<\/ul>\n\n\n\n<p>This is a simple but powerful control.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pentesting My Own Setup<\/h2>\n\n\n\n<p>After hardening, I tested it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. External Scanning<\/h3>\n\n\n\n<p>Port scans using nmap fail from external networks as well as from other internal VLANs. Access is restricted by firewall policies at both the VLAN boundary and the Proxmox host level. This layered filtering is intentional and part of the overall segmentation strategy.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Docker Daemon Exposure<\/h3>\n\n\n\n<p>Checked dockerd:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-ad78929107c6218e1f3fa8c397bec8a4\"><code>\/usr\/bin\/dockerd -H fd:\/\/<\/code><\/pre>\n\n\n\n<p>No TCP listener.<br>No exposed Docker API.<br>Good.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Inside Container Testing<\/h3>\n\n\n\n<p>From inside a container:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Verified mounts (no docker.sock exposed)<\/li>\n\n\n\n<li>Verified no host filesystem mounted<\/li>\n\n\n\n<li>Verified <code>\/sys<\/code> is read-only<\/li>\n\n\n\n<li>Verified cgroup v2 is read-only<\/li>\n<\/ul>\n\n\n\n<p>Attempting:<\/p>\n\n\n\n<pre class=\"wp-block-code has-vivid-purple-color has-text-color has-link-color wp-elements-a2a749771efc7a6ccd0695bc2406ce38\"><code>touch \/etc\/testfile<\/code><\/pre>\n\n\n\n<p>Worked \u2014 but only inside the container overlay filesystem. Not on the host. Important distinction: Container root \u2260 Host root.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What This Does NOT Protect Against<\/h2>\n\n\n\n<p>Let\u2019s stay honest. This setup does not protect against:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Kernel vulnerabilities<\/li>\n\n\n\n<li>Docker daemon zero-days<\/li>\n\n\n\n<li>Malicious container images<\/li>\n\n\n\n<li>Application-layer RCE<\/li>\n\n\n\n<li>API key abuse between services<\/li>\n<\/ul>\n\n\n\n<p>It reduces impact. It does not eliminate risk. Security is layered, not absolute.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Final State Summary<\/h2>\n\n\n\n<p>After hardening, the stack now has:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Dedicated non-root service account<\/li>\n\n\n\n<li>Explicit UID\/GID mapping<\/li>\n\n\n\n<li>Firewall-level isolation<\/li>\n\n\n\n<li>No exposed Docker API<\/li>\n\n\n\n<li>No docker.sock mounts<\/li>\n\n\n\n<li>Dropped Linux capabilities<\/li>\n\n\n\n<li>no-new-privileges enabled<\/li>\n\n\n\n<li>Resource limits enforced<\/li>\n\n\n\n<li>Read-only media mounts<\/li>\n\n\n\n<li>Seccomp + AppArmor active (default Docker security options)<\/li>\n<\/ul>\n\n\n\n<p>If one *arr service is compromised:<\/p>\n\n\n\n<p>The attacker gains:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Container-level access<\/li>\n\n\n\n<li>Limited internal network access<\/li>\n<\/ul>\n\n\n\n<p>They do NOT gain:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Host root<\/li>\n\n\n\n<li>Docker daemon control<\/li>\n\n\n\n<li>System-wide filesystem access<\/li>\n<\/ul>\n\n\n\n<p>That\u2019s a major improvement over the original state.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Lessons Learned<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Docker is not a security boundary.<\/li>\n\n\n\n<li>UID mapping reduces blast radius.<\/li>\n\n\n\n<li>Capabilities matter more than most people think.<\/li>\n\n\n\n<li>Resource limits protect availability.<\/li>\n\n\n\n<li>Network segmentation should match your threat model.<\/li>\n\n\n\n<li>Hardening is iterative \u2014 not a one-time task.<\/li>\n<\/ol>\n\n\n\n<p>Originally, I built this stack for convenience. Now it\u2019s layered, validated, and intentionally designed. Not perfect. But significantly more resilient. And most importantly \u2014 I understand why.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>After a busy PEN-100 training week, I finally had time to revisit something that\u2019s been bothering me in my homelab: My Docker media stack was functional \u2014 but not properly hardened. I run the typical *arr ecosystem: It worked perfectly. Security-wise? It needed work. This post walks through how I hardened it, validated the changes, [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"site-container-style":"default","site-container-layout":"default","site-sidebar-layout":"default","disable-article-header":"default","disable-site-header":"default","disable-site-footer":"default","disable-content-area-spacing":"default","footnotes":""},"categories":[20,7],"tags":[],"class_list":["post-543","post","type-post","status-publish","format-standard","hentry","category-cybersecurity","category-realworld"],"_links":{"self":[{"href":"https:\/\/hackingwithj.com\/index.php?rest_route=\/wp\/v2\/posts\/543","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/hackingwithj.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/hackingwithj.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/hackingwithj.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/hackingwithj.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=543"}],"version-history":[{"count":1,"href":"https:\/\/hackingwithj.com\/index.php?rest_route=\/wp\/v2\/posts\/543\/revisions"}],"predecessor-version":[{"id":544,"href":"https:\/\/hackingwithj.com\/index.php?rest_route=\/wp\/v2\/posts\/543\/revisions\/544"}],"wp:attachment":[{"href":"https:\/\/hackingwithj.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=543"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/hackingwithj.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=543"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/hackingwithj.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=543"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}