Skip to main content

Bubbles

Every project is a bubble that rises until it needs attention.

Bubbles is a first-party attention layer over Gitea, Vikunja and GitHub. Each project rises from the bottom towards the surface at a speed set by its bubble time and importance, and the sources add lift: overdue tasks, stale pull requests, pending review requests, security alerts and activity by others. A surfaced project needs attention; after working on it the bubble is pushed back down, and merged PRs, closed issues or finished tasks sink it on their own. Emergencies — a security label, a Vikunja DO NOW task, a critical Dependabot alert — surface a project at once. The sources stay the golden source: every item carries its origin, and edits made in Bubbles are written to the source first.

Alternatives considered​

Self Hosted

ToolOpen SourceFull FeaturesNotes
Vikunja aloneYesPartlyHolds tasks, but knows nothing about PRs or repo activity and has no notion of a project going stale
Homepage widgetsYesNoCounts per source, no weighting across sources and nothing that sinks after the work is done

Installation​

Architecture​

  • Deployment: Single bubbles deployment in the bubbles namespace, one replica by design (the scheduler runs in-process), Recreate strategy
  • Image: gitea.web.kueber.eu/johnny/bubbles, one static Go binary with the SvelteKit UI embedded, on distroless/static:nonroot; tagged releases are built and pushed by the repo's Gitea workflow (unsigned until the repo moves to GitHub with keyless signing)
  • Database: CNPG PostgreSQL cluster from the shared component (BUBBLES_DB is the uri of the cnpg-app secret) holding the configuration, the sealed provider secrets, bubble state, work log, emergency acknowledgements and a cache of the source items; the app itself owns no volume
  • Networking: ClusterIP service on port 8080, HTTPRoute via the public gateway on bubbles.web.kueber.eu; a second route for /webhooks/github only

Security​

  • Runs as uid 65532, runAsNonRoot: true, read-only root filesystem, all capabilities dropped, RuntimeDefault seccomp, no service account token
  • OIDC login against Keycloak with PKCE and an email allowlist (verified emails only); sessions expire after 12 h idle and 7 days at most; logins rate limited per client IP, which the app reads from X-Forwarded-For of the gateway pods only
  • Webhooks are HMAC-verified per provider and rate limited; the source's URLs must be HTTPS
  • Ingress only from the public gateway, which keeps its DE-only geo allowlist; egress to the internet on 443 for the GitHub API, everything else through the gateway
  • Provider tokens and webhook secrets are stored only in the database, sealed with AES-256-GCM under BUBBLES_SECRET_KEY and bound to their provider; no API response or export carries them, and the key lives in SOPS, never in the database
  • CNPG volumes encrypted at rest via a SOPS-managed key

Updates​

Tagged releases (vX.Y.Z) publish X.Y.Z and latest; the deployment pins X.Y.Z@sha256:…, which Renovate moves. Schema changes ship as goose migrations the binary applies at start. 0.3.0 moved the configuration from projects.yaml into the database and made BUBBLES_SECRET_KEY mandatory; the talos deployment changed from SQLite on a PVC to CNPG at the same time, carrying the state over.

Data Management​

  • Database: CNPG cluster, pg_dump via the backups-cnpg PreBackupPod
  • Backups: k8up Schedules to Hetzner and Backblaze (workload-talos-bubbles-*) via restic, the dump only
  • Issues, pull requests and tasks live in the sources; losing the database costs only the bubble depths and the work log, a full sync rebuilds the cache. A dump restored with the same master key brings the tokens back; with a new key they are re-entered on the settings page

User Management​

One user. Keycloak client bubbles in the kueber.eu realm (confidential, PKCE S256); its browser flow denies anyone without the client role bubbles.user, granted to johnny directly. The app additionally checks OIDC_ALLOWED_EMAILS.

