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@latestbun add freestyle@latestnpm install freestyle@latestyarn 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");