Delete the byte-oriented KVStore/VersionedStore abstraction and the DynamoDB/memory KV backends. Add a generic typed store (DocStore[T] with Provider/Collection/Typed) persisting each value as a flattened native Mongo document (storedDoc[T] via bson inline) — no value envelope. - MongoDB is the only runtime backend; memory kept for tests/local. - All modules + deploynotify use typed stores; persisted structs carry bson tags == json names (incl. nested lolschedule/wordle types). - lolschedule wraps its array/scalar values in named structs. - migrate-dynamo-to-mongo writes the flattened shape via Typed[bson.M] with wrap rules; Scan/--dry-run/--verify retained. Verified: go vet/build clean; full go test green hermetically and in-container vs real Mongo 7 + DynamoDB Local (storage integration + migrator e2e).
7.9 KiB
Deploy: Self-host (Coolify + MongoDB Atlas)
Run miti99bot as a long-lived container on Coolify with
MongoDB Atlas (free M0) for storage. This
replaces the AWS Lambda + DynamoDB + EventBridge path.
Architecture
Telegram <── long poll (getUpdates) ── container (outbound only)
in-process scheduler ───────────────────> module crons
MongoDB Atlas (db / one collection per module)
Coolify env vars (plain secrets)
NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in)
Same Go binary (cmd/server) and module framework as AWS. Three things differ,
all selected automatically from env:
- Storage —
mongodbauto-selected whenMONGO_URLis set (noKV_PROVIDER). - Cron — an in-process scheduler (
internal/cron) runs unconditionally and fires each module cron on itsSchedule(UTC). No EventBridge. - 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.
Required environment
Copy .env.example → .env (gitignored) and fill in.
| Var | Required | Notes |
|---|---|---|
TELEGRAM_BOT_TOKEN |
✅ | from @BotFather |
MONGO_URL |
✅ | Atlas SRV string incl. credentials — secret, never logged |
MONGO_DATABASE |
✅ | e.g. miti99bot |
MODULES |
optional | CSV; empty = all modules |
OWNER_ID |
optional | owner-only commands (renamed from BOT_OWNER_ID) |
ADMIN_IDS |
optional | CSV of admin ids (renamed from ADMIN_USER_IDS) |
GEMINI_API_KEY |
optional | only the twentyq module needs it |
Leave UNSET on self-host: all six *_PARAMETER_NAME vars (they force an
SSM/AWS lookup that fails with no AWS creds and bricks startup), KV_PROVIDER,
PORT, TELEGRAM_WEBHOOK_SECRET, CRON_SHARED_SECRET, GOLD_VNAPP_API_KEY,
and the STOCK/COIN/GOLD *_API_URL overrides (modules use coded defaults).
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 (validated decision). The Coolify host has no stable egress IP, so the Atlas IP allow-list is open to the internet. This widens the surface beyond DynamoDB's IAM-gated posture (where the DB was never internet-reachable). It is knowingly accepted for self-host. 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. The DynamoDB→Mongo migrator writes this shape directly, and is idempotent.
2. Coolify
- New resource → from this Git repo (Docker Compose), or a prebuilt image.
The committed
docker-compose.ymldefines the singlebotservice. - 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. - Build arg for deploynotify: pass
GIT_SHA(Coolify exposes the commit SHA) so the owner gets the "new version" DM. Without it,deploynotifystays silent (no crash) — but you lose that notification. - Health check: use Coolify's HTTP monitor against
GET /(returnstext/plainmiti99bot ok). Do not use a composehealthcheck— the distroless image has no shell/curl andcmd/serverhas no-healthcheckflag. 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. Register the command menu
Long polling needs no webhook registration — only the command menu:
TELEGRAM_BOT_TOKEN=… make telegram-commands-selfhost
Cutover runbook
Zero-loss switch from the live AWS Lambda to the Coolify poller. Coordinate users to pause activity during the brief window.
- Deploy the Coolify container but keep it stopped/scaled-to-0. A running poller would 409 against the live Lambda webhook and its scheduler would overlap EventBridge. Use a fresh, empty Atlas DB.
- Disable/delete the EventBridge schedule
miti99bot-lolschedule-daily-push(it invokes the Lambda directly, independent of transport). The in-process scheduler's per-UTC-date idempotency guard is the backup. deleteWebhook(mandatory):This buffers incoming updates (Telegram retains ~24h) so nothing is lost, and releases the webhook so the poller won't 409. After this the Lambda stops receiving updates.TELEGRAM_BOT_TOKEN=… make telegram-deletewebhook-selfhost- Migrate + verify (read-only on DynamoDB — see
cmd/migrate-dynamo-to-mongo):Keep this window short (target minutes).export MONGO_URL=… MONGO_DATABASE=… AWS_PROFILE=miti99bot-migrate make migrate-dynamo-to-mongo DRY_RUN=1 # review counts make migrate-dynamo-to-mongo # real run make migrate-verify # counts must match, exit 0 - Start the Coolify container (1 replica). Its scheduler runs by default
(safe now that EventBridge is off). On startup it
deleteWebhooks again (idempotent) and begins polling, draining Telegram's buffered queue. - Confirm:
TELEGRAM_BOT_TOKEN=… make telegram-webhook-info-selfhosturlshould be empty andpending_update_countshould drain toward 0. Smoke/ping,/stats, and a coin/stock balance command — migrated state should be visible from Atlas. - Tear down AWS once verified — see
aws-decommission-runbook.md.
Rollback
The only clean revert is before sam delete: stop the poller, then
re-setWebhook to the Lambda Function URL (the still-deployed Lambda runs its
own webhook code until teardown). Lossless only until the first post-cutover
Mongo write — after that, MongoDB/Coolify is the sole system of record. This
short-window RPO was an explicitly accepted decision; no reverse migrator
exists.
Local smoke test
cp .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
docker compose up --build
Boot logs should show storage backend backend=mongodb database=… (no
connection string), cron scheduler started, and telegram long polling started. curl localhost:8080/ returns miti99bot ok. The bot's webhook must
be unset (the container clears it on startup) or getUpdates 409s.