Deployment¶
Two images, one chart. The API image carries the UI; the worker only dials the API. For a single Docker host see Install with Docker Compose. Both are distroless static, non-root (65532), read-only, and satisfy the restricted Pod Security Standard.
| Artifact | Where |
|---|---|
ghcr.io/lusoris/snatcharr-api |
Go control plane + embedded SPA (HTTP 8080, gRPC 9090) |
ghcr.io/lusoris/snatcharr-worker |
Rust snatch-worker (HTTP 8081: /livez, /readyz, /metrics) |
oci://ghcr.io/lusoris/charts/snatcharr |
Helm chart |
Every image and the chart are signed with cosign (keyless, GitHub OIDC) and carry a SLSA
build provenance attestation in the registry. The GitHub release holds the chart archive, an
SPDX SBOM per image, a checksum file snatcharr-<version>-SHA256SUMS over those files, and
snatcharr-<version>-provenance.sigstore.json: one provenance attestation that names every
file of the page, the checksum file included, as its subject. Tags are semver only: there is
no latest.
Verify an artifact before you deploy it. The check needs only cosign (2.4 or later) and no
forge login. It requires the signing certificate to name one of this repository's release
workflows, run on the release tag at the commit of that tag, so an artifact built from any
other commit is refused. After 0.2.1 the images and the chart are built, signed and attested
in .github/workflows/release-build.yml, a reusable workflow that only builds and restores no
cache (SLSA Build Level 3); the files of the release page and every earlier release are signed
by .github/workflows/release.yml. The check accepts these two workflows and no other. Look
the commit up once:
The last line is the commit (an annotated tag prints two lines; the ^{} one is the commit).
Then check both images and the chart in the registry:
COMMIT=<commit of the tag>
tools/release/verify.sh ghcr.io/lusoris/snatcharr-api:0.2.1 lusoris/SnatchArr refs/tags/v0.2.1 "$COMMIT"
tools/release/verify.sh ghcr.io/lusoris/snatcharr-worker:0.2.1 lusoris/SnatchArr refs/tags/v0.2.1 "$COMMIT"
tools/release/verify.sh ghcr.io/lusoris/charts/snatcharr:0.2.1 lusoris/SnatchArr refs/tags/v0.2.1 "$COMMIT"
and the files of the release page, downloaded from the web page or with gh release download
into one directory:
verify.sh prints the identity and commit it verified and runs these commands, which also
work without a checkout:
cosign verify ghcr.io/lusoris/charts/snatcharr:0.2.1 \
--certificate-identity-regexp '^https://github\.com/lusoris/SnatchArr/\.github/workflows/release(-build)?\.yml@refs/tags/v0\.2\.1$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-github-workflow-sha "$COMMIT"
cosign verify-attestation --new-bundle-format --type https://slsa.dev/provenance/v1 \
ghcr.io/lusoris/charts/snatcharr:0.2.1 \
--certificate-identity-regexp '^https://github\.com/lusoris/SnatchArr/\.github/workflows/release(-build)?\.yml@refs/tags/v0\.2\.1$' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-github-workflow-sha "$COMMIT"
For a file of the page, verify-page.sh checks the checksum file, refuses a file it does not
list, and runs cosign verify-blob-attestation --new-bundle-format --bundle
snatcharr-<version>-provenance.sigstore.json with the same identity flags on each file.
The release workflow (.github/workflows/release.yml, job verify) runs the same checks on
both images, the chart and the page, and fails the release when one is not covered. Before
that it proves the refusals: an unattested chart, another commit, another git ref and another
repository, and a prefix of the git ref or a ref whose dots would match any character
(tools/release/verify-selftest.sh), and for the page a wrong commit and a changed
chart with a rewritten checksum file.
Older releases: the release page carries checksum file and provenance bundle from 0.2.1 on;
earlier pages are not covered. 0.2.0 published nothing (no images, chart or page files; its
release notes say so): use 0.2.1, which carries the same changes. The chart carries an attestation from 0.1.5 on;
the charts up to 0.1.4 are signed only. 0.1.1 was rebuilt by hand from main: verify it with
refs/heads/main and commit e0b3e17b1fd0cc81ed8756e43da2bb690fe3e53e. A rebuild runs on the
tag it builds (gh workflow run release.yml --ref v0.2.1 -f tag=v0.2.1); the workflow
refuses to start on any other ref.
Helm¶
Requirements: Kubernetes 1.29+, and the CloudNativePG operator unless you bring your own Postgres.
helm install snatcharr oci://ghcr.io/lusoris/charts/snatcharr \
--namespace snatcharr --create-namespace \
--set config.publicUrl=https://snatcharr.example.com \
--set api.ingress.enabled=true \
--set 'api.ingress.hosts[0].host=snatcharr.example.com' \
--set 'api.ingress.hosts[0].paths[0].path=/' \
--set 'api.ingress.hosts[0].paths[0].pathType=Prefix' \
--set networkPolicy.enabled=true
The first visit runs the setup wizard. helm status prints the port-forward command when
no Ingress or HTTPRoute is enabled.
When exposing the API behind an Ingress or reverse proxy, configure config.trustedProxies
(or APP_SNATCHARR_PROXY_TRUSTED) with the proxy or ingress controller CIDRs. The API uses
this list to trust X-Forwarded-For and resolve the client IP. Without trusted proxies,
all forwarded requests appear to arrive from the proxy's IP address, so 5 failed login
attempts by any client will lock out the proxy IP and block all users from logging in.
CI installs the chart on every pull request that touches deploy/ or a Dockerfile
(make e2e-chart-install, tools/e2e/chart_install.sh): the defaults on CloudNativePG and an
upgrade from the last published chart, each until the API and worker Deployments are Available
and /readyz answers 200, and the upgrade must keep the generated Secret. A planted install
whose Secret holds the published development crypto key must be refused first, with the API's
error named.
What the chart renders¶
| Resource | Notes |
|---|---|
Deployment -api |
probes on /startupz, /livez, /readyz (readiness: Postgres and the leader gate, which is down while the election fails or a periodic loop failed to start); /tmp is a memory-backed emptyDir; replicaCount may exceed 1, periodic work runs on the elected replica |
Deployment -worker |
dials <release>-api:9090; liveness probe on /livez (port worker.httpPort, 8081), which fails once no API call succeeded for worker.staleSecs (180 s); readiness probe on /readyz, which also fails until the first successful API call (a lease long-poll returns within worker.leaseWaitSecs), so the Deployment is Available only once its workers reached the API; /metrics there counts failed API calls per call in snatcharr_worker_api_failures_total; optional HPA 1..4 on CPU, or a KEDA ScaledObject on queued runs (below) |
Service -api |
http 8080, grpc 9090 (h2c) |
| Ingress or HTTPRoute | api.ingress.* or api.httpRoute.* |
CNPG Cluster -db |
postgres.cnpg.*; the API reads the DSN from the operator's -db-app Secret |
Secret -secrets |
crypto key, CSRF secret, API-key HMAC secret, worker token |
| NetworkPolicy | worker may only reach the API, networkPolicy.arrCidrs and networkPolicy.arrPeers (both empty allow no *arr egress, never all of it); gRPC only from the worker |
| ServiceMonitor | serviceMonitor.enabled scrapes /metrics on the API |
| PodDisruptionBudget | per component, opt-in |
The image tags default to the chart's appVersion, so chart and images move together. A pinned
release tag (image.api.tag, image.worker.tag) older than the templates need fails the render:
the worker probes need a worker image newer than 0.2.1. The minimum per component lives in
snatcharr.minImageVersion (templates/_helpers.tpl); tests/image_test.yaml holds the planted
case, chart 0.1.8 with worker image 0.1.5. A tag that is no release, such as a commit build, is
not compared.
Worker autoscaling¶
The worker runs worker.replicaCount replicas unless one of two autoscalers owns the count,
between worker.autoscaling.minReplicas and maxReplicas (1..4):
worker.autoscaling.enabled=true: a HorizontalPodAutoscaler on CPU (targetCPUUtilizationPercentage, 70). A worker waits on *arr most of the time, so CPU rises late.worker.autoscaling.keda.enabled=true: a KEDAScaledObject(templates/worker-scaledobject.yaml) on queued runs. Prerequisites: KEDA in the cluster and a Prometheus that scrapes the API/metrics(for exampleserviceMonitor.enabled=truewith the Prometheus Operator). Setworker.autoscaling.keda.serverAddressto that Prometheus. The default query ismax(snatcharr_runs{status="queued",namespace="<release namespace>"}): the elected API replica exports the gauge every 30 seconds, andmaxkeeps a stale series of a former leader from being added.keda.queryreplaces it, for example when your Prometheus labels the namespace differently. The worker count targetskeda.thresholdqueued runs per replica, by defaultworker.concurrency. KEDA reads Prometheus, never the database, so it needs no database credentials.
The two are exclusive: KEDA creates its own HorizontalPodAutoscaler, and the chart refuses to
render both. With either one the Deployment leaves replicas to the autoscaler.
make kind-keda-proof (tools/e2e/keda_scale.sh, after make kind-up, inside the incus VM only)
installs KEDA and a minimal Prometheus (deploy/kustomize/kind/prometheus.yaml) and applies the
chart with deploy/kustomize/kind/values-keda.yaml. With 50 runs queued it first requires the
worker to stay at one replica while the query is planted to read 0, then to scale up within 120
seconds on the chart's query, and back to one replica within 600 seconds after every run is
cancelled.
Metrics¶
The API and worker both serve Prometheus metrics on /metrics. See the comprehensive Metrics and Observability reference for metric names, types, labels, scrape configuration (ServiceMonitor and PodMonitor), Grafana dashboard setup, and leader-only domain state gauge behaviors.
NetworkPolicy and in-cluster *arr apps¶
networkPolicy.arrCidrs allows egress by IP range. That reaches *arr apps outside the
cluster, but some CNIs never apply CIDR rules to traffic between pods they manage: Cilium
states that "CIDR rules do not apply to traffic where both sides of the connection are
either managed by Cilium" (its layer 3 policy docs). If your *arr apps, Seerr, Prowlarr
or download clients run as pods in the same cluster, select them in networkPolicy.arrPeers:
networkPolicy:
enabled: true
arrPeers:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: media
An entry without ports allows every port of the pods it selects. To allow one port per
app, give the entry a ports list; the chart then renders an egress rule of its own for it:
networkPolicy:
enabled: true
arrCidrs: []
arrPeers:
- podSelector:
matchLabels:
app.kubernetes.io/name: lidarr
ports:
- port: 8686
protocol: TCP
A podSelector without a namespaceSelector selects pods in the release namespace only.
Each ports entry needs a port (number or port name) and may set protocol (TCP by
default) and endPort. arrPeers accepts podSelector, namespaceSelector and ports
only; IP ranges belong in arrCidrs, which always excludes the cloud metadata address and
allows every port. A rule for one port of an IP range belongs in networkPolicy.extraEgress.
Secrets¶
The chart needs four secrets. Leave them empty and Helm generates them once, reusing the
existing Secret on upgrades (helm.sh/resource-policy: keep). Tools that render without
cluster access, such as ArgoCD, cannot reuse them, so create the Secret first:
kubectl -n snatcharr create secret generic snatcharr-secrets \
--from-literal=crypto-key="$(openssl rand -hex 32)" \
--from-literal=csrf-secret="$(openssl rand -hex 32)" \
--from-literal=apikey-secret="$(openssl rand -base64 36)" \
--from-literal=worker-token="$(openssl rand -base64 36)"
and set secrets.existingSecret=snatcharr-secrets. The crypto key encrypts every *arr
API key and download-client password at rest; losing it means re-entering them.
The Helm chart generates a random bootstrap secret (setup-secret) into the Secret by default,
locking POST /auth/setup on new clusters. Retrieve it for the first-run setup wizard:
kubectl -n snatcharr get secret snatcharr-secrets -o jsonpath="{.data.setup-secret}" | base64 -d; echo
If supplying an existingSecret, add --from-literal=setup-secret="$(openssl rand -base64 36)"
(or set secrets.setupSecret). When existingSecret is used without setup-secret, the deployment marks
the environment variable optional so existing clusters continue running without breaking.
Postgres¶
postgres.cnpg.enabled=true (default) renders a CloudNativePG Cluster with
postgres.cnpg.instances replicas and postgres.cnpg.storage.size per instance;
postgres.cnpg.extraSpec is merged verbatim for backups, affinity or parameters, and
postgres.cnpg.bootstrap replaces the default initdb bootstrap, for a restore (see
Backup and restore).
For an external database set postgres.cnpg.enabled=false and point
postgres.external.dsnSecretRef at a Secret holding the DSN. Only the API connects to
Postgres.
sslmode=require in the DSN encrypts the connection without checking the server
certificate. To verify it against the cluster CA (verify-full), mount the CA and point
the API at it. The settings override the DSN, and the CA file is re-read for every new
connection, so a rotated CA applies without a restart. With CloudNativePG the CA lives in
the <cluster>-ca Secret; mount only its ca.crt, never ca.key:
config:
extraEnv:
- name: APP_DB_SSL_MODE
value: verify-full
- name: APP_DB_SSL_ROOTCERT
value: /etc/snatcharr/db-ca/ca.crt
api:
extraVolumes:
- name: db-ca
secret:
secretName: pg-cluster-ca
items:
- key: ca.crt
path: ca.crt
extraVolumeMounts:
- name: db-ca
mountPath: /etc/snatcharr/db-ca
readOnly: true
verify-full also checks the host name, so the DSN must name a host on the server
certificate, such as CloudNativePG's <cluster>-rw.<namespace>.svc.
The schema migrations that run at start use the same settings: a migrator that inherits
APP_DB_DSN also takes the APP_DB_SSL_* values and, when set, the password from the
db.password_file config key. A path in APP_DB_SSL_* must not contain a space. Before 0.1.3
the migrator ignored these settings and the API crash-looped on
x509: certificate signed by unknown authority; on 0.1.2 and older, add sslrootcert=<path> to
the DSN itself. An explicit APP_DB_MIGRATE_DSN is used exactly as given: it carries its own
password and TLS parameters. TestMigratorConnection in api/internal/app checks both rules
against a real server.
Backup and restore¶
With CloudNativePG, back up through the Barman Cloud plugin (the operator's in-tree
barmanObjectStore is deprecated). Install cert-manager and the plugin in the operator's
namespace as the plugin's installation docs describe, create an ObjectStore for your S3
bucket, and turn on WAL archiving in the chart:
postgres:
cnpg:
extraSpec:
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters:
barmanObjectName: snatcharr-backups
Take base backups with a ScheduledBackup or an on-demand Backup of method: plugin. Keep
a copy of the <release>-secrets Secret with the backups: its crypto key decrypts the *arr API
keys and download-client passwords in the database, and a restored database without it means
re-entering them. The chart keeps this Secret when the release is uninstalled.
To restore, install the chart with a recovery bootstrap instead of initdb, pointing an
external cluster at the store and at the serverName of the lost cluster (by default its name,
<release>-db):
postgres:
cnpg:
bootstrap:
recovery:
source: backup
database: snatcharr
owner: snatcharr
extraSpec:
externalClusters:
- name: backup
plugin:
name: barman-cloud.cloudnative-pg.io
parameters:
barmanObjectName: snatcharr-backups
serverName: snatcharr-db
database and owner make the operator create the <release>-db-app Secret the API reads its
DSN from. The recovered cluster archives nothing until you add the plugins entry again; give it
a new serverName parameter (for example snatcharr-db-restored) so it does not write into the
archive it was restored from. A bootstrap key inside extraSpec is refused, since it would
repeat the chart's own.
make e2e-cnpg-backup-restore runs these steps in CI on a kind cluster with the operator,
cert-manager, the plugin and a RustFS store (tools/e2e/cnpg_backup_restore.sh). It completes
the setup, takes a base backup, uninstalls the release and its database, and restores. It then
checks that every table reads back unchanged, that archiving runs again under the new
serverName, and that the API starts with the setup complete and the old password. A planted
reinstall without the recovery bootstrap must be refused first.
Postgres major upgrade¶
CI, the release checks and the dev compose run Postgres 18. An installed database keeps the major it was initialised with until you move it; SnatchArr itself needs no change for the move, only a restart onto the upgraded database, after which its migrations bring the schema to the current version.
CloudNativePG (postgres.cnpg.*): the operator upgrades in place from CloudNativePG 1.26.0
on (upstream docs/src/postgres_upgrades.md, "Offline In-Place Major Upgrades"). Set
postgres.cnpg.imageName to a Postgres 18 operand image built on the same OS distribution as
the running one and apply the chart. The operator then:
- stops every instance, replicas included, so the database is down for the whole upgrade;
- runs a job named after the primary with the suffix
-major-upgrade, which callspg_upgrade --link; - restarts the primary and clones the replicas again, which takes long on a large database.
If the job fails, set imageName back to the previous image: the operator removes the failed
job and starts the old major again, with the data unchanged. With an empty imageName the
operator's default image applies, and a new operator release can change its major; pin
imageName to control when the upgrade happens. Afterwards run ANALYZE (statistics are not
carried over) and take a new base backup: point-in-time recovery does not cross the major
upgrade. With the Barman Cloud plugin, change serverName together with imageName so the
new timeline does not write into the old archive. Postgres 17.0 to 17.5 refuse the upgrade
while max_slot_wal_keep_size is set to anything but -1: upgrade to 17.6 or later first.
With the chart the whole upgrade is one value change:
helm upgrade snatcharr oci://ghcr.io/lusoris/charts/snatcharr --reuse-values \
--set postgres.cnpg.imageName=ghcr.io/cloudnative-pg/postgresql:18.6-system-trixie
kubectl wait cluster/snatcharr-db --timeout=30m \
--for=jsonpath='{.status.pgDataImageInfo.majorVersion}'=18
make e2e-cnpg-pg-upgrade runs this in CI on a kind cluster with the operator. It installs
the chart on 17.10-system-trixie, completes the setup and changes the image to
18.6-system-trixie. It then checks that the operator reports major 18, that every table
reads back unchanged, that the migration version is at least 7, and that the API starts with
the setup complete and the old password. A planted chart upgrade without the image change
must be refused first (tools/e2e/cnpg_pg_upgrade.sh). CI runs it on the API and worker
images built from the checkout (UPGRADE_API_IMAGE, UPGRADE_WORKER_IMAGE), because a release
pull request pins an appVersion whose images are not published yet; without them the test runs
the released images of the chart's appVersion.
External Postgres (postgres.external.dsnSecretRef): dump and restore with the client
tools of the new major. Stop the API first, so nothing writes during the dump (kubectl scale deployment
<fullname>-api --replicas=0; the full name is the release name when it
contains snatcharr, else <release>-snatcharr), then run against the old and the new
server:
pg_dump --format=custom --dbname="$OLD_DSN" \
| pg_restore --no-owner --exit-on-error --dbname="$NEW_DSN"
Point the DSN Secret at the new server and scale the API up again. The new database must
exist and be owned by the role in NEW_DSN. TestPostgresMajorUpgradeByDumpRestore in
api/internal/store runs exactly this command from a Postgres 17 database holding a full
0.1.8 dataset into Postgres 18, then migrates and reads every row back.
Homelab compose (deploy/homelab/compose.yaml): dump with the old image, move the old data
directory aside inside the volume, start the new image and restore. The commands are in
install-compose.md, and
make e2e-compose-pg-upgrade runs them from a Postgres 17 install with a data read-back.
Dev compose (deploy/compose/docker-compose.yml): the volume is mounted at
/var/lib/postgresql, where the 18 image keeps its data in a per-major directory. A volume
first initialised by a 17 image does not start on 18: the container exits with Error: in
18+, these Docker images are configured to store database data in a format which is
compatible with "pg_ctlcluster" and points at the old data it found. Dev data is
disposable: docker compose -f deploy/compose/docker-compose.yml down --volumes removes
the volume, and the next make dev initialises it on 18.
Configarr¶
Already running Configarr? Mount the same files and SnatchArr imports every instance from them (linked mode):
configarr:
enabled: true
existingConfigMap: configarr-config # key config.yml
existingSecret: configarr-secrets # key secrets.yml (optional)
Coexistence with Cleanuparr¶
SnatchArr no longer integrates Cleanuparr: operators who keep Cleanuparr must not let it act on the same queues as SnatchArr's queue management.
ArgoCD¶
deploy/argocd/ holds an AppProject and an Application that track main with
automated prune and self-heal, server-side apply and retries. Override values under
spec.source.helm.valuesObject; keep secrets.existingSecret set, see above.
Local cluster¶
make kind-up builds both images, starts an incus VM (tools/incus-kind-vm.sh, 6 CPUs
and 12 GiB by default), creates a kind cluster inside it, installs CloudNativePG and
applies deploy/kustomize/kind (restricted namespace, NetworkPolicy on, throwaway
secrets). kind never runs in host Docker: its kubelet changes kernel settings such as
vm.overcommit_memory that are not namespaced, so on the host it would change the
workstation's kernel (#61).
The host reaches the cluster through a dedicated kubeconfig:
export KUBECONFIG=$PWD/.kind/snatcharr.kubeconfig
kubectl -n snatcharr port-forward svc/snatcharr-api 8080:8080
make kind-down deletes the VM, its profile and the kubeconfig. The VM needs incus with
KVM on the host; SNATCHARR_KIND_VM_CPUS and SNATCHARR_KIND_VM_MEMORY size it. For plain
local development make dev starts Postgres with Docker Compose and runs the three
processes on the host.
End-to-end image smoke¶
make e2e-image-smoke validates the built container images locally and in CI before release images are pushed:
- Builds
snatcharr-api:e2e(with embedded SvelteKit SPA) andsnatcharr-worker:e2e(Rust musl binary). - Runs
tools/e2e/smoke.shin image mode against a temporary Postgres container (tools/e2e/postgres.sh). - Proves distroless non-root execution (user
65532:65532,--read-only,--cap-drop ALL,no-new-privileges). - Proves the api image carries the CA bundle (
/etc/ssl/certs/ca-certificates.crt) its outbound HTTPS calls read; the worker embeds its root certificates. - Proves the embedded SvelteKit SPA serves
index.html(text/html with SvelteKit markers) and hashed static assets (/_app/immutable/...). - Proves runtime IANA time zone database availability by creating a disabled schedule window in
Europe/Berlin: the API validates the zone with the image's zone data, since the binary embeds none. - Proves multi-worker run leasing, stamina caps, afterglow deduplication, and liveness probes.
- Runs the api and both workers under the chart's
resources.limits.memory(docker run --memory, no swap) and fails when a container's peak (cgroupmemory.peak) passes 80 % of that limit (MEM_SHARE,tools/e2e/mem_limits.sh, cases intools/e2e/mem_limits_test.sh). Measured on 2026-10-17: api 171 MiB of 256 MiB (67 %), each worker 8 MiB of 128 MiB. Raise a limit invalues.yamlbefore the smoke fails, not after a pod is killed.
The release workflow runs the same smoke on amd64 before it pushes images, then builds both images for arm64 and runs tools/e2e/arm64-check.sh. The check reads the ELF header of each entrypoint and refuses a binary that is not aarch64, because both Dockerfiles cross-compile on the build platform and an image labelled arm64 can carry an amd64 binary. It then runs each binary under QEMU: the API must stop on one of the config errors the check lists, the worker must log its start. CI's image smoke runs the same check on the amd64 images (CHECK_ARCH=amd64), so a changed startup error fails the pull request, not the release; tools/e2e/arm64-check_test.sh holds the cases of the list.
To run the image smoke locally:
To run against pre-built images without re-building:
Soak¶
make e2e-soak keeps the image smoke running for SOAK_SECONDS (default 600) after its checks. The API rotates every gRPC connection after SOAK_CONNECTION_AGE (default 15s, instead of the production 2 minutes), so the workers' lease long-polls (30 s, the production default) cross many rotations. Every SOAK_ROUND_SECONDS (default 20) the soak starts a missing run on Sonarr and Radarr, then fails the round on:
- any failed worker API call (
snatcharr_worker_api_failures_totalon either worker grew); - any failed run;
- a last worker contact (
worker_last_contact_atin/api/v1/system/status) older thanSOAK_MAX_CONTACT_AGEseconds (default 45: the long-poll plus 15).
At the end, at least two runs per round must be done. The workflow .github/workflows/soak.yml runs it nightly and on demand; a failure on main opens or comments on the issue "Soak fails on main". The soak catches lease calls cut at a connection rotation and calls a closing connection refused before sending them, which a shorter smoke run does not reach.