Configuration Management​

  • Settings (base URL, OIDC issuer and client, trusted proxies, schedule and lift tuning) in the bubbles-settings ConfigMap, loaded as environment
  • Providers, tanks, projects and rules live in the database and are edited on the settings page; projects.yaml in the overlay is the seed for an empty database (and the import/export format), mounted from a hashed bubbles-projects ConfigMap at /config and ignored once the database holds a configuration
  • Provider tokens and webhook secrets are entered on the settings page and sealed in the database; the session secret, the OIDC client secret and the master key BUBBLES_SECRET_KEY are in the SOPS-encrypted bubbles-env Secret

Administration​

Add providers, tanks and projects on the settings page; provider names and project ids are immutable because bubbles and sync state hang off them. Tokens and webhook secrets are write-only there, and the app can generate a webhook secret and show it once. Settings › Import/Export (or bubbles import / bubbles export) moves a configuration between instances; keep projects.yaml in the overlay in step with an export so a rebuild seeds the same state. To rotate the master key, set the new value in BUBBLES_SECRET_KEY and the old one in BUBBLES_SECRET_KEY_PREVIOUS for one start. A provider whose token is rejected, lacks permissions or is about to expire shows a banner on every page.

Usage​

The dashboard shows only surfaced bubbles; /tank shows the whole water column. Open a project to see its items with their source, comment, label, set due dates or close them (written to the source), then push the bubble down. Weigh a bubble down to keep it below longer.

Cluster-specific deviations from the above live in the per-cluster README — see k8s/apps/bubbles/talos/README.md.

Cluster Deployment​

Bubbles — Talos cluster

Deviations from defaults​

  • Public gateway (bubbles.web.kueber.eu) — so GitHub can deliver webhooks instead of the app polling it. The UI, API and login stay behind the gateway's DE-only geo allowlist; a separate bubbles-webhooks-github HTTPRoute matches only POST /webhooks/github, and its route-level SecurityPolicy restates the DE rule and adds GitHub's hooks ranges from api.github.com/meta. A route-level policy replaces the gateway's for that route, which is why the DE rule is repeated rather than inherited.
  • Login is the app's own OIDC against Keycloak (client bubbles, redirect https://bubbles.web.kueber.eu/auth/callback), not a gateway SecurityPolicy — the app needs its own session and a gateway login would block the webhook endpoints. OIDC_ALLOWED_EMAILS narrows it to one verified address on top. Keycloak's bubbles-browser flow already refuses users without bubbles.user; the allowlist is the second gate.
  • First-party image from the private Gitea registry — pulled via regcred-docker-gitea (namespace label secrets.docker-gitea). The bubbles repo's release workflow builds on v* tags, pushes X.Y.Z and latest, unsigned for now: verify-gitea-johnny-images is in Audit and only warns. Signing arrives with the move to GitHub (keyless OIDC).
  • Rootless distroless — runs as the image's uid 65532 with a read-only root filesystem, all capabilities dropped and RuntimeDefault seccomp. The only writable path is a small /tmp emptyDir; the app owns no volume.
  • Settings as a ConfigMap, secrets as a Secret, both as environment — the binary reads the environment in place of its .env; bubbles-settings holds everything that is not secret, bubbles-env the three deployment secrets: session secret, OIDC client secret and BUBBLES_SECRET_KEY, the master key that seals the provider tokens in the database. The key is in SOPS and nowhere else: lose it and the tokens must be re-entered, the rest of the database stays usable.
  • Provider tokens and webhook secrets live in the database, sealed with the master key and entered on the settings page (or bubbles secret set); nothing in the overlay carries them since 0.3.0.
  • projects.yaml is the seed file, mounted from the hashed bubbles-projects-<hash> ConfigMap at /config. An empty database is seeded from it once; afterwards the settings page is the source of truth and the file is ignored. Keep it current (bubbles export) so a rebuild from scratch lands on the same configuration; an edit still rolls the pod, which is harmless.
  • Trusted proxies = pod CIDR (10.100.0.0/16) — only the Envoy pods reach the app, so their X-Forwarded-For decides the client IP for the login rate limit.
  • Internet egress on 443 only (egress-bubbles) for the GitHub API; Gitea, Vikunja and Keycloak are *.web.kueber.eu and ride the baseline gateway allowance.
  • Database on the cnpg component at tier-3 with 1Gi volumes; BUBBLES_DB is the uri of the operator-generated cnpg-app Secret. The nightly k8up run is the backups-cnpg pg_dump; there is no PVC any more.

