Skip to main content

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

ToolOpen SourceFree TierMonthly Cost
VendooNoLimitedFrom $8.99/mo

Self Hosted

ToolOpen SourceFull FeaturesNotes
Baserow aloneYesPartlyThe table exists anyway; kleinbase adds the listing automation, price hints and statistics on top

Installation​

Architecture​

  • Deployment: Single kleinbase deployment in the kleinbase namespace, one replica by design (in-process row cache, bot job queue and geocoder), Recreate strategy
  • 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/data for 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's X-Forwarded-* headers

Security​

  • Runs as uid 1000, runAsNonRoot: true, read-only root filesystem, all capabilities dropped, RuntimeDefault seccomp; Chromium runs with --no-sandbox inside 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.yaml from the kleinbase-config ConfigMap, 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-env Secret
  • 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 the int.kueber.eu record.
  • Login is the app's own HTTP Basic auth, KLEINBASE_USER / KLEINBASE_PASSWORD from the kleinbase-env Secret. The workstation .env had no password (localhost only), so a generated one went into SOPS — sops -d it for the browser prompt. A gateway SecurityPolicy with 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 via regcred-docker-gitea (namespace label secrets.docker-gitea). The kleinbase repo only ever pushes latest, so the digest pin is what Renovate moves overnight and imagePullPolicy: Always makes 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 RuntimeDefault seccomp. kleinanzeigen-bot drives a headless Chromium with --no-sandbox, which is exactly why the pod must not be root. Writable emptyDirs at /tmp, /home/kleinbase and a 256 MiB memory-backed /dev/shm; memory limit 2Gi for the browser.
  • Config is a ConfigMap, personal data is not — config.yaml is mounted at /config with map.home replaced 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 public baserow-backend.web.kueber.eu, which the baseline gateway egress already allows.
  • /srv/data is 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).

  1. On the workstation, in the kleinbase checkout: remove --headless=new from data/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 -
  1. 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.

Kubernetes Metadata
  • 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