ServerlessIRCd Docs

Docs home

Cloudflare TCP+TLS Deployment Guide (irc+tls :6697)

End-to-end guide for deploying the raw TCP+TLS transport of ServerlessIRCd to Cloudflare (Phase 7 of PLAN.md, TICKET-055). This is the RFC 1459/2812-compliant path that older clients, bots, and bridges use. The default wss transport is documented separately in Cloudflare-Deployment-Guide.md; the two are independent and a deployment can enable either or both.

Read first: ADR-009 for why a second transport exists and the per-platform mapping. This doc covers the how.

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

Cross-reference: apps/cf-tcp-container/ (origin), apps/cf-tcp-container/wrangler.toml (container config), apps/cf-tcp-container/terraform/spectrum.tf (Spectrum TLS config), .github/workflows/deploy-cf-tcp.yml (CI), TICKET-054 (mTLS), ADR-006 (SASL mechanism scope).


1. What gets deployed

The wss path is pure-serverless (Worker + Durable Objects). The TCP path cannot be — Cloudflare Workers cannot accept raw TCP. So the TCP+TLS path adds two stateful pieces in front of the shared IRC core:

  IRC client (irc+tls :6697)
        │
        │  TLS handshake (client ⟷ Cloudflare)
        ▼
  ┌──────────────────────────────────────────────────────────────┐
  │  Cloudflare Spectrum  (Enterprise)                           │
  │    • terminates TLS on :6697                                 │
  │    • re-encrypts to the origin (tls_mode = "full")           │
  │    • PROXY protocol v1 carries the real client IP            │
  └──────────────────────────┬───────────────────────────────────┘
                             │ TLS (:6697, origin cert)
                             ▼
  ┌──────────────────────────────────────────────────────────────┐
  │  Cloudflare Container  (apps/cf-tcp-container)               │
  │    • node:24 process, EXPOSE 6697 — terminates TLS itself    │
  │      (TLS_CERT_PATH / TLS_KEY_PATH, mounted at /data/certs)  │
  │    • ConnectionActor + InMemoryRuntime + TcpByteStream-      │
  │      Transport (the shared transport seam, TICKET-053)       │
  │    • persistence snapshot at /data/state.json                │
  │      (channel topology survives restart; live connections    │
  │      do not — clients reconnect)                             │
  │    • plaintext :6667 = localhost-only debug listener         │
  │      (DEBUG_PLAINTEXT=localhost; never routed, never in      │
  │      prod — a prod-classified boot refuses to start          │
  │      without TLS material)                                   │
  └──────────────────────────────────────────────────────────────┘

TLS is end-to-end: no plaintext IRC bytes ever cross the origin network, so an attacker who can reach the origin address directly can neither read PASS/AUTHENTICATE/PRIVMSG off the wire nor spoof them.

The parser → reducer → dispatch pipeline is shared verbatim with the wss path. Only the line-framing transport differs: a TcpByteStreamTransport reassembles \r\n-terminated IRC lines across arbitrary TCP chunks (partial-line buffering), whereas the wss Worker uses one-message-per-frame.

Two artifacts are deployed and configured:

Artifact Tool What it does
sirc-tcp-origin Cloudflare Container wrangler Builds + runs the IRC core container image (TLS origin).
cloudflare_spectrum_application terraform TLS on :6697 + re-encrypted origin forwarding (tls_mode = "full").

2. Prerequisites

Requirement Detail
Node.js ≥ 24 (engines.node in root package.json).
pnpm 11.x (packageManager: pnpm@11.17.0).
wrangler CLI v4.112+ (comes from apps/cf-tcp-container/devDependencies).
terraform CLI ≥ 1.5 (for the Spectrum step). Install separately.
Cloudflare Enterprise Spectrum is an Enterprise-tier feature. Required for the TCP path.
Containers beta access Cloudflare Containers is in beta; request access in the dashboard.
A Cloudflare zone The public hostname (e.g. irc.example.com) must live in a zone you control.
Git checkout Clean working tree on main for deploys.

The wss path runs on the Workers Free/Paid plan. The TCP path does not — Spectrum mandates Enterprise. If you do not have Enterprise, deploy the wss path instead (see Cloudflare-Deployment-Guide.md).

Confirm the local environment:

node --version          # v24.x or newer
pnpm --version          # 11.x
terraform version       # >= 1.5
pnpm install            # from repo root
pnpm build              # builds irc-core, irc-server, in-memory-runtime, cf-tcp-container

3. First-time setup

Done once per Cloudflare account + zone.

3.1 API token

