Upgrades and backups
What Playarr state is worth preserving, how to snapshot it safely on each deployment shape, and how to upgrade, roll back and verify a running server.
Playarr keeps almost everything that matters in one database. Everything else on disk is either configuration you wrote yourself or a cache the server can rebuild. That makes the operational story short, but it is worth knowing exactly which files fall into which category before you touch a running install.
Read this first. Playarr has a built-in backup feature, but it is off by default and the shipped systemd, Compose and Helm files do not enable it. It writes age-encrypted archives to a local directory (
PLAYARR_BACKUP_DIR) and needs at least one recovery public key (PLAYARR_BACKUP_RECIPIENTS). Restore is an offline command-line action. See Built-in encrypted backups below. The other procedures here, marked general guidance, are standard SQLite and platform steps rather than project-documented ones. Rollback is not documented by the project at all. Test everything on your own install before relying on it.
What state actually needs backing up
| State | Matters? | Why |
|---|---|---|
| The database (the SQLite file) | Critical | Everything durable: catalogue, users and policies, library-manager source-instance registrations and their API keys, Tdarr connection, library views, playlists, playback progress, invites, and this installation’s Ed25519 node identity. |
Configuration, /etc/playarr/playarr.env, your Compose .env, or your Helm values file and Kubernetes Secret |
Critical | Not stored in the database. If you set PLAYARR_JWT_SECRET explicitly, losing it signs every client out. If you do not, the server derives a stable secret from its node identity, which is in the database. |
ACME state, PLAYARR_ACME_CACHE_DIR, default /var/lib/playarr/acme |
Useful | Holds the Let’s Encrypt account and issued certificate. Recoverable by re-issuing, at the cost of rate-limit budget. Only exists if you enabled automatic HTTPS. |
Artwork cache, PLAYARR_ARTWORK_CACHE_DIR |
Optional | A local copy of artwork already published by your library managers. Regenerated on demand; back it up only to avoid a re-fetch storm after a restore. |
| Episode-thumbnail and subtitle caches | Optional | Derived from your own files with ffmpeg/ffprobe. Fully regenerable. |
Transcode working directory, ${TMPDIR}/playarr-transcode |
No | Live session scratch only. Never back it up. |
| Your media files | Out of scope | Playarr never writes to them. Back them up however you already do. |
Two things Playarr does not have, which shortens the list further: there is no object-storage backend of any kind, and there is no configuration file format, all server configuration is environment variables.
Where that state lives
systemd
| Path | Contents |
|---|---|
/var/lib/playarr/playarr.db |
The database, assuming DATABASE_URL=sqlite:///var/lib/playarr/playarr.db. There is no built-in default, infra/systemd/playarr.env.example ships DATABASE_URL= empty, so this is a value you set yourself. |
/var/lib/playarr/playarr.db-wal, …-shm |
SQLite write-ahead log and shared-memory index. Present because Playarr sets PRAGMA journal_mode = WAL at startup. |
/etc/playarr/playarr.env |
All configuration. Mode 0640, root:playarr. |
/var/lib/playarr/cache/artwork |
Default artwork cache when DATABASE_URL is a sqlite:// URL and PLAYARR_ARTWORK_CACHE_DIR is unset. |
/var/lib/playarr/artwork-cache/episode-thumbnails, /var/lib/playarr/subtitle-cache |
Derived caches, co-located with the database file. |
/var/lib/playarr/acme |
ACME account and certificate, if automatic HTTPS is enabled. |
The systemd unit’s sandbox (ProtectSystem=strict,
ReadWritePaths=/var/lib/playarr /var/log/playarr) means the service cannot write anywhere else,
so this list is exhaustive by construction. The one exception is scratch: PrivateTmp=true gives the
unit its own /tmp, which is where the transcode working directory lands and which systemd discards
when the service stops. Nothing there is worth preserving.
Docker Compose
State lives in named Docker volumes, declared in infra/docker/docker-compose.prod.yml:
| Volume | Mounted at | Contents |
|---|---|---|
playarr_prod_data |
/data |
The SQLite database. |
playarr_prod_artwork_cache |
/data/playarr-cache/artwork |
Artwork cache. |
caddy_data, caddy_config |
Caddy | TLS certificates issued by the edge proxy. |
The containerised single-node stack (docker-compose.standalone.yml) is simpler: one volume,
playarr_standalone_data, mounted at /data, containing playarr.db and cache/artwork.
Compose prefixes volume names with the project name. Run
docker volume ls | grep playarrto see the real names on your host before scripting anything against them.
Kubernetes
A Playarr node on Kubernetes is a single-replica StatefulSet whose volume claim template creates a
persistent volume mounted at /data, holding the SQLite database and the caches. The database is that volume’s playarr.db. The
state that is also yours to preserve is your Secret and your values file. Playarr is SQLite-only, so
there is no external database to back up.
Backup procedures
General guidance, not project-documented. Adapt paths to your install.
systemd: a safe SQLite snapshot
Do not cp a live playarr.db. With WAL enabled, recent commits may still be in the -wal
file and a bare copy can be inconsistent. Use SQLite’s online backup instead, which is safe against a
running server:
# The backup directory must be writable by the playarr user, because the
# command below runs as that user in order to read the database file
# (/var/lib/playarr is mode 0750, owned playarr:playarr).
sudo install -d -o playarr -g playarr -m 0700 /var/backups/playarr
sudo -u playarr sqlite3 /var/lib/playarr/playarr.db \
".backup '/var/backups/playarr/playarr-$(date +%F).db'"
VACUUM INTO is an equally safe alternative that also compacts the result:
sudo -u playarr sqlite3 /var/lib/playarr/playarr.db \
"VACUUM INTO '/var/backups/playarr/playarr-$(date +%F).db'"
The
sqlite3CLI is not installed by Playarr. Add it first,apt install sqlite3,dnf install sqlite, or your NAS package manager’s equivalent.
Then capture configuration and the certificate state:
sudo tar czf /var/backups/playarr/playarr-config-$(date +%F).tar.gz \
/etc/playarr/playarr.env \
/var/lib/playarr/acme
Drop the second path if you never enabled automatic HTTPS,
/var/lib/playarr/acmedoes not exist on those installs andtarwill exit non-zero complaining about it, even though it still archives the env file.
A cold, belt-and-braces alternative is to stop the service and archive the whole data directory:
sudo systemctl stop playarr.service
sudo tar czf /var/backups/playarr/playarr-full-$(date +%F).tar.gz \
-C / etc/playarr var/lib/playarr
sudo systemctl start playarr.service
Containerised (Docker Compose)
The runtime image installs only ca-certificates, curl, ffmpeg and tini, so there is no
sqlite3 inside it. Use a throwaway helper container against the volume:
docker run --rm \
-v <YOUR-PROJECT>_playarr_standalone_data:/state \
-v /var/backups/playarr:/backup \
alpine:3 sh -c \
'apk add --no-cache sqlite >/dev/null && \
sqlite3 /state/playarr.db ".backup /backup/playarr-$(date +%F).db"'
Note the volume is not mounted
:ro. The database is in WAL mode, and opening a WAL database , even only to read it, requires creating or attaching the-shmshared-memory index next to it. A read-only mount makes the command fail before it starts..backupitself never modifies the source database.
Kubernetes
The runtime image has no sqlite3, so for a manual snapshot enable the built-in backup feature (next
section), or stop the pod and take a volume snapshot. Then capture the Kubernetes-side
configuration:
kubectl get secret <YOUR-SECRET> -n playarr -o yaml > playarr-secrets.yaml # only if you created one
helm get values playarr -n playarr -o yaml > playarr-values.yaml
Back up the Secret wherever your secrets tool already keeps its source of truth rather than as a plaintext YAML dump if you can. If your storage class supports volume snapshots, a snapshot of the database volume taken while the pod is stopped is a portable second copy.
Built-in encrypted backups
Playarr Server can write its own backups. The design is in docs/architecture/server-backups.md in
the repository. What it does today:
- Opt-in. Nothing happens unless
PLAYARR_BACKUP_DIRis set, and the server refuses to enable backups without a valid age public key inPLAYARR_BACKUP_RECIPIENTS. Generate the key pair offline withplayarr-server backup keygen --out <file>; the server only ever holds the public key, so keep the private identity file somewhere other than the server. - What an archive holds. A consistent snapshot of the SQLite database (
VACUUM INTO, safe while the server runs) and, in the defaultfullmode, the artwork cache. It never contains your media, yourPLAYARR_JWT_SECRET, TLS or ACME material, or any other secret from the environment. Archives are.parbakfiles, age-encrypted, with a SHA-256 manifest. - Schedule and retention. One run every
PLAYARR_BACKUP_INTERVAL_HOURS(default 24,0for manual only), keeping the newestPLAYARR_BACKUP_KEEP_LAST(7) plus anything younger thanPLAYARR_BACKUP_KEEP_DAYS(30). You can also runplayarr-server backup createor use Playarr Admin. - Where it goes. Into
PLAYARR_BACKUP_DIRon the same machine. If that directory is on the same disk as the database, a backup protects you against corruption and mistakes but not against losing the disk or the node. There is no off-node copy unless you arrange one: download archives from Admin, Backups, copy the directory elsewhere, or set the optionalPLAYARR_BACKUP_S3_*variables to also replicate each completed backup to an S3-compatible bucket that you provide. - Restore. An offline command on the replacement server, with the server stopped:
playarr-server backup restore --archive <file> --identity-file <key>. It validates the archive, stages and integrity-checks the new database, and only then swaps it in, keeping the old one as<name>.pre-restore-<timestamp>.playarr-server backup verifychecks an archive without restoring. Restoring signs every user out. There is no restore button in Admin.
How migrations run on upgrade
Schema migrations are automatic and unconditional. There is no separate migrate command and no flag to skip them.
- The migration SQL is compiled into the
playarrbinary at build time (sqlx::migrate!("../../migrations/sqlite")), so the binary and its schema always ship together. - At every process start,
connect_and_migrateopens the SQLite pool and runs the migrations before any traffic is served, for everyPLAYARR_ROLE. - There is one migration set,
backend/migrations/sqlite/, and its files are never edited after release (sqlx checksums every applied file). - Applied migrations are recorded in sqlx’s own
_sqlx_migrationstable, alongside a separate, human-readableschema_versiontable created by0001_init.sql.
Migrations are forward-only. Neither migration directory contains any down or reverse SQL. Once a newer binary has started against your database, the schema has moved and nothing in the product will move it back. This is the single most important fact behind the rollback section below.
Practical consequences worth planning around:
- Take the backup immediately before the upgrade, not on yesterday’s schedule. It is your only route back.
- Every node owns its own database, so each node runs the migrations against its own file when it starts. Upgrade peer-synced nodes one at a time. Migrations are applied by every role, including workers.
- If
PLAYARR_JWT_SECRETis unset (or shorter than 32 bytes), the server derives a stable secret from its persisted node identity, so sessions survive restarts. Setting it explicitly is still the recommended configuration.
Upgrade procedures
Releases are published as
v<version>GitHub Releases with the server tarballs, and the image isghcr.io/playarrapp/playarr:<version>. The Compose files and Helm chart may still reference another image name (ghcr.io/playarr/playarr); point them at the published image or your own build.
systemd
# 1. Back up first (see above).
# 2. Build the replacement binary from the updated checkout.
git -C /path/to/playarr pull
cd /path/to/playarr/backend && cargo build --release --locked --bin playarr
# -> /path/to/playarr/backend/target/release/playarr
# 3. Keep the outgoing binary so you can go back.
sudo cp -a /usr/local/bin/playarr /usr/local/bin/playarr.prev
# 4. Swap in the new binary and restart.
sudo systemctl stop playarr.service
sudo install -o root -g root -m 0755 \
/path/to/playarr/backend/target/release/playarr /usr/local/bin/playarr
sudo systemctl start playarr.service
# 5. Watch migrations and startup.
journalctl -u playarr.service -f
Re-running sudo ./infra/systemd/install.sh /path/to/playarr does the same swap and refreshes the
unit files. It never overwrites an existing /etc/playarr/playarr.env, and it deliberately does
not enable or start anything, so you finish with sudo systemctl restart playarr.service.
Playarr Admin’s assets are not part of the binary and are not upgraded with it. Neither the binary swap nor
install.shtouchesPLAYARR_WEB_ASSETS_DIR. If you co-host the admin UI, rebuild and recopy it in the same maintenance window, otherwise you leave an old UI talking to a new API. The build and copy steps are on the single-server install page; in short, fromclients/tv-web:pnpm install --frozen-lockfile pnpm --filter @playarr-tv/admin... run build sudo cp -r admin/dist/. /var/lib/playarr/web/ sudo chown -R playarr:playarr /var/lib/playarr/web
Self-update does not work yet.
playarr update --checkmakes no network call and always reports the running binary as current;playarr update --yesalways returns “not implemented yet” and touches no files. The disabled-by-defaultplayarr-update-check.timeris therefore inert. Upgrading a systemd install means replacing the binary yourself, as above.
Docker Compose
The playarr service in docker-compose.prod.yml inherits the x-playarr-image anchor, which sets
pull_policy: build. Compose therefore skips it on docker compose pull and builds rather than
pulls on up -d, so the shipped upgrade path is a rebuild from an updated checkout:
git -C /path/to/playarr pull
docker compose -f infra/docker/docker-compose.prod.yml build
docker compose -f infra/docker/docker-compose.prod.yml up -d
docker compose -f infra/docker/docker-compose.prod.yml logs -f playarr
up -d recreates only the container whose image ID actually changed. Pin an explicit
PLAYARR_IMAGE_TAG in .env so each upgrade produces a distinctly named image you can roll back to.
An opt-in Watchtower overlay (infra/docker/docker-compose.watchtower.optional.yml) can roll patch
releases automatically. It labels only the playarr service and polls every 300 seconds. It does
nothing until a published image exists at the tag it follows, and because Watchtower acts through the
Docker Engine API rather than Compose, reconcile afterwards with docker compose ... build && docker compose ... up -d.
Kubernetes
Playarr never updates itself inside a cluster: nothing in the repository reaches out to a registry or patches its own workload.
# Bump image.tag in your values file, then:
helm upgrade playarr infra/kubernetes/helm/playarr \
--namespace playarr \
-f my-values.yaml \
--wait --timeout 5m
kubectl rollout status statefulset/playarr -n playarr
With one replica and one volume, expect a short outage while the pod restarts. For the kustomize
path, edit the image tag in your overlay and re-apply with
kustomize build infra/kubernetes/overlays/prod | kubectl apply -f -; overlays/prod sets
namespace: playarr-prod, so adjust the -n flag accordingly.
Rolling back
General guidance, the repository documents no rollback procedure.
Because migrations are forward-only, reverting the binary alone is not a rollback. An older binary started against a newer schema is untested and unsupported. A sound rollback therefore always has two halves: restore the pre-upgrade database, then put the previous version back.
systemd
sudo systemctl stop playarr.service
# 1. Restore the database, including a clean removal of stale WAL files.
sudo rm -f /var/lib/playarr/playarr.db \
/var/lib/playarr/playarr.db-wal \
/var/lib/playarr/playarr.db-shm
sudo -u playarr cp /var/backups/playarr/playarr-<YYYY-MM-DD>.db \
/var/lib/playarr/playarr.db
# 2. Put the previous binary back.
sudo mv /usr/local/bin/playarr.prev /usr/local/bin/playarr
sudo systemctl start playarr.service
Docker Compose
# 1. Stop the application.
docker compose -f infra/docker/docker-compose.prod.yml stop playarr
# 2. Restore the pre-upgrade database file into the data volume (with the
# -wal and -shm files removed), as in the systemd steps above.
# 3. Point .env's PLAYARR_IMAGE_TAG back at the previous tag, then bring
# it back up. If the previous image has been pruned, check out the
# previous commit and `docker compose ... build` again first.
docker compose -f infra/docker/docker-compose.prod.yml up -d
Kubernetes
helm rollback reverts the workload but does nothing to your database volume, so pair it with a
restore of the pre-upgrade database file:
helm history playarr -n playarr
# 1. Stop the workload.
kubectl scale statefulset/playarr --replicas=0 -n playarr
# 2. Restore the pre-upgrade database onto the volume (for example with
# `playarr-server backup restore` from a built-in backup), then:
helm rollback playarr <REVISION> -n playarr --wait
Verifying health after an upgrade
Work through these in order. The first two need no credentials.
# Liveness and readiness. /readyz returns 200 only after migrations
# have applied and the pool is connected.
curl -fsS http://<YOUR-SERVER-URL>:8484/healthz
curl -fsS http://<YOUR-SERVER-URL>:8484/readyz
# Version envelope: server version, API version, build SHA and the
# client compatibility table.
curl -fsS http://<YOUR-SERVER-URL>:8484/api/system/version
8484 is PLAYARR_HTTP_BIND_ADDR’s port in every shipped configuration. Drop it if you are going
through a reverse proxy on 80/443, the Compose stack’s Caddy service, for instance, or a Kubernetes Ingress.
The same handlers are also mounted at /api/system/health and /api/system/ready.
Then check logs and metrics for your deployment shape:
# systemd
systemctl status playarr.service
journalctl -u playarr.service --since "10 minutes ago"
# Docker Compose
docker compose -f infra/docker/docker-compose.prod.yml ps
docker compose -f infra/docker/docker-compose.prod.yml logs --since 10m playarr
# Kubernetes
kubectl get pods -n playarr
kubectl logs statefulset/playarr -n playarr --since=10m
Prometheus metrics are exposed by every role on PLAYARR_METRICS_BIND_ADDR, default
0.0.0.0:9090:
curl -fsS http://<YOUR-SERVER-URL>:9090/metrics | head
This listener is deliberately private and carries no authentication of its own. The Compose stack only
expose:s 9090 on the internal Compose network, and the Helm chart never publishes it through an Ingress. From outside the host or cluster, reach it withdocker compose exec,kubectl port-forward, or from your monitoring network, not from the public interface.
Finally, confirm the parts of the install that depend on database state survived:
- Sign in to Playarr Admin at
/on the same origin and port as the API. If it 404s, the built assets moved, checkPLAYARR_WEB_ASSETS_DIRand the startup log line about serving the API only. - Check your library manager source instances are still registered under Source Instances. They are
hydrated from the database at every boot; every write is health-checked, so a stale API key shows
up immediately. Trigger a reconciliation with
POST /api/v1/admin/source-instances/{id}/sync(or the “Sync now” control) rather than waiting for the 300-second poll. - Connect one client and start playback. This is the only end-to-end check that exercises playback negotiation and, if the file needs it, server-side transcoding.
What the repository does not cover
Stated plainly, so you are not left looking for something that isn’t there:
- No rollback procedure is documented for any deployment shape. Built-in backups and the offline restore command are documented, but they are opt-in and the shipped manifests do not enable them.
- No down or reverse migrations exist, so there is no supported schema downgrade.
- No Postgres migration path exists: Postgres support was removed (ADR 0002) and
DATABASE_URLmust be asqlite:URL. - No deployment shape has been booted end-to-end and verified in the project’s own environment; the roadmap describes the Compose, Helm and systemd files as “should work, unverified end-to-end”. Treat your first upgrade rehearsal as exactly that, a rehearsal, on a copy.