Deploy on Kubernetes#

Run scrap on a Kubernetes cluster (k3s, a home lab, a cloud cluster) with the Helm chart in charts/scrap. It runs the same container image as the single-server deployment: one pod, a persistent volume for the data and, by default, a nightly backup job.

How the pieces fit (one pod; every CronJob runs beside it on the same node):

        flowchart LR
    phone["Phone and laptops<br/>(tailnet)"]:::user
    cc["Claude Code"]:::claude
    subgraph cluster ["Kubernetes cluster"]
        ts["Tailscale operator<br/>proxy"]
        ing["Ingress / Service"]
        subgraph node ["One node"]
            pod["scrap pod<br/>(1 replica)"]
            data[("data PVC<br/>/data")]:::store
            jobs["CronJobs:<br/>backup, gc,<br/>related, topics"]
            bak[("backups PVC<br/>/backups")]:::store
        end
    end
    api["api.anthropic.com"]:::claude
    phone --> ts --> ing --> pod
    cc -->|"/mcp"| ts
    pod --- data
    jobs --- data
    jobs --> bak
    pod -.->|"ingest on"| api
    classDef store fill:#e8f1ff,stroke:#4a7bd0
    classDef claude fill:#fff3e0,stroke:#e08a00
    classDef user fill:#e9f7ef,stroke:#2e9d5b
    

Warning

scrap has no login of its own. Anyone who can reach its Service can read and change everything. Expose it only on your tailnet (see Tailscale Kubernetes operator (recommended)) or behind an authenticating proxy (see Any other ingress controller), never directly to the internet. Inside the cluster, any pod that can reach the Service has full access too, and can send its own Tailscale-User-Login header: turn on the NetworkPolicy (below) if other workloads share the cluster.

What you get#

Object

Notes

Deployment

Exactly one replica, strategy Recreate. scrap keeps everything in one SQLite database with a single writer, so the chart has no replicas value: the old pod always stops before the new one starts.

PersistentVolumeClaim

/data: the database, files and the preview cache. Kept on helm uninstall. With litestream.enabled the database is on an emptyDir instead: The database on an emptyDir, with Litestream.

Service

ClusterIP, port 80.

ServiceAccount

No API token mounted; scrap never talks to the Kubernetes API.

CronJobs + PVC (on by default)

Nightly scrap backup to a second claim, keeping the newest 7 runs, and a monthly copy in its monthly/ folder, keeping 12.

CronJobs (gc on by default, the Claude ones with ingest)

gc, related pages and topics: see Scheduled gc, related pages and topics and Scheduled jobs.

Ingress, NetworkPolicy (off by default)

See below.

The pod meets the restricted Pod Security Standard: it runs as uid/gid 10001, with a read-only root filesystem (only /data and an emptyDir at /tmp are writable), no capabilities, no privilege escalation and the RuntimeDefault seccomp profile.

Prerequisites#

  • Kubernetes 1.27 or newer, and Helm 3 or 4.

  • A StorageClass that provides ReadWriteOnce volumes on a local or block filesystem (see k3s and storage notes).

  • A way in that is not the open internet: the Tailscale Kubernetes operator is the recommended one.

  • For ingest: a Claude API key.

Get the image#

Releases publish a public, multi-arch (amd64 and arm64) image to ghcr.io/gilesknap/scrap, and the chart’s appVersion names the release it was made with (set from the git tag when the chart is published; a checkout has the placeholder 0.0.0, so set image.tag with one). By default the chart runs that release, ghcr.io/gilesknap/scrap:<appVersion>, so there is nothing to build: set image.tag only to run a different release.

To run your own build instead, push it to a registry your cluster can pull from, and point the chart at it:

podman build --platform linux/amd64,linux/arm64 --manifest registry.example.com/scrap:dev .
podman login registry.example.com
podman manifest push registry.example.com/scrap:dev
image:
  repository: registry.example.com/scrap
  tag: dev
  pullPolicy: Always
imagePullSecrets:
  - name: my-registry-credentials   # if the registry needs a login

Building the arm64 half on an x86 machine needs qemu-user-static; on a mixed cluster you can instead pin scrap to nodes of one architecture with nodeSelector: {kubernetes.io/arch: amd64} and build only that.

Install#

A minimal my-values.yaml for the recommended setup (the Tailscale operator for access, ingest on). It runs the release image the chart names, and assumes the Secret from Secrets and ingest; add an image block as above to run your own build, and drop ingest to run without Claude:

ingest:
  enabled: true
  existingSecret: scrap-secrets
ingress:
  enabled: true
  className: tailscale
  tls:
    - hosts: [scrap]

From the released chart (version = the release tag, with a pre-release 0.4.0rc1 as 0.4.0-rc.1):

kubectl create namespace scrap
helm install scrap oci://ghcr.io/gilesknap/charts/scrap --version <release> --namespace scrap -f my-values.yaml
kubectl -n scrap rollout status deploy/scrap

The commands in this guide assume the release is called scrap in the namespace scrap. For another name, use the chart’s fullname instead (kubectl -n scrap get deploy,cronjob,pvc lists the names, and helm install prints them in its notes).

Check it without exposing it anywhere:

kubectl -n scrap port-forward svc/scrap 8000:80
curl http://127.0.0.1:8000/healthz

and open http://127.0.0.1:8000/ for the PWA. The PWA needs HTTPS to install on a phone (see Capture from your phone (the PWA)), so use one of the exposures below for that.

Values#

Every value is described in values.yaml; unknown top-level keys, and unknown keys under image, ingest, persistence, ingress, mcp, gc, enrichRelated, enrichTopics and public (and under backup, including backup.monthly) are refused, so a typo there fails the install instead of being ignored. Other groups (service, networkPolicy, serviceAccount, resources and the security contexts) are not checked, so a typo inside them is silently ignored. The main ones:

Value

Default

Meaning

image.repository, image.tag

ghcr.io/gilesknap/scrap, the chart’s appVersion

Image to run.

maxUploadMB

50

SCRAP_MAX_UPLOAD_MB: largest capture. Raise a proxy’s body limit to match.

ingest.enabled

false

SCRAP_INGEST: send photos to Claude (see Secrets and ingest).

ingest.visionModel, ingest.budgetDayUSD

app default, 3

SCRAP_VISION_MODEL, SCRAP_BUDGET_DAY_USD (US dollars per UTC day). Like ingest.notifyUrlKey, they take effect only while ingest.enabled is true.

persistence.size, .storageClass, .existingClaim

5Gi, default class, none

The data volume.

litestream.enabled

false

