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.
- Go to https://dash.cloudflare.com/profile/api-tokens → Create Token.
- Use the "Edit Cloudflare Workers" template, which grants:
Account · Workers Scripts · EditAccount · Durable Objects · EditAccount · Worker Routes · Edit(only if using custom domains)
- Restrict to the target Account Resources.
- 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:
- Create the Worker
serverless-ircd. - Apply the
[[migrations]]block (tag = "v1",new_sqlite_classes = [ConnectionDO, RegistryDO, ChannelDO, ChannelRegistryDO]). This is what provisions the DO namespaces. - 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:
- Set
[vars].SERVER_NAMEto 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 whenSERVER_NAMEis unset, so a deployment that forgets to set it never accepts connections (see §7.1). - Set
[vars].NETWORK_NAMEto your network label. - Set
[vars].MOTD_LINESto your message-of-the-day (newline-delimited). - (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):
- The D1
accountstable is authoritative when it has ≥1 row — the table wins and theSASL_ACCOUNTSenv-var seed is ignored. - When the table is empty (or unreachable, or the D1 binding is absent)
the code falls back to the
SASL_ACCOUNTSenv var (newline-delimitedusername:passwordpairs) viaInMemoryAccountStore, preserving the config-seed behaviour for deployments that have not migrated. - When neither source has accounts,
ctx.accountsstays unset andAUTHENTICATE PLAINreturns904 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_ACCOUNTSenv 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):
- Deterministic — every Worker instance computes the same shard for a given nick with zero coordination.
- Case-insensitive —
Alice,alice, andALICEcollide in the same shard, so the single-shard uniqueness check covers all RFC 1459 case variants. - Synchronous & dependency-free — runs on every nick reservation,
so no
awaitand no external hash library.
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.tsdocuments a futureREGISTRY_SHARDSenvironment variable (see the doc comment at line 39). That override path is not yet wired throughEnv/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 incf-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:
- Drain live connections (announce + reconnect window), or accept that in-flight nick reservations during the cutover can briefly succeed against both layouts.
- Iterate the old shard range, read each
nick→connIdentry, and write it to the shard computed under the newN. - 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:
- Workers Free plan: sufficient to bring up the Worker, exercise the smoke e2e, and validate a handful of concurrent connections. Not suitable for sustained traffic (request caps, no reserved DO compute).
- Workers Paid plan (USD $5/mo base + usage): enough headroom for a small production network. The dominant cost driver at scale is fanout requests on hot channels, not connection count — thanks to hibernation. Plan accordingly for channels with >1k members.
Other billable surfaces (all optional for v1):
- Workers KV — not used today; planned for MOTD / config offload.
- D1 — used for SASL account persistence (see §8.1). Free tier covers
the per-connection cold-start
SELECTcomfortably; theaccountstable stays tiny (one row per SASL account). - Workers Analytics — free tier covers basic dashboards.
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):
- Checkout (frozen lockfile).
- Install pnpm 9 + Node 20.
pnpm install --frozen-lockfile.pnpm build— builds all workspace packages.pnpm typecheckandpnpm test— gate the deploy.pnpm deploy:cf—wrangler deploy.- Resolve the smoke URL from the
CF_SMOKE_URLrepo variable. If unset, the smoke step is skipped with a warning. - 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):
- Secret
CLOUDFLARE_API_TOKEN— required. - Secret
CLOUDFLARE_ACCOUNT_ID— required if the token's account context is ambiguous. - Variable
CF_SMOKE_URL— set to the Worker'swss://URL. Without it, the smoke step silently no-ops.
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.
- If you intentionally changed the DO schema, append a new
[[migrations]]block withtag = "v2"(andrenamed_classes/deleted_classesas needed). Never edit an already-appliedtag. - If the namespace has stale state from an aborted experiment, you can destroy and recreate it from the dashboard (Workers & Pages → Durable Objects → namespaces). Namespaces carrying real traffic must never be deleted — that data is gone permanently.
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:
- The deployed Worker is reachable but the DO threw on the first frame.
Tail the logs:
wrangler tailand reproduce. - The
MOTD_LINESorSERVER_NAMEvar was deleted —ConnectionDO'sserverConfig()falls back to defaults, but a malformed value can break parsing. Check the[vars]block. - Client connected over
ws://to a Worker that requireswss://. Use thewss://URL the deploy step printed.
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:
- Cap channel size in application policy (no built-in cap today beyond
+lmode). - For very hot announcement channels, consider a future batched-fanout path (post-v1 — PLAN §10).
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:
- The DO namespace was deleted (irreversible — see 12.1).
- A
[[migrations]]rename or split moved the data to a different class id without a migration entry mapping the old class to the new. - The persisted-state version changed without a deserialize migration (see 12.4).
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). |