
elm.chat
Short-lived, account-free encrypted chat rooms on a Worker and Durable Object.
elm.chat is a self-hosted Slack alternative built on Cloudflare (Durable Objects, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.
Source & license
Upstream license: AGPL-3.0
License TL;DR
You can use and change it, even commercially. If people use your modified version over a network, offer them its corresponding source under the AGPL. Sharing copies has source-sharing duties too. Sharing source code is different from sharing users’ content.
Explain AGPL v3 in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The reviewed Cloudflare deployment is eligible for Free-plan allowances for the stated small workload and feature scope. Usage limits, CPU, required account setup and separate services apply.
Hosting requirements
- Use the root public self-host wrangler.jsonc, which omits maintainer-only Analytics Engine.
- Keep rooms short-lived, relay traffic below daily Worker and SQLite DO request limits, and active duration below the DO free allowance.
- Keep per-invocation CPU below 10 ms; frequent encrypted file chunks consume DO/Worker quotas and should be measured.
- Custom domains and optional abuse-protection setup are separate configuration choices; no external AI, mail or STUN/TURN service is mandatory.
- Workers Free dynamic requests are shared across this account (100,000/day), with 10 ms CPU per invocation; workload fit is conditional and has not been measured.
- Only SQLite Durable Objects qualify for Workers Free. Keep DO requests below 100,000/day, active duration below 13,000 GB-s/day and SQLite storage/operations inside the captured allowances.
Sources checked 01/10/2026
Repository snapshot: 4c3c46c. Hosting eligibility reflects the deployment documentation and listed assumptions.
- slack ↗
vocation; creator removal of connected participants. The room secret stays in the URL fragment. | | Text | AES-GCM encrypted in the browser. Protocol v3 binds the room, key epoch, sender session, message ID, timestamp, and expiry. Every peer event is signed by the admitted session's ephemeral ECDSA key. | | Files | Browser-encrypted, signed, request-driven 64 KiB chunks through the relay, up to 25 MiB. Declared size, chunk bounds, timeout, cancellation, and whole-file SHA-256 are checked before download. | | Lifecycle | Per-message expiry, idle and maximum room de
- workers ↗
ngine datasets. { "$schema": "node_modules/wrangler/config-schema.json", "name": "elm-chat", "main": "workers/api/src/index.ts", "compatibility_date": "2026-04-08", "compatibility_flags": ["nodejs_compat"], "dev": { "port": 8799 }, "observability": { "enabled": true, "head_sampling_rate": 1 }, "assets": { "directory": "apps/web/dist", "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": true }, "durable_objects": { "bindings": [ { "name": "ROOM_OBJECT",
- durable-objects ↗
"run_worker_first": true }, "durable_objects": { "bindings": [ { "name": "ROOM_OBJECT", "class_name": "RoomDurableObject" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["RoomDurableObject"] } ] }
- free-tier-eligible ↗
-provision Analytics Engine datasets. { "$schema": "node_modules/wrangler/config-schema.json", "name": "elm-chat", "main": "workers/api/src/index.ts", "compatibility_date": "2026-04-08", "compatibility_flags": ["nodejs_compat"], "dev": { "port": 8799 }, "observability": { "enabled": true, "head_sampling_rate": 1 }, "assets": { "directory": "apps/web/dist", "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": true }, "durable_objects": { "bindings": [ { "name"
- free-tier-eligible ↗
ount Manager. | | Requests<sup>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 second
- free-tier-eligible ↗
ute and storage. Note Durable Objects are available both on Workers Free and Workers Paid plans. - **Workers Free plan**: Only Durable Objects with [SQLite storage backend](https://developers.cloudflare.com/durable-objects/best-practices/access-durable-objects-storage/#create-sqlite-backed-durable-object-class) are available. - **Workers Paid plan**: Durable Objects with the SQLite storage backend are available. The [key-value storage backend](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/#storage-backends) is only avail
- AGPL-3.0 ↗
GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
- architecture ↗
ngine datasets. { "$schema": "node_modules/wrangler/config-schema.json", "name": "elm-chat", "main": "workers/api/src/index.ts", "compatibility_date": "2026-04-08", "compatibility_flags": ["nodejs_compat"], "dev": { "port": 8799 }, "observability": { "enabled": true, "head_sampling_rate": 1 }, "assets": { "directory": "apps/web/dist", "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": true }, "durable_objects": { "bindings": [ { "name": "ROOM_OBJECT",
Upstream screenshot · shawnbure/elm-chat repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Editorial workflow alternative: Temporary text and file coordination without a permanent workspace; no searchable team archive, accounts or enterprise integration parity.
See supporting source ↗How it works
The shape of elm.chat on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit 4c3c46cafa8c. Files were read as data; upstream applications and CI jobs were not executed.
Deployment configuration · 2 files
Cloudflare Workers · compatibility 2026-04-08
elm-chat · default
Entrypoint: workers/api/src/index.ts
Static assets: apps/web/dist · single-page-application · Worker first: true
ROOM_OBJECT→ Durable Objects · class RoomDurableObjectASSETS→ Static assets
Cloudflare Workers · compatibility 2026-04-08
elm-chat · default
Entrypoint: src/index.ts
Static assets: ../../apps/web/dist · single-page-application · Worker first: true
ROOM_OBJECT→ Durable Objects · class RoomDurableObjectGROWTH→ Analytics EngineASSETS→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L439 · fetch handler exported · references ASSETS · calls canonicalOriginRedirect, withSecurityHeaders, url.pathname.startsWith, routeApi, isWebSocketUpgrade, json, handleCloudflareDeploy, MARKETING_PATHS.has, env.ASSETS.fetch, at, url.pathname.split, headers.set
- L45 · json calls (conditional paths may differ): JSON.stringify
- L57 · withSecurityHeaders calls (conditional paths may differ): headers.set
- L75 · canonicalOriginRedirect calls (conditional paths may differ): toLowerCase, split, request.headers.get, url.protocol.slice, url.toString
- L102 · isWebSocketUpgrade calls (conditional paths may differ): request.headers.get
- L106 · base64Url calls (conditional paths may differ): replace, btoa, String.fromCharCode
- L113 · randomRoomId calls (conditional paths may differ): crypto.getRandomValues, base64Url
- L119 · randomToken calls (conditional paths may differ): base64Url, crypto.getRandomValues
- L123 · safeJson calls (conditional paths may differ): request.json
- L132 · verifyTurnstile calls (conditional paths may differ): form.append, fetch, response.json
- L161 · handleCreateRoom calls (conditional paths may differ): catch, safeJson, verifyTurnstile, request.headers.get, json, Date.now, randomRoomId, randomToken, buildRoomUrl, env.ROOM_OBJECT.getByName, stub.fetch, JSON.stringify, recordGrowth, isAcquisitionSource
- L214 · handleGetRoom calls (conditional paths may differ): env.ROOM_OBJECT.getByName, stub.fetch
- L219 · handleDestroyRoom calls (conditional paths may differ): safeJson, env.ROOM_OBJECT.getByName, stub.fetch, JSON.stringify
- L228 · handleRoomWebSocket calls (conditional paths may differ): env.ROOM_OBJECT.getByName, stub.fetch
- L233 · handleCreateInvite calls (conditional paths may differ): safeJson, env.ROOM_OBJECT.getByName, stub.fetch, JSON.stringify, recordGrowth
- L246 · handleListInvites calls (conditional paths may differ): request.headers.get, json, env.ROOM_OBJECT.getByName, stub.fetch
- L257 · handleRevokeInvite calls (conditional paths may differ): safeJson, env.ROOM_OBJECT.getByName, stub.fetch, JSON.stringify
- L295 · handleGithubStats calls (conditional paths may differ): Date.now, fetch, res.json, json
- L326 · handleCloudflareDeploy calls (conditional paths may differ): searchParams.get, recordGrowth, isAcquisitionSource
- L346 · routeApi calls (conditional paths may differ): catch, then, safeJson, recordGrowth, isAcquisitionSource, isExternalAcquisitionSource, json, handleCreateRoom, handleGithubStats, getCommunityFeed, url.pathname.match, handleRevokeInvite, Promise.resolve, handleGetRoom, handleDestroyRoom, handleCreateInvite, handleListInvites, handleRoomWebSocket
Environment references: env.GROWTH · env.TURNSTILE_SECRET · env.ROOM_OBJECT · env.ASSETS
- L67 · jsonResponse calls (conditional paths may differ): JSON.stringify
- L88 · safeJson calls (conditional paths may differ): request.json
Environment references: env.GROWTH
- L25 · mapIssue calls (conditional paths may differ): Number.isSafeInteger, Number.isFinite, Date.parse, issue.title.slice
- L44 · shapeCommunityFeed calls (conditional paths may differ): slice, sort, filter, map, closed.filter, mapIssue, Date.parse, open.map
- L62 · fetchIssues calls (conditional paths may differ): url.searchParams.set, fetch, response.json, Array.isArray
- L80 · getCommunityFeed calls (conditional paths may differ): Date.now, finally, then, Promise.all, fetchIssues, shapeCommunityFeed
Build and deployment pipeline · 1 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: pull_request, push
Typecheck, build, and test · no job dependencies declared
- Check out source
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - Set up Node.js
actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - Install locked dependencies
npm ci - Check types
npm run typecheck - Build and run repository checks
npm run build - Run tests in the local Workers runtime
npm test
build: npm run check:wrangler-configs && npm run check:growth-privacy && npm run check:message-protocol && npm run check:community-feed && npm run build --workspace @elm-chat/web && npm run check:security-copy && npm run build --workspace @elm-chat/apideploy: npm run check:wrangler-configs && npm run check:growth-privacy && npm run check:message-protocol && npm run check:community-feed && npm run build --workspace @elm-chat/web && npm run check:security-copy && CLOUDFLARE_ACCOUNT_ID="${ELM_CHAT_CLOUDFLARE_ACCOUNT_ID:-$CLOUDFLARE_ACCOUNT_ID}" wrangler deploy -c wrangler.jsoncdeploy:production: npm run check:wrangler-configs && npm run check:growth-privacy && npm run check:message-protocol && npm run check:community-feed && npm run build --workspace @elm-chat/web && npm run check:security-copy && npm run deploy --workspace @elm-chat/api
build: vite build && node scripts/gen-sitemap.mjs && node scripts/prerender-marketing.mjs && node scripts/gen-feed.mjs
build: wrangler deploy --dry-rundeploy: CLOUDFLARE_ACCOUNT_ID="${ELM_CHAT_CLOUDFLARE_ACCOUNT_ID:-$CLOUDFLARE_ACCOUNT_ID}" wrangler deploy
Repository README
View original on GitHub ↗Full upstream document by @shawnbure · README.md · snapshot 4c3c46c
elm.chat
Instant chat. Account-free, encrypted, fast and disposable. End-to-end encrypted rooms that self-destruct, with no persisted server-side transcript. This early-stage release has not had an independent security audit.
ELM stands for Ephemeral Logless Messaging. “Logless” describes the absence of a persisted server-side message transcript; the relay can still observe connection metadata, and participants can keep their own copies.

The current landing page keeps room creation, public project activity, source, and security limits visible in one place.
What ships today
| Area | Current behavior |
|---|---|
| Accounts and identity | No account or contact list. Each browser session gets a temporary color identity instead of a username. |
| Room access | Creator-issued, expiring, single-use invites; invite revocation; creator removal of connected participants. The room secret stays in the URL fragment. |
| Text | AES-GCM encrypted in the browser. Protocol v3 binds the room, key epoch, sender session, message ID, timestamp, and expiry. Every peer event is signed by the admitted session's ephemeral ECDSA key. |
| Files | Browser-encrypted, signed, request-driven 64 KiB chunks through the relay, up to 25 MiB. Declared size, chunk bounds, timeout, cancellation, and whole-file SHA-256 are checked before download. |
| Lifecycle | Per-message expiry, idle and maximum room deadlines, creator-controlled destruction, and server-enforced teardown for connected clients. |
| Reliability | Automatic WebSocket reconnect with bounded backoff, replay IDs retained in bounded tab storage, explicit connection/key state, invite-admission checks, and closed-room handling. |
| Language | The interactive room experience follows browser language preferences for English and Spanish, with English fallback. |
| Project visibility | A same-origin, cached GitHub activity feed shows recent fixes and open requests without loading GitHub scripts in the visitor's browser. |
| Self-hosting | One Worker plus one Durable Object per room, one-click or Wrangler deployment, configuration drift checks, and a redacted smoke-report generator. |
| Tracking boundary | No third-party trackers. Hosted elm.chat can record allowlisted aggregate funnel counters; the public self-host template omits that binding. |
Choose your path
| I want to… | Start here |
|---|---|
| Try the product | Create a disposable room, invite exactly one person, and exchange a low-risk test message. |
| Run my own instance | Deploy to Cloudflare or follow the manual deployment guide. |
| Review the claims | Read the security status, threat model, and architecture. |
| Help build it | Review the current help-wanted issues, the contributor starting points, or CONTRIBUTING.md. |
If elm.chat is worth revisiting, use GitHub's Star button above. Stars are the public signal that helps other open-source users find the project.
Try it with one person
You can test the complete handoff in about a minute with someone you already know:
- Open elm.chat and create a room.
- Click Send invite (or Invite one person where native sharing is unavailable). Choose a share target, or use Copy invite link on the single-use invite.
- Send that invite to one person through a separate channel if you copied it.
- Ask them to open it, exchange a low-risk test message, and destroy the room when you are done.
The invite expires and can only be used once. Start with non-critical information: elm.chat is early-stage, has not had an independent security audit, and is not intended for anonymous, high-risk, regulated, or production-finance communication. Read the security status and limitations before relying on it.
elm.chat is an open effort to build a messaging system for people who need privacy by default, operational simplicity, and as little server trust as possible.
This repository is for builders, reviewers, security researchers, and contributors who want to help push the project toward a genuinely minimal-footprint private communication model.
New here? Start with the public try, review, or contribute guide, ask a question in Discussions, or review the current help-wanted work.
Try elm.chat, review its public security status and limitations, open the press and media kit, read why the internet needs places that are allowed to forget or why I built a messenger designed to disappear, learn what self-destructing chat should actually mean, follow the practical guides to sending a password without leaving it in chat history and sending a file without creating another attachment archive, compare a one-time secret with a disposable chat, create a temporary private chat without signup, choose a communication channel for a journalist and source, examine why deletion is a distributed-systems contract, explore the Cloudflare Durable Objects architecture, see how WebSocket hibernation works without a chat database, or build single-use invite links as explicit capabilities.
Subscribe to the RSS feed for new articles and technical notes.
Run your own in one click
elm.chat is Cloudflare-native, so you can fork and self-host a full private instance in about a minute — Cloudflare clones the repo into your account and provisions the Durable Objects for you:
Tried the self-host path? Share a successful deployment or the exact blocker. Self-hosted instances send no analytics back to elm.chat, so this opt-in report is the only reliable way to improve the path for the next operator.
Prefer to do it by hand? See Deploy to Cloudflare below and the deployment verification checklist. Want to contribute instead of just run it? Start with CONTRIBUTING.md and the contributor starting points.
What People See
The product is intentionally small and direct.
On the landing screen, a visitor sees:
- the core message:
Instant chat. Account-free, encrypted, fast and disposable. - a message vanish control with minutes, hours, days, or indefinite
- a room self-destruct control with minutes, hours, days, or indefinite
- a
Create private conversationaction - a note that the room secret stays in the URL fragment and does not normally reach the server
- a quick summary panel for access, message policy, and room policy
- up to five recently closed GitHub issues and five open requests, each linking to its public GitHub thread
Inside a room, people see:
- a short room code
- the configured vanish and self-destruct rules
- live presence count
- color identity chips instead of usernames
- encrypted message bubbles keyed by participant color
- creator-only
Send invite(orInvite one person) andDestroycontrols - single-use invite links instead of a permanent reusable room invite
- creator ability to revoke invites and remove participants
- a single composer for fast message entry
- encrypted file sharing that streams over the encrypted relay and vanishes on the same policy as messages
- a room that is meant to disappear instead of becoming a permanent archive

The interface is meant to feel immediate, readable, and disposable. It should communicate privacy without turning the user experience into a configuration maze.
The interactive room shell supports English and Spanish from the browser's language preferences, with English as the fallback. The longer articles remain in English. The GitHub activity panel uses a same-origin Worker endpoint with a short cache; voting happens on GitHub and requires a GitHub account.
Intent
The intent of this application is straightforward:
- footprint-less
- log-less
- no-server transcript authority
- no man in the middle with readable content
- no readable content in the middle
- end-to-end encryption
- disposable rooms that die on purpose
Those are the design goals. They matter because a private chat app should not ask users to trust infrastructure any more than absolutely necessary.
This project is trying to move toward a system where:
- the server coordinates live transport but is not the source of truth for message history
- clients hold the transcript
- rooms are short-lived and aggressively self-destruct
- capability links and end-to-end encryption reduce account, identity, and metadata exposure
Current Direction
The shipping implementation is built around:
- a Cloudflare Worker serving the app and API, plus one Durable Object per room
- room secrets kept in the URL fragment so they do not reach the server in normal requests
- end-to-end encrypted message payloads (AES-GCM under a room key derived in the browser)
- text protocol v3 associated data binding for the room ID, key epoch, sender session ID, message ID, timestamp, and expiry
- signed, versioned peer events bound to the admitted ephemeral ECDSA identity and optional target session
- fresh room-key epochs distributed only to remaining participants with ephemeral ECDH when membership changes
- encrypted content relayed — never stored — through the room's Durable Object over a single WebSocket, so the server only ever sees ciphertext
- end-to-end encrypted, chunked file sharing over that same relay
- creator-issued single-use invite links, invite revocation, and participant removal
- automatic WebSocket reconnect with bounded backoff and explicit reconnect status
- optional invisible Cloudflare Turnstile on room creation (inert until keys are configured)
- a disposable room lifecycle (idle + max-age self-destruct, manual destroy) instead of permanent storage
Why relay instead of peer-to-peer
elm-chat does not use WebRTC peer-to-peer transport, and it contacts no STUN or TURN servers. Relaying encrypted payloads through the Durable Object is a deliberate choice: it keeps every participant's IP address private from other room members (naive WebRTC would leak peer IPs via ICE), needs no TURN server, and works reliably on mobile and restrictive networks. The trade-off is that the honest-but-curious server relays ciphertext and can observe connection metadata (timing, sizes, presence).
The long-term direction may add an optional direct-peer transport for participants who accept the IP-exposure trade-off. Ephemeral sender verification is implemented, but it does not establish a person's real-world identity.
If you are contributing, treat the phrases "footprint-less", "log-less", and "no-server" as the product standard we are aiming toward, not as a slogan. See docs/architecture.md and docs/threat-model.md for the precise current model.
What This Is For
elm.chat is an early-stage experiment for ordinary, low-risk conversations between people who already know and trust one another but do not want another permanent chat archive.
It may be useful for:
- a short-lived personal conversation
- live coordination that should not become a searchable channel history
- a temporary password or file handoff where both participants can verify one another through another channel
- developers studying or self-hosting a small encrypted WebSocket application
It is not an anonymity system, an independently audited high-risk communications channel, a whistleblower drop box, a compliance product, or production-ready financial infrastructure. Use purpose-built, independently reviewed systems for regulated, anonymous, adversarial, or high-risk communication.
The point is not just to encrypt message content. It is to explore how much unnecessary server-side retention a small communication system can avoid while stating the remaining risks plainly.
Why Cloudflare
Cloudflare is useful here because it lets a small project run a globally distributed real-time application without maintaining servers.
For this project specifically, Cloudflare provides:
- Workers for the HTTP edge runtime
- Durable Objects for per-room coordination and lifecycle control
- static asset hosting for the client app
- a free entry point for developers who want to experiment or contribute
As of April 10, 2026, Cloudflare documents that:
- Durable Objects are available on the Workers Free plan
- the Workers Free plan includes limited daily usage
- SQLite-backed Durable Objects are the supported backend on the free tier
Official references:
Deploy to Cloudflare
This is the complete, end-to-end guide to running your own elm.chat instance on Cloudflare. The whole app is a single Cloudflare Worker: it serves the static React app and the API, and coordinates each room with a Durable Object. There is no separate database, server, or STUN/TURN service to run.
What you need
- A Cloudflare account (the free plan is enough) — sign up.
- Node.js 18+ and npm.
- Git.
No paid add-ons are required. SQLite-backed Durable Objects (what this project uses) are available on the Workers Free plan.
Option A — one click
- Click Deploy to Cloudflare.
- Choose your GitHub account and authorize Cloudflare to create a copy of the repository and connect it to Workers Builds.
- Confirm the project name and whether the new repository should be private.
- Deploy. Cloudflare detects the root
wrangler.jsonc, runs the repository's build and deploy scripts, and provisions the Worker and Durable Object namespace. - Open the generated
*.workers.devURL and create a low-risk test room. - Run the deployment verification checklist before sharing the instance.
What Cloudflare should detect
The repository includes a root deploy-button configuration specifically because
Cloudflare does not fully support automatic monorepo detection. The setup screen
should recognize wrangler.jsonc and use the root package.json scripts:
- Build command:
npm run build - Deploy command:
npm run deploy - Root directory:
/(repo root)
If the setup screen says No Wrangler configuration detected, stop before deploying and open a deployment issue. That message means Cloudflare is falling back to automatic project configuration instead of the reviewed Worker, assets, and Durable Object bindings.
If the hosted build fails for any reason, use Option B — it is the fully tested path.
Option B — manual deploy with Wrangler (recommended)
# 1. Clone and install
git clone https://github.com/shawnbure/elm-chat.git
cd elm-chat
npm install
# 2. Authenticate Wrangler (opens a browser)
npx wrangler login
# 3. (If your Cloudflare login has more than one account) pick the target.
# This project-scoped variable maps to Wrangler's account for deploys,
# so it won't clobber CLOUDFLARE_ACCOUNT_ID for your other projects.
export ELM_CHAT_CLOUDFLARE_ACCOUNT_ID=<your-account-id>
# 4. Build the web app and deploy the Worker + Durable Object (one command)
npm run deploy
npm run deploy (run from the repo root) builds apps/web/dist and then deploys the Worker. On success, Wrangler prints your live URL, e.g. https://elm-chat.<your-subdomain>.workers.dev. Open it, click Create private conversation, and you have a working room.
Notes:
- Choosing an account.
wrangler.jsoncintentionally does not hardcode anaccount_id, so it deploys to whatever account you logged in with. If your login has access to more than one account, setELM_CHAT_CLOUDFLARE_ACCOUNT_ID(find the id under Workers & Pages → Account details in the dashboard). Thedeployscript maps it to theCLOUDFLARE_ACCOUNT_IDWrangler expects, so it stays scoped to this project. If you already export the standardCLOUDFLARE_ACCOUNT_ID, that is used as a fallback. - workers.dev subdomain. The first time you deploy to an account, Cloudflare may ask you to register a free
*.workers.devsubdomain (in the dashboard under Workers & Pages). Do that once, then re-runnpm run deploy. - Durable Object migration. The
migrationsblock inwrangler.jsonccreates theRoomDurableObjectSQLite class automatically on first deploy — no manual step. - Two checked Wrangler entry points. Root
wrangler.jsoncis the Deploy-to-Cloudflare entry point;workers/api/wrangler.jsoncremains the production/API workspace entry point. The root template intentionally omits elm.chat's optional growth-measurement dataset because Cloudflare does not list Analytics Engine among the resources its deploy button auto-provisions. Self-hosted instances work without that dataset and do not send elm.chat growth events.npm run check:wrangler-configsfails if the runtime resources or resolved paths drift. - Maintainer production deployment. The hosted
elm.chatinstance usesnpm run deploy:production, which selectsworkers/api/wrangler.jsoncand preserves the optional aggregate Analytics Engine binding. Independent self-hosters should continue to usenpm run deploy; it intentionally uses the smaller public template. - Renaming. To run multiple instances or avoid a name clash, change
"name"in both Wrangler configuration files before deploying. - Redeploying after changes. Just run
npm run deployagain — it rebuildsapps/web/distbefore deploying.
Optional — custom domain
To serve the app from your own domain instead of *.workers.dev:
- Add the domain to your Cloudflare account (it must use Cloudflare DNS).
- In the dashboard: Workers & Pages → your Worker → Settings → Domains & Routes → Add custom domain, or add a
routesentry towrangler.jsoncand redeploy. Cloudflare provisions the TLS certificate automatically.
Optional — abuse protection (Turnstile)
Room creation can be gated by an invisible Cloudflare Turnstile challenge. It is off until you add keys, so the steps above work without it. See Abuse Prevention (Turnstile) below for the two-step setup.
Verify it works
- Open your deployed URL and create a room.
- Click Send invite (or Invite one person), copy the single-use invite if needed, then open it in a second browser or an incognito window to confirm two participants can exchange encrypted messages and files.
- Optional: watch live logs with
npx wrangler tailfromworkers/api.
For release or configuration changes, use the fuller Deploy-to-Cloudflare verification checklist.
Free-tier expectations
- keep rooms short-lived
- keep storage minimal
- expect daily usage ceilings on the free plan
- prefer aggressive message expiry and room self-destruct
- large or frequent file transfers consume more of your Workers/Durable Object budget, since file chunks are relayed through the Worker
That matches the philosophy of the project anyway.
Troubleshooting
Missing entry-point/ assets error on deploy — you didn't build first. Runnpm run buildfrom the repo root, thenwrangler deployfromworkers/api.More than one account available— setELM_CHAT_CLOUDFLARE_ACCOUNT_ID(see above), then re-runnpm run deploy.workers.devURL returns 404 or won't register — register your workers.dev subdomain in the dashboard, then redeploy.- Room says "Room not found" right after creating it — you're pointing the web app at a different Worker than the one that created the room (usually a stale local dev setup). In production this is one Worker, so it does not occur.
Local Development
The app runs as two processes in development:
- the Cloudflare Worker + Durable Object under
wrangler devonhttp://localhost:8799(a dedicated port set inworkers/api/wrangler.jsoncso it never collides with other Cloudflare projects that default to8787) - the Vite dev server (React app, hot reload) on
http://localhost:3000
Vite proxies /api (including the room WebSocket) to the Worker, so the app behaves exactly like production, where a single Worker serves both the static assets and the API.
First-time setup:
npm installnpm run buildonce (createsapps/web/dist, whichwrangler devexpects)
Then, to run everything with one command:
npm run dev
Open http://localhost:3000. Create a room, then open the copied link (or an invite link) in a second browser/tab to see live encrypted chat between participants.
Running from VS Code
Two entry points are provided in .vscode/:
- Run without a debugger — open the Command Palette →
Tasks: Run Task→dev(also bound to the default build task,Cmd/Ctrl+Shift+B). This starts both servers and opens elm.chat in your default browser. - Run with the debugger — press
F5and pickDebug: elm.chat (Chrome)orDebug: elm.chat (Edge). This starts both servers and launches the chosen browser attached to the VS Code debugger, so breakpoints in the React/TypeScript source work. (VS Code's JavaScript debugger supports Chrome and Edge only; for other browsers use the no-debugger task above.)
Abuse Prevention (Turnstile)
Room creation can be gated by Cloudflare Turnstile, a privacy-preserving bot check with no cookies, no cross-site tracking, and no persistent user identity. It is optional and stays off until you configure keys, so local dev and unconfigured deploys keep working.
To enable it:
- In the Cloudflare dashboard, create a Turnstile widget (Managed or Invisible mode) for your domain. You get a site key (public) and a secret key (private).
- Give the web build the site key:
VITE_TURNSTILE_SITE_KEY=<site-key> npm run build(or add it to a.envfile underapps/web). - Give the Worker the secret:
cd workers/api && npx wrangler secret put TURNSTILE_SECRET
With both set, the landing page runs an invisible challenge before creating a room, and the Worker rejects room creation unless the token verifies. With neither set, creation is open.
No Third-Party Tracking
elm.chat ships with no third-party analytics, no third-party beacons, and no third-party scripts on any page. A strict Content-Security-Policy with script-src 'self' is applied to every route, so the browser cannot load an external tracker even if one were added by mistake. The only network calls a visitor's browser makes are to elm.chat's own origin. (The GitHub star count on the landing is fetched server-side by the Worker, so visitors' browsers never contact GitHub.)
The hosted elm.chat service can send optional same-origin growth events to /api/growth, and the production Worker can write aggregate counters to a Cloudflare Analytics Engine dataset. The browser payload is limited to event and an enumerated source; the Worker writes only event name, source, and count. These counters are intended to measure public funnel behavior such as article CTAs, invite handoff, and GitHub interest. They must not include room IDs, room secrets, invite tokens, creator tokens, session IDs, identity keys, IP addresses, filenames, message/file content, or durable relationship identifiers. npm run check:growth-privacy fails if the client payload, growth route, or Analytics Engine write drifts toward those fields.
The public self-host wrangler.jsonc intentionally omits the GROWTH Analytics Engine binding. Independent instances deployed from that template do not send analytics to elm.chat. The maintainer production dataset is accessible to the Cloudflare account operators for this hosted instance; exact retention for that aggregate dataset is not yet independently verified. This first-party measurement does not make elm.chat anonymous, audited, compliant, suitable for regulated/high-risk use, or free from ordinary relay metadata.
Durable Object Lifecycle
Each room is coordinated by a dedicated Durable Object instance.
That object is responsible for:
- join and presence coordination
- live room event transport
- room policy enforcement
- timed expiration
- explicit destroy actions
The room is not meant to become a permanent mailbox.
The intended room behavior is:
- create fast
- coordinate live participants
- self-destruct on inactivity or explicit destroy
- leave as little behind as possible
In practical terms, a room should act more like a volatile coordination envelope than a permanent database row.
Access Model
Room access does not rely on a broad reusable guest link.
The current implementation is:
- the creator opens the room
- the creator issues a one-time invite
- one invite is intended for one participant
- invites expire
- invites can be revoked
- the creator can remove connected participants from the room
This is a better model than a permanent share link because a forwarded or stale invite should stop being useful quickly.
Security Posture
This project should be judged against real adversarial conditions, not casual product marketing language.
Contributors should think in terms of:
- hostile infrastructure assumptions
- metadata minimization
- replay resistance
- transcript authority
- peer authentication
- safe room destruction
- low-friction use on mobile and unreliable networks
If a feature improves convenience but expands retention, logging, observability, or recoverable history, it should be challenged hard.
Security Work That Still Matters
Single-use invites and protocol v3 close specific gaps. They do not make the system independently audited or suitable for high-risk use.
What is implemented now:
- at-most-once guest admission with expiring, revocable invites
- protocol v3 authentication of room, key epoch, and message metadata with AES-GCM
- signed peer events for text, transcript sync, file controls, chunks, completion, cancellation, and key rotation
- duplicate message and peer-event rejection across live delivery, transcript sync, reconnect, and refresh in bounded tab storage
- fresh room keys on membership changes, wrapped separately for remaining participants without giving the relay a key
- bounded file chunks, backpressure, transfer timeout/cancellation, declared-size checks, and whole-file SHA-256 verification
- on-path room-deadline checks before WebSocket admission and event handling
- creator-authorized invite management, participant removal, and room destruction
Gaps that remain:
- ephemeral keys authenticate a browser session, not a person's real-world identity; participants still need another channel when human identity matters
- peer-supplied transcript sync remains incomplete by design and cannot prove that no message was omitted
- key rotation protects later epochs from a removed participant but cannot erase old keys, plaintext, screenshots, or files already held by an endpoint
- tab-scoped identity and replay state disappear when the tab session ends; there is intentionally no account-backed recovery or server archive
- if an invite is intercepted before the intended recipient redeems it, the first redeemer can still get in
- if a device is compromised, screenshots, clipboard history, browser history, or malware can still expose the conversation
- if a participant forwards plaintext, screenshots, or the room secret after joining, the protocol cannot stop human leakage
- metadata still exists at the transport and endpoint level even when message content is encrypted
- if the creator leaves a room open too long, exposure time grows even if invites are single-use
Current operating guidance:
- issue invites only when the recipient is ready to use them
- keep invite lifetime short
- revoke unused invites quickly
- remove participants when they no longer need access
- keep message expiry and room self-destruct aggressive
- destroy the room as soon as the conversation is done
- treat every endpoint as a possible weak point
Next Security Work
Issues #106, #107, #108, #109, and #110 produced the signed-event protocol, refresh-safe bounded replay state, membership key epochs, hardened file transfer, and the recovery and accessibility test matrix.
The next security work is independent protocol review, real-device execution of the recovery matrix, stronger human/device verification, traffic-analysis reduction, abuse resistance, and continued threat-model maintenance.
Independent review remains open in #56. Cross-cutting work also includes traffic and metadata minimization, operational hardening, documentation, and threat-model maintenance.
Invitation
This project is for people everywhere who believe private communication should be normal, understandable, and technically defensible.
If you are a developer, designer, cryptographer, security researcher, or careful critic, contribute. Help make this amazing. Help make it safer. Help make it harder to abuse, harder to surveil, and easier to trust.
Repository Notes
Recommended reading in this repository:
- docs/architecture.md
- docs/threat-model.md
- docs/deploy-to-cloudflare-verification.md
- docs/api-spec.md
- docs/room-lifecycle.md
- docs/why-use-elm-chat.md
- docs/truly-private-messaging.md
License
elm.chat is free software licensed under the GNU Affero General Public License v3.0. AGPL is chosen deliberately: because this is a trust-minimizing tool, anyone who runs a modified public instance must make their modified source available to that instance's users (see Section 13 of the license). That keeps every deployment — including forks — honest and inspectable, which is the entire point of a private messenger.
If you deploy a modified version as a network service, you must offer its users access to the corresponding source. Contributions are accepted under the same license; see CONTRIBUTING.md.
Acceptable use
Privacy protects people; it is not a shield for abuse. See docs/abuse-policy.md for what is not allowed, what the architecture does and does not let anyone see, and how to report a problem. Self-hosters are the operators of their own instances and are responsible for acceptable use and local-law compliance.
Disclaimer
Do not market or rely on this project as a completed high-assurance safety tool until its protocol, implementation, and operational guarantees have been independently reviewed and tested under realistic threat conditions.
Frequently asked about elm.chat
What is elm.chat?+
elm.chat is a self-hosted Slack alternative built on the Cloudflare developer platform. Short-lived, account-free encrypted chat rooms on a Worker and Durable Object.
What does elm.chat replace?+
elm.chat is listed as an alternative to Slack. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does elm.chat use?+
elm.chat is built on Durable Objects, Workers.
How much does elm.chat cost to run?+
The reviewed Cloudflare deployment is eligible for Free-plan allowances for the stated small workload and feature scope. Usage limits, CPU, required account setup and separate services apply. Use the root public self-host wrangler.jsonc, which omits maintainer-only Analytics Engine. Keep rooms short-lived, relay traffic below daily Worker and SQLite DO request limits, and active duration below the DO free allowance. Keep per-invocation CPU below 10 ms; frequent encrypted file chunks consume DO/Worker quotas and should be measured. Custom domains and optional abuse-protection setup are separate configuration choices; no external AI, mail or STUN/TURN service is mandatory. Workers Free dynamic requests are shared across this account (100,000/day), with 10 ms CPU per invocation; workload fit is conditional and has not been measured. Only SQLite Durable Objects qualify for Workers Free. Keep DO requests below 100,000/day, active duration below 13,000 GB-s/day and SQLite storage/operations inside the captured allowances. Check current Cloudflare pricing before deploying.
Is elm.chat open source?+
The upstream repository declares the AGPL-3.0 license. Read its terms at https://raw.githubusercontent.com/shawnbure/elm-chat/4c3c46cafa8cf2af3fff0b9ed2e7d28cc7d08f41/LICENSE. Source code and contributor credit are available at https://github.com/shawnbure/elm-chat.

Discussion · 0
sign in to comment →