Cloudflare Workers: From Manual Deploy to Full Automation

Deploying Cloudflare Workers by hand works until it does not. The moment a second engineer touches the project, or you push a fix from a borrowed laptop, the "just run npm run deploy locally" approach starts costing you time and trust.

This article covers two related patterns: automating Worker deployments via GitHub Actions, and building a more sophisticated scheduled Worker that chains RSS parsing, LLM summarization, and social posting into a single cron-triggered pipeline. Both patterns share the same credential management foundations, so we will cover that once and apply it to both.

Why Manual Deploys Are a Liability

The problem with local deploys is not convenience. It is reproducibility. When a deployment lives on a developer's machine, it inherits whatever Node version, whatever wrangler version, and whatever environment variables exist on that machine at that moment. Bugs that only reproduce in production but not in your coworker's deploy are often environment artifacts, not code bugs.

More practically: if the person who normally deploys is unavailable, the project stalls. That is not a tooling problem β€” it is an organizational risk you can eliminate in about thirty minutes.

The fix is straightforward. Push a deployment workflow into version control alongside the code it deploys, store credentials in your CI platform's secret store, and make the main branch merge the canonical trigger for production changes.

Setting Up Wrangler Locally

Before automating anything, you need a working local setup. Cloudflare recommends installing wrangler as a local dev dependency rather than globally β€” this keeps the version pinned per project and avoids the classic "worked on my machine" problem when the global wrangler version drifts.

mkdir my-worker && cd my-worker
npm init -y
npm install -D wrangler@latest
npx wrangler --version

Authenticate against your Cloudflare account:

npx wrangler login

This opens a browser flow. After successful auth, run npx wrangler whoami to confirm the account association and note your Account ID. You will need it shortly.

πŸ‘‹ You are logged in with an OAuth Token
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Account Name         β”‚ Account ID                       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ your-account         β”‚ abc123def456...                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The GitHub Actions Workflow

The deploy workflow itself is short. The key decisions are in the details:

name: Deploy to Cloudflare Workers

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Deploy
        run: npm run deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

A few things worth noting. npm ci instead of npm install β€” this installs from package-lock.json exactly, no version resolution surprises. The cache: npm directive in setup-node stores the npm cache between runs, which cuts install time significantly on warm runs. The timeout-minutes: 15 prevents a hung deploy from consuming your CI quota indefinitely.

The CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID env vars are what wrangler reads when it runs in non-interactive mode. Without them, wrangler will attempt an interactive browser auth, which obviously does not work in a headless CI environment.

Creating and Scoping the API Token

Go to the Cloudflare dashboard under Profile > API Tokens > Create Token. For most Worker deployments, the minimum required permission is:

Account > Workers Scripts > Edit

That is it. Do not grant "Edit" on everything just because the UI makes it easy. If your Worker also creates D1 databases, runs migrations, creates R2 buckets, or modifies route bindings, you will need to add those specific permissions. But for a script-only deploy, keep it narrow.

Once created, copy the token immediately. Cloudflare only shows it once.

In your GitHub repository, navigate to Settings > Secrets and variables > Actions, then add two repository secrets:

Secret NameValue
CLOUDFLARE_API_TOKENThe token you just created
CLOUDFLARE_ACCOUNT_IDYour account ID from wrangler whoami

Push a commit to main. Watch the Actions tab. If the deploy step exits clean, you have a working pipeline.

Takumi's Take: The "Workers Scripts: Edit" permission is sufficient for deploying code changes to an existing Worker. Where people get burned is on first-time setup: if the Worker does not yet exist in Cloudflare's system, the initial deploy creates it, and that operation may need broader account-level write access depending on the bindings in your wrangler.json. I have seen teams give a token full account edit rights, deploy once, then forget to rotate to a narrower token. Run npx wrangler secret list and npx wrangler whoami after any initial setup to verify the state of things.

Beyond Simple Deploys: A Scheduled Worker Pipeline

Now that you have a working deploy pipeline, consider what you can actually do with a Worker that runs on a schedule. Cloudflare Workers support cron triggers natively through wrangler.jsonc configuration. A Worker with a scheduled handler runs at the edge without a persistent server, costs essentially nothing at low frequencies, and has access to Workers KV, D1, R2, and external fetch.

A concrete use case: poll RSS feeds daily, summarize new articles using an LLM API, post results to X (formerly Twitter). The full loop runs inside a single scheduled handler with no infrastructure beyond the Worker itself.

