Install

Self-host on a 5€ VPS in 30 minutes.

Twelve steps, in the right order. Most are Telegram admin (BotFather), the rest are Docker. The BotFather privacy + groups switches come BEFORE you add the bot anywhere — that's the order that avoids the silent-voice-failure trap.

1Create the Telegram bot

DM @BotFather /newbot → display name → username (must end in_bot). BotFather hands you an HTTP API token (1234567890:ABC-DEF...) — store it; you’ll put it in .env later.

2Configure BotFather settings — BEFORE adding to any group

Still in BotFather, with your bot selected:
  • /setjoingroupsEnable (so users can add the bot to groups)
  • /setprivacyDisable (required for group voice transcription and reliable mention handling — without this the bot only sees @mentions and slash commands; voice notes never reach it). The bot still won’t spend tokens on every group message — a code-side filter only forwards mentions / replies to the LLM.
Doing both of these before users invite the bot to a group avoids a confusing first-run where voice silently fails.

3Point a domain at your server

Pick a subdomain (e.g. listbull.mydomain.com). Add an A record pointing at your server’s public IP. Wait for DNS to propagate (a couple of minutes); verify with dig +short. If using Cloudflare, keep proxy mode OFF so Let’s Encrypt’s HTTP-01 challenge works.

4Clone the repo and copy the env template

git clone https://github.com/buraksu42/listbull.git
cd listbull
cp .env.example .env
chmod 600 .env

5Generate secrets

# AES-256-GCM key for password + BYOK encryption (32-byte base64)
echo "ENV_KEY=$(openssl rand -base64 32)"

# Telegram webhook signature (16+ hex chars)
echo "TELEGRAM_WEBHOOK_SECRET=$(openssl rand -hex 32)"

# Postgres password
echo "POSTGRES_PASSWORD=$(openssl rand -hex 16)"

Important: rotating ENV_KEY later invalidates every stored password and OpenRouter key. Generate once, store securely.

6Fill the env file

Open .env and fill in: the secrets you just generated, the bot token from step 1, the public URL from step 3, and the bot username (without @). Optionally set LISTBULL_SHARED_OPENROUTER_KEY to enable the free tier for keyless chats, and LISTBULL_PER_USER_HOURLY_MSG_LIMIT=100 to cap anonymous spend.

7Reverse proxy / TLS

listbull doesn’t terminate TLS — put Caddy / Traefik / Cloudflare in front. Minimal Caddyfile:
listbull.mydomain.com {
    reverse_proxy 127.0.0.1:3000
}
Compose binds the app to 127.0.0.1:3000 only, so the reverse proxy is mandatory for public access.

8Bring up the stack

docker compose up -d
docker compose logs -f app
Wait for ✓ Ready in …ms. First build takes ~3-5 minutes. Then health-check from another shell:
curl -s https://listbull.mydomain.com/api/health
# expected: {"status":"ok","db":"ok","bot":"ok",...}

9Apply DB migrations

The cron container does this automatically on every boot, but you can also do it explicitly:
docker compose run --rm app npm run db:migrate
Migrations are idempotent; already-applied entries are skipped.

10Register the webhook + slash menu

One script does both — and emits any missing BotFather steps if you skipped them earlier:
TELEGRAM_BOT_TOKEN="<your bot token>" \
TELEGRAM_WEBHOOK_SECRET="<your webhook secret>" \
APP_BASE_URL="https://listbull.mydomain.com" \
  npm run setup:bot
Verifies via getWebhookInfoand prints the 12 slash commands that landed in Telegram’s menu.

11Smoke test

DM your bot /start; you should see the welcome message + the “🎯 Quick tour” inline button. Then:
buy milk
buy milk in 2 minutes remind
Within two minutes the bot should ping you with the reminder. Cron logs surface the dispatch (docker compose logs cron).

12Optional — Sentry + Umami

Set NEXT_PUBLIC_SENTRY_DSN for crash reporting, NEXT_PUBLIC_UMAMI_WEBSITE_ID for cookieless analytics. Both are build args — rebuild the image after adding them:
docker compose build --no-cache app
docker compose up -d --force-recreate app
Verify Sentry by triggering an error; verify Umami by opening the marketing page and checking the dashboard.

If something breaks

  • Webhook silent: curl getWebhookInfo → check last_error_message. Usually Telegram can’t reach your URL (DNS / proxy / cert).
  • “OpenRouter key not set”: either set LISTBULL_SHARED_OPENROUTER_KEY in env (free tier for everyone), or have each user paste their key via /settings → 🔑.
  • Updating: git pull && docker compose build app cron && docker compose up -d. The cron container reapplies any new migrations on its next boot.

Deeper reference (in Turkish) lives in docs/self-host.md.