Watches the right Twitch and Kick streams, banks the watch-time, and claims the drops — across several accounts at once. One small self-hosted web app: a Docker image and a single SQLite file.
- 🎯 You set a whitelist (global or per-account). Nothing outside it gets mined.
- 🟣🟢 Twitch and Kick together, several accounts each, all on one page.
- ✅ It checks the game so you never burn watch-time on the wrong stream.
- 🔗 It knows about account links (Krafton, Embark, …) with a per-account "I've linked it" override.
- 🖥️ A live console: lifetime stats, current mining, drops catalog, claim history.
- 🔔 Discord notifications, toggle per event type.
- 🧪 Browserless Kick by default: Kick now starts on a WebSocket watch path (no Chrome, no Docker — light enough for any Pi) and falls back to the Chrome sidecar automatically if WS stops accruing. Force a specific path in Settings → Experimental.
- 🔒 Your credentials stay yours: Twitch uses the official device-code login, Kick uses a session you export. No passwords sent to GrubDrops.
- 🌐 Multi-language support: English and Chinese (中文) built-in. Add new languages by copying a JSON file.
- 🕐 Timezone auto-detection: Set
TZenv var for server-side times; browser automatically shows local time. - 🌍 Proxy support: HTTP/HTTPS/SOCKS5 proxy for all external requests. Configure in Settings → Proxy.
Docker + Docker Compose (quick path) or Go 1.26+ (build from source). What you need depends on which platform you're mining:
| Twitch | Kick | |
|---|---|---|
| Login | device-code (twitch.tv/activate) |
cookies.txt export |
| How it watches | direct HTTP — no browser | Chrome sidecar (real IVS playback) |
| Docker | optional | required — the miner spawns the sidecar over the docker socket |
| Run from source, no Docker | ✅ a plain go build binary works |
❌ needs Docker for the sidecar |
| CPU arch | any — amd64 + arm64 |
amd64 + arm64 (arm64 is heavy — see note) |
Twitch is direct HTTP — a plain Go binary mines it anywhere, no Docker. Kick watch-time needs a real player, so the miner runs a Chrome/Chromium sidecar over the docker socket (Docker required for Kick).
Raspberry Pi / ARM: both images ship
arm64; the sidecar uses Debian Chromium (keeps the H.264/AAC codecs for Kick's IVS stream). Heavy — ~4 GB RAM each.Kick watch path: defaults to WS, fall back to Chrome — tries the browserless WebSocket path first (no Docker) and switches to the Chrome sidecar only if WS stops accruing. Keep the docker-socket mount so the fallback works; force Chrome sidecar or WebSocket only in Settings → Experimental.
| Host | Twitch | Kick |
|---|---|---|
Linux x86-64 |
✅ | ✅ |
Linux arm64 / Raspberry Pi |
✅ | ✅ — Chromium sidecar, ~4 GB RAM each |
| macOS / Windows · Docker Desktop (Intel) | ✅ | ✅ |
| macOS / Windows · Apple Silicon | ✅ | ✅ — arm64 Chromium sidecar |
go build from source (any OS) |
✅ | needs Docker for the sidecar |
Compose with the published image — just the miner. It auto-creates a Chrome sidecar per Kick account on demand over the mounted docker socket; you define no sidecar services.
# compose.yml
services:
miner:
image: ghcr.io/aalejandrofer/grubdrops:latest
restart: unless-stopped
ports: ["8080:8080"]
environment:
GRUB_MASTER_KEY: ${GRUB_MASTER_KEY:?run: head -c32 /dev/urandom | base64}
GRUB_DB_PATH: /data/miner.db
GRUB_SECURE_COOKIES: "0" # plain-HTTP localhost; set 1 behind HTTPS
TZ: Asia/Shanghai # server-side timezone
volumes:
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock # Kick only, if WS BreaksThe image runs as distroless nonroot (UID 65532), so make a bind-mounted
./data writable first — otherwise it can't write miner.db and login fails
with "failed to persist session". (Or use a named volume.)
mkdir -p data && sudo chown 65532:65532 data
GRUB_MASTER_KEY=$(head -c32 /dev/urandom | base64) docker compose up -dOpen http://localhost:8080 and create the admin login.
- Twitch only? Drop the docker-socket mount — no sidecars get created.
- Every knob? Reference compose:
deploy/docker-compose.yml. - Build it?
docker build -f deploy/Dockerfile.miner ., orgo build ./cmd/miner.
Go to Accounts and add one per platform.
Twitch. Click add, then approve the code shown at twitch.tv/activate.
That's the official device-code flow; your password and cookies never touch
GrubDrops.
Kick. Kick has no public login API, so you hand GrubDrops your existing
kick.com session as a cookies.txt file exported from your browser:
- Install a cookie-export extension: Get cookies.txt LOCALLY for Chrome/Edge/Brave, or cookies.txt for Firefox.
- Sign in at
kick.com, click the extension icon, Export the current site. - In GrubDrops, open the account's Authorize page and upload (or paste) the export.
Channels auto-discover from each campaign's game, so there's nothing else to configure. When the session goes stale (discovery logs Cloudflare or 401 errors), re-export and paste again.
GrubDrops is whitelist-driven: it only discovers and mines games you opt into,
so a fresh install mines nothing until you whitelist at least one game. Until
then /drops shows a prompt pointing you here, and accounts sit in a "no games
yet" state (not an error).
Add games either way — by name, no need to wait for a campaign to appear first:
- Global (applies to every account): Settings → Drop Priority → add by name.
- Per account (overrides the global list): Accounts → pick an account → add by name.
Discovery starts crawling that game on the next tick and live campaigns show up
on /drops.
- Twitch: device-code login, then GraphQL + PubSub to track progress and claim.
- Kick: detection/claims ride a Chrome-TLS-fingerprinted HTTP client (
utls) — no Cloudflare dance, no browser. Watch-time needs a real player, so it runs in an on-demand per-account Chrome sidecar (IVS playback) the miner creates/stops over the docker socket; a sweep removes deleted accounts' containers. - Discovery scrapes both catalogs into SQLite every few minutes.
Each account mines one campaign at a time. When several whitelisted campaigns are eligible, GrubDrops picks in this order:
1. Campaign, by your priority mode (Settings):
├─ ordered (default) → your whitelist rank, top of the list first
├─ ending_soonest → soonest deadline first
└─ low_avbl_first → fewest available channels first
2. Tiebreak: closest to a claim (fewest watch-minutes remaining)
3. Restricted (team) campaigns ahead of open ones (both platforms)
4. Channel: a live stream confirmed on the campaign's game,
highest viewer count first (Twitch also probes for one
actually serving the target drop)
Whitelist and priority are per-account, falling back to the global list. A campaign with no live stream is skipped, not slept on.
Environment variables; only GRUB_MASTER_KEY is required, the rest take the
default shown.
| Var | Default | Purpose |
|---|---|---|
GRUB_MASTER_KEY |
required | Key for the age-encrypted session store. |
GRUB_HTTP_ADDR |
:8080 |
Listen address. |
GRUB_DB_PATH |
/data/miner.db |
SQLite path (use e.g. ./miner.db outside Docker). |
GRUB_KICK_SIDECAR_IMAGE |
ghcr.io/aalejandrofer/grubdrops-browser:latest |
Sidecar image the miner pulls per account. |
GRUB_KICK_SIDECAR_NETWORK |
auto-detected | Override the self-detected sidecar network. |
GRUB_KICK_SIDECAR_TEMPLATE |
grubdrops-browser-{slug} |
Sidecar container-name template. |
GRUB_KICK_SIDECAR_PORT |
9090 |
Sidecar gRPC port. |
GRUB_BROWSER_URL |
none | Fixed sidecar address (legacy always-on). |
GRUB_BROWSER_URLS |
none | Always-on sidecar pool, comma-separated. |
GRUB_DISCOVERY_INTERVAL |
60m |
Catalog-scrape cadence; also in Settings. |
GRUB_AUTHCHECK_INTERVAL |
1h |
Auth-health sweep cadence. |
GRUB_DISCORD_WEBHOOK |
none | Global Discord webhook. |
GRUB_SECURE_COOKIES |
0 |
1 marks cookies Secure (HTTPS only); keep 0 for plain HTTP — see note. |
GRUB_LOG_LEVEL |
info |
debug, info, warn, error. |
"Invalid CSRF token"?
GRUB_SECURE_COOKIESmust match your scheme:0over plain HTTP,1over HTTPS (proxy must forwardX-Forwarded-Proto: https). A mismatch marks cookiesSecureover HTTP, so they drop and POSTs fail. A failed check logscsrf check failedwith the likely cause.
Optional (password login stays as fallback). Any OIDC provider; switches on once the first four are set:
| Var | Required | Purpose |
|---|---|---|
GRUB_OIDC_ISSUER |
yes | Issuer URL. |
GRUB_OIDC_CLIENT_ID |
yes | OAuth client ID. |
GRUB_OIDC_CLIENT_SECRET |
yes | OAuth client secret. |
GRUB_OIDC_REDIRECT_URL |
yes | https://<host>/auth/oidc/callback, registered with the IdP. |
GRUB_OIDC_PROVIDER_NAME |
no | Button label (default SSO). |
GRUB_OIDC_ALLOWED_EMAILS |
no | Comma-separated email allowlist. |
GRUB_OIDC_ALLOWED_GROUPS |
no | Required group(s) on the groups claim. |
Heads up: with no allowlist set, anyone the IdP authenticates becomes an admin. Scope membership in the IdP, or set an allowlist.
| Page | What's on it |
|---|---|
Console (/) |
Lifetime stats, per-account mining, live event feed. |
Drops (/drops) |
Past / current / upcoming campaigns, items, connect chips, one-click whitelisting. |
History (/history) |
Claim log across every account. |
Settings (/settings) |
Priority list, intervals, Discord, log level, password. |
| Accounts | Add accounts, per-account whitelists, re-auth, auth health. |
cmd/miner main daemon
internal/platform/... per-platform backends (twitch, kick)
internal/watcher per-account state machine (watch, mine, claim)
internal/dockerctl on-demand sidecar start/stop over the docker socket
internal/discovery catalog scraper
internal/api + web HTMX UI and handlers
internal/store SQLite (sqlc + goose), age-encrypted sessions
Stands on the projects that cracked the hard parts first:
- DevilXD/TwitchDropsMiner — Twitch device-code flow, GraphQL, watch-time mechanics.
- HyperBeats/KickDropsMiner — mapped how Kick drops work.
GrubDrops is its own Go rewrite (web UI, multi-account) but wouldn't exist without their groundwork.
Released under the MIT License.
Self-hosted, single-tenant. /healthz for liveness; keep /data across
redeploys; reverse-proxy it if exposed. Stay within each platform's ToS, on your
own accounts, at your own risk.
Built by @aalejandrofer with Claude Code. See the changelog and design notes.


