ServerlessIRCd Docs

Docs home

Cloudflare Deployment Guide

End-to-end guide for deploying ServerlessIRCd to Cloudflare Workers (Phase 3 of PLAN.md). Covers prerequisites, first-time account setup, local development, deploy, configuration, secrets, sharding, CI/CD, cost, and troubleshooting.

Cross-reference: PLAN.md §6.1 (CF mapping), apps/cf-worker/wrangler.toml, packages/cf-adapter/, .github/workflows/deploy-cf.yml. For the AWS equivalent, see docs/AWS-Deployment.md.

Acceptance criterion (TICKET-038): a new contributor can deploy their own instance following only this doc.


1. What gets deployed

A single Cloudflare Worker (serverless-ircd) plus three Durable Object namespaces that hold all IRC state:

   ┌─────────────────────────────────────────────────────────────┐
   │  Worker: serverless-ircd  (apps/cf-worker/src/worker.ts)    │
   │                                                             │
   │   fetch(request, env):                                      │
   │     Upgrade: websocket  ──▶  ConnectionDO.idFromName(uuid)  │
   │     else                 ──▶  200 human health string       │
   └─────────────────────────┬───────────────────────────────────┘
                             │ env.CONNECTION_DO.get(id).fetch()
                             ▼
   ┌─────────────────────────────────────────────────────────────┐
   │  ConnectionDO  (one per WebSocket connection)               │
   │    • hibernatable WS via HibernatingWebSocketHandler        │
   │    • owns ConnectionState, drives ConnectionActor           │
   │    • DO storage persists state across hibernation           │
   │    • alarm() drives the PING / no-PONG disconnect sweep     │
   └────┬───────────────────────────────┬────────────────────────┘
        │ stub() RPC                    │ stub() RPC
        ▼                               ▼
   ┌──────────────────────┐    ┌──────────────────────────────────┐
   │  RegistryDO          │    │  ChannelDO                       │
   │  (sharded by nick)   │    │  (one per lowercased chan name)  │
   │  • nick uniqueness   │    │  • roster + modes + topic        │
   │  • nick→connId map   │    │  • fanout to members             │
   └──────────────────────┘    └──────────────────────────────────┘

The Worker itself is a thin edge: it allocates a server-generated connectionId (crypto.randomUUID()) and routes the upgrade to that connection's ConnectionDO. All IRC protocol logic lives in the shared irc-core reducers, run by ConnectionActor inside the DO. No state is held in the Worker process itself.

There is a single deploy target — the staging/prod split was collapsed. The same wrangler.toml ships to whichever Cloudflare account the deploy credentials target; staging vs production isolation is by account, not by a [env.staging] block.


2. Prerequisites

Requirement Version / detail
Node.js ≥ 20 (matches CI; engines.node in root package.json)
pnpm 9.x (packageManager: pnpm@9.15.9 in root package.json)
Cloudflare account Free tier is enough to bring up the Worker. Workers Paid
plan (USD $5/mo) is recommended for real traffic — see §10.
wrangler CLI Comes from apps/cf-worker/devDependencies; no global install needed.
Git checkout Clean working tree on main for deploys.

A Workers Paid plan is not strictly required to bring the Worker up, but Durable Objects only run on the Paid plan in any meaningful production scenario. The free tier caps DO requests and disables hibernation budget headroom. See §10 (Cost).

Confirm the local environment:

node --version          # v20.x or newer
pnpm --version          # 9.x
pnpm install            # from repo root, installs the whole workspace
pnpm build              # builds irc-core, irc-server, cf-adapter, cf-worker

pnpm build must succeed before wrangler deploy — the Worker's main = "src/worker.ts" is transpiled by wrangler, but it imports compiled dist/ from the workspace packages.


3. First-time Cloudflare setup

These steps are done once per Cloudflare account.

3.1 Claim a workers.dev subdomain

Every Worker is reachable at <worker-name>.<subdomain>.workers.dev. Claim your subdomain in the dashboard: Workers & Pages → General → Subdomain (or via wrangler subdomain <name>). This guide assumes your subdomain is example; substitute your own.

If you intend to use a custom domain (irc.example.com), you can skip the workers.dev subdomain and add a Route + DNS record instead (see §8.4).

