Docker Compose
Run Playarr as a container stack with one server on a SQLite volume, behind Caddy, using the reference Compose topology in the repository.
This shape runs Playarr as containers on a single Docker host, a home server, a small VPS, or anywhere Docker is already how you run things. The reference topology lives at infra/docker/docker-compose.prod.yml: a caddy edge proxy and one playarr container (role all) whose SQLite database lives on a named volume. Playarr is SQLite-only, so there is no database service to run. This page walks the stack from a clean checkout to a healthy server.
Nothing here has been booted end to end yet. The stack builds the image locally from source, and the Compose files have not been verified on live hardware. Expect to read logs.
What the stack runs
| Service | Image | Role | Published ports |
|---|---|---|---|
playarr |
built from infra/docker/backend.Dockerfile |
PLAYARR_ROLE=all, one replica |
none, exposes 8484 and 9090 |
caddy |
caddy:2-alpine |
Reverse proxy and TLS | 80, 443, 443/udp |
Two Docker networks are used: playarr-net carries everything, and playarr-edge carries only Caddy. Run one playarr container per database: two containers must never open the same SQLite file from different hosts. For more than one node, run separate stacks and let them peer-sync.
Before you start
- A Linux host with Docker Engine and the Compose v2 plugin (
docker compose version). - A git checkout of the repository. The host needs enough headroom to compile the Rust binary and build the web assets; the build is multi-stage and not small.
- Your media already on disk, reachable from this host at a stable path.
- Ports 80 and 443 free on the host.
1. Get the repository
git clone https://github.com/PlayarrApp/playarr.git
cd playarr
Every build in infra/docker/ uses the repository root as its build context, so run the commands below from the repository root.
2. Write infra/docker/.env
The .env file must sit next to the Compose file, not at the repository root: Compose resolves it from the directory containing the first -f file. infra/docker/.env is covered by .gitignore. There is no required value; this optional example lists the keys the Compose file interpolates:
# infra/docker/.env, untracked; never commit this file.
# Caddy's site address. Left at the default, Caddy serves plain HTTP on
# :80 and a locally trusted certificate on :443. Set a real public
# hostname and Caddy obtains a certificate automatically on :443.
PLAYARR_DOMAIN=localhost
# Mapped onto the binary's PLAYARR_LOG (a tracing EnvFilter directive).
PLAYARR_LOG_LEVEL=info
Compose
.envvalues are for interpolation only. They reach a container solely becausedocker-compose.prod.ymlnames them in anenvironment:block. Everything else the binary reads goes in the override file in step 3.
3. Write an override for media, caches and the rest of the configuration
The reference file mounts no media and sets only the variables it needs. Create infra/docker/docker-compose.local.yml (not in the repository; you write it and pass it with a second -f on every command):
# infra/docker/docker-compose.local.yml, yours, untracked.
services:
playarr:
environment:
# 32 bytes or more. Shorter is ignored with a warning and a secret
# derived from the node identity is used instead.
PLAYARR_JWT_SECRET: ${PLAYARR_JWT_SECRET:?Set PLAYARR_JWT_SECRET in infra/docker/.env}
PLAYARR_AUTH_MODE: full-account
# Only if the library managers report paths that differ from where this
# host can read them. Both must be set.
# PLAYARR_MEDIA_REMOTE_ROOT: /data
# PLAYARR_MEDIA_LOCAL_ROOT: /srv/media
# Extracted-subtitle cache; needs a volume (see below).
PLAYARR_SUBTITLE_CACHE_DIR: /data/playarr-cache/subtitles
volumes:
# Your media, read-only. Mount it at the SAME path the library managers use
# and no path mapping is needed at all.
- /srv/media:/srv/media:ro
- playarr_prod_subtitle_cache:/data/playarr-cache/subtitles
volumes:
playarr_prod_subtitle_cache:
Add the secret to infra/docker/.env:
printf 'PLAYARR_JWT_SECRET=%s\n' "$(openssl rand -hex 32)" >> infra/docker/.env
Compose merges environment maps and volumes lists across -f files, so the base file’s DATABASE_URL, bind addresses and cache directories survive. docker compose config shows the merged result.
Every writable path must be a volume or a tmpfs. The base file sets
read_only: trueon theplayarrservice. Only/tmp(tmpfs) and explicitly mounted volumes can be written.
Mount media at the identical path the library managers report, and no path mapping is needed. Only reach for
PLAYARR_MEDIA_REMOTE_ROOT/PLAYARR_MEDIA_LOCAL_ROOTwhen you cannot. A single prefix pair is supported.
/datais the one path you cannot use for media. The base file mounts volumes beneath/data, and Docker cannot create those nested mount points inside a read-only bind mount at/data. Mount the tree elsewhere and use the prefix substitution pair.
4. Build the image
docker compose \
-f infra/docker/docker-compose.prod.yml \
-f infra/docker/docker-compose.local.yml \
build
The build is multi-stage: cargo-chef dependency caching, the Rust compile of the playarr binary, a Node stage that builds Playarr Admin, and a slim runtime that installs ca-certificates, curl, ffmpeg and tini. The result runs as non-root uid/gid 10001, exposes 8484 and 9090, and carries a HEALTHCHECK on /healthz.
5. Bring the stack up
docker compose \
-f infra/docker/docker-compose.prod.yml \
-f infra/docker/docker-compose.local.yml \
up -d
On first start the playarr container creates the SQLite file on its data volume, runs the embedded migrations (there is no migration container and no migrate subcommand), seeds first-run state, and provisions a bootstrap admin if the database has no users. Caddy then proxies to it. Follow the logs with docker compose ... logs -f playarr.
6. Verify
Container health
docker compose \
-f infra/docker/docker-compose.prod.yml \
-f infra/docker/docker-compose.local.yml \
ps
Every playarr-* container should reach healthy, driven by the image’s own HEALTHCHECK.
Through the edge
curl -fsS http://localhost/healthz && echo OK
curl -fsS http://localhost/readyz && echo READY
curl -fsS http://localhost/api/system/version
/healthz is liveness. /readyz returns 200 only once startup finished, database opened and migrations applied, and 503 before that. /api/system/version is an unauthenticated version envelope. If you set a real PLAYARR_DOMAIN, use https://<YOUR-DOMAIN>/ instead.
Metrics
Port 9090 is deliberately not proxied by Caddy; /metrics should never be reachable from the public edge. Reach it over the internal network instead:
docker compose \
-f infra/docker/docker-compose.prod.yml \
-f infra/docker/docker-compose.local.yml \
exec playarr \
curl -fsS http://127.0.0.1:9090/metrics | head
First sign-in
If the database had no users, one admin was provisioned and its password was written to the log exactly once, at WARN level:
docker compose \
-f infra/docker/docker-compose.prod.yml \
-f infra/docker/docker-compose.local.yml \
logs playarr | grep 'bootstrap admin'
The username defaults to admin; the password is 64 random hex characters unless you set one. To choose both up front, add them to your override’s x-local-env block before the first boot, they are only consulted when the user table is empty:
PLAYARR_BOOTSTRAP_ADMIN_USERNAME: admin
PLAYARR_BOOTSTRAP_ADMIN_PASSWORD: ${PLAYARR_BOOTSTRAP_ADMIN_PASSWORD:-}
The playarr container serves Playarr Admin at /. Its login identifies itself as
playarr-admin, so the non-streaming bootstrap administrator can sign in to the operator UI
without being granted consumer playback access.
The image already serves Playarr Admin at /. Two ways forward:
-
Use the credentials against the API. Post to
/api/v1/auth/loginwith"client_platform": "playarr-admin", which skips the streaming gate. First run walks through that request and what to do with the token. -
Override the bundled Admin build during development. From your checkout:
cd clients/tv-web pnpm install --frozen-lockfile pnpm --filter @playarr-tv/admin... run buildThen point the container at the result. Add to your override’s
x-local-env:PLAYARR_WEB_ASSETS_DIR: /srv/playarr-adminand to
x-local-volumes, using an absolute host path:- /absolute/path/to/playarr/clients/tv-web/admin/dist:/srv/playarr-admin:roRecreate the container and
/serves your local admin build. If noindex.htmlis found at the resolved path the server runs API-only and logs an informational line saying so, that log line is how you tell a bad path from a bad build.
Either way, create a separate ordinary account with streaming access for actually watching things. The bootstrap admin is an operator account and nothing else.
From here, continue to First run to register your library managers and connect a client.
Environment variable reference
Set by docker-compose.prod.yml itself:
| Variable | Value in the shipped file |
|---|---|
DATABASE_URL |
sqlite:///data/playarr.db, on the playarr_prod_data volume |
PLAYARR_ROLE |
all |
PLAYARR_LOG |
${PLAYARR_LOG_LEVEL:-info} |
PLAYARR_HTTP_BIND_ADDR |
0.0.0.0:8484, a full socket address, not a bare port |
PLAYARR_METRICS_BIND_ADDR |
0.0.0.0:9090, likewise |
PLAYARR_ARTWORK_CACHE_DIR |
/data/playarr-cache/artwork |
Worth adding through the override file:
| Variable | Default | Why |
|---|---|---|
PLAYARR_JWT_SECRET |
required by the reference file | docker-compose.prod.yml refuses to start without it. Under 32 bytes it is ignored with a warning and a secret derived from the node identity is used, which does not agree between containers. |
PLAYARR_AUTH_MODE |
full-account |
trusted-network auto-logs in any request from an allowed CIDR with no credentials. |
PLAYARR_TRUSTED_NETWORK_CIDR |
RFC 1918 plus loopback | Replaces the default allowlist rather than extending it. Only relevant in trusted-network mode. |
PLAYARR_SUBTITLE_CACHE_DIR |
temp directory | Otherwise extracted subtitles land in the container’s tmpfs and are rebuilt after every restart. Must point at a volume mount, the root filesystem is read-only. |
PLAYARR_WEB_ASSETS_DIR |
web/ next to the binary, i.e. /app/web in this image |
Which built UI is served at /. The image ships Playarr Admin here. |
PLAYARR_MEDIA_REMOTE_ROOT / PLAYARR_MEDIA_LOCAL_ROOT |
unset | Prefix substitution when the path a library manager reports is not the path this host can read. Both required. |
PLAYARR_BOOTSTRAP_ADMIN_USERNAME / _PASSWORD |
admin / random |
First-boot admin only; ignored once any user exists. |
PLAYARR_OTLP_ENDPOINT |
unset | OTLP collector. When unset, the OpenTelemetry layer is a no-op. |
PLAYARR_TRANSCODE_SESSION_IDLE_TTL_SECS |
60 |
Idle deadline for an on-demand transcode session; every manifest or segment request slides it forward. |
Playarr’s TLS and ACME variables (PLAYARR_TLS_CERT_PATH, PLAYARR_ACME_DOMAIN and friends) are not useful here, Caddy terminates TLS at the edge and the Playarr containers speak plain HTTP on the internal network.
Volumes and state
| Volume | Mounted at | Holds |
|---|---|---|
playarr_prod_data |
/data |
The SQLite database and the artwork cache (/data/playarr-cache/artwork). The database is what matters; the cache is rebuildable but slow to rebuild. |
caddy_data, caddy_config |
Caddy’s own paths | Issued certificates and Caddy state. Losing caddy_data means re-issuing certificates. |
playarr_prod_subtitle_cache |
/data/playarr-cache/subtitles |
Extracted subtitles, yours, from the override in step 3, not the base file. |
Compose names the project after the directory holding the first
-ffile. That isinfra/docker/, so the project is calleddockerand every volume above is created with adocker_prefix,docker_playarr_prod_data, notplayarr_prod_data. That is whatdocker volume lsshows you, and what any backup you write has to name. Pass-p playarron every invocation if you would rather it were called something else, but be consistent: changing it later orphans the old volumes.
The playarr container runs with read_only: true, no-new-privileges:true and a tmpfs at /tmp, and restart: unless-stopped. On-demand transcode output goes to /tmp, which means it is a RAM disk sized by Docker’s default, no environment variable relocates it.
Do not copy the live database file. Playarr can write its own age-encrypted backups, but only once you set
PLAYARR_BACKUP_DIRandPLAYARR_BACKUP_RECIPIENTSin your override file; the reference stack does not. Keep the backup directory on a different volume fromplayarr_prod_data, or copy the archives off the host, because a backup on the same disk does not survive losing it. See Upgrade and backup.
Hardware acceleration
Not built yet. There is no VAAPI, NVENC, QSV, CUDA or VideoToolbox support anywhere in the backend, and no /dev/dri device passthrough, NVIDIA runtime or GPU resource request in any Compose file, Dockerfile or Kubernetes manifest in the repository. The on-demand transcode path invokes ffmpeg with the software encoder libx264. Only the binary paths are overridable, via PLAYARR_FFMPEG_BINARY and PLAYARR_FFPROBE_BINARY, so substituting a wrapper is possible, but nothing in the product exposes device passthrough as a supported feature, and none of it is documented. Size your host for software encoding.
Optional: Prometheus and Grafana
infra/docker/observability/docker-compose.yml is a separate Compose project that joins the already-running playarr-net network as external. Start the base stack first, then:
docker compose -f infra/docker/observability/docker-compose.yml up -d
Prometheus lands on host port 9091 (remapped from its in-container 9090) and Grafana on 3000. Run it as its own project, not merged with -f onto the base file, relative bind-mount paths would otherwise resolve against the first file’s directory.
Updating
There is no working self-update . playarr update --check makes no network call and always reports the running binary as current; playarr update --yes returns “not implemented yet”. The image also carries a root-owned /.playarr-container marker intended to make the binary refuse to self-update inside a container, but nothing reads it today.
The real path is rebuild and recreate:
git pull
docker compose \
-f infra/docker/docker-compose.prod.yml \
-f infra/docker/docker-compose.local.yml \
build
docker compose \
-f infra/docker/docker-compose.prod.yml \
-f infra/docker/docker-compose.local.yml \
up -d
up -d recreates only the container whose image actually changed, and migrations run automatically on the next start. Once images are published to a registry, docker compose pull replaces the build step.
infra/docker/docker-compose.watchtower.optional.yml is an opt-in overlay that labels only the playarr service for Watchtower, repins them from the exact MAJOR.MINOR.PATCH tag to a floating ${PLAYARR_MINOR_TAG:-0.1}, and runs a real containrrr/watchtower container polling every 300 seconds. It needs a published registry image to be of any use, and it acts through the Docker Engine API rather than Compose, so run docker compose up -d afterwards to reconcile Compose’s own view of the stack.
More than one node
Playarr is SQLite-only: each stack owns its own database. To run a second node, start a second stack (on another host) and connect the two with peer sync. There is no shared database to point several containers at.
Licensing
The container images built from infra/docker/backend.Dockerfile bundle FFmpeg, which is licensed GPLv2-or-later, while Playarr’s own source is MIT, see Licences and attribution.