Initialize a new project with the cron template:

npx wrangler init rss-autoposter
# Select: Hello World example
# Select: Scheduled Worker (Cron Trigger)
# Select: TypeScript

Configuring the Cron Schedule

Cron expressions in Workers use UTC. If you want to fire at 08:00 JST (Japan Standard Time, UTC+9), you use 23:00 UTC the previous day:

// wrangler.jsonc
{
  "name": "rss-autoposter",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "triggers": {
    "crons": ["0 23 * * *"]
  },
  "vars": {
    "OPENAI_MODEL": "gpt-4o-mini",
    "FEED_URLS": "https://example.com/feed.xml,https://other-site.com/rss",
    "POST_HASHTAG": "#devblog"
  },
  "secrets": {
    "required": [
      "OPENAI_API_KEY",
      "X_CONSUMER_KEY",
      "X_CONSUMER_SECRET",
      "X_ACCESS_TOKEN",
      "X_ACCESS_TOKEN_SECRET"
    ]
  }
}

The secrets.required array is a Worker configuration property that tells Cloudflare to refuse deployment if any listed secret is absent. This is a cheap but effective safety net β€” you cannot accidentally deploy a half-configured Worker that silently fails at runtime.

Managing Secrets with Wrangler

Secrets are set per Worker via the CLI. They are stored encrypted on Cloudflare's platform and injected into the Worker's environment at execution time. You cannot read them back after storing β€” only list names and delete.

npx wrangler secret put OPENAI_API_KEY
npx wrangler secret put X_CONSUMER_KEY
npx wrangler secret put X_CONSUMER_SECRET
npx wrangler secret put X_ACCESS_TOKEN
npx wrangler secret put X_ACCESS_TOKEN_SECRET

Each command prompts for the value interactively. For local development, create a .dev.vars file at the project root. This file is the local equivalent of Cloudflare's secret store:

# .dev.vars β€” never commit this file
OPENAI_API_KEY=sk-your-key-here
X_CONSUMER_KEY=your-consumer-key
X_CONSUMER_SECRET=your-consumer-secret
X_ACCESS_TOKEN=your-access-token
X_ACCESS_TOKEN_SECRET=your-access-token-secret

Add .dev.vars to .gitignore immediately. Then run npx wrangler types to regenerate the TypeScript Env interface so your editor knows about all bindings and vars.

The Scheduled Handler

The Worker entry point is minimal. It reads configuration, fetches feeds in parallel, and processes each article sequentially.

// src/index.ts
import { collectRecentArticles } from './rss';
import { generateSummary } from './openai';
import { postToX, type XAuth } from './x';
import { buildPostText, fitsInPostLimit } from './post';

const SUMMARY_POINT_COUNTS = [3, 2, 1] as const;

const handler: ExportedHandler<Env> = {
  async scheduled(event, env, _ctx): Promise<void> {
    console.log(`Cron triggered: ${event.cron}`);

    const feedUrls = env.FEED_URLS
      .split(',')
      .map((u) => u.trim())
      .filter((u) => u.length > 0);

    const articles = await collectRecentArticles(feedUrls);
    console.log(`Found ${articles.length} new article(s) in the last 24h`);

    const auth = extractXAuth(env);

    for (const article of articles) {
      try {
        const postText = await buildFittingPost(article, env);
        const postId = await postToX(postText, auth);
        console.log(`Posted id=${postId}: ${article.title}`);
      } catch (err) {
        console.error(`Failed on "${article.title}":`, err);
      }
    }
  },
};

export default handler;

async function buildFittingPost(article: Article, env: Env): Promise<string> {
  for (const numPoints of SUMMARY_POINT_COUNTS) {
    const summary = await generateSummary(
      article,
      numPoints,
      env.OPENAI_API_KEY,
      env.OPENAI_MODEL
    );
    const text = buildPostText({ article, summary, hashtag: env.POST_HASHTAG });
    if (fitsInPostLimit(text)) return text;
    console.log(`Post too long at ${numPoints} points, reducing...`);
  }
  // Final fallback: title + link + hashtag, no summary
  return buildPostText({ article, hashtag: env.POST_HASHTAG });
}

function extractXAuth(env: Env): XAuth {
  return {
    consumerKey: env.X_CONSUMER_KEY,
    consumerSecret: env.X_CONSUMER_SECRET,
    accessToken: env.X_ACCESS_TOKEN,
    accessTokenSecret: env.X_ACCESS_TOKEN_SECRET,
  };
}