Same token shape as the wss path works, but it additionally needs Spectrum edit rights. Create a token at https://dash.cloudflare.com/profile/api-tokens with:

Restrict to the target zone. Set as CLOUDFLARE_API_TOKEN locally and as a GitHub Actions secret for CI.

3.2 Collect the IDs the deploy needs

Secret / var Where to find it
CLOUDFLARE_ACCOUNT_ID Dashboard sidebar (any zone → Overview, lower right).
CF_ZONE_ID Dashboard sidebar of the target zone → Overview.
CF_TCP_ORIGIN_ADDRESS The Container's origin hostname (printed after first wrangler deploy, see §5.1).

3.3 Authenticate the CLI

npx wrangler login                  # interactive
# or non-interactive:
export CLOUDFLARE_API_TOKEN=...
export CLOUDFLARE_ACCOUNT_ID=...

4. Local development

Run the container origin locally without Spectrum. The plaintext TCP listener is debug-only and localhost-only: it starts solely when DEBUG_PLAINTEXT=localhost is set (the config loader then forces the bind to 127.0.0.1 — TCP_HOST cannot widen it). This exercises the exact ConnectionActor + TcpByteStreamTransport pipeline.

# Build the workspace + the container package.
pnpm build

# Run the origin with the localhost-only plaintext debug listener.
DEBUG_PLAINTEXT=localhost SERVER_NAME=irc.local TCP_PORT=6667 \
  pnpm --filter @serverless-ircd/cf-tcp-container start

Connect a plaintext client (no TLS) to localhost:6667:

/eval /connect localhost 6667        # WeeChat, no SSL
NICK alice
USER alice 0 * :Alice
JOIN #test
PRIVMSG #test :hello over plaintext TCP

For a TLS local loop that mirrors production (TLS terminating at the origin), point the origin at a local cert pair instead — no debug flag needed:

TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key SERVER_NAME=irc.local \
  pnpm --filter @serverless-ircd/cf-tcp-container start
# → TLS listener on :6697; plaintext stays closed.

The unit + the TLS e2e suites live in apps/cf-tcp-container/tests/ (including tls-e2e.test.ts and container-server-tls.test.ts, which run real TLS handshakes against the origin). Run them with:

pnpm --filter @serverless-ircd/cf-tcp-container test

5. Deploy

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

5.1 Deploy the container origin

# From the repo root:
pnpm deploy:cf-tcp
# Equivalent to:
#   pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy
#   → wrangler deploy   (inside apps/cf-tcp-container)

This builds the container image from apps/cf-tcp-container/Dockerfile and deploys the sirc-tcp-origin Container. The first deploy prints the origin address — copy it into CF_TCP_ORIGIN_ADDRESS (used by the Spectrum step next).

5.2 Provision the origin certificate

