Kleinbase
Sell what is in the Baserow table: list it, track it, record the sale.
Kleinbase is a first-party web UI over one Baserow table of things to sell. It shows the unsold rows with their listing status, publishes an item to kleinanzeigen.de, either automatically through kleinanzeigen-bot driving a headless Chromium or as a pre-filled form to copy from, optionally to eBay through the Sell API, and records the sale with price, channel, shipping and a structured delivery address. Sold items feed a history page with revenue statistics and a map of where parcels went, geocoded once via Nominatim. Baserow stays the single source of truth; the app keeps only a row cache in memory.
Alternatives considered
Cloud Hosted
| Tool | Open Source | Free Tier | Monthly Cost |
|---|---|---|---|
| Vendoo | No | Limited | From $8.99/mo |
Self Hosted
| Tool | Open Source | Full Features | Notes |
|---|---|---|---|
| Baserow alone | Yes | Partly | The table exists anyway; kleinbase adds the listing automation, price hints and statistics on top |
Installation
Architecture
- Deployment: Single
kleinbasedeployment in thekleinbasenamespace, one replica by design (in-process row cache, bot job queue and geocoder),Recreatestrategy - Image:
gitea.web.kueber.eu/johnny/kleinbase:latest, digest-pinned; the repo's Gitea workflow builds and pushes only that tag - Storage: Longhorn PVC (
kleinbase-data, 2Gi,longhorn-encrypted) at/srv/datafor the kleinanzeigen-bot workspace: its config, the Chromium profile with the logged-in session, ad files and the category cache - Networking: ClusterIP service on port 8000, HTTPRoute via the internal gateway on
kleinbase.int.kueber.eu; uvicorn trusts the gateway'sX-Forwarded-*headers
Security
- Runs as uid 1000,
runAsNonRoot: true, read-only root filesystem, all capabilities dropped,RuntimeDefaultseccomp; Chromium runs with--no-sandboxinside the container, which is why root is never an option - HTTP Basic auth in front of every page except
/healthz; same-origin checks on every non-GET request; CSP,X-Frame-Options: DENY,nosniff - Ingress only from the internal gateway; egress to the internet on 443/80 for kleinanzeigen.de, Nominatim and GitHub
- Longhorn PVC encrypted at rest via a SOPS-managed key
Updates
Managed by Renovate through the image digest, which it pins and auto-merges overnight; the deployment pulls with imagePullPolicy: Always.
Data Management
- PVC:
kleinbase-data(2Gi, Longhorn-encrypted), seeded once from the workstation checkout - Backups: k8up
Schedules to Hetzner and Backblaze (workload-talos-kleinbase-*) via restic, running as uid 1000. The backup contains the logged-in Kleinanzeigen browser session, so these repositories are private by definition - The items themselves live in Baserow and are covered by its backups
User Management
One user. KLEINBASE_USER and KLEINBASE_PASSWORD in the SOPS-encrypted kleinbase-env Secret enable the app's Basic auth. No OIDC yet; a Keycloak-backed gateway SecurityPolicy as on Prefect would be the next step.
Configuration Management
config.yamlfrom thekleinbase-configConfigMap, mounted at/config; every${VAR}in it is expanded from the environment- Secrets and personal values (Baserow token, Kleinanzeigen login, UI password, home address for the map) in the
kleinbase-envSecret - The bot's own config with contact data stays on the PVC, outside the repo
Usage
Add an item with title, description, asking price, category and photos; the form suggests kleinanzeigen-bot's category names and shows what similar items sold for. On the item page publish it automatically (a background job with a live log), link an ad posted by hand, or mark it sold with the delivery address. The history page has the statistics, the map the delivery addresses. Bot runs take minutes and run one at a time.
Cluster-specific deviations from the above live in the per-cluster README — see k8s/apps/kleinbase/talos/README.md.
Cluster Deployment
Kleinbase — Talos cluster
Deviations from defaults
- Internal gateway only (
kleinbase.int.kueber.eu) — the UI shows buyer names and addresses and can publish or delete ads, and it has one user. LAN and NetBird reach the internal VIP, the Crossplane workspace writes theint.kueber.eurecord. - Login is the app's own HTTP Basic auth,
KLEINBASE_USER/KLEINBASE_PASSWORDfrom thekleinbase-envSecret. The workstation.envhad no password (localhost only), so a generated one went into SOPS —sops -dit for the browser prompt. A gatewaySecurityPolicywith Keycloak, as on prefect, would be the stronger option and needs a client first. - First-party image from the private Gitea registry —
gitea.web.kueber.eu/johnny/kleinbase:latest, pulled viaregcred-docker-gitea(namespace labelsecrets.docker-gitea). The kleinbase repo only ever pusheslatest, so the digest pin is what Renovate moves overnight andimagePullPolicy: Alwaysmakes a rollout pick it up. - Rootless with Chromium — runs as the image's uid 1000 with a read-only root filesystem, all capabilities dropped and
RuntimeDefaultseccomp. kleinanzeigen-bot drives a headless Chromium with--no-sandbox, which is exactly why the pod must not be root. Writable emptyDirs at/tmp,/home/kleinbaseand a 256 MiB memory-backed/dev/shm; memory limit 2Gi for the browser. - Config is a ConfigMap, personal data is not —
config.yamlis mounted at/configwithmap.homereplaced by${KLEINBASE_MAP_HOME}; the app expands every${VAR}from the environment, so the home address lives in the Secret next to the tokens. The Baserow API host is the publicbaserow-backend.web.kueber.eu, which the baseline gateway egress already allows. /srv/datais seeded, not declared — the kleinanzeigen-bot workspace holds the bot config (contact name, zip, phone), the Chromium profile with the logged-in Kleinanzeigen session, ad files and the category cache. None of that belongs in a public repo, and the session can only be created with a visible browser on the workstation. The PVC (kleinbase-data, 2Gi, encrypted) is backed up by k8up, run as uid 1000 to match the file owner.- Internet egress on 443/80 (
egress-kleinbase) — kleinanzeigen.de through the bot, Nominatim for geocoding, GitHub for the category list. The map tiles are fetched by the browser, not the pod.
Bootstrap
Kleinanzeigen answers a headless login with IP_RANGE_BLOCKED (bot detection, not the address), so the bot can only ever reuse a session that was created in a visible browser. The cookies are encrypted with the key of the active password store: the profile must be logged in while --password-store=basic is already in the bot config, otherwise the cookies are unreadable in the container (and adding the flag later logs a desktop profile out).
- On the workstation, in the kleinbase checkout: remove
--headless=newfromdata/kleinanzeigen/config.yaml(keep--password-store=basic), run
uv run --env-file .env kleinanzeigen-bot --config data/kleinanzeigen/config.yaml --workspace-mode=portable download --ads=new
and complete login and captcha in the window. Put --headless=new back and run the same command again: it must log Already logged in. Skipping login. before anything is copied.
2. Copy the workspace into the running pod, without Chromium's host-bound lock files and caches:
P=$(kubectl -n kleinbase get pod -l app.kubernetes.io/name=kleinbase -o jsonpath='{.items[0].metadata.name}')
tar -C ~/projects/kleinbase/data -cf - --exclude='Singleton*' --exclude='*/Cache/*' --exclude='*/Code Cache/*' --exclude='*/GPUCache/*' --exclude='.temp' kleinanzeigen \
| kubectl -n kleinbase exec -i "$P" -- tar -C /srv/data -xf -
- Prove the session inside the cluster with the same read-only command:
kubectl -n kleinbase exec "$P" -- sh -c 'cd /srv && kleinanzeigen-bot --config data/kleinanzeigen/config.yaml --workspace-mode=portable download --ads=new'
Already logged in means the bot is onboarded. A fresh Logging in... will fail; do not retry it in a loop, repeat step 1 instead.
4. Open https://kleinbase.int.kueber.eu and log in with the Basic-auth pair from secret-env.yaml.
5. When the session expires later, repeat steps 1 to 3 with only data/kleinanzeigen/profile.
- Image:
gitea.web.kueber.eu/johnny/kleinbase:latest@sha256:3bb6006ce20e6b775b019208d2578b8f86ff766d5f26da23bc6ef9acbf35f9f8
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/name: kleinbase
name: kleinbase
namespace: kleinbase
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: kleinbase
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: kleinbase
spec:
automountServiceAccountToken: false
containers:
- env:
- name: KLEINBASE_CONFIG
value: /config/config.yaml
envFrom:
- secretRef:
name: kleinbase-env
image: gitea.web.kueber.eu/johnny/kleinbase:latest@sha256:3bb6006ce20e6b775b019208d2578b8f86ff766d5f26da23bc6ef9acbf35f9f8
imagePullPolicy: Always
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 4
name: kleinbase
ports:
- containerPort: 8000
name: web
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 4
resources:
limits:
memory: 2Gi
requests:
cpu: 100m
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
volumeMounts:
- mountPath: /config
name: config
readOnly: true
- mountPath: /srv/data
name: data
- mountPath: /tmp
name: tmp
- mountPath: /home/kleinbase
name: home
- mountPath: /dev/shm
name: shm
imagePullSecrets:
- name: regcred-docker-gitea
priorityClassName: tier-3
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 1000
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
volumes:
- configMap:
name: kleinbase-config
name: config
- name: data
persistentVolumeClaim:
claimName: kleinbase-data
- emptyDir: {}
name: tmp
- emptyDir: {}
name: home
- emptyDir:
medium: Memory
sizeLimit: 256Mi
name: shm