The retry-with-fewer-points loop in buildFittingPost is worth explaining. X (and its character counting system) treats URLs as fixed-width regardless of actual length, and counts multi-byte Unicode characters as 2 units each. A summary that looks short in your editor can easily breach the 140-character equivalent limit once encoded. Rather than hard-coding a character budget for the summary, the loop tries generating a 3-point summary first, then falls back to 2, then 1, before giving up on the summary entirely. Each attempt calls the LLM, so this has a cost implication β€” but it is preferable to silent truncation or failed posts.

RSS Parsing

The feed layer fetches all URLs in parallel and filters to articles published in the last 24 hours:

// src/rss.ts
import { XMLParser } from 'fast-xml-parser';

const ONE_DAY_MS = 24 * 60 * 60 * 1000;

export interface Article {
  readonly title: string;
  readonly link: string;
  readonly content: string;
  readonly publishedAt: Date;
}

export async function collectRecentArticles(
  feedUrls: readonly string[]
): Promise<Article[]> {
  const cutoff = Date.now() - ONE_DAY_MS;
  const perFeed = await Promise.all(feedUrls.map(fetchOneFeed));
  return perFeed
    .flat()
    .filter((a) => a.publishedAt.getTime() >= cutoff)
    .sort((a, b) => a.publishedAt.getTime() - b.publishedAt.getTime());
}

async function fetchOneFeed(url: string): Promise<Article[]> {
  const res = await fetch(url, {
    headers: { 'User-Agent': 'RssAutoposter/1.0' },
  });
  if (!res.ok) {
    throw new Error(`Feed fetch failed for ${url}: ${res.status}`);
  }
  const xml = await res.text();
  return parseFeedXml(xml);
}

function parseFeedXml(xml: string): Article[] {
  const parser = new XMLParser({
    ignoreAttributes: false,
    attributeNamePrefix: '@_',
  });
  const doc: unknown = parser.parse(xml);
  if (!isObj(doc)) return [];

  // Try RSS 2.0 first
  const items = deepGet(doc, ['rss', 'channel', 'item']);
  if (items !== undefined) {
    return asArray(items).map(parseRssItem);
  }

  // Fall back to Atom
  const entries = deepGet(doc, ['feed', 'entry']);
  if (entries !== undefined) {
    return asArray(entries).map(parseAtomEntry);
  }

  console.warn('Unrecognized feed format');
  return [];
}

function parseRssItem(raw: unknown): Article {
  if (!isObj(raw)) throw new Error('RSS item is not an object');
  return {
    title: textOf(raw.title) ?? '(untitled)',
    link: textOf(raw.link) ?? '',
    content: textOf(raw['content:encoded']) ?? textOf(raw.description) ?? '',
    publishedAt: safeDate(textOf(raw.pubDate)),
  };
}

function parseAtomEntry(raw: unknown): Article {
  if (!isObj(raw)) throw new Error('Atom entry is not an object');
  return {
    title: textOf(raw.title) ?? '(untitled)',
    link: atomLinkHref(raw.link),
    content: textOf(raw.content) ?? textOf(raw.summary) ?? '',
    publishedAt: safeDate(textOf(raw.published) ?? textOf(raw.updated)),
  };
}

function atomLinkHref(link: unknown): string {
  const links = asArray(link);
  const alt = links.find((l): l is Record<string, unknown> =>
    isObj(l) && l['@_rel'] === 'alternate'
  );
  const target = alt ?? links[0];
  if (!target) return '';
  if (isObj(target)) {
    const href = target['@_href'];
    if (typeof href === 'string') return href;
  }
  return textOf(target) ?? '';
}