The database on an emptyDir, replicated to the data volume, for a data volume on NFS: The database on an emptyDir, with Litestream. Under the restricted or baseline Pod Security Standard a backup.persistence.hostPath then also stops the scrap pod starting, as it mounts the backups.

backup.enabled, .schedule, .timeZone, .keep

true, 30 3 * * *, the cluster’s default (usually UTC), 7

The backup CronJob.

gc.enabled, .schedule, .timeZone

unset (follows backup.enabled), 30 4 * * *, the cluster’s default

The scrap gc CronJob (see Scheduled gc, related pages and topics).

gc.delete, gc.graceHours, gc.runDays, gc.requireBackup

true, 24, 30, true

delete: false, or graceHours: 0, makes the job only count (a dry run in its log). Keep files younger than this many hours; keep model and enrich run records younger than this many days (at least 1); delete only after a backup less than 26 hours old.

enrichRelated.enabled, .schedule, .timeZone

true, 41 3 * * *, the cluster’s default

The related-pages CronJob. It exists only while ingest.enabled is true. Every scheduled job: Scheduled jobs.

enrichTopics.enabled, .schedule, .timeZone

true, 11 4 * * 0, the cluster’s default

The topics CronJob (Sundays). It exists only while ingest.enabled is true.

backup.monthly.enabled, .schedule, .timeZone, .keep

true, 50 3 1 * *, the cluster’s default, 12

The monthly copy CronJob (needs backup.enabled; see Backup and restore).

backup.persistence.size, .storageClass, .existingClaim

20Gi, default class, none

The backups volume. Each run is a full copy: allow about keep + monthly.keep + 1 times the data (or lower keep and monthly.keep).

backup.persistence.hostPath

none

An absolute directory on every node (a shared filesystem) to keep the backups in, instead of a claim. It must exist and be writable by the podSecurityContext user. A namespace that enforces the baseline or restricted Pod Security Standard rejects hostPath volumes, so the backup jobs never start there: use the claim.

resources.limits.memory

1Gi

scrap’s photo decoding is budgeted for a 1 GB container; a lower limit risks the pod being OOM-killed on large photos.

ingress.*, service.annotations

off

Exposure, below.

service.type, service.port

ClusterIP, 80

The Service.

networkPolicy.enabled, .from

off

Who may connect, below.

mcp.tokenSecret.name, .key

none, SCRAP_MCP_TOKEN

An existing Secret and the key in it holding the bearer token(s) /mcp requires (SCRAP_MCP_TOKEN, comma separated for several). Never a token as a value; the chart creates no Secret. Empty name: no token needed. See Bearer token on /mcp.

identityHeader

Tailscale-User-Login

SCRAP_IDENTITY_HEADER: the header recorded as the author, e.g. X-Auth-Request-User behind oauth2-proxy. Not read with authProxy.enabled, where the author is the token’s username.

authProxy.enabled, .service, .port, .allowedUsers

off, none, 80, none

A login for the web app through an oauth2-proxy you run, with ingress-nginx only (A central oauth2-proxy with ingress-nginx).

authProxy.oidc.issuer, .clientId

none

Required with authProxy.enabled: the identity provider’s issuer URL (https) and the proxy’s client ID, whose access tokens scrap checks on every request (SCRAP_OIDC_ISSUER, SCRAP_OIDC_CLIENT_ID; A central oauth2-proxy with ingress-nginx).

extraEnv, extraEnvFrom

none

Anything else for the container, such as SCRAP_PUBLIC_URL (see Claude Code (MCP)) or SCRAP_ENRICH.

public.enabled, .site, .tokenSecret.*, .ingress.*, .networkPolicy.webFrom

off

Public pages: the public site and its upload receiver in their own namespace (Public pages).

public.namespace

scrap-public

The public side’s namespace. It may be the release namespace; then one Secret serves both sides.

public.networkPolicy.enabled

true

false: no NetworkPolicy for the public side, where you may not create one. The receiver still needs its token.

nodeSelector, tolerations, affinity, podAnnotations

none

Scheduling and annotations.

nameOverride, fullnameOverride, serviceAccount.*, terminationGracePeriodSeconds, backup.resources

see values.yaml

Naming, the ServiceAccount, the stop timeout (30) and the backup job’s resources.

Leave service.type at ClusterIP: the chart assumes access goes through the Ingress or a port-forward. LoadBalancer and NodePort open the Service to whatever network the node or load balancer is on, and scrap has no login, so use them only on a private network. With ingress.hosts empty, the Ingress has one rule that matches any host (the Tailscale example below relies on this); with hosts set, each gets its own host-specific rule. The charts/scrap/ci directory holds the value sets the chart is tested with (defaults, no backup, existing claims, a generic ingress, ingest, long names, and the Tailscale Ingress and Service), which are worked examples to copy from.

The settings themselves are in the API reference. The chart sets SCRAP_DATA_DIR=/data; SCRAP_WEB_DIR (for trying out a PWA build) has no value of its own, use extraEnv.

Set TZ the same way, to your own time zone: the server decides what “today” means for due:today searches from its own clock (UTC by default), while the PWA uses the phone’s date.

extraEnv:
  - name: TZ
    value: Europe/London

The preview cache (cache/previews under /data) stays on the data volume: it is regenerable and never backed up, and keeping it saves redoing every preview after a restart, which is slow on small ARM nodes.

Secrets and ingest#

The chart never takes the API key as a value and never creates a Secret: it only reads a Secret you create. Create it from a file or stdin so the key stays out of your shell history and out of any values file:

read -rs KEY && printf %s "$KEY" | kubectl -n scrap create secret generic scrap-secrets \
    --from-file=ANTHROPIC_API_KEY=/dev/stdin; unset KEY

Optionally add the notification URL (an ntfy topic URL is itself a secret) to the same Secret under another key, e.g. SCRAP_NOTIFY_URL. Then:

ingest:
  enabled: true
  existingSecret: scrap-secrets
  apiKeyKey: ANTHROPIC_API_KEY     # the default
  notifyUrlKey: SCRAP_NOTIFY_URL   # optional

With ingest on, Claude also tags, summarises and links text pages and fetches the links you save (SCRAP_ENRICH, on by default). That shares the daily budget with photos (ingest.budgetDayUSD, $3 per UTC day by default), and the pod then makes outbound HTTP and HTTPS requests (ports 80 and 443) to public sites. To keep Claude to photos, switch it off:

extraEnv:
  - name: SCRAP_ENRICH
    value: "off"

With enrich on, a page made from a PDF or text file also has the start of its text (at most 4,000 characters, never the file) sent to Claude once it is read, for a title, tags and a summary (ADR 38). To keep file text on the host unless you press Ask Claude, set SCRAP_FILE_ENRICH to ask:

