Freestyle Docs

Freestyle / Guides

How to Run a Telegram Bot in a Sandbox

Run a Node.js Telegram bot on a VM, taking updates by webhook on a domain.

Run a grammY bot under systemd on one VM, taking updates by webhook. The default image is a full Ubuntu 24.04 with node and npm already on PATH, so the only install is the bot’s own dependency.

Install the SDK

pnpm add freestyle@latest
bun add freestyle@latest
npm install freestyle@latest
yarn add freestyle@latest

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Get a Bot Token

Message @BotFather on Telegram, send /newbot, and answer with a display name and a username ending in bot. BotFather replies with a token shaped 123456789:AAH….

export TELEGRAM_BOT_TOKEN="123456789:AAH…"

Create the VM

The bot’s replies call api.telegram.org, so the VM needs outbound Internet. idleTimeoutSeconds pauses it while no traffic reaches it — an incoming webhook starts it again — and a paused VM bills nothing.

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

const { vm, vmId } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  slug: "telegram-bot",
  idleTimeoutSeconds: 300,
});

Write the Bot

grammY’s http adapter compares X-Telegram-Bot-Api-Secret-Token against secretToken and answers 401 when it does not match, so nothing but Telegram can post updates.

await vm.fs.writeTextFile(
  "/srv/bot/package.json",
  JSON.stringify({ name: "telegram-bot", private: true, type: "module" }, null, 2),
);

await vm.fs.writeTextFile(
  "/srv/bot/bot.js",
  `import { createServer } from "node:http";
import { Bot, webhookCallback } from "grammy";

const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN);

bot.command("start", (ctx) => ctx.reply("Hello from a Freestyle VM."));
bot.on("message:text", (ctx) => ctx.reply(\`You said: \${ctx.message.text}\`));

// Without this, one throwing handler takes the process down.
bot.catch((err) => console.error("bot error:", err));

// Fail at startup on a bad token, not on the first update.
await bot.init();

const handle = webhookCallback(bot, "http", {
  secretToken: process.env.TELEGRAM_WEBHOOK_SECRET,
});

createServer((req, res) => {
  if (req.method === "POST" && req.url === "/telegram") return handle(req, res);
  if (req.url === "/healthz") return res.writeHead(200).end("ok");
  res.writeHead(404).end();
}).listen(8000, "0.0.0.0", () => console.log("listening on 0.0.0.0:8000"));
`,
);

await vm.exec({
  command: "cd /srv/bot && HOME=/root npm install grammy@1.46.0",
  timeoutMs: 120000,
});

The server binds 0.0.0.0, not 127.0.0.1, so traffic routed in from outside the VM reaches it.

Run It Under systemd

Write the token and the webhook secret to a root-only file and point the unit at it with EnvironmentFile. A unit does not source a shell profile, so ExecStart names node at its absolute path, /usr/local/bin/node.

const webhookSecret = crypto.randomUUID();

await vm.fs.writeTextFile(
  "/etc/telegram-bot.env",
  `TELEGRAM_BOT_TOKEN=${process.env.TELEGRAM_BOT_TOKEN}\n` +
    `TELEGRAM_WEBHOOK_SECRET=${webhookSecret}\n`,
  { mode: 0o600 },
);

await vm.fs.writeTextFile(
  "/etc/systemd/system/telegram-bot.service",
  `[Unit]
Description=Telegram bot
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/node /srv/bot/bot.js
WorkingDirectory=/srv/bot
EnvironmentFile=/etc/telegram-bot.env
Restart=always
RestartSec=5
Environment=HOME=/root

[Install]
WantedBy=multi-user.target
`,
);

await vm.exec("systemctl daemon-reload && systemctl enable --now telegram-bot");

Wait for the server to answer before routing traffic to it:

let ready = false;
for (let i = 0; i < 30 && !ready; i++) {
  const probe = await vm.exec(
    "curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/healthz || true",
  );
  ready = probe.stdout?.trim() === "200";
  if (!ready) await new Promise((r) => setTimeout(r, 1000));
}
if (!ready) {
  console.log((await vm.exec("journalctl -u telegram-bot -n 20 --no-pager")).stdout);
  throw new Error("bot did not become ready");
}

Publish It On A Domain

Telegram requires an HTTPS webhook URL. Route a domain to port 8000 with a TLS rule and the Freestyle edge terminates HTTPS and forwards to the VM. Any unused subdomain of style.dev is yours to take, with no verification and no DNS records; to use a name you own, verify it and point its DNS at Freestyle first.

const domain = "my-bot.style.dev"; // any unused style.dev subdomain

await freestyle.tls.rules.create({
  action: "allow",
  domain,
  source: { public: true },
  destination: { vmId, port: 8000 },
});

Point Telegram at it:

const api = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`;
const res = await fetch(`${api}/setWebhook`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    url: `https://${domain}/telegram`,
    secret_token: webhookSecret,
    drop_pending_updates: true,
  }),
});
console.log(await res.json()); // { ok: true, result: true, description: "Webhook was set" }

Send /start to the bot in Telegram and it answers.

Failed deliveries are reported by Telegram, not by the journal:

const info = await (await fetch(`${api}/getWebhookInfo`)).json();
console.log(info.result.pending_update_count, info.result.last_error_message);

Stream the Bot’s Logs

vm.exec() buffers a command and returns once it finishes, so it cannot follow a running service. Open a PTY — a real terminal over a WebSocket, server-side only — and tail the journal:

const session = await vm.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(Buffer.from(bytes)),
});

session.write("journalctl -u telegram-bot -f\n");

// session.detach() drops your handle — the bot keeps running.

Ship a New Version

Write the file and restart the unit:

await vm.fs.writeTextFile("/srv/bot/bot.js", updatedSource);
await vm.exec("systemctl restart telegram-bot");
esc