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 |
PersistentVolumeClaim |
|
Service |
|
ServiceAccount |
No API token mounted; scrap never talks to the Kubernetes API. |
CronJobs + PVC (on by default) |
Nightly |
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
ReadWriteOncevolumes 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 to run. |
|
|
|
|
|
|
|
app default, |
|
|
|
The data volume. |
|
|
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 |
|
|
The backup CronJob. |
|
unset (follows |
The |
|
|
|
|
|
The related-pages CronJob. It exists only while |
|
|
The topics CronJob (Sundays). It exists only while |
|
|
The monthly copy CronJob (needs |
|
|
The backups volume. Each run is a full copy: allow about |
|
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 |
|
|
scrap’s photo decoding is budgeted for a 1 GB container; a lower limit risks the pod being OOM-killed on large photos. |
|
off |
Exposure, below. |
|
|
The Service. |
|
off |
Who may connect, below. |
|
none, |
An existing Secret and the key in it holding the bearer token(s) |
|
|
|
|
off, none, |
A login for the web app through an oauth2-proxy you run, with ingress-nginx only (A central oauth2-proxy with ingress-nginx). |
|
none |
Required with |
|
none |
Anything else for the container, such as |
|
off |
Public pages: the public site and its upload receiver in their own namespace (Public pages). |
|
|
The public side’s namespace. It may be the release namespace; then one Secret serves both sides. |
|
|
|
|
none |
Scheduling and annotations. |
|
see |
Naming, the ServiceAccount, the stop timeout ( |
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#
Tailscale Kubernetes operator (recommended)#
With the Tailscale Kubernetes operator
installed, an Ingress of class tailscale publishes scrap over HTTPS on your tailnet only, with a
MagicDNS name and a Let’s Encrypt certificate
(cluster ingress,
L7 ingress how-to).
Your tailnet needs MagicDNS and HTTPS certificates
enabled.
ingress:
enabled: true
className: tailscale
tls:
- hosts:
- scrap # becomes https://scrap.<tailnet>.ts.net
The operator’s proxy accepts traffic through Tailscale Serve (architecture). Never add the Funnel annotation: that publishes scrap to the internet.
The alternative, the Service annotation tailscale.com/expose: "true" (with
tailscale.com/hostname), forwards plain TCP: there is no HTTPS, so the PWA cannot install or
work offline, and there are no identity headers. Use the Ingress.
Identity headers. scrap records the Tailscale-User-Login header as the author of each change.
Tailscale Serve sets it, and strips any value the client sent, for requests from user-owned
devices, and sets none for tagged devices. The operator’s documentation does not say whether its
Ingress proxy does the same. Its proxy configures Serve through the same local API, and in
Tailscale’s source the Serve reverse proxy adds the headers to every proxied request
(ipn/ipnlocal/serve.go,
addTailscaleIdentityHeaders), so they are expected to arrive, but this is not yet verified
end to end. To check on your cluster: make a change from a phone or laptop through the Ingress,
then look at the page’s history (or GET /api/pages/<id>): your tailnet login should be its
author, not the fixed fallback author tagged. If it is not, every change is recorded as
tagged; nothing else breaks.
The chart leaves networkPolicy off and /mcp without a token by default; see
MCP server and access for the values to set on a shared cluster.
Because a pod inside the cluster can send its own Tailscale-User-Login header straight to the
Service, let only the operator’s proxies in (they run in the operator’s namespace, tailscale by
default):
networkPolicy:
enabled: true
from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: tailscale
A NetworkPolicy needs a CNI that enforces it (k3s’s built-in controller does). Check that the
policy exists (kubectl -n scrap get networkpolicy): a misspelt key inside networkPolicy is
not rejected. Whether kubelet probes still get through depends on the CNI; if the pod never
becomes ready after turning the policy on, check that first.
Tailnet ACL: deny tagged devices by default#
The operator’s proxies are tagged devices (tag:k8s by default). As for the single server, use a
tailnet policy that lets your own devices reach them and gives other tagged devices (servers, CI,
anything unattended) no access unless you grant it:
"grants": [
{"src": ["autogroup:member"], "dst": ["tag:k8s"], "ip": ["tcp:443"]}
]
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, defaultscrap-public), labelled Pod Securityrestricted, which you create first (below);one Deployment with two containers sharing the site volume:
web(nginx, the site mounted read only) andreceiver(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 |
|
|---|---|
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: |
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.
Get a run onto the machine. A run directory holds
scrap.db, afiles/folder (once any file has been stored) and aCOMPLETEfile; copy the whole directory, whatever else is in it (ascrap.db-walorscrap.db-shmbesidescrap.dbis a harmless SQLite leftover:scrap restoreuses a-walif 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 underrestore.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
ReadWriteOnceunless 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/instanceis the release name, andnamethe chart name unlessnameOverrideis set). With scrap scaled to zero, or aReadWriteManyclaim, leaveaffinityout. 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:30by default, so20261029T0330xxZ): 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 withoutCOMPLETEwas not finished: take another.kubectl cpneedstarin 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 undermonthly/is/backups/monthly/<run>.Get a
scrapcommand. Either check out the release tag you are testing and runuv synconce, then useuv run scrap ...for everyscrapbelow; 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.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.Start scrap on a spare port, in the background. Leave
SCRAP_INGESTand the Claude settings unset, so it makes no calls. A clone without the PWA build logsThe scrap PWA is not built; the API checks do not need it:scrap serve --port 8793 & SERVE=$! sleep 3 && curl -s localhost:8793/healthz
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=$!
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/pageshas grown fields over time (athumbwas 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/topicsTodos
GET /api/pages?kind=todo&limit=500: count the list. (GET /api/searchstops at 20 results unless asked for up to 100, so it cannot confirm a count.)Write-ups
writeupinGET /api/pages/<slug>(the script below)Trash
GET /api/trash?limit=500: comparetotal, which counts the whole trash;resultsstops atlimit(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 againstkubectl -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/healthThe 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 (
capturesare 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/pageslists at most 500 pages: with more, compare in full some other way (for examplescrap export).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_checkshould sayok.GET /healthzshows 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 andrestorerefuses it.The backup holds the database and every file in
files/. It does not holdcache/, the page previews, which scrap makes again the first time a photo is shown.enrich.onandwriteup.onreadfalseon the scratch instance only because Claude is off there. A newer scrap can also count more inGET /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.dbon an emptyDir on the node’s disk, limited bylitestream.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 thejobslog. A Litestream sidecar copies each change to/data/litestreamon 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 (
terminationGracePeriodSecondsgrows by 30 s; thejobscontainer 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. Thejobscontainer stamps the database every 15 minutes and looks each minute for a new file in the replica. With none for 20 minutes it logsREPLICATION STALLED, pushes toSCRAP_NOTIFY_URLif 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-initinit container keeps a database already on the emptyDir, restores the replica, or on the first start after turning this on brings/data/scrap.dbacross once and leaves it there (a file with no pages does not count). That is all a plainhelm upgradeneeds. 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-initsays 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.jsonsays there was one, or there are files or backup runs but no database or replica, or/data/scrap.dbchanged, or the replica is far behind. Usually the answer is to restore a backup. If you are sure,litestream.init.override: truegoes 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 runkubectl -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 --deleteonly counts, and fails, when the database has no captures butfiles/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-largegoes ahead once.Backups stay CronJobs: they restore the replica to a temporary file and back that up with
files/, with the same integrity check andCOMPLETEmarker. They never touch the live database, but they still run beside the scrap pod, for theReadWriteOncedata 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 atkubectl -n scrap logs deploy/scrap -c litestream.gc and the Claude passes need the live database, so they run in a
jobscontainer 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 emptytimeZoneis UTC here, not the controller’s zone. All of them sharelitestream.jobs.resources:gc.resourcesandenrich*.resourcesare not used. Run one now withkubectl -n scrap exec deploy/scrap -c jobs -- scrap schedule --run gc(orenrich-related,enrich-topics). A failure is inkubectl -n scrap logs deploy/scrap -c jobsand, 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 recentCOMPLETEbackup, 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
jobscontainer that keeps crashing takes scrap out of its Service, as Litestream’s does: nothing would then be watching replication.Turning it off, or
helm rollbackto a release with it off, does not bring the database back:/data/scrap.dbis 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:Take a backup, and find its run:
kubectl -n scrap create job --from=cronjob/scrap-backup scrap-backup-off, thenkubectl -n scrap logs -f job/scrap-backup-off.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.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.dband removes the state file.helm upgradewithlitestream.enabled: false. It setsreplicas: 1, so scrap starts, on the restored file. If you do this before step 3 it refuses to start instead, and does no harm.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-pathprovisioner 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-pathdoes not applyfsGroup; its directories are world-writable, so uid 10001 can still write. On other storage the chart’sfsGroup: 10001makes 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.
ReadWriteOncePodvolumes would keep the backup job out; useReadWriteOnce.Mixed arm64 and amd64 nodes (e.g. ARM boards and an x86 mini PC) need no settings: the image is multi-arch. Use
nodeSelectoronly 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 uninstallleaves the claim, and Argo CD treats it asDelete=false, so deleting the Application leaves it too (Argo CD and Helm).argocd.argoproj.io/sync-options: Prune=false: withprune: true, Argo CD deletes any object that drops out of the rendered chart, which is what a claim does when you switch topersistence.existingClaimor setbackup.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 whileSCRAP_ENRICHis 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, withimage.tagset.
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).