3.2 Create an API token

The wrangler CLI needs a token with permission to deploy Workers and manage Durable Objects.

  1. Go to https://dash.cloudflare.com/profile/api-tokens → Create Token.
  2. Use the "Edit Cloudflare Workers" template, which grants:
    • Account · Workers Scripts · Edit
    • Account · Durable Objects · Edit
    • Account · Worker Routes · Edit (only if using custom domains)
  3. Restrict to the target Account Resources.
  4. Copy the token. You will set it as CLOUDFLARE_API_TOKEN (env var for local CLI; Actions secret for CI).

Also grab your Account ID from the dashboard sidebar — set it as CLOUDFLARE_ACCOUNT_ID if your token doesn't carry an unambiguous account context.

3.3 Authenticate the CLI locally

Two equivalent options:

# Interactive (recommended for humans):
npx wrangler login

# Non-interactive (CI or ephemeral):
export CLOUDFLARE_API_TOKEN=...
export CLOUDFLARE_ACCOUNT_ID=...

When CLOUDFLARE_API_TOKEN is set in the environment, wrangler uses it directly and skips the OAuth store. The deploy command in apps/cf-worker/package.json (deploy) honors both.


4. Local development

Before deploying, run the Worker locally with the real workerd runtime. This exercises the DOs, storage, and alarms against Miniflare without touching Cloudflare.

From the repo root:

pnpm build
pnpm --filter @serverless-ircd/cf-worker dev
# → boots wrangler dev on http://localhost:8787

