Coolify passes predefined vars to Docker Compose via --env-file for
interpolation only; a var reaches the container solely if the compose file
references it. Removing the reference left SOURCE_COMMIT unset, so deploynotify
reported "unknown". Re-add SOURCE_COMMIT: ${SOURCE_COMMIT:-} per Coolify docs.
8.4 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 *_PARAMETER_NAME vars (they force an
SSM/AWS lookup that fails with no AWS creds and bricks startup), KV_PROVIDER,
PORT, TELEGRAM_WEBHOOK_SECRET, GOLD_VNAPP_API_KEY, and the
STOCK/COIN/GOLD *_API_URL overrides (modules use coded defaults).
Cron runs in-process (
internal/cron) — there is no/cronHTTP route and noCRON_SHARED_SECRET. The scheduler is the sole trigger; nothing inbound.
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. - deploynotify commit SHA:
SOURCE_COMMITis a Coolify predefined variable. For Docker Compose, Coolify passes predefined vars via--env-filefor interpolation only, so the value reaches the container only becausedocker-compose.ymlreferences it (SOURCE_COMMIT: ${SOURCE_COMMIT:-}). 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. The "Include Source Commit in Build" Coolify setting affects build args only and is not needed for this runtime path. - 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.