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-009for 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:
Account · Workers Scripts · Edit(Containers deploy)Zone · Spectrum · Edit(terraformcloudflare_spectrum_application)Zone · DNS · Edit(Spectrum creates a CNAME)
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 withoutTLS_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"
max_instancescaps horizontal scale; theInMemoryRuntimeis per-process, so connections on different instances do not share state. Tunemax_instancesto your expected concurrency (one origin handles thousands of idle connections thanks to the async TCP server).sleep_after = "2h"lets an idle origin scale to zero; the first new connection cold-starts it. Persistence (§8) means channel topology is not lost across sleep.
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:
- Across chunks within a connection — the
TcpByteStreamTransportbuffers 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. - 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/datavolume 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, keepmax_instances = 1until 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
saslcapability advertisesPLAINonly, and - a client sending
AUTHENTICATE EXTERNALis rejected up front with904 ERR_SASLFAILcarrying the stable messageSASL EXTERNAL not supported on this transport.
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.
- Flexible mode (explicit downgrade): Spectrum terminates TLS and forwards plaintext TCP to the origin. EXTERNAL is equally unavailable there, and plaintext origin traffic reintroduces the direct-reach exposure this topology closed. Avoid it.
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):
- Checkout → pnpm 11 + Node 24 →
pnpm install --frozen-lockfile. pnpm build,pnpm typecheck,pnpm --filter cf-tcp-container test.wrangler deploy(container).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.hcland fails the deploy on provider drift (see §5.4 for the reviewed upgrade path).- TLS smoke (
openssl s_client), gated onCF_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:
- The container is scaled to zero (
sleep_after) and the cold start is slow — wait a few seconds and retry. CF_TCP_ORIGIN_ADDRESSpoints at the wrong origin. It must match the address printed bywrangler deploy(§5.1).- The origin crashed on boot. Check
wrangler tailfor the container (or container logs in the dashboard). Common causes: the cert pair is missing/unreadable atTLS_CERT_PATH/TLS_KEY_PATH(prod boots refuse to start without it — the error names the offending path), an invalidMOTD_LINES, or a malformedSASL_ACCOUNTSline.
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. |