Install scrap on Kubernetes#
In this tutorial you run scrap on a Kubernetes cluster with its Helm chart, and publish it on
your own domain through a Cloudflare Tunnel.
Cloudflare Access sits in front: it asks for an email one-time PIN before anyone reaches the web
app, and for a service token before anyone reaches the MCP endpoint, which Claude Code uses. At
the end you have scrap on https://scrap.<your-domain>, installed on your phone, and Claude Code
connected to it.
The cluster makes no inbound connection and opens no port. The tunnel is outbound only.
How a request reaches scrap:
flowchart LR
phone([Phone or browser]):::user -->|email PIN| cf
cc([Claude Code]):::claude -->|service token + bearer| cf
subgraph edge [Cloudflare]
cf[Access]
end
subgraph cluster [Your cluster]
cfd[cloudflared] -->|NetworkPolicy| scrap[scrap pod]
scrap --> pvc[(Data volume)]:::store
job[Backup CronJob] --> bk[(Backup volume)]:::store
end
cf -->|outbound tunnel| cfd
classDef store fill:#e8f1ff,stroke:#4a7bd0
classDef claude fill:#fff3e0,stroke:#e08a00
classDef user fill:#e9f7ef,stroke:#2e9d5b
It is the same container image as the other installs. For a first try on one machine, see Try scrap on your LAN or Install on a server with Tailscale. This page follows one happy path; every chart value, upgrades, backup and restore, and troubleshooting are in Deploy on Kubernetes.
Warning
scrap has no login of its own. Anyone who can reach it can read and change everything. Here, Cloudflare Access is the login, and a NetworkPolicy stops other pods and LAN hosts reaching scrap directly. Do not skip either.
What you need#
A Kubernetes cluster, version 1.27 or newer, with a StorageClass that gives
ReadWriteOncevolumes on a local or block filesystem, and a CNI that enforces NetworkPolicy (k3s does). SQLite does not work reliably on network filesystems such as NFS. See k3s and storage notes.kubectlpointed at that cluster and Helm 3 or 4.A domain whose DNS is on Cloudflare, and a Cloudflare account with Zero Trust enabled (the free plan is enough).
A Claude API key for ingest (read photos and tag pages). Without one, photos queue and nothing more happens.
A phone to try the web app on.
scrap always runs as one replica: SQLite has a single writer. The chart has no replica count for that reason, and the Deployment stops the old pod before it starts the new one.
Throughout, scrap.example.com is your own hostname. Replace it.
1. Choose a release#
The chart is published as an OCI artifact, oci://ghcr.io/gilesknap/charts/scrap, and its version
is the release tag (a pre-release such as 1.0.0rc1 is chart 1.0.0-rc.1). This page installs
--version 1.7.0; use a newer release if there is one (see the
releases). The chart’s appVersion is the same tag,
so it runs ghcr.io/gilesknap/scrap:1.7.0 (amd64 and arm64). There is nothing to build
and nothing to clone.
2. Create the secrets#
The chart never takes a key as a value and never creates a Secret. You create them, from stdin so nothing lands in your shell history.
kubectl create namespace scrap
read -rs KEY && printf %s "$KEY" | kubectl -n scrap create secret generic scrap-secrets \
--from-file=ANTHROPIC_API_KEY=/dev/stdin; unset KEY
Add the MCP bearer token to the same Secret. It is a second lock on /mcp, behind Cloudflare’s.
Generate it and keep a copy for Claude Code later:
python3 -c 'import secrets; print(secrets.token_urlsafe(32))' > scrap-mcp-token.txt
kubectl -n scrap patch secret scrap-secrets --type merge \
-p "{\"stringData\":{\"SCRAP_MCP_TOKEN\":\"$(cat scrap-mcp-token.txt)\"}}"
Move scrap-mcp-token.txt into your password manager and delete the file. Do not commit it.
3. Install scrap#
Write my-values.yaml:
ingest:
enabled: true
existingSecret: scrap-secrets
mcp:
tokenSecret:
name: scrap-secrets
key: SCRAP_MCP_TOKEN
identityHeader: Cf-Access-Authenticated-User-Email
extraEnv:
- name: SCRAP_PUBLIC_URL
value: https://scrap.example.com
networkPolicy:
enabled: true
from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: cloudflared
podSelector:
matchLabels:
app: cloudflared
backup:
enabled: true
What each part does:
ingestturns on Claude ingest, reading the API key fromscrap-secrets.mcp.tokenSecretmakes/mcp, and the downloads under it, requireAuthorization: Bearer <token>. The web app, the API and/healthzare not affected.identityHeaderrecords the signed-in email, which Cloudflare Access adds to each request, as the author of a change. Changes made through/mcpare always authorclaude.SCRAP_PUBLIC_URLis the address you reach scrap at./mcp, the web app and the API answer421to any other host name (and403to a write from anotherOrigin), apart from/healthz. The web app and the API also accept an IP address as the host;/mcpdoes not.networkPolicyadmits only the cloudflared pods, in thecloudflarednamespace you create in the next step. Without it, any pod in the cluster could reach scrap directly and send its own identity header. With it, and a CNI that enforces NetworkPolicy, other pods and LAN hosts cannot go around Cloudflare. Cluster admins, node access andkubectl port-forwardare outside that guarantee.backup.enabledis already the default. It runs a nightlyscrap backuponto a second volume, plus a monthly one kept longer.
Install it:
helm install scrap oci://ghcr.io/gilesknap/charts/scrap --version 1.7.0 \
--namespace scrap -f my-values.yaml
kubectl -n scrap rollout status deploy/scrap
The NetworkPolicy only lets cloudflared in, so until the next step nothing outside the pod can
reach scrap. To look at it yourself first, kubectl -n scrap port-forward svc/scrap 8000:80
still works (it goes through the kubelet, not the pod network), then open
http://127.0.0.1:8000/.
4. Create the tunnel#
In the Cloudflare dashboard, open Zero Trust.
Go to Networks > Tunnels (named Connectors in newer dashboards) and choose Create a tunnel.
Pick Cloudflared, name it
scrap, and save.Cloudflare shows install commands for several systems. Do not run them. Copy only the long token that follows
--token(orservice install).Create the namespace and store the token:
kubectl create namespace cloudflared read -rs T && printf %s "$T" | kubectl -n cloudflared create secret generic cloudflared-credentials \ --from-file=TUNNEL_TOKEN=/dev/stdin; unset T
Save this as
cloudflared.yaml:apiVersion: apps/v1 kind: Deployment metadata: name: cloudflared namespace: cloudflared spec: replicas: 1 selector: matchLabels: app: cloudflared template: metadata: labels: app: cloudflared spec: securityContext: runAsNonRoot: true runAsUser: 65532 seccompProfile: type: RuntimeDefault containers: - name: cloudflared image: cloudflare/cloudflared:2026.10.0 args: ["tunnel", "--no-autoupdate", "run"] env: - name: TUNNEL_TOKEN valueFrom: secretKeyRef: name: cloudflared-credentials key: TUNNEL_TOKEN securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: ["ALL"] resources: requests: cpu: 20m memory: 64Mi limits: memory: 256Mi
The
app: cloudflaredlabel is what the NetworkPolicy matches. Keep it.kubectl apply -f cloudflared.yaml kubectl -n cloudflared rollout status deploy/cloudflared
Back in the dashboard the tunnel shows Healthy after a few seconds.
Open the tunnel’s Published application routes (older dashboards: Public Hostname) and add one:
Hostname:
scrap.example.com(pick the domain, leave the path empty).Service: type
HTTP, URLscrap.scrap.svc.cluster.local:80. That is<service>.<namespace>.svc.cluster.localand the chart’s Service port, 80.
Cloudflare creates the DNS record for you.
The route goes straight to the Service. There is no ingress controller and no extra login proxy in this setup.
5. Put Cloudflare Access in front#
An Access application is what makes Cloudflare ask who you are. You make two, and the more
specific one (with the path) wins for /mcp.
The web app. Go to Access controls > Applications (older: Access > Applications), Add an application, Self-hosted.
Name:
scrap. Session duration:1 month, or the longest the list offers. A long session matters: the phone app queues captures offline and sends them later, and a request that meets a login page instead of scrap is not delivered.Public hostname:
scrap.example.com, no path. This covers every path on the host.Add a policy: name
Me, action Allow, include Emails with your address (or Emails ending in your domain).Under login methods, keep One-time PIN on. It needs no identity provider.
The MCP endpoint. Claude Code cannot fill in a PIN, so /mcp gets its own application that
accepts a machine credential instead.
Create the service token: Access controls > Service credentials > Service Tokens, Create Service Token, name
scrap-mcp, a long duration. Copy the Client ID and Client Secret now. The secret is shown once.Add a second self-hosted application named
scrap-mcpwith Public hostnamescrap.example.comand Path/mcp.Add a policy: name
Claude Code, action Service Auth, include Service Tokenscrap-mcp. Remove any email policy from this application.
Cloudflare matches the most specific application, so /mcp uses the service-token policy and
every other path uses the email PIN. You check that in the next section. The path covers what is
under it too: the downloads MCP clients fetch (images, files, drawings and page downloads under
/mcp/blobs/..., /mcp/pages/... and /mcp/captures/...) take the same service token and
bearer token, with no extra Access application.
6. Check it works#
First the cluster:
kubectl -n scrap get pods,pvc
kubectl -n scrap get networkpolicy
kubectl -n cloudflared get pods
You want scrap pods Running and ready (the nightly backup and other CronJob pods appear
later), the data claim Bound, the scrap NetworkPolicy present, and cloudflared Running. The
backups claim is Bound too, unless your storage binds a volume only when a pod first uses it
(WaitForFirstConsumer, as k3s’s local-path does): then it stays Pending until the first
backup runs. To run one now:
kubectl -n scrap create job --from=cronjob/scrap-backup scrap-backup-now
Then the edge, from your own machine:
curl -sI https://scrap.example.com/ # redirected to the Cloudflare login
curl -sI https://scrap.example.com/mcp # refused by Cloudflare (302 or 403), scrap never sees it
curl -sI https://scrap.example.com/mcp \
-H "CF-Access-Client-Id: <id>" -H "CF-Access-Client-Secret: <secret>"
# 401, scrap wants the bearer token
curl -sI https://scrap.example.com/mcp \
-H "CF-Access-Client-Id: <id>" -H "CF-Access-Client-Secret: <secret>" \
-H "Authorization: Bearer $(cat scrap-mcp-token.txt)"
# reaches scrap
The last request may answer a plain HEAD with a 405 or 406. That is scrap speaking, not a
refusal: what matters is that the third is 401 and the fourth is not.
If the second request is not refused, or the first shows scrap’s page, the Access applications do not cover the host: recheck section 5.
Now the web app. Open https://scrap.example.com in a private window. Cloudflare asks for your
email, mails you a code, and then scrap opens. Make a change, open its history, and check that
your email is the author.
On your phone, open the same address, sign in, and install it (Share, Add to Home Screen on iOS; the browser’s Install on Android). Take a photo and capture it. See Capture from your phone (the PWA).
7. Connect Claude Code#
Claude Code needs three headers: both Cloudflare ones and the bearer token. The recommended way
is a small headersHelper script that reads them from an env file (~/.config/terminal-config/.env
with CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET and SCRAP_MCP_TOKEN, the token from
step 2), so it also works in a devcontainer or claude-sandbox. Set it up as in
If /mcp needs a token, with https://scrap.example.com/mcp as the URL.
If you prefer, and the variables are exported in the shell that starts claude, add the server
with ${VAR} headers instead. Single quotes keep the shell from expanding the variables, so
Claude Code reads them at start up:
export CF_ACCESS_CLIENT_ID=<client id>
export CF_ACCESS_CLIENT_SECRET=<client secret>
export SCRAP_MCP_TOKEN=<the token from step 2>
claude mcp add --transport http --scope user scrap https://scrap.example.com/mcp \
--header 'CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}' \
--header 'CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}' \
--header 'Authorization: Bearer ${SCRAP_MCP_TOKEN}'
claude mcp list should show scrap as connected. Ask Claude to search your pages. More in
Connect a Claude Code session.
Note
Claude.ai’s hosted connectors cannot send custom headers, so they cannot reach this /mcp.
Next steps#
Update scrap, restore a backup and troubleshoot: Deploy on Kubernetes. Back up first: a backup that stays in the cluster is not enough, so copy it off too.
Add ingest with a Claude subscription instead of an API key: Ingest on a Claude seat instead of an API key.
Use the web app: Capture from your phone (the PWA).
How the author runs it
The author’s home cluster runs scrap behind the same Cloudflare Access and service-token design, and adds ingress-nginx, an oauth2-proxy GitHub login on the web app, Argo CD and sealed secrets. It is a larger stack than you need to start with. Its write-up is docs/how-to/scrap.md in tpi-k3s-ansible.