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:

docker
pnpm create yaebal my-bot --deploy docker

add --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

targetgeneratesbot runs as
nonejust the project — deploy it however you like
dockerDockerfile + .dockerignore, single-stage, no build steppolling
composethe same Dockerfile plus compose.yaml for local/vps runspolling
flythe same Dockerfile plus fly.tomlpolling
railwaythe same Dockerfile plus railway.jsonpolling
cloudflarewrangler.jsonc (nodejs_compat on) + a cloudflareAdapter bootstrapwebhook
vercelvercel.json + an api/bot.ts edge functionwebhook

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.

Dockerfile
# 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"]
needs node ≥22.6. --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
docker build -t my-bot .
docker run --env-file .env my-bot

docker compose

use this over bare docker when you want one command to (re)start the bot alongside its env file.

compose.yaml
# create-yaebal --deploy compose adds this next to the same Dockerfile
services:
  my-bot:
    build: .
    env_file: .env
    restart: unless-stopped

systemd

for a single VPS without containers, systemd gives restart, logs and simple secret loading.

my-bot.service
[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.target
systemd
sudo systemctl enable --now my-bot
sudo journalctl -u my-bot -f

graceful 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.

bot.ts
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.

src/index.ts
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 }),
};
cloudflare
wrangler secret put BOT_TOKEN
wrangler secret put SECRET_TOKEN
wrangler deploy

writing 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
// 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
vercel env add BOT_TOKEN
vercel env add SECRET_TOKEN
vercel deploy --prod
webhook.ts
import { 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
fly launch
fly secrets set BOT_TOKEN=... WEBHOOK_SECRET=...
fly deploy

target matrix

targetbest modewatch out for
vps/systemdpolling or webhookprocess restarts, logs, token rotation
docker/fly/railwaypollingexactly one polling replica per token — a second one gets 409 conflict, see troubleshooting
vercel/cloudflarewebhookno persistent filesystem, request timeout, cold starts
kuberneteswebhook, or a single polling replicaleader 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.