Skip to content

Search docs

Search the Skiffly documentation

Deploy a Telegram bot

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#

src/bot.ts
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();
package.json
{ "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 -f

Expected: 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:

src/bot.ts
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.cloud
src/bot.ts
import { 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#

.skiffly/skiffly.ts
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#

SymptomFix
Deployment FAILED after 5 min, bot answered messages meanwhileno listening port: add the health endpoint and the healthcheck path
409: Conflict: terminated by other getUpdates request in the loganother poller (local run, a second replica); harmless during a rollout
401 Unauthorizedwrong BOT_TOKEN (check for a trailing newline when set with --stdin)
Webhook set but no updatesgetWebhookInfo shows last_error_message: usually a 404 path or the wrong domain after a custom-domain change
Bot goes silent after ~10 minutesApp Sleeping is on for a polling bot: turn it off (sleepApplication: false)

Next: Node.js · Python · Services: App Sleeping