Nothing here has been run against a real Cloudflare account yet. Each step is what the tooling documents; treat the first deploy as the verification and report anything that differs.
npm install doneBLOB1..BLOB8 when those bindings exist (create each with wrangler d1 create, add a [[d1_databases]] block, never remove one that holds files; their table is created on first use), so R2 and a payment method are not needed. D1’s free 5 GB is shared by messages and files, so keep USER_QUOTA_MB small. To use R2 instead, create a bucket and add [[r2_buckets]] binding = "FILES" bucket_name = "..." to wrangler.toml, then raise MAX_UPLOAD_MB and MAX_FILE_MB.USER_QUOTA_MB, default 500) and per-file limit (MAX_UPLOAD_MB, default 25) are guards, not a billing cap.Cloudflare D1 is the default database and file store. Each of these is switched on by setting its secrets on the Worker (dashboard: Workers, parley, Settings, Variables and Secrets). Unset means unused. Add secrets yourself; never commit them.
| Service | Secrets | What it does | Free limits (check current terms) |
|---|---|---|---|
| Turso | TURSO_URL (libsql://...), TURSO_TOKEN |
Replaces D1 as the main database. The schema is created automatically on first request. Also stores files. | about 5 GB. Every query becomes an internet round trip, so expect it to be slower than D1 |
| Supabase Storage | SUPABASE_URL, SUPABASE_KEY (service role key), optional SUPABASE_BUCKET (default parley, create it as a private bucket) |
Stores files up to 16 MiB | about 1 GB, 50 MB per object |
| Firebase Firestore | FIREBASE_SA (the whole service account JSON, Firestore in Native mode on the free Spark plan) |
Stores files up to 16 MiB in 512 KiB documents | about 1 GiB, 20k writes per day |
Files are placed on one of the configured stores by a hash of the file key, not by free space, and the store is recorded in the file’s key. Switching Turso on does not move existing data from D1.
None of these three adapters has ever run against the real services. They are tested only against local stand-ins that follow each service’s documented protocol (npm run test:store, and the API suite with test/fakes.mjs). Expect to debug the first real run.
Sign in the CLI. This opens a browser for you to approve; do it yourself.
npx wrangler login
Create the database and put its id in wrangler.toml.
npx wrangler d1 create parley
Add the printed database_id = "..." line under [[d1_databases]]. Recent Wrangler versions can also create bindings automatically on first deploy; that path has not been tried here.
Nothing to create for files: they use D1.
Apply the schema.
npm run migrate:remote
Optional: voice relay. In the Cloudflare dashboard open Realtime, TURN, and create a key. Then:
npx wrangler secret put TURN_KEY_ID
npx wrangler secret put TURN_API_TOKEN
Never commit these. .dev.vars is git-ignored for local use; .dev.vars.example lists the names.
Deploy.
npm run deploy
Check it.
curl https://<your-worker>.workers.dev/api/health
Then open the URL, register, and keep the recovery code.
Make yourself an admin. Add your username to ADMIN_USERNAMES in wrangler.toml (comma separated), then npm run deploy again. The admin panel is in the status menu under your name.
Each provider is offered on the sign-in screen only when both of its secrets exist. Register an OAuth app with the provider, set its callback (redirect) URL to https://YOUR-HOST/api/auth/oauth/PROVIDER/callback, then add the secrets on the Worker.
| Provider | Secrets | Scope requested | Callback path |
|---|---|---|---|
| GitHub | OAUTH_GITHUB_ID, OAUTH_GITHUB_SECRET |
read:user |
/api/auth/oauth/github/callback |
OAUTH_GOOGLE_ID, OAUTH_GOOGLE_SECRET |
openid profile |
/api/auth/oauth/google/callback |
|
| Discord | OAUTH_DISCORD_ID, OAUTH_DISCORD_SECRET |
identify |
/api/auth/oauth/discord/callback |
Only the provider’s user id and display label are stored. These flows are tested against a local fake provider (npm run test:signin with the .dev.vars.example values pointed at it), not against GitHub, Google or Discord themselves, so expect to debug the first real run. Passkeys need no setup but only work on the exact host they were registered on.
| Variable | Default | Meaning |
|---|---|---|
MAX_UPLOAD_MB |
25 | Largest file sent in a single request (the free plan request body limit is 100 MB). |
MAX_FILE_MB |
25 | Largest file via multipart upload (16 MiB parts). |
USER_QUOTA_MB |
100 | Total attachment storage per user, including unfinished uploads. D1 free storage is 5 GB in total, shared with messages, so size this to your member count. |
PBKDF2_ITERS |
100000 | Password hashing cost. 100,000 is the maximum Workers’ WebCrypto accepts. Stored per hash. |
REGISTRATION_OPEN |
true | Set "false" to close signups. |
ADMIN_USERNAMES |
empty | Comma separated usernames that get the admin panel. |
POLLINATIONS_KEY |
unset | Optional Pollinations API key (a Worker secret) used by the Experimental AI features when a user has not pasted their own. Pollinations rejects most anonymous requests. |
OWNER_USERNAME |
ParleyOfficial |
The one account that gets the owner Admin page in settings, including the database editor. Owner implies admin. |
OPENROUTER_API_KEY |
unset | Optional Worker secret from openrouter.ai/keys. Admin Server studio sends its request to OpenRouter with it; without it, studio reports that the key is missing. Without OPENROUTER_MODEL the Worker reads OpenRouter’s public model list, picks the strongest free models by size and context window (cached for an hour) and tries up to four in order. Set OPENROUTER_MODEL to pin one. |
KLIPY_API_KEY |
unset | Optional Worker secret. A free key from the KLIPY partner dashboard turns on KLIPY search and trending in the GIF picker (npx wrangler secret put KLIPY_API_KEY). Written from KLIPY’s published response format and not tested against the live service, because that needs a key. Takes priority over GIF_API_BASE. Without any key, GIF search uses the built-in keyless sources. |
GIF_API_BASE |
unset | Optional. Base URL of a Tenor v2 compatible GIF API. Without it, GIF search needs no key: it uses reaction GIFs from otakugifs.xyz, purrbot.site and nekos.best reaction GIFs plus Wikimedia Commons search. That is a much smaller library than Tenor. Google shut the Tenor API down on 30 June 2026. |
GIF_API_KEY |
unset | Optional API key (a Worker secret) for a provider set in GIF_API_BASE. TENOR_API_KEY is accepted as an alias. |
GIF_MEDIA_HOSTS |
tenor.com,klipy.com,giphy.com,cdn.otakugifs.xyz,cdn.purrbot.site,nekos.best,upload.wikimedia.org |
Comma separated host suffixes the server will download GIFs from. Add your provider’s media host here. |
EXPERIMENTAL_USERNAMES |
empty | Comma separated usernames that see the Experimental settings tab, in addition to the first account and anyone an admin enables. Set it as a plain variable in the Cloudflare dashboard. |
Secrets, all optional:
TURN_KEY_ID, TURN_API_TOKEN: Cloudflare Realtime TURN.SIGNING_JWK: Ed25519 message signing key from node scripts/gen-signing.mjs. Without it a key is generated and kept in D1.VAPID_PRIVATE_JWK: from node scripts/gen-vapid.mjs; turns on Web Push. Optional variable VAPID_SUBJECT (a mailto: or https URL).Set secrets with wrangler secret put NAME. Never commit them. Run npm run migrate:remote to apply the migrations (the Workers Builds deploy command does this for you). A cron trigger aborts multipart uploads left open for 24 hours.
Two options:
npx wrangler deploy. Run npm run migrate:remote yourself when a release adds a migration..github/workflows/deploy.yml is manual (workflow_dispatch) and applies migrations and deploys. It needs repository secrets CLOUDFLARE_API_TOKEN (a token with Workers, D1 and R2 edit permission) and CLOUDFLARE_ACCOUNT_ID. It does nothing until you add them and run it.Add a route or custom domain to the worker in the dashboard. Cookies are Secure automatically on https, and WebSocket and API origin checks compare against the request’s own origin, so no extra setting is needed.
/api/rtc/ice returns turn: false when it fell back to STUN.PBKDF2_ITERS (weaker hashes, existing hashes keep their own count), or use the Workers Paid plan.npm test fails with 429s: you are hitting rate limits from repeated runs; wait a few minutes.Content-Length header; uploads require it.wrangler.toml declares [ai] binding = "AI" for the image part of the explicit content scanner. Workers AI has a free daily allowance on the free plan. wrangler dev cannot start with that binding when you are not logged in to Cloudflare, so npm run dev writes a copy of the config without it (wrangler.local.toml, ignored by git) and the image check is simply off locally. To run the integration tests start the server with npx wrangler dev -c wrangler.local.toml --port 8787 --test-scheduled.