Coolify ignores its UI health-check settings for Docker Compose apps and reads each service's healthcheck instead, so the bot reported an unknown status. It now checks GET / with the image's busybox wget.
12 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 —
mongodbauto-selected whenMONGO_URLis set (noKV_PROVIDER). - Cron — an in-process scheduler (
internal/cron) runs unconditionally and fires each module cron on itsSchedule, evaluated in UTC. The only cron today is theloldaily digest at0 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) |
RENDERER_URL |
leave unset | base URL of the animation renderer; fixed by compose.yml to the bundled renderer (http://renderer:3000), so a Coolify value is ignored |
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 5 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/cronHTTP route and noCRON_SHARED_SECRET. The scheduler is the sole trigger; nothing inbound.
Animation renderer
/wheelofnames, /gacha, and /genshin are drawn by the Node renderer in
renderer/ (Remotion and headless Chrome).
compose.yml deploys it as a second service, renderer, next to the bot. It
is internal only: the bot reaches it at http://renderer:3000 over the
compose network, so it needs no domain, publishes no port, and takes no auth
token. Its API is unauthenticated, so never publish a port or attach a domain
to it.
Renderer tuning (MAX_CONCURRENT_RENDERS, RENDER_TIMEOUT_MS, MAX_OPTIONS,
MAX_OPTION_CHARS) can be set in Coolify too; the defaults are in
renderer/docs/deployment.md. Give the host
1-2 GB of headroom for the renderer's Chrome.
Outside compose, set RENDERER_URL to the base URL of any service that
implements the same /api/gif, /api/gacha, and /api/genshin routes, e.g.
http://localhost:3000. The bot appends each route itself.
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 renderer
is unset, unavailable, 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.
/gacha uses the same service at /api/gacha. It renders a 6-second
360x640 portrait silent MP4 wish animation (a card pack torn open), posts Wishing... while it renders, and
falls back to a text reply such as ★★★★★ Pizza on the same failures. Every
option is equally likely, as with /random; the rarity only sets what the
animation and reply show. Options are 5★ by default; prefix 4* or 3* to
lower one, e.g. /gacha Pizza, 4* Pho, 3* Rice.
The unlisted /genshin takes the same input and renders the Genshin-style
meteor wish from .../api/genshin on the same service as a 7-second 640x360
MP4, with the same text fallback.
1. MongoDB Atlas (M0)
-
Create a free M0 cluster (512 MB — ample for the tiny paper-trading KV).
-
Database user (least privilege): create a user with role
readWriteon the single app database only (e.g.miti99bot) — never Atlas admin or cluster-wide. Use a strong unique password. -
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). -
Copy the
mongodb+srv://…connection string intoMONGO_URLand put the db name inMONGO_DATABASE.
Storage layout
- One collection per module. Each document is a flattened native document
—
{ _id: <user key>, ...payload fields, version, updatedAt }with novalueenvelope. 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:lolschedule subscribers undersubscribers(array) and the daily push date underdate. Concurrency uses theversionfield (optimistic lock);updatedAtis a BSON Date. statsuses queryable aggregate documents for command/user counts and creates its indexes on startup. Deleted legacy command rows are retained withdeleted: true, and/statsfilters them from visible results.stockstores cash asvnd, embeds positions asassets.<symbol>.{quantity,base,openedAt}, and retains normalized per-user SSI dividend history underdividends.<symbol>.<ssi_event_id>. The README describes how those records are replayed and expired.coinstores cash asusdand embeds positions asassets.<symbol>.{quantity,base}.systemholds one marker per completed one-time startup migration. Keep those records as audit history. The current markers aremigration:stats-delete-stock-dividend-v1(retires historical/stock_dividendstats rows without erasing them),migration:stock-dividend-history-v1(removes the retired dividend cursor and hashed applied-event ledger), andmigration:sticker-drop-legacy-packs-v1(removes records left by the retired per-user sticker pack commands).
2. Coolify
- New resource → from this Git repo (Docker Compose), or a prebuilt image.
The committed
compose.ymldefines thebotservice and the internalrendererservice. - Set the env vars above in Coolify.
- No public domain / port is needed — polling is outbound-only. Do not
publish a port or attach a domain.
expose: 8080keeps the health endpoint reachable only inside Coolify's network. - Exactly one replica. Telegram permits only one
getUpdatesconsumer 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. - deploynotify commit SHA:
SOURCE_COMMITis a Coolify predefined variable. The bot reads it at startup and DMs the owner on every boot; outside Coolify (localdocker compose up) it is unset and the DM showsunknown. 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 addSOURCE_COMMITtocompose.yml; an interpolated empty value can override Coolify's runtime env-file value. - Health check: Coolify's UI health-check settings do not apply to
Docker Compose apps; Coolify reads each service's
healthcheck:incompose.ymlinstead. Thebotservice checksGET /(returnstext/plainmiti99bot ok) with the image's busyboxwget, and therendererservice checks/api/healthz. Note:/reports healthy even if Mongo is unreachable (the driver auto-reconnects on the next op); a DB outage will not mark the container unhealthy — 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.