extraEnv:
  - name: SCRAP_FILE_ENRICH
    value: "ask"

With GitOps, create the Secret with whatever your cluster uses for secrets (Sealed Secrets, External Secrets, SOPS) under the same name; never commit the key. A changed Secret is not picked up by a running pod: kubectl -n scrap rollout restart deploy/scrap.

Exposure#

Any other ingress controller#

scrap has no login, so put an authenticating proxy in front: for example oauth2-proxy through your controller’s external auth annotations, or your SSO’s forward-auth middleware. Raise the controller’s request body limit a little above maxUploadMB, so an upload just over it gets scrap’s error rather than the controller’s.

ingress:
  enabled: true
  className: <your-ingress-class>
  annotations:
    # Your controller's annotations for external authentication and for a
    # request body limit a little above maxUploadMB; see its documentation.
    <auth-annotation>: <auth-service-url>
    <body-size-annotation>: 51m
  hosts:
    - scrap.example.com
  tls:
    - hosts:
        - scrap.example.com
      secretName: scrap-tls

The annotation names differ for every controller (Traefik uses Middleware objects instead), so the chart takes them as plain ingress.annotations. Make sure the proxy removes any Tailscale-User-* headers sent by clients; changes are then recorded under the fixed fallback author tagged. Never expose the Service without authentication.

The Ingress sends every path to scrap, so the proxy covers /mcp and /healthz too. /mcp is called by Claude Code, not a browser, so a login page in front of it will block it unless the proxy exempts that path or the client can pass its check; the tailnet route avoids the problem.

Behind oauth2-proxy. Set identityHeader: X-Auth-Request-User (the header oauth2-proxy returns and the controller copies to the request), so the author is the real login. scrap trusts only that header, but most controllers do not strip a client-sent copy, so the proxy plus the NetworkPolicy must be the only way to the Service; a forged value only changes the recorded author.

Exposing /mcp (Cloudflare Access, a second ingress). For a complete walk-through with a Cloudflare Tunnel straight to the Service, see Install scrap on Kubernetes. With an ingress controller, give /mcp its own Ingress rule or host that skips the login proxy, and guard it another way: a Cloudflare Access service token in front, and scrap’s own bearer token behind it (below), so a leaked service token alone does not open scrap. Keep the PWA and API on the login-proxied host.

A central oauth2-proxy with ingress-nginx#

If you run oauth2-proxy in the cluster (with reverse_proxy and set_xauthrequest on, and no fixed redirect_url), the chart wires it in:

authProxy:
  enabled: true
  service: oauth2-proxy.scrap.svc.cluster.local   # the proxy's in-cluster DNS name
  allowedUsers: [abc12345]                            # who may use the web app (usernames)
  oidc:
    issuer: https://identity.example.org/realms/main  # the realm, as in its tokens' iss
    clientId: scrap                                   # the proxy's client

