Remote access and TLS
Reach your Playarr server from outside your own network, ports, reverse proxies, certificates and the client-side address settings that go with them.
Playarr speaks plain HTTP on one port by default and assumes it is sitting on a network you already trust. Making it reachable from elsewhere is a deliberate step you take yourself, and this page covers the whole of it: which ports matter, the three ways TLS can be terminated, complete nginx and Caddy configurations that do not break long media responses, what each Playarr client needs to be told about your server’s address, and exactly what the project does and does not do for your security.
This is your decision and your responsibility. Putting any self-hosted service on the public internet exposes it to the public internet. Playarr does not open firewall ports, configure UPnP, register a hostname, or tunnel anything on your behalf. Nothing on this page is a recommendation to route around restrictions imposed by a network you do not control.
Ports involved
| Port | Protocol | What listens | Set by | Expose publicly? |
|---|---|---|---|---|
8484 |
TCP | The API, the HLS and byte-range media endpoints, and, when PLAYARR_WEB_ASSETS_DIR points at a built Admin bundle, Playarr Admin at / |
PLAYARR_HTTP_BIND_ADDR (default 0.0.0.0:8484) |
This is the only one that should ever be reachable, and only behind TLS |
9090 |
TCP | Prometheus /metrics, served unconditionally by every role |
PLAYARR_METRICS_BIND_ADDR (default 0.0.0.0:9090) |
No. Never proxy it to the edge |
80 |
TCP | ACME HTTP-01 challenge listener, only when Playarr’s own automatic HTTPS is enabled with the default http-01 challenge (not needed with relay-dns-01) |
PLAYARR_ACME_HTTP01_BIND_ADDR (default 0.0.0.0:80) |
Only while HTTP-01 automatic HTTPS is in use |
Every one of those bind variables takes a full socket address, not a bare port number.
PLAYARR_HTTP_BIND_ADDR=8484 is a startup error.
If you are terminating TLS in a reverse proxy on the same host, bind Playarr to loopback so the plain-HTTP port is not reachable from the LAN at all:
# /etc/playarr/playarr.env
PLAYARR_HTTP_BIND_ADDR=127.0.0.1:8484
PLAYARR_METRICS_BIND_ADDR=127.0.0.1:9090
Apply it with sudo systemctl restart playarr.service. There is no hot reload, every value
Config::from_env resolves is read once at startup.
Choose how TLS is terminated
There are three mutually exclusive options. Pick one.
| Mode | How it is enabled | Certificate source | Reverse proxy needed |
|---|---|---|---|
| Plain HTTP | The default, no TLS variables set | None | No, but then nothing is encrypted |
| Static-certificate HTTPS | PLAYARR_TLS_CERT_PATH + PLAYARR_TLS_KEY_PATH |
A PEM chain and key you supply | No |
| Automatic HTTPS | PLAYARR_ACME_DOMAIN + PLAYARR_ACME_ENVIRONMENT + PLAYARR_ACME_ACCEPT_TERMS |
Let’s Encrypt, via HTTP-01 | No |
Configuring ACME and the static PLAYARR_TLS_* paths at the same time is a hard startup failure,
as is setting any other PLAYARR_ACME_* variable without PLAYARR_ACME_DOMAIN.
If you instead front Playarr with nginx or Caddy, leave all of these unset and let the proxy handle TLS.
Playarr terminates TLS itself, with your own certificate
# /etc/playarr/playarr.env
PLAYARR_TLS_CERT_PATH=/etc/playarr/tls/fullchain.pem
PLAYARR_TLS_KEY_PATH=/etc/playarr/tls/privkey.pem
Both must be set together or startup fails. Create the directory, put your PEM chain and key in it,
and make the key readable by the playarr group the service unit runs as:
sudo install -d -o root -g playarr -m 0750 /etc/playarr/tls
sudo install -o root -g playarr -m 0644 <YOUR-FULLCHAIN>.pem /etc/playarr/tls/fullchain.pem
sudo install -o root -g playarr -m 0640 <YOUR-PRIVATE-KEY>.pem /etc/playarr/tls/privkey.pem
sudo systemctl restart playarr.service
Both files are read once, at startup, renewing the certificate on disk requires a restart.
Playarr terminates TLS itself, with automatic Let’s Encrypt
# /etc/playarr/playarr.env
PLAYARR_ACME_DOMAIN=<YOUR-SERVER-HOSTNAME>
PLAYARR_ACME_ENVIRONMENT=production # or `staging` while testing
PLAYARR_ACME_ACCEPT_TERMS=true # must be exactly `true`
PLAYARR_ACME_CONTACT=<YOU>@example.com # optional
PLAYARR_ACME_CACHE_DIR=/var/lib/playarr/acme
PLAYARR_ACME_ENVIRONMENT is deliberately mandatory so a staging certificate can never be
mistaken for a browser-trusted one. PLAYARR_ACME_ACCEPT_TERMS must be the literal string true;
this is your explicit acceptance of Let’s Encrypt’s subscriber agreement.
Prerequisites, both on you:
<YOUR-SERVER-HOSTNAME>already resolves publicly to this machine. Playarr validates the value as a bare DNS hostname, no scheme, port, path or trailing dot.- Inbound TCP port 80 reaches the machine, for the HTTP-01 challenge.
Playarr then runs its own challenge listener on port 80, persists the account and certificate
under PLAYARR_ACME_CACHE_DIR, serves HTTPS on PLAYARR_HTTP_BIND_ADDR, and hot-renews without
a restart. Cleartext requests arriving on the challenge listener are redirected to the HTTPS origin.
If no certificate is issued within 120 seconds the process exits with an error, so watch the journal
on first start:
sudo systemctl restart playarr.service
journalctl -u playarr.service -f
The shipped systemd unit grants CAP_NET_BIND_SERVICE, which is what allows the unprivileged
playarr user to bind port 80 for HTTP-01. No other privilege is granted. A relay node using relay-dns-01
(below) needs neither the capability nor port 80.
HTTP-01 is the default challenge. If inbound port 80 cannot reach the machine, automatic HTTPS will fail after 120 seconds and the process will exit. For a public IPv4 relay node you can instead set
PLAYARR_ACME_CHALLENGE=relay-dns-01together withPLAYARR_RELAY_REGISTER=true: the certificate is then obtained with ACME DNS-01 through theplayarr.appWorker, which publishes the temporary TXT record for you, so no port 80 is needed. See “Reaching a public IPv4 address” below anddocs/deployment/playarr-relay.mdin the repository.
Running behind a reverse proxy
Leave PLAYARR_TLS_* and PLAYARR_ACME_* unset, bind Playarr to 127.0.0.1:8484, and proxy
to it. Four things the proxy must get right:
X-Forwarded-ProtoandX-Forwarded-Host. WhenPLAYARR_DEVICE_VERIFICATION_URIis a relative path, and its default,/link, is,POST /api/v1/oauth/device/codebuilds the absolute verification URI it returns from these two headers, falling back toHostand then tohttp://localhost. Get them wrong and every client using the server’s own device-code flow displays an unreachable pairing address.- No response buffering. HLS segments and byte-range direct play must stream through, not be spooled to a temporary file first.
- Long timeouts. A single direct-play response can run for the length of the file.
- Never proxy port 9090.
/metricsis unauthenticated; scrape it over your private network only.
Caddy
Complete Caddyfile, TLS included, Caddy obtains and renews the certificate itself as long as the
hostname resolves to this machine and ports 80 and 443 are reachable:
<YOUR-SERVER-HOSTNAME> {
encode {
zstd gzip
# Never recompress media. Segments and byte-range responses are
# already compressed and buffering them defeats streaming.
match {
header Content-Type text/*
header Content-Type application/json*
header Content-Type application/javascript*
header Content-Type image/svg+xml*
}
}
log {
output stdout
format json
}
reverse_proxy 127.0.0.1:8484 {
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
# -1 disables response buffering entirely: bytes are flushed to the
# client as they arrive, which is what HLS and long range reads need.
flush_interval -1
transport http {
dial_timeout 10s
response_header_timeout 60s
# No read_timeout: a direct-play response may legitimately run
# for hours.
}
health_uri /healthz
health_interval 10s
health_timeout 3s
health_status 200
}
}
Reload without dropping connections:
caddy reload --config /etc/caddy/Caddyfile
The reference Docker Compose stack ships a shorter Caddyfile at infra/docker/prod/Caddyfile, load
balanced across the playarr-api containers and reading its hostname from {$PLAYARR_DOMAIN}
(defaulting to localhost). It deliberately does not proxy 9090. Two differences matter if you copy
it rather than the configuration above: it sets X-Forwarded-Host but not X-Forwarded-Proto,
so device-code pairing URIs come back as http://, and it does not set flush_interval -1. Add
both lines if you use it as a starting point for a real public deployment.
nginx
nginx does not obtain certificates itself. Get one first with certbot:
sudo certbot certonly --nginx -d <YOUR-SERVER-HOSTNAME>
Then /etc/nginx/sites-available/playarr.conf:
upstream playarr {
server 127.0.0.1:8484;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name <YOUR-SERVER-HOSTNAME>;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 308 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name <YOUR-SERVER-HOSTNAME>;
ssl_certificate /etc/letsencrypt/live/<YOUR-SERVER-HOSTNAME>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<YOUR-SERVER-HOSTNAME>/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Playarr streams whole files; nothing is uploaded through this path.
client_max_body_size 16m;
# Media responses are already compressed.
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
gzip_proxied any;
location / {
proxy_pass http://playarr;
proxy_http_version 1.1;
# Required: the device-pairing URI shown on TVs is built from these.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Pass through Playarr's own client-identification headers and any
# CORS preflight the packaged TV apps send.
proxy_pass_request_headers on;
# Harmless today (Playarr exposes no WebSocket endpoint yet) and
# correct if one is ever added.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Stream, do not spool. Without these, nginx buffers a multi-gigabyte
# response to disk before the player sees a single byte.
proxy_buffering off;
proxy_request_buffering off;
proxy_max_temp_file_size 0;
# A direct-play response can run for the length of the file.
proxy_connect_timeout 10s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
send_timeout 3600s;
}
}
$connection_upgrade is not built in. Add it once, in the http block of /etc/nginx/nginx.conf:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Enable and reload:
sudo ln -s /etc/nginx/sites-available/playarr.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Warning,
trusted-networkauth and reverse proxies do not mix. WithPLAYARR_AUTH_MODE=trusted-network, Playarr decides whether to auto-log-in a caller as admin from the socket peer address, not fromX-Forwarded-For. Behind a proxy, every request appears to come from the proxy itself, which is on a private range, and therefore inside the default allowlist. The effect is that anyone who reaches the proxy is admitted as an admin with no credentials. Keep the defaultPLAYARR_AUTH_MODE=full-accountfor anything reachable from outside your LAN.PLAYARR_TRUSTED_NETWORK_CIDRreplaces the default allowlist with a single range of your choosing, but narrowing it does not fix this: the address Playarr sees is still the proxy’s, so either the proxy is inside the range and everyone through it is an admin, or it is outside and nobody can sign in at all.
CORS and allowed hosts
Two honest statements about what the server exposes here.
CORS is permissive and there is no configuration key for it. The API attaches
CorsLayer::permissive, any origin, any method, any header, no credentials, as the outermost
layer, so preflight OPTIONS requests are answered before authentication runs. This is deliberate,
not an oversight: the API is Bearer-token authenticated and never cookie- or session-authenticated,
so there is no CSRF surface an origin allowlist would protect. It is also load-bearing, because the
packaged webOS, Tizen and VIDAA shells run from their own app origin and always call the API
cross-origin.
There is no allowed-hosts, trusted-origins or trusted-proxy setting. Playarr does not validate
the Host header against a configured list, and does not have a setting that tells it which
upstream proxies to trust. If you need origin restriction or proxy-aware client-IP handling, it has
to be implemented in your proxy.
Three request headers must survive the proxy, all three are already covered by the configurations above, but strip headers at your own risk:
| Header | Purpose |
|---|---|
x-playarr-client-platform |
Client platform identity, read by the API version gate |
x-playarr-client-version |
Client build version, read by the API version gate |
Authorization |
Bearer access token, the only access control the API has |
Telling the clients where the server is
No Playarr client ships with a server address baked in. Each one learns it, once, from you, but not all of them learn it the same way.
| Client | Status | How the address is supplied |
|---|---|---|
Browser (playarr.app) |
Available | Absolute URL typed on the sign-in screen, e.g. https://<YOUR-SERVER-HOSTNAME> or http://192.168.1.50:8484 |
| Android phones and tablets | Available | Editable server address on the sign-in screen, with a missing scheme read as http:// |
| Android TV, Google TV | Available | No address entry at all. The television is linked from an already-signed-in browser through the hosted link page at playarr.app/link, which hands it the server addresses |
| LG webOS, Samsung Tizen | Available · Experimental install | Same hosted link flow as Android TV on a generic first launch |
| Hisense VIDAA | Available · Experimental install | Same hosted link flow |
| Roku | Available · Experimental install | Server base URL on first launch, then RFC 8628 device-code sign-in; current builds also point at the hosted link flow |
| iOS, iPadOS, Apple TV | Coming soon, not built yet, source only, no distributable signed build | Editable server address plus phone-friendly device-code pairing, in the source as it stands |
Which of those two paths a device takes decides which of the settings below matters, so it is worth
being clear about it: the hosted link flow at playarr.app/link never touches
PLAYARR_DEVICE_VERIFICATION_URI, while the server’s own RFC 8628 flow depends on it entirely.
Additional settings that affect what clients see:
-
PLAYARR_DEVICE_VERIFICATION_URI(default/link) sets the base URI returned byPOST /api/v1/oauth/device/codefor on-screen display. Because the default is a relative path it is resolved againstX-Forwarded-Proto/X-Forwarded-Host, which is what turns it into the address a viewer types on their phone. Note that Playarr Admin serves no/linkroute , the default therefore produces a URL on your own host that has no page behind it. Set this to an absolute URL that does (for examplehttps://playarr.app/link) if you rely on the server’s own device flow. Setting an absolute value skips the forwarded-header rewrite altogether. -
PUT /api/v1/admin/peer-nodes/selfrecords the addresses this node advertises to clients. Each entry carries aclient_reachableflag that is operator-asserted and never auto-detected , NAT and firewall topology cannot be guessed. Onlyclient_reachable: trueaddresses are handed to clients:PUT /api/v1/admin/peer-nodes/self Authorization: Bearer <ADMIN-ACCESS-TOKEN> Content-Type: application/json { "name": "home", "addresses": [ {"url": "https://<YOUR-SERVER-HOSTNAME>", "priority": 0, "label": "wan", "client_reachable": true}, {"url": "http://192.168.1.10:8484", "priority": 1, "label": "lan", "client_reachable": false} ] }Lower
prioritysorts first.labelis informational (lan,wan,relay).
Browsers, mixed content and private addresses
The hosted browser client is served over HTTPS. A page loaded over HTTPS cannot generally reach a plain-HTTP server. Playarr marks private and loopback addresses as local-network requests so a browser that implements Local Network Access can prompt for permission and relax mixed-content blocking, and you approve that prompt the first time. Browser support is still uneven: a browser without Local Network Access cannot connect from the HTTPS hosted app to a private plain-HTTP server. Giving your server a real hostname and a real certificate, by either route above, is the way around that.
For a public IPv4 address, Playarr rewrites what you typed into the deterministic hostname
https://v4-A-B-C-D.relay.playarr.app:8484. The parent DNS records are DNS-only and no traffic is
relayed through them. Cloudflare only holds DNS: when you opt in with PLAYARR_RELAY_REGISTER=true,
your server tells playarr.app its public IPv4 address (override with PLAYARR_PUBLIC_IPV4, or let
playarr.app report the address it sees), on start, when the address changes and hourly. Playarr
calls your server back on http://<ip>:8484/.well-known/playarr-relay/<token> to prove you control
that address, then publishes the matching DNS-only record, which is removed after seven days without a
heartbeat. Playarr terminates TLS itself. Set PLAYARR_ACME_DOMAIN to the matching
v4-A-B-C-D.relay.playarr.app hostname with PLAYARR_ACME_ENVIRONMENT=production,
PLAYARR_ACME_ACCEPT_TERMS=true and, if port 80 is not reachable, PLAYARR_ACME_CHALLENGE=relay-dns-01.
Registration sends your public IPv4 address and a key fingerprint to playarr.app, which is why it is
off by default.
Two consequences of that, both easy to miss. The clients build the URL with port 8484 hard-coded,
so PLAYARR_HTTP_BIND_ADDR must keep listening on 8484, do not move it. And because Playarr
issues that certificate over HTTP-01, inbound port 80 still has to reach the machine, exactly as in
the automatic-HTTPS section above.
Security posture, what the project does and does not do
What Playarr does:
- Authenticates every non-public route with a Bearer access token. The unauthenticated route
allowlist is a short, closed list:
/api/system/health,/api/system/ready,/api/system/version,/api/v1/auth/login,/api/v1/auth/signup(which itself requires a validinvite_tokenin the body, it is not open registration),/api/v1/auth/refresh,/api/v1/oauth/device/code,/api/v1/oauth/token,/webhooks/{instance_id}and/api/v1/peer/enroll(authorised by a one-shot join token in the request body rather than a JWT). Note that/webhooks/{instance_id}takes no token at all, if you expose the server publicly, anyone who guesses an instance UUID can post to it. - Hashes account passwords with Argon2id.
- Generates a random bootstrap admin password when
PLAYARR_BOOTSTRAP_ADMIN_PASSWORDis unset and logs it exactly once, at WARN. There is no shipped default password. - Defaults
PLAYARR_AUTH_MODEtofull-account, and scopes the opt-intrusted-networkmode to RFC 1918 plus loopback rather than0.0.0.0/0, so a stray port-forward does not hand out admin. - Terminates TLS in-process when you configure it to, and hot-renews ACME certificates without a restart.
- Runs under a hardened systemd unit:
NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp,UMask=0027, andCAP_NET_BIND_SERVICEas the only capability.
What Playarr does not do:
- It does not enable TLS for you. The default is plain HTTP.
- It does not restrict origins or validate the
Hostheader, and has no trusted-proxy setting. - It does not rate-limit or lock out repeated sign-in attempts.
- It does not encrypt integration credentials at rest. Despite the
api_key_encryptedcolumn name, the API keys you give it for your library-management apps are stored as plain text, protect the database file and its backups accordingly. - It does not open ports, manage your firewall, register DNS, or create tunnels.
- It has no built-in fail2ban-style protection, no WAF, and no audit log of failed authentication.
Exposing a server to the internet is your decision and your responsibility. If you are not comfortable operating a public HTTPS endpoint, keep Playarr on your LAN and reach it over a VPN you control instead, the client address settings above work identically over a VPN, and nothing in Playarr needs to change.
Verifying it works
# Liveness through the proxy, from another machine.
curl -fsS https://<YOUR-SERVER-HOSTNAME>/healthz
# Readiness, 200 only once migrations have applied and the pool is connected.
curl -fsS -o /dev/null -w '%{http_code}\n' https://<YOUR-SERVER-HOSTNAME>/readyz
# Confirm the forwarded headers reach Playarr: the verification_uri in this
# response must be your public HTTPS address, not http://localhost/link.
# client_platform is required and must be one of the ClientPlatform values ,
# android-mobile, android-tv, ios, web, tv-webos, tv-tizen, tv-vidaa,
# playarr-admin. Omitting it returns 422, not a device code.
curl -fsS -X POST https://<YOUR-SERVER-HOSTNAME>/api/v1/oauth/device/code \
-H 'Content-Type: application/json' \
-d '{"client_platform":"web"}'
# Confirm metrics are NOT reachable from outside. This must fail. Port 9090
# is plain HTTP even when 8484 is behind TLS, so test it as http://.
curl -fsS --max-time 5 http://<YOUR-SERVER-HOSTNAME>:9090/metrics
If the third command returns a verification_uri of http://localhost/link, your proxy is not
sending X-Forwarded-Proto and X-Forwarded-Host.
The repository ships a reference Caddy configuration for the Docker Compose stack at
infra/docker/prod/Caddyfile, and no reverse-proxy example at all for the systemd install or the Kubernetes install, the Helm chart and kustomize manifests contain no Ingress or Gateway resource, because ingress class, certificate issuer and hostname are all cluster-specific. The nginx configuration above is written for this documentation rather than copied from the repository.