function isObj(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

function asArray<T>(v: T | readonly T[]): T[] {
  return Array.isArray(v) ? [...v] : [v as T];
}

function deepGet(obj: Record<string, unknown>, path: string[]): unknown {
  let cur: unknown = obj;
  for (const key of path) {
    if (!isObj(cur)) return undefined;
    cur = cur[key];
  }
  return cur;
}

function textOf(v: unknown): string | undefined {
  if (v === undefined || v === null) return undefined;
  if (typeof v === 'string') return v;
  if (isObj(v) && typeof v['#text'] === 'string') return v['#text'];
  return undefined;
}

function safeDate(v: string | undefined): Date {
  if (!v) return new Date();
  const d = new Date(v);
  return isNaN(d.getTime()) ? new Date() : d;
}

One thing the parseFeedXml function handles that naive implementations miss: RSS 2.0 can have a single <item> element (not in an array) when there is only one article in the feed. The asArray utility normalizes this. If you feed this parser a fresh blog with one post, you will not get a mysterious silent empty result.

Post Formatting and Character Counting

X counts characters by Unicode code point weight, not byte length. ASCII characters count as 1 unit, everything else as 2. URLs are replaced by t.co short links with a fixed display length (currently 23 characters, but conservative code uses 24 to be safe). The limit is 280 units total, which works out to 140 Japanese characters.

// src/post.ts
const MAX_POST_WEIGHT = 280;
const TCO_DISPLAY_LENGTH = 24; // conservative, actual is ~23

export interface PostInputs {
  readonly article: Article;
  readonly summary?: { readonly points: readonly string[] };
  readonly hashtag: string;
}

export function buildPostText(inputs: PostInputs): string {
  const { article, summary, hashtag } = inputs;
  const header = `${article.title}\n${hashtag}\n${article.link}`;
  if (!summary) return header;
  const bulletPoints = summary.points.map((p) => `- ${p}`).join('\n');
  return `${header}\n\n${bulletPoints}`;
}

export function fitsInPostLimit(text: string): boolean {
  return computeWeight(text) <= MAX_POST_WEIGHT;
}

function computeWeight(text: string): number {
  const normalized = text.replace(/https?:\S+/g, 'x'.repeat(TCO_DISPLAY_LENGTH));
  let weight = 0;
  for (const ch of normalized) {
    const cp = ch.codePointAt(0);
    if (cp === undefined) continue;
    weight += cp <= 0x7f ? 1 : 2;
  }
  return weight;
}

Testing Locally Before You Deploy

Run npx wrangler dev to start the local server. Then trigger the scheduled handler manually from a second terminal:

curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=0+23+*+*+*"

This fires the actual handler against the real APIs using your .dev.vars secrets. It will hit OpenAI and post to the real X account you configured. Do this against a test account first. Verifying the full pipeline locally before deploying saves you from discovering a character encoding bug via an embarrassing live post.

After local testing:

npx tsc --noEmit  # verify types before committing
git add .
git commit -m "Add RSS autoposter worker"
git push origin main

The GitHub Actions workflow fires, deploys the Worker, and from that point the cron trigger handles scheduling.

Connecting Deployment to the Cloudflare Dashboard

Alternatively, you can connect your GitHub repository directly through the Cloudflare dashboard under Workers & Pages > your worker > Settings > Build. This approach uses Cloudflare's own CI infrastructure instead of GitHub Actions. The tradeoff: Cloudflare's native CI is simpler to configure for basic cases, but GitHub Actions gives you more control β€” parallel jobs, additional test steps, notifications, conditional deploys by branch.

For teams that already invest in GitHub Actions workflows, the Actions approach keeps everything in one place. For solo projects where simplicity matters more, the Cloudflare dashboard connection is fine.

What to Monitor After Deployment

The Worker's execution logs are available in the Cloudflare dashboard under your Worker's Logs tab. For scheduled Workers, look for the cron trigger timestamp and trace through the log lines from each run. If you see Failed on "article title": in the logs, the error message following it will tell you whether the OpenAI call failed, the X API rejected the post, or the RSS fetch returned a non-200.

The Date.now() - 24h window for article freshness has a known edge case: if the cron fires late (Cloudflare's scheduler has some tolerance on timing), you might miss articles published just before the window cutoff. For personal projects this is acceptable. For systems where article coverage matters, anchor the cutoff to the previous scheduled execution time stored in Workers KV rather than using a rolling 24-hour window.

Takumi's Take: I have seen this exact time-window drift bite people in production. Cloudflare Cron Triggers fire within approximately 30 seconds of the scheduled time under normal conditions, but can slip further under load. If your window is exactly 24 hours calculated from Date.now(), a Worker that fires at 08:01 instead of 08:00 will silently miss articles published between 08:00 yesterday and 08:01 yesterday. Storing the previous execution timestamp in KV and using that as the lower bound costs one KV read per run. It is worth it if your use case cares about completeness.

The combination of a clean GitHub Actions deploy pipeline and a Cron-triggered Worker makes for a genuinely low-maintenance automation setup. The entire operational surface is: one YAML file, two GitHub secrets, a handful of Cloudflare secrets, and Worker logs you check when something looks off. There is no server to patch, no daemon to restart, no task scheduler to babysit. The edge handles the scheduling, and git handles the release process.