tls_mode = "full" means the origin terminates TLS itself, so it needs a PEM cert/key pair. Mount it at /data/certs/ (the image's documented default: TLS_CERT_PATH=/data/certs/tls.pem, TLS_KEY_PATH=/data/certs/tls.key; override via env if the pair lives elsewhere). Cert files must be readable by UID 1000 (node) — the container drops privileges before reading them. Any of these work:

Option How Notes
Cloudflare Origin Cert Dashboard → SSL/TLS → Origin Server → Create Certificate. Free, long-lived, issued for your zone; the easiest option when the origin is only reached via Spectrum. Not publicly trusted (fine — clients validate the edge cert).
Let's Encrypt certbot certonly --dns-plugin … -d origin.example.com, then copy fullchain.pem/privkey.pem to /data/certs/tls.{pem,key} and renew on a cron/sidecar. Publicly trusted; needs automation for the 90-day rotation.
BYO cert Any PEM pair from your CA. Your compliance rules apply.

A prod-classified boot (NODE_ENV=production, set by the Dockerfile) refuses to start without TLS_CERT_PATH/TLS_KEY_PATH — there is no silent plaintext fallback. A missing/unreadable cert file also fails fast at boot with the offending path in the error.

5.3 Lock origin ingress down to Cloudflare IPs

Spectrum is the only thing that should ever reach the origin's :6697. On any platform with a firewall / security-group primitive, restrict ingress to Cloudflare's published ranges (https://www.cloudflare.com/ips/) — otherwise a direct-reach attacker skips the edge entirely. spectrum.tf ships a commented cloudflare_list reference for keeping a managed list of the current ranges; Cloudflare Containers origins rely on Spectrum ip_firewall + the platform's own network isolation, but external-compute origins must apply the firewall on their side.

5.4 Apply the Spectrum TLS config

Provider versions are pinned exactly by the committed apps/cf-tcp-container/terraform/.terraform.lock.hcl (checksums for all platforms), so terraform init installs the reviewed provider binaries instead of resolving the newest matching release from the registry on every deploy.

cd apps/cf-tcp-container/terraform
terraform init -backend=false -lockfile=readonly
terraform apply \
  -var zone_id=$CF_ZONE_ID \
  -var origin_address=$CF_TCP_ORIGIN_ADDRESS \
  -var hostname=irc.example.com

-lockfile=readonly makes init fail on provider drift — a provider upgrade must be an explicit, reviewed lock-file change:

terraform providers lock \
  -platform=darwin_amd64 -platform=darwin_arm64 \
  -platform=linux_amd64 -platform=linux_arm64 \
  -platform=windows_amd64
# …then commit the regenerated .terraform.lock.hcl together with any
# provider.tf version-constraint change.

This creates the cloudflare_spectrum_application.irc_tls resource: TLS on :6697 at the edge, re-encrypted to the container origin's TLS listener on :6697 (tls_mode = "full" — the secure default).

5.5 Smoke test over TLS

# Minimal smoke: register + quit over the real TLS endpoint.
printf 'NICK smoke\r\nUSER smoke 0 * :Smoke\r\nQUIT :smoke\r\n' | \
  timeout 10 openssl s_client -connect irc.example.com:6697 -quiet | \
  grep -q '001' && echo "SMOKE PASS" || echo "SMOKE FAIL"

CI does the same — see .github/workflows/deploy-cf-tcp.yml step "Smoke e2e (irc+tls)", gated on the CF_TCP_SMOKE_HOST repo variable.

5.6 Connect a real IRC client

/server add ircd irc.example.com/6697
/set irc.server.ircd.ssl on
/connect ircd
/join #test

7. Configuration reference

7.1 Container — apps/cf-tcp-container/wrangler.toml

Top-level [vars] map directly to the container config loader (apps/cf-tcp-container/src/config-loader.ts):

Var Purpose Default
SERVER_NAME Server name in 001/005 numerics. Required — the container config loader fails fast when unset. No default; must be set.
NETWORK_NAME Network label in 005 NETWORK=…. ExampleNet
SERVER_VERSION Version string in 002. (from package version)
CREATED_AT Creation text/time in 003. build/deploy timestamp
MOTD_LINES Message-of-the-day, \n-delimited. welcome banner
NODE_ENV production classifies the boot as prod: the loader then refuses to start without TLS material and rejects DEBUG_PLAINTEXT. The Dockerfile sets it. (unset = dev)
TLS_CERT_PATH PEM certificate the origin TLS listener presents. Required in prod (with TLS_KEY_PATH). image: /data/certs/tls.pem
TLS_KEY_PATH PEM private key for the origin TLS listener. Required in prod (with TLS_CERT_PATH). image: /data/certs/tls.key
TLS_PORT Origin TLS listener port. 6697
TCP_PORT Plaintext debug-listener port — only used when DEBUG_PLAINTEXT=localhost is set. 6667
TCP_HOST Bind address. Forced to 127.0.0.1 whenever the plaintext debug listener is enabled. 0.0.0.0
DEBUG_PLAINTEXT Exact value localhost opts into the localhost-only plaintext debug listener. Any other value is rejected; forbidden in prod. (unset = TLS-only)
PERSISTENCE_PATH Snapshot file path; enables channel-state survival. /data/state.json
SNAPSHOT_INTERVAL_MS Auto-snapshot cadence (ms). 60000
OPER_USER / OPER_PASSWORD Oper credentials for OPER. —
SASL_ACCOUNTS \n-delimited user:password lines for SASL PLAIN. —
MAX_CHANNELS_PER_USER, MAX_TARGETS_PER_COMMAND, NICK_LEN, CHANNEL_LEN, TOPIC_LEN, MAX_LIST_ENTRIES, QUIT_MESSAGE Standard ServerConfig knobs (mirror the wss path). shared defaults

The container block itself:

[[containers]]
class_name = "IrcTcpOrigin"
image = "./Dockerfile"
instance_type = "standard-1"
max_instances = 3
sleep_after = "2h"

7.2 Spectrum — apps/cf-tcp-container/terraform/spectrum.tf

Variable Purpose Default
zone_id Cloudflare zone for the hostname. (required)
origin_address Hostname/IP of the container origin. (required)
origin_port Port the origin's TLS listener serves. 6697
hostname Public hostname clients connect to. irc.example.com
spectrum_port Public port Spectrum listens on. 6697
tls_mode full (TLS end-to-end — the secure default) / strict (edge also validates the origin cert). full

ip_firewall = true (Cloudflare's L3/L4 DDoS) and argo_smart_routing = true are on by default. proxy_protocol = "v1" forwards the real client IP to the origin so the container's per-IP admission gate sees the actual source, not Spectrum's address.


8. Persistence & state survival

The origin is a stateful process — a hard requirement that the wss path (Durable Objects) does not have. Two layers of survival:

  1. Across chunks within a connection — the TcpByteStreamTransport buffers partial lines in memory; a chunk may split an IRC command at an arbitrary byte boundary and the next chunk completes it. This is all in-process; nothing to configure.
  2. Across origin restarts (crash, deploy, scale-to-zero) — every live TCP connection drops; clients must reconnect. But the channel topology (topics, modes, ban masks) is snapshotted to PERSISTENCE_PATH (/data/state.json, a /data volume in the Dockerfile) on an interval (SNAPSHOT_INTERVAL_MS) and at graceful shutdown, then restored on boot. Member rosters are not persisted — they reference ConnIds that are stale after a restart, so a reconnecting client rejoins an otherwise-empty channel.

Operational caveat: the snapshot is per-instance. With max_instances > 1, each instance owns disjoint channels (no cross-instance state share). For a single coordinated channel space, keep max_instances = 1 until the Durable-Object-backed persistent variant lands (ADR-009 "Negative", follow-up to this ticket).


9. mTLS (SASL EXTERNAL) — not supported on this transport

SASL EXTERNAL requires the verified client certificate to reach the application layer. On the TCP container path it cannot: Spectrum forwards to the origin with PROXY protocol v1, which carries no client-cert subject, and although tls_mode = "full" terminates TLS at the origin (TLS_CERT_PATH/TLS_KEY_PATH), no client-certificate trust pool is wired through to the IRC core — there is no MtlsIdentityProvider to feed, in either TLS mode.

The container therefore pins the mechanism off explicitly via the sasl.externalUnsupportedMessage transport override:

The refusal does not count toward the per-connection SASL failure lockout. Clients that need certificate-backed SASL should use the wss path (CF API Shield mTLS — see Cloudflare-Deployment-Guide.md) or the AWS NLB+TLS path (API Gateway custom-domain mTLS — see AWS-TCP-Deployment.md). Forwarding the client-cert subject via PROXY-protocol-v2 TLVs (which Spectrum can emit) is the documented follow-up if demand materialises; see SASL-EXTERNAL.md.

Cross-reference: ADR-006 (SASL scope), SASL-EXTERNAL.md (mechanism scope + transport availability).


10. Secrets

The container consumes no Cloudflare secret bindings for v1; oper and SASL credentials ship as [vars] (OPER_*, SASL_ACCOUNTS). When strong-secret handling lands, set them via:

wrangler secret put OPER_PASSWORD

CI uses these GitHub Actions secrets/variables (configure under Settings → Secrets and variables → Actions):

Secret / var Used by Purpose
CLOUDFLARE_API_TOKEN deploy-cf-tcp.yml wrangler deploy + terraform (TF_VAR_*).
CLOUDFLARE_ACCOUNT_ID deploy-cf-tcp.yml Account context.
CF_ZONE_ID deploy-cf-tcp.yml TF_VAR_zone_id.
CF_TCP_ORIGIN_ADDRESS deploy-cf-tcp.yml TF_VAR_origin_address.
CF_TCP_SMOKE_HOST (var) deploy-cf-tcp.yml Host the TLS smoke step connects to.

Never commit credentials to wrangler.toml or terraform .tfvars.


11. CI/CD

.github/workflows/deploy-cf-tcp.yml runs alongside deploy-cf.yml (the wss path). The two are independent — a deployment can enable either or both.

Triggers: workflow_dispatch only (a maintainer triggers it by hand; there is no automatic push-to-main deploy). Steps (in order):

  1. Checkout → pnpm 11 + Node 24 → pnpm install --frozen-lockfile.
  2. pnpm build, pnpm typecheck, pnpm --filter cf-tcp-container test.
  3. wrangler deploy (container).
  4. terraform init -backend=false -lockfile=readonly && terraform apply -auto-approve (Spectrum). The readonly lockfile flag installs exactly the versions pinned in the committed .terraform.lock.hcl and fails the deploy on provider drift (see §5.4 for the reviewed upgrade path).
  5. TLS smoke (openssl s_client), gated on CF_TCP_SMOKE_HOST.

Concurrency is serialized via concurrency.group: cf-tcp-deploy.

The required hostname dispatch input is validated by a guard step before anything runs: 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. Guard messages strip control characters, so a multi-line input cannot forge GitHub ::error:: annotations.

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


12. Cost notes (versus the wss path)

The TCP path is materially more expensive to operate than the wss path because of the stateful origin:

Dimension wss path (Workers + DO) TCP path (Spectrum + Container)
Plan tier Workers Paid ($5/mo) suffices. Enterprise required (Spectrum).
Idle connections Hibernation → ~zero memory cost. Origin holds every socket; sleep_after only helps when all connections are idle.
Compute Per-request (WebSocket frames). GB-second of running container time. tls_mode = "full" moves TLS termination into the origin container, so it now pays the per-connection handshake CPU — budget handshakes/sec (each reconnect = one handshake) against the container's compute allocation when sizing instance_type.
State durability DO storage (managed). Container volume snapshot (self-managed).
DDoS Cloudflare built-in. Spectrum ip_firewall + Argo.

Recommendation: keep the wss path as the default and only enable the TCP path when you specifically need RFC-compliant clients or mTLS-via-TLS. Most modern clients (WeeChat, IRCCloud, TheLounge) work over wss.


13. Troubleshooting

13.1 terraform apply fails: Spectrum app creation not permitted

Spectrum is Enterprise-only. The API token's account/zone must be on an Enterprise plan. Confirm in the dashboard (Network → Spectrum). If the zone is not Enterprise, use the wss path.

13.2 Client connects to :6697 but gets no 001

The TLS handshake completed (Spectrum is up) but the origin did not respond. Causes:

13.3 Origin refuses to boot: "refusing to start a prod-classified origin without TLS"

NODE_ENV=production is set (the Dockerfile sets it by default) but TLS_CERT_PATH/TLS_KEY_PATH are not. Provision a cert pair (§5.2), mount it at /data/certs/, and restart. There is deliberately no plaintext fallback in prod.

13.4 Channel topic/modes lost after a restart

PERSISTENCE_PATH is unset or the volume is not attached. Confirm PERSISTENCE_PATH=/data/state.json in [vars] and that the /data volume (VOLUME ["/data"] in the Dockerfile) is mounted on the container platform. Without it the snapshot is written to ephemeral storage and lost.

13.5 Client IP seen as Spectrum's address

proxy_protocol = "v1" must be set in spectrum.tf (it is by default). If you overrode it, the origin's per-IP admission gate sees Spectrum's egress IP for every client and will wrongly throttle/cap.

13.6 Messages split across chunks get lost

This is a transport-layer bug, not a config issue — the TcpByteStreamTransport is contract-tested for chunk boundaries (TICKET-053 / TICKET-057). If you see it, run pnpm --filter @serverless-ircd/cf-tcp-container test and the shared transport suite; a regression there will fail.

13.7 wrangler deploy fails: Containers beta not enabled

Cloudflare Containers is in beta. Request access in the dashboard (Workers & Pages → Containers). Until granted, only the local Docker path (§4) and the wss transport work.


14. Quick reference

# One-time account setup
npx wrangler login                           # or set CLOUDFLARE_API_TOKEN
terraform version                            # >= 1.5

# Local dev (TLS origin on :6697 with a local cert pair)
pnpm build
TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key \
  pnpm --filter @serverless-ircd/cf-tcp-container start
# …or the localhost-only plaintext debug listener:
DEBUG_PLAINTEXT=localhost pnpm --filter @serverless-ircd/cf-tcp-container start

# Deploy (single target — staging vs prod is which account you point at)
pnpm deploy:cf-tcp                            # container
cd apps/cf-tcp-container/terraform && \
  terraform apply -var zone_id=$CF_ZONE_ID \
    -var origin_address=$CF_TCP_ORIGIN_ADDRESS \
    -var hostname=irc.example.com             # Spectrum (tls_mode=full)

# Tests
pnpm --filter @serverless-ircd/cf-tcp-container test

Key files:

Path What
apps/cf-tcp-container/src/main.ts Container entry point.
apps/cf-tcp-container/src/container-server.ts TCP/TLS listeners + actor wiring + persistence.
apps/cf-tcp-container/src/config-loader.ts Env → ServerConfig + container knobs (TLS + plaintext-debug gates).
apps/cf-tcp-container/src/persistence.ts Channel-topology snapshot/restore.
apps/cf-tcp-container/wrangler.toml Container deploy config.
apps/cf-tcp-container/Dockerfile Image (node:24, /data volume, :6697, UID 1000).
apps/cf-tcp-container/terraform/spectrum.tf Spectrum TLS :6697 → origin TLS :6697 (full).
.github/workflows/deploy-cf-tcp.yml Staging deploy + Spectrum + smoke CI.