Files
miti99bot/docs/deploy-coolify-selfhosted.md
T

11 KiB

Deploy: Self-host (Coolify + MongoDB Atlas)

Run miti99bot as a long-lived container on Coolify with MongoDB Atlas (free M0) for storage.

Architecture

   Telegram  <── long poll (getUpdates) ──  container  (outbound only)
   in-process scheduler ───────────────────> module crons
   MongoDB Atlas (db / one collection per module + system metadata)
   Coolify env vars (plain secrets)
   NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in)
  • Storage — mongodb auto-selected when MONGO_URL is set (no KV_PROVIDER).
  • Cron — an in-process scheduler (internal/cron) runs unconditionally and fires each module cron on its Schedule, evaluated in UTC. The only cron today is the lol daily digest at 0 1 * * * (08:00 ICT).
  • Transport — long polling (b.Start) is the only transport. The bot opens an outbound connection to Telegram and pulls updates, so there is no public domain, no /webhook, and no webhook secret. The container clears any leftover webhook on startup (deleteWebhook) before polling.

Environment

Copy .env.example → .env (gitignored) and fill in.

Var Required Notes
TELEGRAM_BOT_TOKEN ✅ from @BotFather; startup fails without it
MONGO_URL ✅ Atlas SRV string incl. credentials — secret, never logged
MONGO_DATABASE ✅ e.g. miti99bot
MODULES optional CSV; empty = all modules, including any added later
OWNER_ID optional Telegram user id for owner-only commands, the deploy DM, and the /addsticker pack owner. Unset = owner-only commands are denied and /addsticker refuses
ADMIN_IDS optional CSV of Telegram user ids for admin-only commands
STICKER_PACK_NAME optional set /addsticker writes to; default miti99_by_miti99bot. See sticker packs
LOL_PANDASCORE_TOKEN ✅ for lol module PandaScore API token (free tier) — secret, never logged; without it every /lol* fetch fails (stale cache may still serve briefly)
WHEELOFNAMES_API_URL optional full /api/gif endpoint for remote /wheelofnames GIF rendering
WHEELOFNAMES_API_TOKEN optional bearer token matching the wheelofnames service API_TOKEN
LOG_LEVEL optional debug, info (default), warn, or error; logs are JSON on stdout
GOLD_VNAPP_API_KEY leave unset VNAppMob key; unset = the gold module fetches one and caches it in MongoDB
KV_PROVIDER leave unset memory or mongodb; unset = mongodb when MONGO_URL is set, otherwise memory
PORT leave unset health server port; default 8080
SOURCE_COMMIT never set provided by Coolify at runtime for the deploy DM (see step 6 below)

Stock, coin, and gold provider URL overrides are not supported in runtime env; modules use coded defaults. There is no TELEGRAM_WEBHOOK_SECRET: long polling has no webhook.

Cron runs in-process (internal/cron) — there is no /cron HTTP route and no CRON_SHARED_SECRET. The scheduler is the sole trigger; nothing inbound.

Optional wheelofnames renderer

/wheelofnames uses a remote GIF renderer when WHEELOFNAMES_API_URL is set. The standard renderer is a deployment of tiennm99/wheelofnames, but any service that implements the same /api/gif contract can be used. Set the URL to the full GIF endpoint and set the token to the same value as the service API_TOKEN:

WHEELOFNAMES_API_URL=http://wheelofnames:3000/api/gif
WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>

Use a public HTTPS URL instead when the bot cannot reach the service on a private Coolify/Docker network:

WHEELOFNAMES_API_URL=https://wheelofnames.example.com/api/gif
WHEELOFNAMES_API_TOKEN=<same value as wheelofnames API_TOKEN>

The bot sends outbound HTTP only; no public bot ingress is required. Remote renders use 512px, 20fps, and 7 seconds total by default. If the remote service is unset, unavailable, unauthorized, or returns a non-GIF response, /wheelofnames falls back to the same plain text winner reply as /random. Successful GIF replies include the result behind Telegram spoiler formatting.

When a renderer is configured, the bot posts a Spinning... holding message first, because the render takes several seconds. The GIF then replaces it; a render or upload failure edits that same message into the plain text winner instead. With no renderer configured there is no holding message — the winner reply is immediate.

1. MongoDB Atlas (M0)

  1. Create a free M0 cluster (512 MB — ample for the tiny paper-trading KV).

  2. Database user (least privilege): create a user with role readWrite on the single app database only (e.g. miti99bot) — never Atlas admin or cluster-wide. Use a strong unique password.

  3. Network access: add 0.0.0.0/0.

    Accepted trade-off. The Coolify host has no stable egress IP, so the Atlas IP allow-list is open to the internet. This widens the database surface. The mandatory compensating controls are: (1) strong unique password, (2) least-privilege readWrite-on-one-db user, (3) the connection string is a secret and is never logged (the bot logs only the database name on startup).

  4. Copy the mongodb+srv://… connection string into MONGO_URL and put the db name in MONGO_DATABASE.