Bootstrap​

  1. The image comes from the bubbles repo (johnny/bubbles): push a v* tag, then pin the new digest in base/deployment.yaml. The release workflow uses the user-level Actions secrets DOCKER_REGISTRY_GITEA_USER and DOCKER_REGISTRY_GITEA_TOKEN.
  2. The Keycloak client bubbles exists (created 2026-09-27, stump/memos recipe): confidential, PKCE S256 enforced, redirect https://bubbles.web.kueber.eu/auth/callback, post-logout redirect https://bubbles.web.kueber.eu/, secret = OIDC_CLIENT_SECRET in secret-env.yaml. Its bubbles-browser flow denies anyone without the client role bubbles.user, which is granted to johnny directly, not to a group. Rotating the secret means changing both sides.
  3. On a fresh database the pod seeds providers, tank and projects from projects.yaml; then enter each provider's token and webhook secret under Settings › Providers. A restore from the k8up dump brings the sealed secrets back as long as BUBBLES_SECRET_KEY is the same.
  4. Webhooks, per source, POST https://bubbles.web.kueber.eu/webhooks/<provider> with the secret shown once by the settings page:
    • GitHub (github): per repo, content type JSON, events issues, pull requests, push.
    • Gitea (gitea) and Vikunja (vikunja): both sit in this cluster and dial the public hostname; Gitea needs the host in [webhook] ALLOWED_HOST_LIST, Vikunja outgoingrequests.allownonroutableips: true. Until they are configured, polling (BUBBLES_SYNC_INTERVAL, default 20m) covers them.

History​

  • 2026-10-15, 0.2.1 → 0.3.0: configuration moved from the file into the database, SQLite replaced by the cnpg component. The state (bubble depths, work log, cache, sync status) was carried over by converting a consistent SQLite snapshot into a Postgres dump offline and loading it before the new pod first started; the bubbles-data PVC and its Longhorn key were retired afterwards.
Kubernetes Metadata
  • Image: gitea.web.kueber.eu/johnny/bubbles:0.3.0@sha256:a87ec008973bf9ca0127e33227056f6a4d95a68380dc97b854fa694b7bb6dc2d
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app.kubernetes.io/name: bubbles
name: bubbles
namespace: bubbles
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app.kubernetes.io/name: bubbles
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: bubbles
spec:
automountServiceAccountToken: false
containers:
- env:
- name: BUBBLES_DB
valueFrom:
secretKeyRef:
key: uri
name: cnpg-app
- name: BUBBLES_LISTEN
value: :8080
- name: BUBBLES_CONFIG
value: /config/projects.yaml
envFrom:
- configMapRef:
name: bubbles-settings
- secretRef:
name: bubbles-env
image: gitea.web.kueber.eu/johnny/bubbles:0.3.0@sha256:a87ec008973bf9ca0127e33227056f6a4d95a68380dc97b854fa694b7bb6dc2d
livenessProbe:
failureThreshold: 3
httpGet:
path: /healthz
port: web
periodSeconds: 30
timeoutSeconds: 4
name: bubbles
ports:
- containerPort: 8080
name: web
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: web
periodSeconds: 10
timeoutSeconds: 4
resources:
limits:
memory: 256Mi
requests:
cpu: 10m
memory: 64Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
readOnlyRootFilesystem: true
startupProbe:
failureThreshold: 30
httpGet:
path: /healthz
port: web
periodSeconds: 2
volumeMounts:
- mountPath: /config
name: config
readOnly: true
- mountPath: /tmp
name: tmp
imagePullSecrets:
- name: regcred-docker-gitea
priorityClassName: tier-3
securityContext:
fsGroup: 65532
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 65532
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
volumes:
- configMap:
name: bubbles-projects-mk4m997k46
name: config
- emptyDir:
sizeLimit: 64Mi
name: tmp