Bot do Telegram
Run a Telegram bot on Skiffly as a worker service — long polling with grammY (Node) or aiogram (Python), no domain needed — then switch to webhooks with a generated domain, add Postgres for state and keep it from double-polling during deploys.
What you build: a service bot from a GitHub repository, running a Telegram bot with long polling (no public URL required), with its token in a variable and Postgres for state. Then the webhook variant. About 10 minutes.
You need: a bot token from @BotFather, the skiffly CLI, and a repository with one of the examples below.
1. The bot#
import { Bot } from "grammy";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("Hello from Skiffly"));
bot.on("message:text", (ctx) => ctx.reply(ctx.message.text));
process.once("SIGTERM", () => bot.stop());
process.once("SIGINT", () => bot.stop());
bot.start();{ "scripts": { "start": "node --import tsx src/bot.ts" }, "dependencies": { "grammy": "^1", "tsx": "^4" } }Railpack sees package.json and the start script; no build step.
Push the repository to GitHub.
2. Deploy as a worker#
skiffly init --name telegram-bot
skiffly variables set --skip-deploys BOT_TOKEN=123456:ABC… # after `skiffly up -y -d` created the service, or set it in the dashboard first
skiffly up
skiffly logs -fExpected: the build finishes, the runtime log stays quiet (grammY) or prints Start polling (aiogram), and the deployment is SUCCESS. No domain is needed for polling. There is one catch, though:
import { createServer } from "node:http";
createServer((_, res) => res.end("ok")).listen(Number(process.env.PORT ?? 8080), "0.0.0.0");Set the healthcheck path to /healthz in Settings → Deploy (or healthcheckPath), redeploy, and send /start to the bot.
3. Only one poller at a time#
Telegram allows one getUpdates consumer per token. During a rollout the old container keeps running until the new one is healthy, so for a few seconds two instances poll and Telegram answers 409 Conflict to one of them. Both libraries retry, so this is harmless — but keep replicas at 1 and do not run the same token locally while the service is up. If you want zero overlap, use webhooks.
4. Webhooks instead of polling#
Webhooks need a public HTTPS URL, which a generated domain provides:
skiffly domain # https://telegram-bot-x1y2.skiffly.cloudimport { Bot, webhookCallback } from "grammy";
import { createServer } from "node:http";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("Hello via webhook"));
const handle = webhookCallback(bot, "http");
const url = `https://${process.env.SKIFFLY_PUBLIC_DOMAIN}/telegram`;
createServer((req, res) => {
if (req.url === "/healthz") return res.end("ok");
if (req.url === "/telegram" && req.method === "POST") return handle(req, res);
res.statusCode = 404; res.end();
}).listen(Number(process.env.PORT ?? 8080), "0.0.0.0", async () => {
await bot.api.setWebhook(url, { secret_token: process.env.WEBHOOK_SECRET });
});SKIFFLY_PUBLIC_DOMAIN is set automatically once the domain exists; add WEBHOOK_SECRET as a variable (openssl rand -hex 16). Redeploy and check with curl https://api.telegram.org/bot<token>/getWebhookInfo. Webhook mode also lets App Sleeping work (the first message after idle wakes the bot in a few seconds) — polling bots must stay awake.
5. State in Postgres#
skiffly deploy --template postgres
skiffly variables set 'DATABASE_URL=${{Postgres.DATABASE_URL}}'Create tables from the start command (sh -c "node scripts/migrate.js && node --import tsx src/bot.ts") or once with skiffly ssh -- node scripts/migrate.js. For small bots, SQLite on a volume (skiffly volume add --mount-path /data, DB_PATH=/data/bot.sqlite) is enough — one replica, which a bot has anyway.
6. Config as code#
import { defineSkiffly, github, postgres, preserve, project, service } from "@skiffly/config";
export default defineSkiffly(() => {
const db = postgres("Postgres");
const bot = service("bot", {
source: github("acme/telegram-bot"),
healthcheck: "/healthz",
env: { BOT_TOKEN: process.env.BOT_TOKEN ?? preserve(), DATABASE_URL: db.env.DATABASE_URL },
replicas: 1,
domain: true, // webhook mode; drop for polling
});
return project("telegram-bot", { resources: [db, bot] });
});Troubleshooting#
| Symptom | Fix |
|---|---|
Deployment FAILED after 5 min, bot answered messages meanwhile | no listening port: add the health endpoint and the healthcheck path |
409: Conflict: terminated by other getUpdates request in the log | another poller (local run, a second replica); harmless during a rollout |
401 Unauthorized | wrong BOT_TOKEN (check for a trailing newline when set with --stdin) |
| Webhook set but no updates | getWebhookInfo shows last_error_message: usually a 404 path or the wrong domain after a custom-domain change |
| Bot goes silent after ~10 minutes | App Sleeping is on for a polling bot: turn it off (sleepApplication: false) |
Next: Node.js · Python · Services: App Sleeping