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
| Tool | Open Source | Full Features | Notes |
|---|---|---|---|
| Vikunja alone | Yes | Partly | Holds tasks, but knows nothing about PRs or repo activity and has no notion of a project going stale |
| Homepage widgets | Yes | No | Counts per source, no weighting across sources and nothing that sinks after the work is done |
Installation
Architecture
- Deployment: Single
bubblesdeployment in thebubblesnamespace, one replica by design (the scheduler runs in-process),Recreatestrategy - Image:
gitea.web.kueber.eu/johnny/bubbles, one static Go binary with the SvelteKit UI embedded, ondistroless/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_DBis theuriof thecnpg-appsecret) 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/githubonly
Security
- Runs as uid 65532,
runAsNonRoot: true, read-only root filesystem, all capabilities dropped,RuntimeDefaultseccomp, 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-Forof 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_KEYand 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_dumpvia thebackups-cnpgPreBackupPod - 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-settingsConfigMap, loaded as environment - Providers, tanks, projects and rules live in the database and are edited on the settings page;
projects.yamlin the overlay is the seed for an empty database (and the import/export format), mounted from a hashedbubbles-projectsConfigMap at/configand 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_KEYare in the SOPS-encryptedbubbles-envSecret
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 separatebubbles-webhooks-githubHTTPRoute matches onlyPOST /webhooks/github, and its route-levelSecurityPolicyrestates the DE rule and adds GitHub'shooksranges fromapi.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, redirecthttps://bubbles.web.kueber.eu/auth/callback), not a gatewaySecurityPolicy— the app needs its own session and a gateway login would block the webhook endpoints.OIDC_ALLOWED_EMAILSnarrows it to one verified address on top. Keycloak'sbubbles-browserflow already refuses users withoutbubbles.user; the allowlist is the second gate. - First-party image from the private Gitea registry — pulled via
regcred-docker-gitea(namespace labelsecrets.docker-gitea). The bubbles repo'sreleaseworkflow builds onv*tags, pushesX.Y.Zandlatest, unsigned for now:verify-gitea-johnny-imagesis 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
RuntimeDefaultseccomp. The only writable path is a small/tmpemptyDir; 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-settingsholds everything that is not secret,bubbles-envthe three deployment secrets: session secret, OIDC client secret andBUBBLES_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.yamlis the seed file, mounted from the hashedbubbles-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 theirX-Forwarded-Fordecides 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.euand ride the baseline gateway allowance. - Database on the cnpg component at tier-3 with 1Gi volumes;
BUBBLES_DBis theuriof the operator-generatedcnpg-appSecret. The nightly k8up run is thebackups-cnpgpg_dump; there is no PVC any more.
Bootstrap
- The image comes from the bubbles repo (
johnny/bubbles): push av*tag, then pin the new digest inbase/deployment.yaml. The release workflow uses the user-level Actions secretsDOCKER_REGISTRY_GITEA_USERandDOCKER_REGISTRY_GITEA_TOKEN. - The Keycloak client
bubblesexists (created 2026-09-27, stump/memos recipe): confidential, PKCE S256 enforced, redirecthttps://bubbles.web.kueber.eu/auth/callback, post-logout redirecthttps://bubbles.web.kueber.eu/, secret =OIDC_CLIENT_SECRETinsecret-env.yaml. Itsbubbles-browserflow denies anyone without the client rolebubbles.user, which is granted tojohnnydirectly, not to a group. Rotating the secret means changing both sides. - 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 asBUBBLES_SECRET_KEYis the same. - 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, Vikunjaoutgoingrequests.allownonroutableips: true. Until they are configured, polling (BUBBLES_SYNC_INTERVAL, default 20m) covers them.
- GitHub (
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-dataPVC and its Longhorn key were retired afterwards.
- 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