deploy targets
pick polling for containers and VPS, webhooks for serverless and edge.
scaffold it
create-yaebal generates the deploy files directly — the Dockerfile, compose/fly/railway config, or the edge function — matched to your chosen runtime. Skip the copy-pasting below and start from a working setup:
pnpm create yaebal my-bot --deploy dockeradd --ci for a GitHub Actions workflow (install + typecheck on every push), or run
the CLI with no flags for an interactive prompt that asks for all of this, including the deploy
target.
deploy target catalog
| target | generates | bot runs as |
|---|---|---|
none | just the project — deploy it however you like | — |
docker | Dockerfile + .dockerignore, single-stage, no build step | polling |
compose | the same Dockerfile plus compose.yaml for local/vps runs | polling |
fly | the same Dockerfile plus fly.toml | polling |
railway | the same Dockerfile plus railway.json | polling |
cloudflare | wrangler.jsonc (nodejs_compat on) + a cloudflareAdapter bootstrap | webhook |
vercel | vercel.json + an api/bot.ts edge function | webhook |
docker, compose, fly and railway all just run the Dockerfile's CMD — a
long-polling process with no inbound HTTP to expose. cloudflare and vercel are serverless, so
they get a webhook entry point instead; there's no persistent process to poll from.
docker
the generated Dockerfile is single-stage on purpose — node's --experimental-strip-types runs .ts directly, so there's no build output to copy out of a separate builder
stage. bun and deno get their own Dockerfile shape (bun install / deno
cache); this is the node one.
# generated by `create-yaebal --deploy docker` (node runtime shown; bun/deno differ)
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --prod --frozen-lockfile; \
elif [ -f yarn.lock ]; then corepack enable && yarn install --production --frozen-lockfile; \
else npm install --omit=dev; fi
COPY . .
ENV NODE_ENV=production
CMD ["node", "--experimental-strip-types", "src/index.ts"]--experimental-strip-types is what lets the
scaffolded project skip a build step. on an older node, add a build script and point CMD at the compiled output instead.docker build -t my-bot .
docker run --env-file .env my-botdocker compose
use this over bare docker when you want one command to (re)start the bot alongside its env file.
# create-yaebal --deploy compose adds this next to the same Dockerfile
services:
my-bot:
build: .
env_file: .env
restart: unless-stoppedsystemd
for a single VPS without containers, systemd gives restart, logs and simple secret loading.
[Unit]
Description=yaebal bot
After=network-online.target
[Service]
WorkingDirectory=/srv/my-bot
EnvironmentFile=/srv/my-bot/.env
ExecStart=/usr/bin/node --experimental-strip-types src/index.ts
Restart=always
RestartSec=5
User=yaebal
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now my-bot
sudo journalctl -u my-bot -fgraceful shutdown
every target above can send SIGTERM before killing the process — docker on stop, systemd on restart, kubernetes before a pod eviction. without a handler, that
SIGTERM kills the process mid long-poll or mid-update.
import { createBot } from "yaebal";
export const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("hi"));
// docker, systemd and kubernetes all send SIGTERM before killing the process.
// bot.stop() lets the in-flight update finish and the current long-poll
// return before the process actually exits — an unhandled SIGTERM just
// kills mid-update.
process.once("SIGTERM", () => bot.stop());
process.once("SIGINT", () => bot.stop());
await bot.start();cloudflare workers
create-yaebal's cloudflare target turns on wrangler's nodejs_compat flag, so process.env works at module scope exactly like it does on node — no per-request env plumbing. it wires cloudflareAdapter instead of the plain webhook() so an update that outlives the response (under a slow handler) still
finishes via ctx.waitUntil.
import { createBot } from "yaebal";
import { cloudflareAdapter } from "@yaebal/web";
// wrangler.jsonc sets "compatibility_flags": ["nodejs_compat"], so — unlike a
// bare Workers fetch handler — process.env works at module scope here, the
// same as on every other runtime. no per-request env plumbing needed.
export const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("hi from the edge"));
export default {
fetch: cloudflareAdapter(bot, { secretToken: process.env.SECRET_TOKEN }),
};wrangler secret put BOT_TOKEN
wrangler secret put SECRET_TOKEN
wrangler deploywriting the worker by hand instead of scaffolding it? the bare-Workers pattern without nodejs_compat — building the bot inside fetch(request, env) — is on runtime support.
vercel
the vercel target is a single edge function at api/bot.ts, no adapter needed — webhook() already returns a standard fetch handler.
// api/bot.ts — Vercel edge function
import { createBot, webhook } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("hi from the edge"));
export const config = { runtime: "edge" };
export default webhook(bot, { secretToken: process.env.SECRET_TOKEN });vercel env add BOT_TOKEN
vercel env add SECRET_TOKEN
vercel deploy --prodimport { createBot, webhook } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) => ctx.reply("webhook ready"));
export default {
fetch: webhook(bot, { secretToken: process.env.WEBHOOK_SECRET ?? "dev" }),
};fly.io and railway
both reuse the same generated Dockerfile and run the bot in polling mode — fly.toml and railway.json just point the platform at it, with no inbound HTTP to configure.
fly launch
fly secrets set BOT_TOKEN=... WEBHOOK_SECRET=...
fly deploytarget matrix
| target | best mode | watch out for |
|---|---|---|
| vps/systemd | polling or webhook | process restarts, logs, token rotation |
| docker/fly/railway | polling | exactly one polling replica per token — a second one gets 409 conflict, see troubleshooting |
| vercel/cloudflare | webhook | no persistent filesystem, request timeout, cold starts |
| kubernetes | webhook, or a single polling replica | leader election if you must poll from multiple pods; always handle SIGTERM for pod evictions |
for concurrent polling that still needs exactly-once-per-chat-order semantics under real traffic, see @yaebal/runner instead of scaling polling replicas horizontally.