Storage layout

  • One collection per module. Each document is a flattened native document — { _id: <user key>, ...payload fields, version, updatedAt } with no value envelope. Payload fields are hoisted to the document root so they expand and are queryable in Compass. The two non-object values are wrapped in a named field: lol schedule subscribers under subscribers (array) and the daily push date under date. Concurrency uses the version field (optimistic lock); updatedAt is a BSON Date.
  • stats uses queryable aggregate documents for command/user counts and creates its indexes on startup. Deleted legacy command rows are retained with deleted: true, and /stats filters them from visible results.
  • stock stores cash as vnd, embeds positions as assets.<symbol>.{quantity,base,openedAt}, and retains normalized per-user SSI dividend history under dividends.<symbol>.<ssi_event_id>. The README describes how those records are replayed and expired.
  • coin stores cash as usd and embeds positions as assets.<symbol>.{quantity,base}.
  • system holds one marker per completed one-time startup migration. Keep those records as audit history. The current markers are migration:stats-delete-stock-dividend-v1 (retires historical /stock_dividend stats rows without erasing them), migration:stock-dividend-history-v1 (removes the retired dividend cursor and hashed applied-event ledger), and migration:sticker-drop-legacy-packs-v1 (removes records left by the retired per-user sticker pack commands).

2. Coolify

  1. New resource → from this Git repo (Docker Compose), or a prebuilt image. The committed compose.yml defines the single bot service.
  2. Enable submodule checkout. The monkeyd module builds against third_party/monkeyd-crawler, a git submodule wired in through a go.mod replace directive. Coolify must clone submodules, or the Docker build fails at go mod download with an unresolved github.com/tiennm99/monkeyd-crawler. Turn on Coolify's recursive-clone / submodule option for the resource. There is no build without it: leaving monkeyd out of MODULES only disables the commands at runtime.
  3. Set the env vars above in Coolify.
  4. No public domain / port is needed — polling is outbound-only. Do not publish a port or attach a domain. expose: 8080 keeps the health endpoint reachable only inside Coolify's network.
  5. Exactly one replica. Telegram permits only one getUpdates consumer per bot token; a second poller gets HTTP 409, and a second in-process scheduler double-fires crons. Prefer stop-first redeploys so two containers never overlap near a cron time.
  6. deploynotify commit SHA: SOURCE_COMMIT is a Coolify predefined variable. The bot reads it at startup and DMs the owner on every boot; outside Coolify (local docker compose up) it is unset and the DM shows unknown. Keep "Include Source Commit in Build" disabled: that setting affects build args only, is not needed for this runtime path, and would invalidate Docker cache on every commit. Do not add SOURCE_COMMIT to compose.yml; an interpolated empty value can override Coolify's runtime env-file value.
  7. Health check: use Coolify's HTTP monitor against GET / (returns text/plain miti99bot ok). The committed compose.yml defines no healthcheck, and cmd/server has no -healthcheck flag. Note: / reports healthy even if Mongo is unreachable (the driver auto-reconnects on the next op); a DB outage will not auto-restart the container — accepted trade-off.

3. Command menu

The bot registers its Telegram command menu from the loaded modules' public commands on every startup. The Go module registry is the single source of truth, so no separate command-menu file or manual registration step is required. See Command discovery for how the menu text is built.

Operations

The live deployment is the Coolify container and MongoDB is the sole system of record. Keep exactly one replica running. To confirm Telegram is in polling mode:

# POSIX shells (Linux/macOS)
curl "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getWebhookInfo"
# PowerShell
Invoke-RestMethod "https://api.telegram.org/bot$env:TELEGRAM_BOT_TOKEN/getWebhookInfo"

url should be empty. If needed, clear the webhook explicitly:

# POSIX shells (Linux/macOS)
curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/deleteWebhook" \
  --data "drop_pending_updates=false"
# PowerShell
Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$env:TELEGRAM_BOT_TOKEN/deleteWebhook" -Body @{ drop_pending_updates = "false" }

Local smoke test

# PowerShell
Copy-Item .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
docker compose up --build
# POSIX shells (Linux/macOS)
cp .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
docker compose up --build

Boot logs are JSON lines. Look for "msg":"storage backend" with "backend":"mongodb" and the database name (never the connection string), "msg":"cron scheduler started", and "msg":"telegram long polling started". compose.yml does not publish port 8080 to the host, so check the health endpoint from inside the container:

docker compose exec bot wget -qO- http://127.0.0.1:8080/

It returns miti99bot ok. The bot's webhook must be unset (the container clears it on startup) or getUpdates 409s.