The Ingress then gets ingress-nginx’s auth-url (the proxy’s /oauth2/auth, with no allowed_emails query, which some ingress-nginx annotation validation refuses), auth-signin and auth-response-headers: X-Auth-Request-Email,X-Auth-Request-Access-Token annotations, on top of ingress.annotations. A second Ingress, <fullname>-open, with the same class, hosts and tls and no auth, sends /mcp to scrap (set mcp.tokenSecret) and /oauth2 to the proxy through an ExternalName Service, so the sign-in, its callback (https://<host>/oauth2/callback, which the identity provider must allow) and the cookie are on scrap’s own host. Both Ingresses get proxy-buffer-size: 16k unless ingress.annotations sets it, since the login’s cookies overflow nginx’s 4k default. The author of a change is the username (preferred_username) in the proxy’s token, so identityHeader is not read. The install fails without ingress.enabled, ingress.tls (the cookie is https only), mcp.tokenSecret.name, authProxy.service, authProxy.allowedUsers, authProxy.oidc.issuer or authProxy.oidc.clientId.

The login alone guards the Ingress only: without a NetworkPolicy, any pod can reach the Service and send its own X-Auth-Request-Email. So scrap checks the proxy’s access token itself (ADR 45): the Deployment gets SCRAP_OIDC_ISSUER, SCRAP_OIDC_CLIENT_ID and SCRAP_ALLOWED_USERS, and every request but /healthz and /mcp needs a valid token for an allowed username (401 without). This is also the only owner check: anyone the proxy lets log in passes the Ingress. Run the proxy with pass_access_token = true, and a cookie_refresh shorter than the access tokens’ life. Other controllers need their own annotations, above. DLS runs one proxy for every user this way: Run the DLS login proxy.

Bearer token on /mcp#

Create the Secret yourself (the chart never takes a token as a value) and name it:

mcp:
  tokenSecret:
    name: scrap-secrets      # may be the Secret that holds ANTHROPIC_API_KEY
    key: SCRAP_MCP_TOKEN     # one token, or several comma separated to rotate

Generate one with python -c 'import secrets; print(secrets.token_urlsafe(32))'. To rotate, set the key to old,new, roll the pod, move the clients to new, then drop old. /healthz stays open for the probes. Clients send Authorization: Bearer <token>: see Connect a Claude Code session.

Claude Code (MCP)#

Claude Code sessions talk to scrap’s /mcp endpoint (see Connect a Claude Code session and MCP server). It answers 421 to any Host that is not loopback or listed in SCRAP_PUBLIC_URL, and the chart has no value for it, so set it through extraEnv to the address you reach scrap at:

extraEnv:
  - name: SCRAP_PUBLIC_URL
    value: https://scrap.<tailnet>.ts.net

The same address makes the page links the MCP tools return absolute.

Warning

The web app and API check Host as well: a hostname missing from SCRAP_PUBLIC_URL gets 421 on every page, not just /mcp. Set it to the address you reach scrap at (/healthz, which the probes use, answers to any name), and roll the pod after changing it.

Port-forward#

For a quick try, kubectl -n scrap port-forward svc/scrap 8000:80. Nothing is exposed beyond your own machine.

Public pages#

scrap can publish the pages you mark Public (issue 497) to a static site on the internet, for example https://pages.example.org. The site is served by nginx from a separate image, ghcr.io/gilesknap/scrap-public, which also holds a small upload receiver. scrap is to push each public page to the receiver; nothing on the public side can reach scrap. scrap’s publisher arrives with the scrap side of issue 497: until then this section runs the public side only, and the site stays empty. The contract between the two sides (the receiver’s API, what each unit may hold, nginx’s paths and headers) is Public site, and why it is built this way is ADR 43. The chart’s public section, off by default, runs it:

  • it runs in a namespace of its own (public.namespace, default scrap-public), labelled Pod Security restricted, which you create first (below);

  • one Deployment with two containers sharing the site volume: web (nginx, the site mounted read only) and receiver (the only one given the token, mounted as a file), both non-root with read-only root filesystems;

  • two ClusterIP Services, and an Ingress for nginx alone (public.ingress); the receiver is never on an Ingress;

  • a NetworkPolicy: the receiver’s port only from scrap’s pods (the release namespace and scrap’s labels), nginx’s port from public.networkPolicy.webFrom (empty: anyone, as nginx serves only public files; set it to your ingress controller’s namespace to narrow it), and no egress at all.

The site lives in an emptyDir (public.sizeLimit, default 3Gi: the receiver’s 2 GiB quota plus room for an upload). It starts empty when the pod moves. With the scrap side of issue 497, scrap fills it again: it checks the receiver’s manifest at start and every 10 minutes and pushes what is missing.

First create the public namespace, labelled Pod Security restricted, and the upload token. The token is a Secret you create, never a value. The receiver runs in the public namespace and scrap in its own, and a pod reads only its own namespace’s Secrets, so create it, with the same name and key, in both:

kubectl create namespace scrap-public
kubectl label namespace scrap-public pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/audit=restricted pod-security.kubernetes.io/warn=restricted
token=$(openssl rand -hex 32)
for ns in scrap scrap-public; do
  kubectl -n "$ns" create secret generic scrap-publish --from-literal=token="$token"
done

Both pods read the token as a mounted file, never an environment variable: the receiver through RECEIVER_TOKEN_FILE, scrap through SCRAP_PUBLISH_TOKEN_FILE.

public.createNamespace: true makes the chart create the namespace instead, with the same labels. Argo CD’s CreateNamespace=true does not do it: that creates only the Application’s destination namespace (scrap’s), so scrap-public must exist first or come from public.createNamespace. With the chart creating it, the Secret can only be made after the first install, and the receiver waits for it. helm uninstall (or an Argo CD prune) then deletes the namespace, and the Secret in it, which is why it is off by default.

Then:

public:
  enabled: true
  site: https://pages.example.org     # SCRAP_PUBLISH_SITE
  tokenSecret:
    name: scrap-publish
    key: token
  ingress:
    className: nginx                  # or your controller's
    hosts: [pages.example.org]
    tls:
      - hosts: [pages.example.org]
        secretName: pages-tls

The install fails if public.enabled is set without public.tokenSecret.name or public.site. scrap’s pod then gets SCRAP_PUBLISH_TO (the receiver’s Service, http://<fullname>-receiver.<public.namespace>.svc:8081, scrap-receiver.scrap-public for a release named scrap), SCRAP_PUBLISH_SITE and SCRAP_PUBLISH_TOKEN_FILE (see the settings). The site is noindex: every answer has X-Robots-Tag: noindex, nofollow and robots.txt disallows all, unless public.indexing is true.

Behind Cloudflare Access: if your domain has a wildcard Access application (*.example.org), it covers every subdomain, the public site’s too, and visitors meet a login. Give the site’s host (pages.example.org) its own Access application with a policy whose Action is Bypass, so anyone may reach it. A more specific application takes precedence over the wildcard.

Cloudflare’s browser cache: a zone’s Browser Cache TTL (4 hours by default) rewrites the site’s Cache-Control: no-cache on /assets/*.css and *.js to max-age=14400, so readers see last release’s stylesheet (#534). Add a Cache Rule for the site’s host with Browser TTL: Respect origin TTL, then check: curl -sI https://pages.example.org/assets/page.css shows cache-control: no-cache.

Upgrades#

Back up first, then upgrade to the newer chart version (its image follows, unless your values set image.tag: change that too).

kubectl -n scrap create job --from=cronjob/scrap-backup scrap-backup-pre-upgrade
kubectl -n scrap wait --for=condition=complete job/scrap-backup-pre-upgrade --timeout=30m
helm upgrade scrap oci://ghcr.io/gilesknap/charts/scrap --version <new> -n scrap -f my-values.yaml

From a checkout, helm upgrade scrap ./charts/scrap -n scrap -f my-values.yaml --set image.tag=<new> instead.

The old pod stops before the new one starts (Recreate), so there is a short outage; the PWA queues captures meanwhile. A clean stop gives any ingest job in flight back to the queue. scrap runs its database migrations at startup, before it answers /healthz: the startup probe allows them five minutes.

Migrations are forward only, and an older scrap refuses a database migrated by a newer one. So helm rollback alone does not go back: roll back to the old image and restore the pre-upgrade backup with --force (below). Anything captured since the upgrade is then lost from the database; its files stay.

Backup and restore#

The daily CronJob runs scrap backup into a new timestamped directory on the backup claim (/backups/20261019T033000Z), then keeps only the newest backup.keep runs, using the same rules as the single-server deployment: keep must be a whole number of at least 1 (the chart refuses anything else), and only real directories named like a run are ever deleted; anything else on the volume, including symlinks, is left alone. A failed run removes only its own directory. scrap backup is safe while scrap is serving. The job runs on the same node as the scrap pod (pod affinity), because the data volume is ReadWriteOnce; while scrap is scaled to zero, a run waits and gives up after an hour. Runs never overlap, and one that cannot start within an hour of its schedule is skipped. A failed run is retried once and then counted as failed; the last 3 successful and the last 3 failed Jobs are kept, so kubectl logs still works on them.

Monthly copy. A second CronJob, <fullname>-backup-monthly (03:50 on the 1st), makes the same full backup into the monthly/ folder of the backup claim and keeps the newest backup.monthly.keep (12) there. It takes the same lock as the daily backup and gc, so they never overlap. The daily prune, gc’s recent-backup check and the restore commands below look only at runs directly in the claim, so none of them touches monthly/; to restore one, point restore at /backups/monthly/<run>. Switch it off with backup.monthly.enabled: false.

        flowchart LR
    d["daily CronJob<br/>03:30"] -->|"keep 7"| r[("/backups/<br/>YYYYMMDDTHHMMSSZ")]:::store
    m["monthly CronJob<br/>03:50 on the 1st"] -->|"keep 12"| mm[("/backups/monthly/<br/>YYYYMMDDTHHMMSSZ")]:::store
    g["gc CronJob<br/>04:30"] -.->|"needs a COMPLETE run<br/>under 26 h old"| r
    classDef store fill:#e8f1ff,stroke:#4a7bd0
    

The daily CronJob is called <fullname>-backup, which is scrap-backup for the release used in this guide. When a CronJob’s name would be longer than 52 characters, the chart shortens the fullname and adds a 6-character hash, so the name differs: read it from kubectl -n scrap get cronjob and use it in place of scrap-backup in the commands below and under Upgrades.

Backups are on by default because the data is personal and irreplaceable; set backup.enabled: false if you back up the data volume another way (volume snapshots, Longhorn or Velero backups). Either way the backup claim lives in the same cluster: copy it somewhere else too.

Run one now and follow it:

kubectl -n scrap create job --from=cronjob/scrap-backup scrap-backup-now
kubectl -n scrap logs -f job/scrap-backup-now

To restore, stop scrap (and anything that would restart it), then run scrap restore in a one-off pod with both claims mounted:

kubectl -n scrap patch cronjob scrap-backup -p '{"spec":{"suspend":true}}'
kubectl -n scrap scale deploy/scrap --replicas=0
kubectl -n scrap wait --for=delete pod -l app.kubernetes.io/name=scrap --timeout=2m
kubectl -n scrap apply -f restore.yaml
kubectl -n scrap wait --for=jsonpath='{.status.phase}'=Succeeded pod/scrap-restore --timeout=10m
kubectl -n scrap delete pod scrap-restore
kubectl -n scrap scale deploy/scrap --replicas=1
kubectl -n scrap patch cronjob scrap-backup -p '{"spec":{"suspend":false}}'

With Argo CD or another GitOps controller, pause its automatic sync first, or it scales scrap straight back up. restore.yaml, with the run to restore, the image of the scrap version you want (the chart’s image.repository and tag of the release that wrote the backup, or a newer one: restore refuses a backup from a newer scrap, see Which backups restore) and, for a non-empty data volume, --force (which replaces the whole database; see scrap restore --help):

apiVersion: v1
kind: Pod
metadata:
  name: scrap-restore
spec:
  restartPolicy: Never
  automountServiceAccountToken: false
  # imagePullSecrets: [{name: my-registry-credentials}]   # if the registry needs a login
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: restore
      image: ghcr.io/gilesknap/scrap:<version>
      args: ["restore", "/backups/<run>", "--force"]
      env:
        - name: SCRAP_DATA_DIR
          value: /data
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: [ALL]
      volumeMounts:
        - {name: data, mountPath: /data}
        - {name: backups, mountPath: /backups, readOnly: true}
        - {name: tmp, mountPath: /tmp}
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: scrap-data
    - name: backups
      persistentVolumeClaim:
        claimName: scrap-backups
    - name: tmp
      emptyDir: {}

The claim names are <fullname>-data and <fullname>-backups, where the fullname is the release name if it contains scrap, else <release>-scrap: kubectl -n scrap get pvc shows them. scrap restore prints nothing when it works and exits non-zero with a message when it does not (kubectl -n scrap logs pod/scrap-restore; the wait above then times out instead of returning). It works on a copy and never writes to the backup, so the backups claim can be mounted readOnly: true, as above. The same pod with other arguments runs any scrap command against the data, for example ["export", "/backups/export"] (which does need the claim writable; pruning only ever deletes directories named like a backup run, so an export there is kept). To restore into a fresh volume instead, create a new claim, restore into it without --force, and point persistence.existingClaim at it.

With litestream.enabled. Do not restore over the database: a plain restore would be undone, because the next start restores the replica. Follow the same steps, with --stage in the pod’s arguments:

      args: ["restore", "/backups/<run>", "--stage", "--force"]

--force is needed unless files/ is empty: files are only ever added. This checks and migrates the backup and leaves it at /data/scrap.db.restored. The next start takes it up in place of the replica, and renames the old replica /data/litestream.retired-<time>; Litestream starts a new one. Pages written since the backup are gone from the database, as with --force above. The retired replica holds their database rows, but you can only get them back by hand, restoring it to a scratch copy (litestream restore -o <file> file:///data/litestream.retired-<time>); it does not hold files. Files added since the backup are in files/, but nothing refers to them now, so gc would delete them: for 14 days after a restore gc only counts, to give you time to go through them. scrap db-hold --clear ends that sooner. See what happened with kubectl -n scrap logs deploy/scrap -c db-init, then make a backup. Once you are happy, delete the retired folder with the one-off pod (["sh", "-c", "rm -rf /data/litestream.retired-*"] with command: in place of args:), as it takes up room on the volume.

If you forget --stage the pod stops in Init, saying /data/scrap.db has changed: it will not choose between that file and the replica, either of which may be newer. Rename the file away to keep the replica, or to scrap.db.restored to keep the file and retire the replica. scrap restore in the running pod is refused, with or without --stage. If scrap is not scaled to 0 when you stage a restore from a one-off pod, it goes on taking writes, and nothing stops it: the restore waits for the next start. That start refuses if the pod has kept its database, or if the replica has anything newer than the staged file, and says how to choose: delete the staged file to keep those writes, or touch it with scrap at 0 to restore anyway.

Backups in a hostPath. With backup.persistence.hostPath, there is no backups claim. Mount the directory instead, and run the pod as the release’s podSecurityContext user, who owns the backups (the DLS install uses your own uid and gid; id shows them):

  securityContext:
    runAsNonRoot: true
    runAsUser: <uid>
    runAsGroup: <gid>
    fsGroup: <gid>
    seccompProfile:
      type: RuntimeDefault
  ...
  volumes:
    - name: backups
      hostPath:
        path: <the backup directory>
        type: Directory

Which backups restore#

What scrap restore accepts depends on the schema of the backup (the database version, named in the error message below) against the release that runs the restore:

The backup is

scrap restore

the same schema as the release

Copied as it is.

lower than the release’s schema (an older release made it)

Migrates: restore runs the numbered migration scripts after the backup’s version on its copy before the swap, so the data folder only ever receives a current database. A failed migration leaves the database as it was (files already copied stay, and are harmless).

above the release’s schema (a newer release made it)

Refuses: bad backup: <db> has schema N; this scrap knows M. Use the release that wrote the backup, or a newer one.

Pointing a newer server at an older store migrates it in place when it starts (scrap serve migrates before it answers /healthz), so an upgrade needs no restore. Migrations are one-way: once a newer server has migrated the live store, going back to the old image needs a restore of a backup taken before the upgrade (see Upgrades above), and backups taken after it are refused by the old image.

Try a restore in a scratch instance#

A backup you have never opened is a hope, not a backup. This checks one on any machine, with nothing touching the cluster’s data: restore it into a throwaway folder, start scrap on another port and compare what it shows with the live store.

Room. You need room for two copies of the store: the run you copy off the cluster and the restored folder. Check the size first with kubectl -n scrap exec deploy/scrap -- du -sh /data (about 500 MB free for a 250 MB store), and keep everything in one folder you can delete at the end. The steps call it $RT; pick a disk with room, not a small /tmp; it is created new, so the final rm -rf "$RT" removes nothing else.

export RT=$(mktemp -d "$HOME/rt.XXXXXX")   # a new, empty folder; put another disk's path in place of $HOME if needed
mkdir "$RT/backup"

Tested. On 2026-10-29 with a main-branch build of scrap 1.0.0rc4.dev5, on a throwaway instance (not the cluster): a scrap backup of a store of synthetic pages (todos and notes, 110 of them in the trash, one file) was restored into a scratch folder and compared with the instance it came from (steps 2 to 7, the script included). It is not yet tried on a real run copied off the backup claim by step 1, nor on 1.0.0 itself: re-run it there and update this paragraph.

  1. Get a run onto the machine. A run directory holds scrap.db, a files/ folder (once any file has been stored) and a COMPLETE file; copy the whole directory, whatever else is in it (a scrap.db-wal or scrap.db-shm beside scrap.db is a harmless SQLite leftover: scrap restore uses a -wal if there is one and ignores a -shm). No running pod mounts the backup claim (the scrap pod mounts only data and tmp, and backup pods are Completed), so start a read-only helper pod that does. reader.yaml, with the image of the scrap version that wrote the backup or a newer one (restore refuses a newer backup’s schema; see Which backups restore) and the claim name <fullname>-backups (see the note under restore.yaml):

    apiVersion: v1
    kind: Pod
    metadata:
      name: scrap-backup-reader
    spec:
      restartPolicy: Never
      automountServiceAccountToken: false
      # imagePullSecrets: [{name: my-registry-credentials}]   # if the registry needs a login
      affinity:
        podAffinity:   # the backup claim is ReadWriteOnce: land on the scrap pod's node
          requiredDuringSchedulingIgnoredDuringExecution:
            - topologyKey: kubernetes.io/hostname
              labelSelector:
                matchLabels:
                  app.kubernetes.io/name: scrap
                  app.kubernetes.io/instance: scrap   # the release name
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: reader
          image: ghcr.io/gilesknap/scrap:<version>
          command: ["sleep", "3600"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          volumeMounts:
            - {name: backups, mountPath: /backups, readOnly: true}
      volumes:
        - name: backups
          persistentVolumeClaim:
            claimName: scrap-backups   # <fullname>-backups, or backup.persistence.existingClaim
            readOnly: true
    

    The backup claim is ReadWriteOnce unless your values say otherwise, so the pod has the same affinity as the CronJobs: it needs the scrap pod running, on the node where the claim is bound (the labels are the chart’s selector labels: app.kubernetes.io/instance is the release name, and name the chart name unless nameOverride is set). With scrap scaled to zero, or a ReadWriteMany claim, leave affinity out. Then list the runs and copy one:

    kubectl -n scrap apply -f reader.yaml
    kubectl -n scrap wait --for=condition=Ready pod/scrap-backup-reader --timeout=2m
    kubectl -n scrap exec scrap-backup-reader -- ls /backups
    kubectl -n scrap cp scrap-backup-reader:/backups/<run> "$RT/backup/<run>"
    kubectl -n scrap delete pod scrap-backup-reader
    ls "$RT/backup/<run>/COMPLETE"
    

    Which run? A run is named for the UTC time it began, and nightly, pre-upgrade and manual runs (kubectl create job --from=cronjob/scrap-backup) all land in the same folder, so the newest name is not necessarily a nightly one. A nightly run begins within seconds of its schedule (03:30 by default, so 20261029T0330xxZ): to try the CronJob’s own output, take the newest run whose name has that time. Any other run restores the same way. A run without COMPLETE was not finished: take another. kubectl cp needs tar in the image, and the scrap image has it. Where the backup claim is an NFS share, you can mount that share on your machine instead and copy the run from there. A run under monthly/ is /backups/monthly/<run>.

  2. Get a scrap command. Either check out the release tag you are testing and run uv sync once, then use uv run scrap ... for every scrap below; or use the released image with the folders mounted (podman run --rm -v "$RT:$RT" -e SCRAP_DATA_DIR="$RT/scratch" --user 10001:10001 ghcr.io/gilesknap/scrap:<version> restore "$RT/backup/<run>"; the user must be able to write the scratch folder). Use the version that wrote the backup or a newer one.

  3. Restore into an empty folder of its own, without --force. It prints nothing when it works:

    export SCRAP_DATA_DIR="$RT/scratch"
    scrap restore "$RT/backup/<run>"
    

    Running it again says $RT/scratch is not empty; stop scrap and use --force (with the folder spelt out) and changes nothing.

  4. Start scrap on a spare port, in the background. Leave SCRAP_INGEST and the Claude settings unset, so it makes no calls. A clone without the PWA build logs The scrap PWA is not built; the API checks do not need it:

    scrap serve --port 8793 &
    SERVE=$!
    sleep 3 && curl -s localhost:8793/healthz
    
  5. Port-forward the live service, in the background, so the same GETs answer from both:

    kubectl -n scrap port-forward svc/scrap 8794:80 &
    FWD=$!
    
  6. Compare with the live store, read only. The live store keeps changing: a page edited or captured since the run was taken, or one in the trash since, will differ or be missing, and that is not a failed restore. Take a run from today to keep that small. The list GET /api/pages has grown fields over time (a thumb was added), so compare ids, titles and tags per page, not the whole body of the list against output recorded earlier. Run each call against port 8793 (restored) and 8794 (live):

    Check

    Call

    Pages

    GET /api/pages?limit=500 (the trash is left out; 500 is the most it lists)

    Pages in full

    GET /api/pages/<slug> for each (the script below)

    Tags, topics

    GET /api/tags, /api/topics

    Todos

    GET /api/pages?kind=todo&limit=500: count the list. (GET /api/search stops at 20 results unless asked for up to 100, so it cannot confirm a count.)

    Write-ups

    writeup in GET /api/pages/<slug> (the script below)

    Trash

    GET /api/trash?limit=500: compare total, which counts the whole trash; results stops at limit (100 unless asked for up to 500)

    Files

    GET /api/blobs/<sha> for each file a page refers to; the count and total bytes of the files (they sit in sub-folders, so count files, not entries): find files -type f -printf '%s\n' | awk '{n+=$1} END{print NR, n}' in the restored folder against kubectl -n scrap exec deploy/scrap -- sh -c "cd /data && find files -type f -printf '%s\n' | awk '{n+=\$1} END{print NR, n}'"

    Search

    GET /api/search?q=<a word you know>

    Health

    GET /healthz, GET /api/health

    The script loops over every page, compares the two ports and prints each slug whose text, tags, kind or fields differ, then the page, todo and trash counts for both (captures are left out, because their files are checked by count and size above). A page’s tags come back most-used first, by a count over the whole store, so the same tags can arrive in another order on the live store; the script compares them sorted.

    python3 - <<'PY'
    import json, urllib.request
    
    def get(port, path):
        with urllib.request.urlopen(f"http://localhost:{port}{path}") as r:
            return json.load(r)
    
    def key(p):
        return [p.get("title"), p.get("body"), sorted(p.get("tags") or []), p.get("kind"),
                p.get("fields"), p.get("rev"), p.get("writeup")]
    
    a, b = get(8793, "/api/pages?limit=500"), get(8794, "/api/pages?limit=500")
    sa, sb = {p["slug"] for p in a}, {p["slug"] for p in b}
    print(len(sa), "restored,", len(sb), "live; only restored:", sorted(sa - sb), "only live:", sorted(sb - sa))
    bad = 0
    for slug in sorted(sa & sb):
        if key(get(8793, f"/api/pages/{slug}")) != key(get(8794, f"/api/pages/{slug}")):
            bad += 1
            print("differs:", slug)
    print(bad, "differ")
    for port in (8793, 8794):
        todos = len(get(port, "/api/pages?kind=todo&limit=500"))
        trash = get(port, "/api/trash?limit=500")["total"]
        print(port, "todos:", todos, "trash:", trash)
    PY
    

    /api/pages lists at most 500 pages: with more, compare in full some other way (for example scrap export).

  7. Stop the server and the port-forward, and delete the folders:

    kill $SERVE $FWD
    rm -rf "$RT"
    

What to expect:

  • A backup from an older release is migrated by restore, on the copy, before it is swapped into place (see Which backups restore). PRAGMA integrity_check should say ok. GET /healthz shows the version of the scratch instance, which is the scrap you ran in step 2, so it differs from the live version if you used another release. Same or newer than live is fine; older cannot read a newer backup and restore refuses it.

  • The backup holds the database and every file in files/. It does not hold cache/, the page previews, which scrap makes again the first time a photo is shown.

  • enrich.on and writeup.on read false on the scratch instance only because Claude is off there. A newer scrap can also count more in GET /api/health: the restored copy may report a check (is:outdated, for example) as nonzero that a live service on an older version does not run.

Health and monitoring#

/healthz answers 200 with the version while scrap is well. It answers 503 when ingest is on and its worker thread has died; the liveness probe then restarts the pod, which is intended (the readiness probe takes it out of the Service first). Logs: kubectl -n scrap logs deploy/scrap. Ingest pauses (budget spent, credit, auth) are reported once per episode to SCRAP_NOTIFY_URL if set, and are visible at GET /api/ingest. The backup CronJob’s failed jobs stay listed (kubectl -n scrap get jobs); alert on them with whatever watches your cluster.

The database on an emptyDir, with Litestream#

litestream.enabled: true is for a data volume on NFS or another network filesystem, where SQLite’s write-ahead log is not safe. The DLS installer sets it. Why, and the choices behind it: ADR 46. It needs Kubernetes 1.29 and the litestream/litestream image, pinned in litestream.image.

  • Where things are. The database is at /db/scrap.db on an emptyDir on the node’s disk, limited by litestream.sizeLimit (2 Gi), which the WAL and Litestream’s own files share: a database over half of it is refused at start and warned of in the jobs log. A Litestream sidecar copies each change to /data/litestream on the data volume. Files and the preview cache stay on the data volume.

  • What can be lost. While Litestream is healthy, a copy follows each write by about a second: if the node dies, those last moments are lost. A clean stop or upgrade loses nothing: Litestream stops after scrap, with its last sync (terminationGracePeriodSeconds grows by 30 s; the jobs container stops its jobs within 15 s, so they never eat into it). Backups remain the protection against everything else, including a mistake that replicates faithfully.

  • If replication stalls (Litestream stays up, logging monitor error, if it cannot write), “about a second” no longer holds. The jobs container stamps the database every 15 minutes and looks each minute for a new file in the replica. With none for 20 minutes it logs REPLICATION STALLED, pushes to SCRAP_NOTIFY_URL if set, and fails its readiness probe, so the pod leaves its Service and scrap takes no more writes from outside. It comes back by itself. Writes made in those 20 minutes are not in the replica until it recovers, and are lost if the pod goes first: the next start then compares the replica with the last stamp noted on the volume, warns if it is behind, and refuses if over 30 minutes behind. This cannot see a whole volume failing, where the note cannot be written either: that shows as the same stall, and in scrap’s file errors.

  • Each start. The db-init init container keeps a database already on the emptyDir, restores the replica, or on the first start after turning this on brings /data/scrap.db across once and leaves it there (a file with no pages does not count). That is all a plain helm upgrade needs. You can delete the old file once you are happy. A new database is made only if nothing says there should be one. A bad schedule or time zone also stops it here.

  • When a pod stays in Init. kubectl -n scrap logs deploy/scrap -c db-init says why. It fails on purpose rather than start empty: a restore failed (a damaged replica, a database from a newer scrap), or the replica is missing though the state file /data/.scrap-db-state.json says there was one, or there are files or backup runs but no database or replica, or /data/scrap.db changed, or the replica is far behind. Usually the answer is to restore a backup. If you are sure, litestream.init.override: true goes on from what is there. Set it back at once: while it is on a later loss of the replica would also start. Each use is recorded, and gc then only counts, and its run fails, until you run kubectl -n scrap exec deploy/scrap -c jobs -- scrap db-hold --clear, since what came back may be older than the files.

  • gc’s safety limit. gc deletes files nothing refers to, so a database older than the files makes the newest look unused: the one loss a restore cannot undo. So on these installs gc --delete only counts, and fails, when the database has no captures but files/ has content, or when the files to go are over 10 % of them and at least 5. After a look, kubectl -n scrap exec deploy/scrap -c jobs -- scrap gc --delete --allow-large goes ahead once.

  • Backups stay CronJobs: they restore the replica to a temporary file and back that up with files/, with the same integrity check and COMPLETE marker. They never touch the live database, but they still run beside the scrap pod, for the ReadWriteOnce data volume, so they wait while scrap is scaled to zero. A replica whose heartbeat is over 3 hours old is refused, and the job fails: look at kubectl -n scrap logs deploy/scrap -c litestream.

  • gc and the Claude passes need the live database, so they run in a jobs container in the scrap pod, not as CronJobs, with the same values for their schedules, read as a CronJob reads them but for @every, which is refused. An empty timeZone is UTC here, not the controller’s zone. All of them share litestream.jobs.resources: gc.resources and enrich*.resources are not used. Run one now with kubectl -n scrap exec deploy/scrap -c jobs -- scrap schedule --run gc (or enrich-related, enrich-topics). A failure is in kubectl -n scrap logs deploy/scrap -c jobs and, if set, SCRAP_NOTIFY_URL, not a failed Job; a Claude pass stopping at a queue pause or a spent budget is logged, and is not a failure. gc still deletes only with a recent COMPLETE backup, otherwise only counts. A time missed while the pod is restarting is not made up, and a rollout stops a running pass: the next run carries on, so a rollout around 04:30 skips that night’s gc, and one on a Sunday around 04:11 that week’s topics.

  • A jobs container that keeps crashing takes scrap out of its Service, as Litestream’s does: nothing would then be watching replication.

  • Turning it off, or helm rollback to a release with it off, does not bring the database back: /data/scrap.db is the old one. So scrap and gc refuse to start on it while the state file is there. Rolling back to a chart from before this existed cannot be guarded: that version serves the old file, and its gc deletes the files only the newer database uses. Do not. The way off, with Argo CD’s auto-sync paused:

    1. Take a backup, and find its run: kubectl -n scrap create job --from=cronjob/scrap-backup scrap-backup-off, then kubectl -n scrap logs -f job/scrap-backup-off.

    2. kubectl -n scrap scale deploy/scrap --replicas=0, and suspend the backup CronJobs. The chart is still the Litestream one, and nothing starts while it is at 0.

    3. Restore that run with the pod from the restore steps, with args: ["restore", "/backups/<run>", "--force"]: this is the one time without --stage. It writes /data/scrap.db and removes the state file.

    4. helm upgrade with litestream.enabled: false. It sets replicas: 1, so scrap starts, on the restored file. If you do this before step 3 it refuses to start instead, and does no harm.

    5. Remove /data/litestream* with the same pod once you are happy (they take up room, and would stop a later turn back on). The CronJobs of gc and the Claude passes return by themselves.

k3s and storage notes#

  • k3s’s default local-path provisioner keeps the volume in a directory on one node and pins the pod to that node: if the node is down, scrap is down. That is fine for a home cluster; move the data with a backup and restore.

  • local-path does not apply fsGroup; its directories are world-writable, so uid 10001 can still write. On other storage the chart’s fsGroup: 10001 makes the volume writable.

  • Do not put the database on NFS or another network filesystem: SQLite’s WAL mode needs shared memory and locking that network filesystems do not provide reliably. Block storage (Longhorn, a cloud disk) is fine. If NFS is all there is, use The database on an emptyDir, with Litestream. ReadWriteOncePod volumes would keep the backup job out; use ReadWriteOnce.

  • Mixed arm64 and amd64 nodes (e.g. ARM boards and an x86 mini PC) need no settings: the image is multi-arch. Use nodeSelector only to choose where the data lives.

Argo CD#

A generic Application that installs the released chart from ghcr.io (replace the placeholders, and pin targetRevision to a release version; a pre-release 0.4.0rc1 is chart version 0.4.0-rc.1). The chart version and appVersion come from the git tag, so the image tag follows with no image.tag set. A Git source (repoURL: https://github.com/gilesknap/scrap.git, path: charts/scrap) also works, but the checked-in chart has appVersion 0.0.0, so set image.tag too:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: scrap
  namespace: argocd
spec:
  project: default
  source:
    repoURL: ghcr.io/gilesknap/charts
    chart: scrap
    targetRevision: <release-version>
    helm:
      valuesObject:
        ingress:
          enabled: true
          className: tailscale
          tls:
            - hosts: [scrap]
        ingest:
          enabled: true
          existingSecret: scrap-secrets
  destination:
    server: https://kubernetes.default.svc
    namespace: scrap
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

Both claims carry two annotations by default, and keep them if you set your own persistence.annotations or backup.persistence.annotations:

  • helm.sh/resource-policy: keep: helm uninstall leaves the claim, and Argo CD treats it as Delete=false, so deleting the Application leaves it too (Argo CD and Helm).

  • argocd.argoproj.io/sync-options: Prune=false: with prune: true, Argo CD deletes any object that drops out of the rendered chart, which is what a claim does when you switch to persistence.existingClaim or set backup.enabled: false. This annotation stops that (sync options); Helm ignores it. Delete a claim you no longer need by hand.

Also use a StorageClass (or set the PV) with the Retain reclaim policy: k3s’s local-path defaults to Delete, so a deleted claim takes the data with it.

Uninstall#

helm uninstall scrap -n scrap

removes the Deployment, CronJobs, Service, ServiceAccount, Ingress and NetworkPolicy, but keeps both claims (data and backups), because they are annotated helm.sh/resource-policy: keep. The namespace and the Secret you created stay too. Installing again with the same release name reuses the surviving claims.

To delete the data for good, first copy a final backup off the cluster, then delete the claims:

kubectl -n scrap get pvc
kubectl -n scrap delete pvc scrap-data scrap-backups

Use the names get pvc shows (<fullname>-data and <fullname>-backups). A claim you supplied with persistence.existingClaim or backup.persistence.existingClaim is not the chart’s to delete. What happens to the underlying volume depends on the StorageClass reclaim policy: with Delete (k3s’s local-path) it goes with the claim, with Retain it stays, and you remove the PersistentVolume by hand.

What the chart does not do#

  • Run more than one replica, or any high availability: SQLite has a single writer.

  • Create Secrets, take the API key as a value, or log in users.

  • Expose scrap to the internet, or add authentication. Public pages (Public pages) put only the static site of the pages you mark Public on the internet, never scrap.

  • Copy backups off the cluster.

  • Restrict egress (ingest needs api.anthropic.com, and any public site a saved link points to while SCRAP_ENRICH is on; notifications need your notify URL).

  • Run gc, the related pass or the topics pass anywhere but beside the scrap pod, or without the pod running (see Scheduled gc, related pages and topics).

  • Publish a Helm repository: releases publish the chart as an OCI artifact only (oci://ghcr.io/gilesknap/charts/scrap); a checkout or a Git source also works, with image.tag set.

Next steps#

Open scrap’s tailnet address and install the PWA (Capture from your phone (the PWA)), then connect Claude Code sessions (Connect a Claude Code session).