Plex behind nginx: setup guide (reference deployment) Oct 20, 2026 Overview Plex runs in a Docker container; nginx on the host publishes it at its own HTTPS hostname, filters every request, and proxies the allowed ones to Plex on 127.0.0.1:32400. Local devices can still reach Plex directly on port 32400, so the proxy is an added front door, not the only one. The path of a request through this setup: 1. DNS. Outside the house, plex.example.net is a normal public A record that points at the router's WAN address (DNS only, not proxied by Cloudflare). Inside the house, the router answers the same name with the server's LAN address, so local clients skip the trip out and back. 2. Router. The router forwards TCP 443 (and 80, only to redirect to HTTPS) to the server. 3. nginx. TLS with a Let's Encrypt certificate, then four filters: the scanner block, the Plex path guard, a rate limit, and the CrowdSec bouncer with its web application firewall. nginx runs them in its own phase order, not the order they appear in the file. 4. Proxy. Requests that pass go to the Plex container with buffering off, so video streams straight through. 5. Plex. Plex sees every proxied request as coming from the local machine, which matters for security (see the settings and gotchas sections). Plex behind nginx: setup guide (reference deployment) Page 1 of 20 Outside clients reach Plex only through the filtered path on port 443; local clients can also go straight to port 32400. Why this shape: clients get a real certificate and one port (443) instead of exposing 32400 to the internet, and a single place does the logging, rate limiting and exploit filtering. The hostname stays grey-cloud because streaming video through Cloudflare's proxy is not what it is built for. Names used below. The guide uses example values; swap them for yours: Prerequisites You need a hostname with a public A record, a certificate for it, nginx with Lua support, and the Plex container reachable on the host. None of it needs Plex's own port 32400 to be exposed to the internet. request path · 5 boxes, two routes Placeholder Value here Plex hostname plex.example.net Server LAN address 192.168.1.10 Plex container address 172.20.0.250 on the plex-network Docker network nginx 1.28.3 (Ubuntu package, with Lua support) Plex behind nginx: setup guide (reference deployment) Page 2 of 20 1. DNS. Create plex.example.net as an A record pointing at your WAN address, set to DNS only (grey cloud in Cloudflare). A small dynamic-DNS container updates it when the WAN address changes. Keep it grey: Cloudflare's proxy is not meant for streaming video, and clients then connect straight to your server. 2. Router. Forward TCP 443 to the server. Forward 80 only if you want the HTTP-to-HTTPS redirect. Do not forward 32400; remote clients use 443 instead. Here the router forwards 443 and 80, and UPnP is off. 3. Certificate. Use Let's Encrypt with the DNS-01 challenge through the Cloudflare plugin. DNS-01 needs no inbound connection and does not depend on the A record being reachable: certbot certonly --dns-cloudflare \ --dns-cloudflare-credentials /etc/cloudflare-plex.ini \ --dns-cloudflare-propagation-seconds 30 \ -d plex.example.net --key-type ecdsa The credentials file holds an API token limited to editing DNS for the zone; keep it mode 600. Renewals run on certbot's timer. Add a deploy hook that reloads nginx ( /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ): without it certbot writes the new files and nginx keeps serving the old certificate until the next restart. 4. nginx packages. Install nginx plus the Lua module ( libnginx-mod-http-lua , which pulls in the NDK module) and the CrowdSec nginx bouncer; the path guard and the bouncer both run Lua. Confirm with ls /etc/nginx/modules-enabled (you want the NDK and Lua entries). 5. The Plex container. The deployment uses the official plexinc/pms-docker:public image (the stable track). The settings that matter for this guide: Publish 32400/tcp on the host, plus Plex's discovery ports (1900/udp, 32410 and 32412 to 32414/udp) for local discovery. Give the container a fixed address on its own Docker network ( 172.20.0.250 here). Firewall rules that limit what a compromised Plex could reach are keyed to that single address. Keep a health check on http://plex:32400/identity , restart: unless-stopped , no-new-privileges , and put the transcode directory on a tmpfs so transcoding never touches the media disks. Claim the server once with a claim token on first start, then remove the token from the compose file. Plex behind nginx: setup guide (reference deployment) Page 3 of 20 6. Host firewall. Allow 80 and 443 in. Published container ports are filtered separately by Docker's DOCKER-USER chain, which here admits only private source addresses, so 32400 stays LAN-only even though Docker publishes it. The full compose service is in the appendix. The nginx virtual host The vhost is two server blocks: port 80 only redirects to HTTPS, and port 443 terminates TLS, runs four filters, and proxies to Plex. The whole file is in the appendix; this table explains each choice. Directive Why it is there listen 443 ssl + Let's Encrypt fullchain.pem / privkey.pem TLS terminates at nginx. HTTP/1.1 only (no http2 directive). No client-certificate check: this name is grey-cloud and reached directly by real clients. server_tokens off , proxy_hide_header Server Hide the nginx and Plex versions from scanners. Security headers (HSTS, nosniff , frame, referrer, permissions) Standard hardening. The HSTS header here is max-age=63072000; includeSubDomains; preload , which commits the whole domain to HTTPS: drop preload and includeSubDomains unless every subdomain really is HTTPS-only. client_max_body_size 100m Plex uploads artwork and metadata. if ($should_block) { return 403; } Scanner filter (maps below). include .../plex-guard-server.conf Path-based exploit filter (next section). limit_req zone=plex burst=100 nodelay 20 requests per second per client address, with a burst of 100. Loose enough for Plex clients that poll and load many thumbnails at once, tight enough to slow scanners. proxy_pass http://127.0.0.1:32400 + proxy_http_version 1.1 Talk to the published Plex port on the loopback address. Plex behind nginx: setup guide (reference deployment) Page 4 of 20 Pieces defined outside the vhost The vhost uses names that must already exist when nginx parses it. Files in conf.d load in alphabetical order, so default.conf and example.net.conf are read before plex.example.net.conf ; nginx -t fails with unknown variable if one is missing. Rate-limit zone , in the http block of nginx.conf : limit_req_zone $binary_remote_addr zone=plex:10m rate=20r/s; WebSocket map , which here lives in another vhost file ( example.net.conf ) and is shared: map $http_upgrade $connection_upgrade { default upgrade; '' close; } Scanner filter maps , in default.conf . A request is blocked only if its User-Agent is empty or a known scanner, it does not come from localhost, and it carries no Plex token. The token exemption exists because some real Plex internals (photo and thumbnail Directive Why it is there Upgrade / Connection $connection_upgrade Plex clients keep a WebSocket open for notifications. Host , X-Real-IP , X-Forwarded-For , X- Forwarded-Proto Pass the client details through for logs. Plex does not use them to decide trust (see gotchas). proxy_buffering off , proxy_request_buffering off Media must stream straight to the client, not be spooled to disk first. proxy_buffer_size 128k , proxy_buffers 4 256k , proxy_busy_buffers_size 256k Plex sends very large response headers; the defaults cut them off. proxy_read_timeout , proxy_send_timeout , send_timeout all 3600s The global defaults are short ( send_timeout 10s , keepalive_timeout 15s , set in nginx.conf for every vhost). Without the override, long streams stall and drop. Plex behind nginx: setup guide (reference deployment) Page 5 of 20 transcode requests) send no User-Agent at all; a legitimate remote client was blocked on 2026-07-30 before this was added. The $arg_ shortcut does not work for X-Plex-Token because of the hyphens, so the query string is matched directly: map $http_user_agent $blocked_agent { default 0; "" 1; ~*(sqlmap|nikto|nmap|masscan|zgrab) 1; } geo $is_local { default 0; 127.0.0.1 1; ::1 1; } map $http_x_plex_token $has_plex_token_header { default 1; "" 0; } map $args $has_plex_token_arg { default 0; "~*(^|&)X-Plex-Token=" 1; } map "$has_plex_token_header:$has_plex_token_arg" $has_plex_token { default 0; "~1" 1; } map "$is_local:$blocked_agent:$has_plex_token" $should_block { default 0; "0:1:0" 1; } Global TLS settings , in nginx.conf : TLS 1.2 and 1.3 only, a short list of AEAD ciphers, ssl_prefer_server_ciphers off , a 10 MB shared session cache, and session tickets off. Plex settings that must match The one setting that makes the hostname work is Custom server access URLs ; if Plex loses it, outside clients can no longer find the server. Everything else here keeps Plex from advertising ports you did not forward. Set these in Plex under Settings, then Network (names as in the web app; the stored preference name is in the last column): Setting Value here Stored as Custom server access URLs http://plex.example.net:443,http://plex.exa mple.net:80,https://plex.example.net:443 customConnections Remote access Off. Manual port mapping, no UPnP on the router PublishServerOnPlexOnlineKey=0 , ManualPortMappingMode=1 Secure connections Value 1 (check the label in Settings, Network; it should be the stricter option) secureConnections LAN networks 192.168.1.0/24,172.20.0.0/16 LanNetworksBandwidth Plex behind nginx: setup guide (reference deployment) Page 6 of 20 A fresh install needs only the HTTPS entry, https://plex.example.net:443 . The two plain-HTTP entries are what this server was confirmed to work with, so they were kept. How Plex decides who is local. Plex looks at the source address of the TCP connection, not at X-Forwarded-For . Because nginx connects from the Docker network ( 172.20.0.0/16 , which is also in the LAN networks list above), every proxied client looks local to Plex, including clients on the internet. Two consequences follow: Plex's own remote-streaming caps do not apply to clients that arrive through nginx. If you need a bandwidth cap, add limit_rate in nginx; this deployment has not. A few Plex endpoints answer without a token to a local-looking request. In tests here, /identity and /system/agents answered without one, while library endpoints returned 401. That is why the path guard in the next section exists. Guard the setting itself. Plex can silently rewrite customConnections (for example when the container's visible network interfaces change), which takes the server offline for outside users with no error. A small script, plex-custom-connections-watch.sh , compares the setting against the expected value every 10 minutes and sends a notification when it drifts. Compare against a fixed expected value, not against the previous run: an earlier version adopted whatever Plex had drifted to as the new normal and stayed quiet. Two more checks watch for sessions falling back to Plex's relay: plex- relay-monitor.sh and plex-session-relay-monitor.sh (every 5 minutes). Hardening Four layers sit between the internet and Plex, and a fifth limits the damage if Plex itself is ever compromised. The first four cost almost nothing at runtime. Setting Value here Stored as Relay Allowed, as a fallback for clients that cannot reach the server RelayEnabled=1 DLNA server Off DlnaEnabled=0 Transcoder temporary directory /transcode (a tmpfs, see prerequisites) TranscoderTempDirectory Remote upload cap 700000 kbps WanTotalMaxUploadRate Plex behind nginx: setup guide (reference deployment) Page 7 of 20 1. Scanner block and rate limit Both are in the vhost (see the nginx section): empty or scanner User-Agents without a Plex token get a 403, and each client address is held to 20 requests per second with a burst of 100. 2. CrowdSec bouncer and web application firewall The CrowdSec nginx bouncer runs as an access_by_lua_block at the http level, so it protects every vhost that does not define its own access-phase Lua. It enforces ban decisions and sends requests to CrowdSec's AppSec engine (OWASP core rules plus virtual patching) for in-line blocking. Install the crowdsecurity/plex collection as well: it allowlists the request shapes normal Plex clients produce (transcoding, timelines, library metadata, session status, Plex Cast logging) so that they are not counted as scanner behaviour. Expect to tune. This deployment needed exclusions for core-rule 920450 on Plex transcode and theme paths and for 932370 on the ultrablur artwork endpoint. 3. Path guard A vulnerability chain published on 2026-10-18 (the "plex2shell" CVEs) starts with a request that makes Plex call itself and then reaches endpoints that Plex normally only serves to the local machine. Signature-based WAF rules catch the published first request but not trivial variants, so the guard uses rules that depend only on the path and method. Two small files do it (full text in the appendix): plex-guard-http.conf ( http context, included at the top of the vhost file): maps that flag write methods on /:/prefs and /system/agents , and any .. path segment in any encoding. plex-guard-server.conf (inside the 443 server block): returns 403 for the flagged requests, blocks everything under /player and /system with one regex location, and runs a short Lua check that rejects TranscoderH264Options in any query, a transcode path= that is not a server-relative path, and more than 200 query arguments. Why it is built this way: Match on $uri with ~* It is decoded and normalised, so case changes and percent- encoding do not slip past. Reject dot-dot first. nginx forwards the original request line to Plex, so a path that nginx and Plex normalise differently could dodge the other rules. Plex behind nginx: setup guide (reference deployment) Page 8 of 20 Use rewrite_by_lua_block , never access_by_lua_block A server- or location-level access-phase Lua block replaces the http-level CrowdSec bouncer for that vhost, and the bouncer's allow call ends the whole handler. Putting the guard in the rewrite phase keeps both running. Fail open on a guard error. If the Lua check itself errors, it logs and allows the request, so a guard bug cannot take Plex down. One rule was loosened after the fact. The Plex web app calls GET /system/agents when an admin edits a library, and the first version blocked all of /system . Only that exact path is allowed now ( location ~* ^/(player|system(?!/agents$))(/|$) ); write methods on it, sub-paths and every other /system path stay blocked. The lesson: "no uses in 29 days of logs" did not mean "never", so check a rule against your own traffic and expect one surprise. Cost of the guard: remote Plex Companion control ( /player/* ) no longer works through this hostname. Local clients still use port 32400 directly. 4. Keep Plex's own port off the internet Port 32400 is not forwarded and is published by Docker only to private source addresses, so the only public path to Plex is the filtered one on 443. 5. Contain the container Plex mounts the media read-write, so treat it as the most exposed container. Give it a fixed address and drop its new outbound connections to the machines it has no reason to touch. Here that is the torrent client host, the second site's server, the router (all TCP except DNS), and the host's own SSH and file-sharing ports. Plex keeps its internet access, DNS and plex.tv. The rules are keyed to the one address, not the network, because monitoring containers on the same network legitimately probe those hosts: PLEX=172.20.0.250 iptables -I DOCKER-USER -s $PLEX -d <torrent-host> -m conntrack --ctstate NEW -j DROP iptables -I DOCKER-USER -s $PLEX -d <other-site> -m conntrack --ctstate NEW -j DROP iptables -I DOCKER-USER -s $PLEX -d <router> -p tcp ! --dport 53 -m conntrack --ctstate NEW -j DROP iptables -I INPUT -s $PLEX -p tcp -m multiport --dports <ssh>,139,445 - m conntrack --ctstate NEW -j DROP Plex behind nginx: setup guide (reference deployment) Page 9 of 20 The rules match only new connections, so replies to connections other hosts open to Plex keep working. Save them in your persistent rule file by hand, and keep read-only snapshots of the media filesystem (weekly ZFS snapshots here) so a compromised Plex cannot destroy the library. Local access Local devices keep working two ways, and the hostname is the better one. Both avoid the internet round trip, and the direct route keeps working even if nginx is down. 1. Direct to Plex: http://192.168.1.10:32400 from any trusted local address. Docker publishes the port, and the DOCKER-USER chain admits private sources only. Apps on the same network also find the server by themselves through Plex's discovery broadcasts (UDP 1900 and 32410 to 32414), which is why those ports are published. 2. By hostname: https://plex.example.net , resolved to the server's LAN address by a local DNS record on the router . The certificate is the same one, so the browser lock stays valid. Why add the local record. Without it, a local client resolves the public address, sends the request out to the router and back in again (NAT loopback), and shows up in nginx's logs as the router's address instead of its own. That costs router CPU and blinds CrowdSec and your logs to which device is asking. With the local record, requests go straight to the server and the logs carry real client addresses. The record, in the router's DNS (dnsmasq syntax; Pi-hole, Unbound and others have an equivalent "local data" entry): address=/plex.example.net/192.168.1.10 The record only answers that one name. Everything else, and the public record in Cloudflare, stays as it was. In this deployment the router's local records live in a file kept in git and are pushed with a script that validates, restarts the router's DNS, and checks every record, rolling back on failure. A second site. The second site's router answers the same name with the main site's LAN address, and the traffic crosses the site-to-site VPN. One device at the second site streamed tens of gigabytes in two days this way. The trade-off: those clients depend on the link, where the public route would also work if only the link were down. Check it: Plex behind nginx: setup guide (reference deployment) Page 10 of 20 dig +short @<router> plex.example.net # should show the LAN address, not the public one resolvectl flush-caches # then clear the local cache on a test machine curl -sI https://plex.example.net/identity # 200, with a valid certificate Local DNS answers are only as fresh as each device's cache, so a device that resolved the public address a minute earlier can keep using it for a few minutes. Testing and verification After any change run nginx -t && systemctl reload nginx , then the checks below. Run the scanner-block rows from another machine: requests from localhost are exempt from that rule, so the same test on the server itself passes for the wrong reason. The results below were recorded on the live setup on 2026-10-20: Request Result What it shows http://plex.example.net/ 301 to https://plex.example.net/ The port 80 redirect works. GET /identity 200 The proxy reaches Plex. GET /system/agents 200 The one allowed /system path. POST /system/agents 403 Write methods on it are blocked. GET /system/plugins 403 Other /system paths are blocked. Plex behind nginx: setup guide (reference deployment) Page 11 of 20 Also confirm the certificate ( echo | openssl s_client -connect 127.0.0.1:443 - servername plex.example.net | openssl x509 -noout -issuer -enddate should show Let's Encrypt and a date about 60 to 90 days out) and that the response carries the HSTS and nosniff headers and Server: nginx with no version. Then play something. In a Plex app, start a long video through the hostname and leave it past the old timeouts; open the web app and watch that live updates arrive (the WebSocket). Those two are what the buffering and timeout settings protect, and a curl Request Result What it shows GET /player/timeline/subscribe 403 The Companion protocol is blocked. PUT /:/prefs? TranscoderH264Options=x 403 Settings writes are blocked. GET /video/:/transcode/universal/start? path=http://evil/x 403 A transcode path that is not server- relative is blocked. Empty User-Agent, no token (from another machine) 403 Scanner block. sqlmap User-Agent, no token (from another machine) 403 Scanner block. Empty User-Agent, with an X-Plex- Token header or query value 401 Passes the scanner block; Plex itself refuses an invalid token. Normal User-Agent, no token, GET /library/sections 401 Plex requires a valid token for library data. Plex behind nginx: setup guide (reference deployment) Page 12 of 20 will not catch them. Regression harness for the guard The guard has 75 test cases (24 that must pass and 51 that must be blocked), including encoding and case tricks, so test it with a script instead of by hand. The harness starts a throwaway nginx on 127.0.0.1:18080 that includes the same two snippet files as the live vhost and a stub backend, then runs a matrix of requests that must pass and requests that must be blocked: mkdir -p /etc/nginx/plex-guard-test /tmp/plex-guard- test/{body,proxy,fcgi,uwsgi,scgi} /run/nginx cp scratch.conf /etc/nginx/plex-guard-test/ && chown -R www-data:www-data /tmp/plex-guard-test nginx -c /etc/nginx/plex-guard-test/scratch.conf python3 guard_matrix.py 127.0.0.1 18080 # expect: ALL AS EXPECTED python3 replay.py # replays real access logs; expect 0 blocked requests that returned 2xx nginx -c /etc/nginx/plex-guard-test/scratch.conf -s stop; rm -rf /etc/nginx/plex-guard-test /tmp/plex-guard-test The replay step is the important one: when the guard was first built it replayed 532,553 real requests, would have blocked 255 of them, and every one was an internet scanner that had already been refused. Re-run the harness after editing the snippets or upgrading nginx or its Lua module. The harness (about 80 lines of Python and a 25-line nginx config) is kept in the server's configuration repository under nginx/tests/plex-guard/ Two testing traps: the stub must be reached through proxy_pass , because a return 200 in the same location ends the rewrite phase before the Lua check runs and makes the Lua rules look untested; and curl quietly simplifies .. in a path unless you pass --path-as- is Reading the logs. Blocks by the Lua check appear in the nginx error log as [plex-guard] blocked <ip> <method> <path>: <reason> . Blocks by the maps and the regex location appear only as 403 lines in the access log. A healthy hour has many 4xx from scanners on the public side and almost none for local clients. Gotchas and maintenance Each of these cost real time here. Plex behind nginx: setup guide (reference deployment) Page 13 of 20 Plex trusts the connection, not the headers. Everything that arrives through nginx looks local to Plex, so some endpoints answer without a token and Plex's remote bandwidth caps do not apply. Do not rely on Plex alone to refuse an outsider; that is the path guard's job. Plex can rewrite customConnections on its own. It happens when the container's visible network interfaces change, and outside clients then lose the server with no error. Watch the value against a fixed expected string, on a timer, and send yourself a notification when it differs. Copying the vhost alone will not load. It uses names defined in other files: the rate- limit zone in nginx.conf , the WebSocket map in another vhost file, the scanner maps in default.conf , and the guard snippets. Files in conf.d load alphabetically and a variable must exist before a server block uses it, otherwise nginx -t says unknown variable Do not add an access_by_lua block to this vhost. At server or location level it replaces the http-level CrowdSec bouncer, and the bouncer's allow call ends the handler, so anything placed after it in the same phase is skipped. Use rewrite_by_lua_block An add_header inside a location drops every server-level header for that location. If you add one, repeat the others. Certificate renewals need a deploy hook. Certbot writes new files but nginx keeps serving the old certificate until it reloads. This was found missing here, after a renewal. Do not block empty User-Agents without the token exemption. Some real Plex internals send none, and a legitimate remote client was refused for it. Allow-listing a path needs a log check first. "No uses in 29 days" missed the web app's /system/agents call. After tightening a rule, watch the access log for 403s on requests that carry a Plex token. An alert on "blocked request with a valid token" also fires for the path guard, not only for the web application firewall. When it fires, find the line in the nginx access log before assuming the WAF. A restart drops every stream. Update Plex with a script that checks for active sessions, waits, and restarts only when idle; the container's own updater installs the new build on each start, so pulling the image alone changes nothing. Expect WAF tuning after Plex updates. New client versions can produce request shapes the core rules dislike. Look at CrowdSec's alerts for Plex paths after an update. Plex behind nginx: setup guide (reference deployment) Page 14 of 20 Docker bypasses the host's INPUT firewall rules for published ports. Restrict Plex's ports in DOCKER-USER , not with rules on INPUT Keep the configuration in git, and back up Plex's config directory (the library database lives in it). Both are small, and a rebuild is a restore of the two. Appendix: the files as deployed Based on a live deployment, with names and addresses replaced by examples and comments shortened; the directives are unchanged. The other pieces the vhost needs (the rate-limit zone, the WebSocket map and the scanner maps) are listed in the nginx section. Nothing here is secret: the compose service reads its first-run settings from a separate .env file that is not reproduced. /etc/nginx/conf.d/plex.example.net.conf # Exploit-surface rules for Plex (maps), see snippets/plex-guard-*.conf include /etc/nginx/snippets/plex-guard-http.conf; server { listen 80; listen [::]:80; server_name plex.example.net; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name plex.example.net; # Let's Encrypt, no Cloudflare client cert (grey-cloud DNS, direct connections) # ssl_stapling omitted: Let's Encrypt no longer includes OCSP responder URLs ssl_certificate /etc/letsencrypt/live/plex.example.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/plex.example.net/privkey.pem; server_tokens off; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; Plex behind nginx: setup guide (reference deployment) Page 15 of 20 add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header X-XSS-Protection "1; mode=block" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; # Plex uploads artwork and metadata client_max_body_size 100m; # Block scanners (maps defined in default.conf) if ($should_block) { return 403; } # Path-based rules against the plex2shell exploit chain. Uses rewrite_by_lua so the # http-level CrowdSec access_by_lua still runs. include /etc/nginx/snippets/plex-guard-server.conf; # Rate limit: loose enough for Plex client polling, tight enough to slow scanners limit_req zone=plex burst=100 nodelay; location / { proxy_pass http://127.0.0.1:32400; proxy_http_version 1.1; # WebSocket support for Plex notification channel proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Hide Plex version from Server response header proxy_hide_header Server; Plex behind nginx: setup guide (reference deployment) Page 16 of 20 # Disable buffering: media must stream directly to the client proxy_buffering off; proxy_request_buffering off; # Large header buffers for Plex's verbose response headers proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; # Override the global short timeouts for long-lived streams proxy_read_timeout 3600s; proxy_send_timeout 3600s; send_timeout 3600s; } } /etc/nginx/snippets/plex-guard-http.conf # Plex exploit-surface reduction, http context. Included at the top of the vhost file. map $request_method $plex_write_method { default 0; ~*^(PUT|POST|DELETE|PATCH)$ 1; } # Settings writes (GET /:/prefs is used by real clients and stays allowed). map "$plex_write_method:$uri" $plex_block_admin { default 0; "~*^1:/:/prefs" 1; "~*^1:/system/agents" 1; } # Reject dot-dot segments in any encoding: nginx forwards the original request line to Plex, so a path # that the two normalise differently could slip past the path rules below. map $request_uri $plex_block_dotdot { default 0; "~*(/|%2f)(\.|%2e){2}(/|%2f|\?|$)" 1; } Plex behind nginx: setup guide (reference deployment) Page 17 of 20 /etc/nginx/snippets/plex-guard-server.conf # Uses rewrite_by_lua_block on purpose: the CrowdSec bouncer is an http-level access_by_lua_block, # and a more specific access_by_lua_block here would REPLACE it for this vhost. if ($plex_block_dotdot) { return 403; } # Settings writes, e.g. PUT /:/prefs?TranscoderH264Options=... if ($plex_block_admin) { return 403; } # Companion/remote-control protocol and the loopback-only plugin/agents API. # /system/agents (exact path, GET only, see plex_block_admin) is called by the Plex Web UI # when an admin edits a library; everything else under /system, and all of /player, stays blocked. location ~* ^/(player|system(?!/agents$))(/|$) { return 403; } rewrite_by_lua_block { local function violates() local qs = ngx.var.args if not qs or qs == "" then return nil end local uri = string.lower(ngx.var.uri or "") local is_transcode = string.find(uri, "^/video/:/transcode") or string.find(uri, "^/audio/:/transcode") -- get_uri_args percent-decodes names and values and returns every occurrence of a repeated name local args, err = ngx.req.get_uri_args(200) if err == "truncated" then return "more than 200 query arguments" end for k, v in pairs(args) do local name = string.lower(k) if name == "transcoderh264options" then return "TranscoderH264Options in query" end if is_transcode and name == "path" then local vals = (type(v) == "table") and v or { v } for _, val in ipairs(vals) do Plex behind nginx: setup guide (reference deployment) Page 18 of 20 if type(val) ~= "string" or string.sub(val, 1, 1) ~= "/" or string.sub(val, 1, 2) == "//" then return "transcode path= is not a server-relative path" end end end end return nil end local ok, why = pcall(violates) if not ok then ngx.log(ngx.ERR, "[plex-guard] check failed, allowing request: ", tostring(why)) elseif why then ngx.log(ngx.ERR, "[plex-guard] blocked ", ngx.var.remote_addr, " ", ngx.var.request_method, " ", ngx.var.uri, ": ", why) return ngx.exit(403) end } The Plex service in the compose file plex: container_name: plex image: plexinc/pms-docker:public restart: unless-stopped mem_limit: 24g env_file: - ./plex/.env runtime: nvidia # hardware transcoding; drop if you have no GPU ports: - 32400:32400/tcp - 8324:8324/tcp - 32469:32469/tcp - 1900:1900/udp - 32410:32410/udp - 32412:32412/udp - 32413:32413/udp - 32414:32414/udp security_opt: Plex behind nginx: setup guide (reference deployment) Page 19 of 20 - no-new-privileges:true hostname: plex volumes: - ./plex/config:/config - /path/to/media:/plexmedia - type: tmpfs target: /transcode tmpfs: size: 17179869184 # 16 GiB, transcoding never touches the media disks networks: plex-network: ipv4_address: 172.20.0.250 # fixed: the isolation firewall rules are keyed to it healthcheck: test: ["CMD", "/usr/bin/curl", "--fail", "http://plex:32400/identity"] interval: 30s timeout: 10s retries: 3 start_period: 15s labels: - autoheal=true # restarted by the autoheal container if the health check fails This is trimmed to the lines that matter for this guide: the deployed service mounts several more media paths, and its update-detection labels are omitted. Plex behind nginx: setup guide (reference deployment) Page 20 of 20