In another terminal, run the smoke e2e against the local URL (the script defaults to ws://localhost:8787):

node apps/cf-worker/scripts/smoke.mjs

Expected output (success):

{"ts":"...","level":"info","msg":"cf-worker smoke e2e passed","url":"ws://localhost:8787"}

The smoke script (scripts/smoke.mjs) replays CONNECT → NICK/USER → JOIN #smoke → PRIVMSG → QUIT and asserts the server emits 001, 376, 353, 366, and closes the socket. It is the exact same script CI runs against the deployed URL.

The unit + integration suites live in packages/cf-adapter/tests/ and apps/cf-worker/tests/; both run under @cloudflare/vitest-pool-workers against real workerd, so behavior matches production. Run them with:

pnpm --filter @serverless-ircd/cf-adapter test
pnpm --filter @serverless-ircd/cf-worker test

5. Deploy

The deploy is workflow_dispatch-only (a maintainer triggers it by hand); you can also deploy manually from a clean checkout.

5.1 Deploy from your machine

# From the repo root:
pnpm deploy:cf
# Equivalent to:
#   pnpm --filter @serverless-ircd/cf-worker run deploy
#   → wrangler deploy

The first deploy of a given account will:

  1. Create the Worker serverless-ircd.
  2. Apply the [[migrations]] block (tag = "v1", new_sqlite_classes = [ConnectionDO, RegistryDO, ChannelDO, ChannelRegistryDO]). This is what provisions the DO namespaces.
  3. Print the deployed URL, e.g. https://serverless-ircd.example.workers.dev.

If you see Migration tag has already been applied, the namespaces already exist; subsequent deploys just update the Worker code.

5.2 Verify with the smoke script

node apps/cf-worker/scripts/smoke.mjs \
  --url wss://serverless-ircd.example.workers.dev

The same JSON level: "info" line on stdout means the full REGISTER/JOIN/PRIVMSG/QUIT flow completed against the deployed Worker.

5.3 Connect a real IRC client

Point any RFC-compliant WebSocket-aware IRC client at the deployed URL. For WeeChat:

/server add ircd serverless-ircd.example.workers.dev/443
/set irc.server.ircd.ssl on
/connect ircd
/join #test

For a quick text-only check, the apps/local-cli and tools/tcp-ws-forwarder packages bridge a plain TCP IRC client to the Worker's WebSocket endpoint — see the README "Bridging raw TCP clients" section.


6. Production hardening

There is a single deploy target per platform (pnpm deploy:cf). The staging/prod split was collapsed — there is no [env.staging] block and no --env flag. The Worker is always named serverless-ircd.

Before your first deploy against a real account, edit apps/cf-worker/wrangler.toml:

  1. Set [vars].SERVER_NAME to the public hostname clients should see in numerics (001, 005, etc.) — e.g. irc.your-domain.com. Required: the CF config loader fails fast at boot when SERVER_NAME is unset, so a deployment that forgets to set it never accepts connections (see §7.1).
  2. Set [vars].NETWORK_NAME to your network label.
  3. Set [vars].MOTD_LINES to your message-of-the-day (newline-delimited).
  4. (Optional) Add a custom-domain Route under [[routes]] — see §8.4.

First-time migration: the [[migrations]] block applies on the first deploy and creates the DO classes. This is one-way: once a DO namespace has data, you cannot rename or delete a class without an explicit migration entry (see §9.2).

Staging vs production isolation is by Cloudflare account — point a separate account's CLOUDFLARE_API_TOKEN at the deploy to ship to a different Worker namespace.


7. Configuration reference

All deployment knobs live in apps/cf-worker/wrangler.toml. The file is the single source of truth for the Worker.

7.1 Top-level / [vars]

Var Purpose Default
name Worker name. serverless-ircd
main Worker entry. src/worker.ts
compatibility_date Pins Workers runtime behavior. 2024-11-01
compatibility_flags nodejs_compat enables Node-style APIs. ["nodejs_compat"]
SERVER_NAME Server name sent in 001/005 numerics. Required — the CF config loader fails fast at boot when unset. No default; must be set in wrangler.toml.
NETWORK_NAME Network label in 005 NETWORK=…. ServerlessIRCd
MOTD_LINES Message-of-the-day, \n-delimited. Welcome banner string.
MAX_CLIENTS Global live-connection cap (enforced by CounterDO at the edge). schema default
MAX_CONNECTIONS_PER_IP Per-IP simultaneous-connection cap (in-memory admission in ConnectionDO). schema default (10)
PER_IP_CONNECTION_RATE_MAX Per-IP upgrade rate budget — how many WebSocket upgrades one CF-Connecting-IP may open per sliding window. Enforced at the edge by RateLimitDO (429 when over budget). schema default (5)
PER_IP_CONNECTION_RATE_WINDOW_MS Sliding-window length in ms for the upgrade rate budget. Setting only one half of the pair falls back to the schema default (60 000 ms) for the other. schema default (60 000)

These are read by ConnectionDO via Env (see packages/cf-adapter/src/env.ts:20 and the serverConfig() method in connection-do.ts:337). They are not secrets — they ship in the bundle and are visible in the dashboard.

7.2 Durable Object bindings

The DO classes are exported from src/worker.ts (re-exported from @serverless-ircd/cf-adapter) and bound in wrangler.toml:

Binding name Class Owned state
CONNECTION_DO ConnectionDO Socket + ConnectionState + PING alarms.
REGISTRY_DO RegistryDO Nick uniqueness, nick→connId map (sharded).
CHANNEL_DO ChannelDO Per-channel roster, modes, topic, fanout.
CHANNEL_REGISTRY_DO ChannelRegistryDO The channel-name registry (shard index).
COUNTER_DO CounterDO Global live-connection counter (maxClients cap, TICKET-194).
RATE_LIMIT_DO RateLimitDO Per-IP upgrade rate limiter — one sliding-window admission list per CF-Connecting-IP; the Worker edge checks the budget before forwarding an upgrade and rejects over-budget IPs with 429 (the per-IP layer of the connect throttling; runs before the maxClients counter so a flooding IP cannot consume global slots).
OPER_LOCKOUT_DO OperLockoutDO Shared per-IP failed-OPER counter — one sliding-window failure-timestamp list per source host. Every ConnectionDO hydrates its actor's OPER lockout gate from it before a frame runs and flushes recorded failures / login resets back after, so the oper.maxFailures-within-failureWindowSeconds → 491 + oper notice budget persists across every ConnectionDO on the deployment.

The class names in wrangler.toml's class_name field MUST match the re-exports at the top of apps/cf-worker/src/worker.ts:29.

7.3 Migrations

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO", "OperLockoutDO"]

tag is the migration id (string, monotonically tracked by wrangler). new_classes declares the DO classes on first deploy. To rename or delete a class after data exists, append a new [[migrations]] block with tag = "v2" (etc.) and a renamed_classes or deleted_classes entry. See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/.

There is a single migration list — the [env.staging] block was removed when staging/prod collapsed into one target.

7.4 Custom domain (optional)

To serve the Worker on irc.example.com instead of the workers.dev subdomain, add a route and bind a Cloudflare-managed DNS record:

[[routes]]
pattern = "irc.example.com/*"
zone_name = "example.com"
custom_domain = false   # true if you'd rather use a Worker Custom Domain

Custom domains are optional for a first deploy and recommended for real traffic (many IRC clients expect a stable, owned hostname). DNS records are managed separately in the dashboard.


8. Secrets

The Worker consumes one optional secret binding: SERVER_PASSWORD. When set, every connection must supply the matching PASS <value> (or SASL-identify) before 001 RPL_WELCOME; when unset/empty the gate is disabled (the default). Configure it as a wrangler secret, never a plaintext [vars] entry:

wrangler secret put SERVER_PASSWORD
# → prompts for the value; stores it in the Workers secret store,
#   NOT in wrangler.toml or the repo.

Unset (wrangler secret delete SERVER_PASSWORD) disables the gate. SASL short-circuit: a connection that has authenticated an account via SASL (AUTHENTICATE PLAIN / EXTERNAL) is exempt from the server password — the reducer's gate treats state.account !== undefined as already authorised, so a deployment with both SERVER_PASSWORD and configured SASL accounts does not need to hand the shared password to identified users.

Oper creds (OPER_USER / OPER_PASSWORD) are still on the roadmap; when they ship they will follow the same wrangler-secret pattern. SASL account credentials are not secrets — they are persisted as scrypt hashes in D1 (see §8.1 below).

Secrets are visible to the Worker as plain env.SECRET_NAME bindings, but their values are never written to disk, the bundle, or the dashboard's variable listing. To rotate, run wrangler secret put again; to remove, wrangler secret delete <NAME>. Never log SERVER_PASSWORD — treat it as a shared deployment secret, distinct from per-account SASL credentials (which go through the hashed D1 store).

Never put credentials, API tokens, or password hashes in wrangler.toml, in [vars], or in committed files. The wrangler.toml header enforces this with a comment block (see lines 15-20).

The CI deploy uses two GitHub Actions secrets, not Workers secrets:

Secret Used by Purpose
CLOUDFLARE_API_TOKEN deploy-cf.yml Authenticates wrangler deploy.
CLOUDFLARE_ACCOUNT_ID deploy-cf.yml Disambiguates account context.

And one Actions variable:

Variable Purpose
CF_SMOKE_URL wss:// URL the smoke e2e step hits. If unset, smoke is skipped with a warning.

Configure both under repo Settings → Secrets and variables → Actions.

8.1 SASL accounts (D1 accounts table + seed tooling)

SASL PLAIN credentials live in a D1 accounts table as scrypt hashes (never plaintext). Each row is { account (PK), algorithm, salt, hash }, all base64 strings — the same shape the AWS adapter stores in its DynamoDB Accounts table. The hashing helpers and the synchronous HashedAccountStore are shared from irc-core, so both adapters verify with one implementation.

Two-phase load (mirrors the AWS loadDynamoAccountStore pattern): ConnectionDO queries D1 once at first-frame time and pre-loads every hashed row into a HashedAccountStore. The AccountStore.verify port is synchronous, so the pre-load completes before the first SASL frame dispatches.

Precedence (table-then-config):

  1. The D1 accounts table is authoritative when it has ≥1 row — the table wins and the SASL_ACCOUNTS env-var seed is ignored.
  2. When the table is empty (or unreachable, or the D1 binding is absent) the code falls back to the SASL_ACCOUNTS env var (newline-delimited username:password pairs) via InMemoryAccountStore, preserving the config-seed behaviour for deployments that have not migrated.
  3. When neither source has accounts, ctx.accounts stays unset and AUTHENTICATE PLAIN returns 904 ERR_SASLFAIL.

Provisioning (per environment):

# 1. Create the D1 database (once per environment).
wrangler d1 create serverless-ircd-accounts
# → prints a database_id; paste it into wrangler.toml's [[d1_databases]] block.

# 2. Create the table.
wrangler d1 execute serverless-ircd-accounts --remote \
  --command "CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)"

# 3. Seed accounts (scrypt-hashed, never plaintext). Re-run overwrites.
node --import tsx tools/seed-cf-accounts.ts \
  --database serverless-ircd-accounts \
  --remote \
  --accounts alice:change-me bob:also-change-me

The seed CLI hashes each password locally and executes the INSERT OR REPLACE via wrangler d1 execute.

The SASL_ACCOUNTS env var remains the quick-start path (no D1 needed): set it in [vars] and the in-memory fallback handles verification. Migrate to D1 when you need runtime CRUD without redeploying.


9. Sharding

9.1 How nick registry sharding works

The RegistryDO fleet is sharded so no single DO becomes a hot spot for popular nicknames. Shard assignment is:

shard = fnv1a32(lowercased(nick)) mod N

Properties (see packages/cf-adapter/src/sharding.ts for full rationale):

The shard id (s0, s1, …, s31) becomes the idFromName() key for that nick's RegistryDO. Two clients racing on Alice both route to RegistryDO s7, where DO single-threadedness makes the uniqueness invariant structural rather than optimistic.

9.2 REGISTRY_SHARDS

The default fleet size is 32, defined as DEFAULT_REGISTRY_SHARDS in packages/cf-adapter/src/sharding.ts:42. This is the value used by the production CfRuntime (cf-runtime.ts:91).

Planned knob: sharding.ts documents a future REGISTRY_SHARDS environment variable (see the doc comment at line 39). That override path is not yet wired through Env / ConnectionDO — the shard count is currently a code-level default. Once TICKET-047 (config schema) ships, this will become a true env var. Until then, changing the shard count requires a code edit in cf-runtime.ts.

9.3 Re-sharding caveat

Changing REGISTRY_SHARDS after the DOs hold data is a migration. Existing nick→connId mappings live in the old shard layout; the new layout will not find them. Plan a migration window:

  1. Drain live connections (announce + reconnect window), or accept that in-flight nick reservations during the cutover can briefly succeed against both layouts.
  2. Iterate the old shard range, read each nick→connId entry, and write it to the shard computed under the new N.
  3. Verify with a sample of known nicks before re-enrolling write traffic.

There is no in-repo re-sharding tool today (post-v1 work). For v1 deployments the recommendation is: pick REGISTRY_SHARDS once, and prefer over-provisioning (64 or 128) if you anticipate >10k concurrent users — the per-shard cost is negligible because hibernated DOs are free to hold.


10. Cost notes

Cloudflare bills Durable Objects on three dimensions (figures below are the public list price as of 2024-11; verify current pricing at https://developers.cloudflare.com/durable-objects/platform/pricing/):

Dimension Notes for ServerlessIRCd
Duration (GB-second) Hibernation (HibernatingWebSocketHandler) drops idle connections to ~zero memory while asleep. A 10k-user deployment with most connections idle is the entire reason this adapter exists.
Requests Each WebSocket frame in/out is a request. PRIVMSG to a 1k-member channel fans out via the ChannelDO; each member's ConnectionDO.deliver() is one request.
DO storage (MB-month) ConnectionState is small (a few KB). Channel roster / modes are small per channel. The Nicks registry is the only fleet that grows linearly with MAU.

Practical numbers:

Other billable surfaces (all optional for v1):

See PLAN §10 (Risk / Cost) for the scaling ceilings and post-v1 mitigations (batched fanout, per-channel send-list cache).


11. CI/CD

The GitHub Actions workflow at .github/workflows/deploy-cf.yml is workflow_dispatch-only (a maintainer triggers it by hand); there is no automatic push-to-main deploy. It deploys the single Worker target and replays the smoke e2e.

Steps performed by the workflow (in order):

  1. Checkout (frozen lockfile).
  2. Install pnpm 9 + Node 20.
  3. pnpm install --frozen-lockfile.
  4. pnpm build — builds all workspace packages.
  5. pnpm typecheck and pnpm test — gate the deploy.
  6. pnpm deploy:cf — wrangler deploy.
  7. Resolve the smoke URL from the CF_SMOKE_URL repo variable. If unset, the smoke step is skipped with a warning.
  8. Run node scripts/smoke.mjs --url "$SMOKE_URL". Failure fails the build.

Concurrency is serialized via concurrency.group: cf-deploy so two deploys cannot race the same DO namespace.

The required hostname dispatch input is validated by a guard step before the deploy: it must be a real RFC 1123 DNS name (labels of letters/digits/hyphens — no port, no trailing dot, no raw IP address, no control characters) and not one of the repo's placeholder hostnames. IDN policy: non-ASCII hostnames are rejected — pass internationalized names in punycode (xn--…) form, which is accepted. The same validation guards SERVER_NAME on manual pnpm deploy:cf runs (via the cf-deploy wrapper).

Required repo configuration (under Settings → Secrets and variables → Actions):

There is a single deploy target — the staging/prod split was collapsed, so the same workflow + account ships to whichever Worker namespace the credentials target.


12. Troubleshooting

12.1 wrangler deploy fails with Migration tag has already been applied

The on-disk migration list in wrangler.toml doesn't match the live Worker state. If you previously deployed tag = "v1" and then edited the [[migrations]] block, wrangler refuses to proceed.

12.2 Cannot find module '@serverless-ircd/cf-adapter'

The workspace packages weren't built. Run pnpm build from the repo root before wrangler deploy. The Worker entry imports compiled dist/ from irc-core, irc-server, and cf-adapter.

12.3 Smoke e2e times out waiting for 001

The connection upgrade succeeded but registration didn't complete. Common causes:

12.4 wrangler tail shows alarm() threw

The DO alarm fired but the handler threw. The most likely cause is a stale persisted-state version after a code change; the serializer in packages/cf-adapter/src/serialize.ts carries PERSISTED_STATE_VERSION and will reject incompatible blobs. If this happens after an upgrade, ship a deserialize migration that upgrades the old version (or, on a throwaway deployment, clear the DO storage from the dashboard).

12.5 Nick reservation always fails

A client's NICK command always returns 433 ERR_NICKNAMEINUSE, even for nicknames that should be free. This happens when two deployments share a RegistryDO namespace but were supposed to be separate — e.g., staging and production point at the same DO class name. Verify that each deployment has its own DO namespace (a distinct Worker name per Cloudflare account means wrangler allocates a distinct namespace per Worker).

12.6 High request cost / DO request spike

Hot-channel fanout is the dominant cost driver. Each PRIVMSG to a channel of N members produces ~N ConnectionDO.deliver() RPC requests (one per member). Mitigations:

Reach for the Workers Analytics dashboard to identify the channel driving the spike before optimizing.

12.7 Lost connection state after a deploy

DO storage persists across Worker code deploys by design. If state appears lost, either:

For a throwaway deployment, the fastest recovery is usually to clear DO storage and re-test. For real traffic, treat any migration tag change as a release blocker requiring a runbook entry.


13. Quick reference

# One-time account setup
npx wrangler login                              # or set CLOUDFLARE_API_TOKEN

# Local dev
pnpm install
pnpm build
pnpm --filter @serverless-ircd/cf-worker dev    # http://localhost:8787
node apps/cf-worker/scripts/smoke.mjs           # smoke against localhost

# Deploy (single target — staging vs prod is which account you point at)
pnpm deploy:cf
node apps/cf-worker/scripts/smoke.mjs \
  --url wss://serverless-ircd.<subdomain>.workers.dev
npx wrangler tail                                  # live logs

# Tests / lint
pnpm test                                       # full workspace
pnpm --filter @serverless-ircd/cf-adapter test
pnpm --filter @serverless-ircd/cf-worker test
pnpm lint

Key files:

Path What
apps/cf-worker/wrangler.toml Deploy config, DO bindings, migrations.
apps/cf-worker/src/worker.ts Edge fetch handler; re-exports DO classes.
apps/cf-worker/scripts/smoke.mjs CI + local smoke e2e.
packages/cf-adapter/src/ ConnectionDO, RegistryDO, ChannelDO, CfRuntime, sharding.
packages/cf-adapter/src/sharding.ts Shard function + DEFAULT_REGISTRY_SHARDS.
packages/cf-adapter/src/env.ts Env interface (binding contract).
.github/workflows/deploy-cf.yml Deploy + smoke e2e CI (manual dispatch).