One API key, one SDK, every communication primitive:
import { Coss } from "coss";
const coss = new Coss({ apiKey: process.env.COSS_API_KEY });
const { data, error } = await coss.email.send({
from: "Acme <hello@acme.dev>",
to: "user@example.com",
subject: "Welcome",
html: "<p>Hi there</p>",
});Transactional email is the first product, delivered by Amazon SES (EMAIL_PROVIDER=ses) or a fake
provider for local development (nothing leaves your machine). Development (sandbox) environments always use the
fake provider, which simulates delivery events like the SES mailbox simulator. Live sends come from verified
domains; every recipient gets a delivery status, and signed webhooks report email.delivered, email.bounced
and the rest.
Bun workspaces · Turborepo · TypeScript 7 · Biome · Next.js 16 (dashboard) · Hono (public API) · Postgres + Drizzle · Valkey (rate limits) · Better Auth (organization, admin, API key plugins) · Tailwind v4 + coss ui · Fumadocs (coss.com and its docs: OpenAPI reference, Twoslash) · Expo + Expo Router (iOS app)
See AGENTS.md for the layout, conventions and guardrails.
- Bun ≥ 1.4.2 and Node.js ≥ 24 (Next.js and the Better Auth CLI run on Node)
- PostgreSQL 18 (e.g. Postgres.app)
- Valkey 8 (or Redis) for the test suite, e.g.
brew install valkey && brew services start valkey - VS Code: install the recommended extensions (Biome, TypeScript Native Preview)
bun install
createdb coss && createdb coss_test
cp .env.example .env # then set DATABASE_URL, TEST_DATABASE_URL, BETTER_AUTH_SECRET and
# WEBHOOK_SECRETS_KEY (each: openssl rand -base64 32)
bun run db:migrate
bun run db:seed # creates demo@coss.local (admin), org "acme" with the verified domain
# acme.test, a project, and prints a dev API key
bun run dev # dashboard http://localhost:3000/dashboard · API http://localhost:3001 ·
# site + docs http://localhost:3002 · workerThen set up the shared build cache once per machine (Remote cache): without it everything works, but CI rebuilds what you already built.
db:migrate also installs the pgboss schema: sends return 202 with status: "queued" and the worker
dispatches them moments later, so keep the worker running.
Send an email with the key printed by the seed:
curl -X POST http://localhost:3001/v1/emails \
-H "Authorization: Bearer $COSS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"from":"Acme <hello@acme.dev>","to":"user@example.com","subject":"Hello","text":"Hi!"}'
# or with the SDK from this repo
COSS_API_KEY=coss_test_... COSS_BASE_URL=http://localhost:3001 bun packages/sdk/examples/send-email.tsThe email shows up in the dashboard under the project's Development environment, with per-recipient
status and events. Send to bounce@simulator.amazonses.com (or complaint@, suppressionlist@) to simulate
failures. Add a webhook endpoint in the environment's Webhooks page (local http:// URLs are allowed outside
production) and the worker delivers signed events to it. Auth emails (email verification, invitations, password
resets) are printed to the dashboard's server console in development.
- Organizations & members — Better Auth organization plugin (roles: owner, admin, member).
- Projects — belong to an organization; each gets two environments:
production(modelive, keyscoss_live_…) anddevelopment(modesandbox, keyscoss_test_…, never really sends). - API keys — Better Auth API key plugin, owned by the organization and bound to one environment.
- Domains — owned by the organization and shared by its projects. Live keys can only send from verified
domains (Easy DKIM + a
send.MAIL FROM subdomain); sandbox keys accept anyfrom. - Recipients & events — each email has one recipient per distinct address, whose status follows delivery
events:
queued→sent→delivery_delayed→delivered, thenbounced/suppressed/failed,complained. - Attachments — up to 20 base64 files per send (25 MB combined), inline images via
content_id(cid:). Only metadata is kept once the email is sent. - Suppressions — per environment. Sends skip a suppressed recipient (status
suppressed, eventemail.suppressed) and still deliver to the rest. An entry covers every email (scope*) or one category (the optionalcategoryof a send, e.g.marketing), so it can back an unsubscribe flow. Permanent bounces and complaints add*entries automatically (except for mailbox simulator addresses), one-click unsubscribes an entry for the email's category. coss.com keeps the only list: SES suppression is turned off. - Scheduled sends — pass
scheduled_at(ISO 8601 with an offset, up to 30 days ahead) and the email is stored asscheduled(eventemail.scheduled); the worker sends it when it is due, re-checking the domain and the suppression list. Until thenPOST /v1/emails/{id}/reschedulemoves it to a new time (eventemail.rescheduled) andPOST /v1/emails/{id}/cancelcancels it (canceled, eventemail.canceled). - One-click unsubscribe (RFC 8058) — off by default; turned on per category with the tracking rules (or per send,
tracking: { unsubscribe: true }). Emails with a category and a single recipient getList-Unsubscribe+List-Unsubscribe-Postheaders pointing at the tracking host (/t/u/<token>); the one-click POST suppresses the recipient for that category (reasonunsubscribe) and recordsemail.unsubscribed. - Open & click tracking — off by default. Turn it on per environment (a default plus per-category rules, in the
dashboard) or per send (
tracking: { opens, clicks }). coss.com adds an invisible image and rewrites HTML links to its own redirect (API_PUBLIC_URL/t/…) when it hands the email to the provider; the stored email keeps the original HTML. Opens and clicks show up asemail.opened/email.clickedevents, webhooks, counts on the email (tracking) and per-link click counts. Independent of SES's own tracking, which stays off. A verified domain can have a custom tracking domain (e.g.links.acme.com, a CNAME totrack.cosslinks.com): emails from it are tracked through that hostname while it is active. coss.com registers it as a Cloudflare for SaaS custom hostname (TRACKING_DOMAIN_PROVIDER=cloudflare), which issues its certificate and proxies/t/*to the API. - Webhooks — per environment, signed with Standard Webhooks headers and
retried for about a day. Verify them with
verifyWebhookfrom the SDK. - Single sign-on — per organization, OpenID Connect or SAML 2.0 (Better Auth's SSO plugin), set up under
the organization's Settings → Single sign-on. Its domains must be the organization's verified email domains:
no second DNS check. People at those domains join as members on their first SSO sign-in, and existing accounts
with a verified email are linked. Owners can require SSO: members and admins then need an SSO session from the
organization's provider (owners keep password "break-glass" access). An SSO session also satisfies the
organization's two-factor requirement — the IdP is responsible for MFA. Support recovers broken setups from
/admin(unlink a user's SSO accounts, stop requiring SSO, disable a provider). - Directory sync (SCIM 2.0) — per organization, under Settings → Directory sync (needs an active SSO provider).
The IdP (Okta, Entra ID, JumpCloud…) provisions users at
/api/auth/scim/v2with an expiring bearer token: active users are members, members of groups mapped to admin are admins, and deactivating or deleting a user removes them from the organization and ends their SSO sessions there. While a directory is connected, only its active users (and owners) sign in with SSO. Owners are never changed by the directory. Support can disconnect a directory from/admin. - Reputation guard — the worker watches each organization's live bounce and complaint rates (per recipient, over
7 days) and acts without a human: a warning at 4% bounces / 0.08% complaints, an automatic suspension of
live sending at 8% / 0.3% (from 200 / 500 recipients). Suspended organizations get
403 account_suspendedon live sends and can't create live keys; sandbox keys keep working. Customers see it in the dashboard (banner + Reputation page), viaGET /v1/reputation, throughaccount.*webhooks and by email to the organization's owners and admins (sent through coss.com itself, see "Deploying to AWS"). Only platform admins reinstate (/admin/organizations), where they can also suspend manually and override thresholds per organization. - Content screening — right before a sampled live email reaches the provider, the worker asks an AI decision
model (TypeSafe AI's Jev through the Vercel AI SDK and AI Gateway, swappable with
SCREENING_MODEL) how likely it is spam, phishing, malware or fraud. Sampled: each organization's first 10 live emails, its 25th, 50th, 100th, 250th, 500th and 1000th, then 2% at random. At ≥ 0.95 the email fails withcontent_rejectedand the organization is suspended (the reputation guard'scontent_screeningtrigger); at ≥ 0.70 it is sent but the organization is warned until the flag ages out of the window. In enforcement mode, classifier errors and timeouts (3 s) hold the email: it is tried (and screened) again every minute, and fails ascontent_screening_unavailableonly if screening is still down an hour after it was due. coss.com's own notification organization always sends so an AI outage cannot stop account recovery. Admins see every verdict on the organization's reputation page and can tune thresholds and sampling, switch to shadow mode, or turn screening off per organization (e.g. for phishing-simulation senders). LocallySCREENING_CLASSIFIER=fakeflags content containing[coss-test:phishing](orspam,malware,fraud;[coss-test:spam:0.8]sets the score). - Organization deletion — owners delete an organization from its Settings by typing its slug (not while it
is suspended; platform admins can, from
/admin, audited). Members, invitations, API keys, the SSO provider and the sessions it created go at once and scheduled emails never send; the worker then removes the organization's SES identities and tenant, deletes its data in batches and finally the organization, which frees its slug. A record of the deletion (who, when) is kept. There is no undo. - Data retention — the worker deletes data once it passes its published period (
packages/retention, shown at/docs/data-retentionand in each organization's settings): emails with their events and links after 45 days, webhook deliveries after 30, screening verdicts after 90, standing history and the admin audit log after 2 years, expired sessions and invitations 7 days after expiry. Suppressions and configuration are kept until deleted. - Platform admins — the console at
/admin(promote withbun run admin:create --email …): search users and organizations; ban users (with a reason and duration — signs them out everywhere), grant/remove the admin role, revoke sessions, impersonate (with a reason, never other admins); suspend organizations (API keys get 403organization_suspended, webhook deliveries are held until lifted, the dashboard turns read-only and membership changes are blocked; members never see the internal reason); revoke a single leaked API key. Every action is recorded in an append-only audit log (/admin/audit). Banning a user doesn't stop their organizations' keys — suspend the organization for that.
| Command | |
|---|---|
bun run dev |
Run dashboard + API + site + worker |
bun run lint / bun run format |
Biome check / fix |
bun run typecheck |
Type-check every package |
bun run test |
Run all tests (against coss_test) |
bun run build |
Build dashboard + site (standalone servers) + SDK |
bun run db:generate / db:migrate / db:studio |
Drizzle migrations |
bun run auth:generate |
Regenerate Better Auth tables after changing auth plugins (then db:generate + db:migrate) |
bun run signals:update-disposable |
Refresh the list of disposable email domains (refused at sign-up, flagged by risk assessments) |
bun run ses:bootstrap |
Create/update the shared AWS resources for EMAIL_PROVIDER=ses (see below) |
bun run valkey:bootstrap |
Create/update the ElastiCache Serverless for Valkey cache for rate limits (see below) |
bun run ratelimit:set --org <slug> [--bucket … --limit … | --reset] |
Show or change an organization's rate limits |
typecheck and build results are shared through Turborepo's Vercel Remote Cache
(Vercel team cal) by everyone's machines, agents and CI. Turbo fingerprints each task's inputs (source,
lockfile, turbo.json, NODE_ENV, NEXT_DEPLOYMENT_ID); when CI computes a fingerprint someone already built, it
downloads that result instead of building again, so running bun run typecheck && bun run build before pushing
makes CI's check and build jobs near-instant. On pull requests those two jobs check out the pushed commit (what you
built), while the test job runs on GitHub's merge with main; main's own run covers the merged result. Any change
to the inputs is a miss and CI runs the task itself, so nothing goes unchecked. Tests are never cached. Artifacts are HMAC-signed with a shared key; Turbo ignores
(rebuilds) anything that doesn't verify.
One-time setup per machine (a laptop, or a box agents run on; every worktree on it picks it up):
- Be a member of the
calteam on Vercel. - Put the team and the signing key (password manager: "coss.com turbo remote cache signing key") in a private file
and load it from your shell profile (open a new shell afterwards). Turbo only reads the shell's environment, never
.env:mkdir -p ~/.config/coss && install -m 600 /dev/null ~/.config/coss/turbo.env cat > ~/.config/coss/turbo.env <<'SH' export TURBO_TEAM=cal export TURBO_REMOTE_CACHE_SIGNATURE_KEY='<signing key>' SH # first line of ~/.zshrc (macOS) or ~/.bashrc — above any "not interactive → return" guard, so agents' # non-interactive shells load it too: [ -f "$HOME/.config/coss/turbo.env" ] && . "$HOME/.config/coss/turbo.env"
- Log Turbo in (stores a token in Turbo's user config; the Vercel CLI's own login is not used):
bunx turbo login # pick the cal team if asked # headless box: create a token at https://vercel.com/account/tokens (scope: cal), then bunx turbo login --manual # Remote Cache URL: https://vercel.com/api (not https://vercel.com); # needs TURBO_TEAM=cal from step 2 in this shell
- Check it:
bun run typecheckprintsRemote caching enabledin its header.Remote caching disabledorRemote caching unavailable (Authentication failed …)means step 2 or 3 is missing; builds still work, unshared.
CI reads secrets.TURBO_TOKEN (a Vercel token scoped to cal), vars.TURBO_TEAM (cal) and
secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY (gh secret list, gh variable list); without them it runs every task.
Rotating the signing key means updating the GitHub secret and every machine's turbo.env (old artifacts then miss
and are rebuilt). Anyone with a cal token and the key can upload results CI will accept: treat them like push
access, and revoke a leaver's token (vercel.com/account/tokens, or remove them from the team).
Runtime settings (DATABASE_URL, secrets, URLs, …) are pass-through and never part of a fingerprint, which is what
lets laptops and CI share results — see "Adding one" in AGENTS.md before adding a variable that changes build output.
apps/mobile is the iOS companion app (Expo SDK 57). It needs Xcode with an iOS Simulator and the dashboard running
(bun run dev):
bun run --cwd apps/mobile ios # generates ios/, builds, installs and opens the app on the Simulator
bun run --cwd apps/mobile start # Metro only, once the app is installed"Sign in with coss.com" opens the dashboard's sign-in in a browser sheet (demo@coss.local / coss-demo-password
after db:seed). The app reads EXPO_PUBLIC_DASHBOARD_URL (default http://localhost:3000); on a physical device
set it to your Mac's LAN address in apps/mobile/.env.local, and run the dashboard with BETTER_AUTH_URL on that
address too. Signed-in devices are listed under Settings → Sessions. See apps/mobile/AGENTS.md.
POST /v1/emails— send an email (Idempotency-Keyheader supported)POST /v1/emails/batch— send up to 100 emails at once, all or nothing (oneIdempotency-Keyfor the batch)GET /v1/emails/{id}— retrieve an email, with its recipients' delivery statusGET /v1/emails/{id}/events— list an email's delivery, open and click eventsGET /v1/emails/{id}/links— list an email's tracked links with their click countsPOST /v1/emails/{id}/cancel— cancel a scheduled email (scheduled_aton send)POST|GET /v1/domains,GET|DELETE /v1/domains/{id},POST /v1/domains/{id}/verify— sending domains (organization-wide, so every key of the organization sees the same ones)POST|GET /v1/webhook_endpoints,GET|PATCH|DELETE /v1/webhook_endpoints/{id}— webhook endpoints of the key's own environment; the signing secret is returned only when one is createdPOST /v1/suppressions— suppress an address (scope:*or a category)GET /v1/suppressions— list the environment's suppressions (address,scope,limit,after)GET /v1/suppressions/check?address=…&category=…— would a send skip this address?GET /v1/suppressions/{id}/DELETE /v1/suppressions/{id}— retrieve / remove an entryPOST /v1/suppressions/import— add up to 1,000 entries at once (invalid ones are skipped and reported)GET /v1/suppressions/export— the whole list as CSV (scope,reasonfilters); it imports again as isGET /openapi.json— OpenAPI 3.1 documentGET /v1/reputation— the organization's standing and latest ratesGET /t/o/{token},GET /t/c/{token}/{position}— public open-pixel and click-redirect endpoints that tracked emails link to (not part of the documented API)
Everything is also available in the dashboard, and through the SDK as coss.email, coss.domains,
coss.webhookEndpoints and coss.reputation.
Requests are rate limited per API key by route class (reads, writes, sends; responses carry RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset), and sends count their distinct recipients against the
organization's quota per second and per day (sandbox environments have a small daily quota of their own).
Over a limit the API answers 429 rate_limit_error (rate_limited or quota_exceeded) with Retry-After.
Defaults live in packages/ratelimit/src/buckets.ts; platform admins override them per organization on the
organization's page in the admin console (/admin/organizations, recorded in the audit log) or with
bun run ratelimit:set. The dashboard limits sign-in, sign-up, password reset and verification emails per
client IP and per email address.
apps/smtp lets anything that only speaks SMTP send through coss.com (Supabase Auth, WordPress, Rails, Laravel,
Django, nodemailer, older apps): host smtp.coss.com, port 465 (TLS) or 587 (STARTTLS) (2465/2587 as
alternatives), username coss, an API key as the password. Each message is mapped onto the same request as
POST /v1/emails (envelope recipients decide who receives it; To/Cc headers keep their roles and the rest are
Bcc; X-Coss-Category, X-Coss-Tags, X-Coss-Tracking, X-Coss-Scheduled-At and X-Coss-Idempotency-Key set
the API's fields) and stored through the same email service, rate limits and quotas; the worker sends it. Locally,
bun run dev starts it on 2465/2587 with a self-signed certificate (made with openssl):
swaks --server localhost:2587 --tls --auth-user coss --auth-password "$COSS_API_KEY" \
--from hello@acme.test --to ada@example.com --header "Subject: Hello"The docs' guide is /docs/email/smtp.
Email is split into two streams, transactional and marketing (email.stream), so marketing complaints never
pause password resets. Each organization gets one SES tenant per stream (isolated reputation and pausing),
created with its first domain, and every domain identity is associated with both. Each stream has its own
configuration set, <name> and <name>-marketing, both publishing delivery events to SNS → SQS, where the
worker picks them up; an EventBridge rule sends tenant pause/resume events to the same queue. SES suppression is
off for tenants and configuration sets: coss.com's own suppression lists decide who is skipped.
- Use a dedicated AWS account (or at least region) per stage: SES identities are account-wide, so the same domain can't be verified in two stages of one account. New accounts start in the SES sandbox; request production access in the SES console.
- Apply
bun run db:migrate, then with admin credentials and this stage'sDATABASE_URL, create the shared resources (idempotent; re-run after upgrades):AWS_REGION=eu-west-1 bun run ses:bootstrap [--name coss] [--tenant-prefix coss] [--alert-email ops@…]This also creates least-privilege IAM policies<name>-api,<name>-dashboardand<name>-worker(path/coss/, defined inapps/worker/scripts/ses-policies.ts) and, with--alert-email, CloudWatch alarms (<name>-events-dead-letters: events failed to be recorded;<name>-events-backlog: the worker isn't consuming) sent to that address once its SNS subscription is confirmed. - Set what it prints (
EMAIL_PROVIDER=ses,AWS_REGION,SES_CONFIGURATION_SET,SES_TENANT_PREFIX,SES_EVENTS_QUEUE_URL) for the API, dashboard and worker, and attach each app's policy to the identity it runs as (role, or credentials as a last resort). When upgrading an existing SES deployment, pause sends and domain changes, drain the SES event queue with the worker, then run bootstrap before resuming with the new application version. For scheduled sends, first apply migration0014_pretty_thunderboltswithbun run db:migrate. Deploy the API and worker from the same release. Rerun bootstrap with the deployment's existing region, name and tenant prefix to update the attached<name>-workerpolicy with SES send permissions before starting the new worker. Bootstrap makes the updated policy version the default; existing attachments stay in place. If you manage IAM policies separately, apply both workerses:SendEmailgrants fromapps/worker/scripts/ses-policies.ts(identities and the coss.com configuration set). Organization deletion needsses:DeleteTenantin the<name>-workerpolicy: rerun bootstrap (or add it) before deploying that release, or deletions retry until it is there. Bootstrap imports historical SES permanent bounces and complaints into their original live environments, and conservatively copies the SES account and tenant lists to every live environment of each matching organization (SES lists have no environment attribution). Sandbox lists are untouched. The database records completion per tenant in the same transaction; reruns preserve later coss.com removals. All imports must succeed before bootstrap disables any SES suppression. Keep traffic paused if bootstrap fails, correct the error, and rerun it. Bootstrap needsses:ListSuppressedDestinationsfor both account and tenant lists in addition to its infrastructure permissions; an unmapped SES tenant stops the transition. Upgrading to streams: apply the migration, run bootstrap before deploying (it creates the<name>-marketingconfiguration set, lets SES publish its events to the topic, adds it to the IAM policies and creates every existing organization's marketing tenant with its domains' identities associated), deploy, then run bootstrap once more: it associates domains the old release added meanwhile with the marketing tenants too. Keep domain changes paused from the migration until the deploy (as above): the migration re-keysemail_tenantby stream, so the old release can't store an organization's first tenant, and adding a first domain fails until the new release runs (retrying afterwards works). Tenants double, so ask AWS for a higher SES tenant quota first if you are near it. The tenant prefix is now at most 23 characters (<prefix>-<organization id>-marketingmust fit SES's 64). - Optionally, put a stream on a managed dedicated IP pool:
--transactional-pool <pool>and/or--marketing-pool <pool>create the pool (ScalingMode: MANAGED) and set it as that configuration set's sending pool; omitting a flag leaves the set as it is. Dedicated IPs are billed per pool and by volume and warm up over weeks, so start them well before the traffic they are for.
coss.com tags the identities it creates coss_managed=<tenant prefix> and only ever recreates or deletes identities
carrying its own tag (enforced in code and by the IAM policies), so domains verified by hand in the same account
are left alone. Sending can't be limited by tag (SES authorizes sends against the sender address), so the API
and worker policies allow ses:SendEmail on any identity but only with the coss.com configuration sets (another set,
or none, is denied); coss.com always sends with the organization's tenant of the email's stream, which SES only allows
for identities associated with it.
Environments receive email at their own <label>.<INBOUND_MANAGED_DOMAIN> addresses and at customers' receiving
domains. On SES this is one receipt rule set per account and region, with a single catch-all rule: it
scans each message for spam and viruses, writes it to the inbound bucket under landing/ and notifies an SNS
topic, whose SQS queue the worker consumes. The worker routes each message by its envelope recipients, stores a
copy per receiving environment under orgs/…, then deletes the landing object. Mail for anything that doesn't
route is dropped.
- Pick a region where SES receives email, and the managed domain (production:
inbound.coss.com). - Run bootstrap with
--managed-domain inbound.coss.com. It also creates the bucket<name>-inbound-<account>(private, SSE-S3, TLS only; lifecycle:landing/21 days,orgs/received email retention + 1 day), the topic<name>-ses-inbound, the queues<name>-inbound(+-dlq), the rule set<name>-inbound(made active), the managed domain's SES identity (taggedcoss_inbound=<name>) and, with--alert-email, the alarms<name>-inbound-dead-lettersand<name>-inbound-backlog. An account/region has only one active rule set: if another one is active, bootstrap stops unless you pass--replace-active-rule-set(mail its rules handle stops being handled). - Publish the records it prints in the coss.com zone on Cloudflare: the managed domain's three DKIM
CNAMEs,MX 10 inbound-smtp.<region>.amazonaws.comfor bothinbound.coss.comand*.inbound.coss.com, andTXT "v=DMARC1"at*._report._dmarc.inbound.coss.com. That last one lets mailbox providers send DMARC aggregate reports todmarc-<token>@inbound.coss.comfor customers' domains (RFC 7489's external destination check); without it they send none. - Set
INBOUND_MANAGED_DOMAINandSES_INBOUND_BUCKETfor the API, dashboard and worker, andSES_INBOUND_QUEUE_URL(plusVALKEY_URL, for the daily inbound limit) for the worker. Rerun bootstrap first on upgrades: the updated<name>-*policies grant the S3 and SQS access.
Before relying on it in a new account, check against a live account (and note the results here): that a verified
parent domain receives for its subdomains (in.acme.com on acme.com's identity) and one identity covers the
managed domain's wildcard; that identities associated with SES tenants still receive; that
receipt.action.objectKey includes the landing/ prefix; and what SES's own Received: header names (copies
drop for <address> clauses naming other environments' recipients).
- Create an AI Gateway API key in the Vercel team that will pay for screening. Requests ask for zero data
retention (the content is customer email), which Vercel offers on Pro and Enterprise teams. In enforced
mode, a failed request records an
errorverdict and holds the email for a retry a minute later (it fails withcontent_screening_unavailableafter an hour of outage); shadow mode records the failure and sends the email. - Set
SCREENING_CLASSIFIER=aiandAI_GATEWAY_API_KEYfor the worker (which screens emails), andRISK_CLASSIFIER=aiwith the same key for the api (risk's content classification, at request time, 3 s timeout, limited per organization by therisk.classify*buckets).SCREENING_MODELdefaults totypesafe-ai/jev; any Gateway decision model id works. - Cost: one screening is about 2k input tokens (bodies are truncated to 8,000 characters) — about $0.0001 with Jev — so the default sampling costs a few dollars per million live emails.
Rate limit counters live in Valkey (one GCRA bucket per key, updated atomically by a Lua script with the
server's clock). Without VALKEY_URL each process counts in memory, which is fine for development but
refused in production. Every bucket declares its outage policy: read-only API traffic is allowed when Valkey
is unreachable, while authentication, writes, sends, quotas and public-ingress abuse controls are denied.
A warning is logged in either case.
- The cache lives in a VPC: the API, dashboard and worker must run in it, and its security group must allow inbound TCP 6379 from them.
- With admin credentials, create it (idempotent; it takes a few minutes the first time):
AWS_REGION=eu-west-1 bun run valkey:bootstrap --subnet-ids subnet-a,subnet-b --security-group-ids sg-… [--name coss] [--max-ecpu 5000] [--max-storage-gb 1]It creates a password user limited to the<prefix>:*keys and the commands the limiter uses, a disableddefaultuser, a user group and the serverless cache (Valkey 8, TLS). - Set what it prints (
VALKEY_URL=rediss://…,VALKEY_CLUSTER=true,RATE_LIMIT_KEY_PREFIX) for the API, dashboard and worker (which counts the daily inbound limit). The password is only printed when the user is created;--rotate-passwordmakes a new one (running processes apply each bucket's outage policy until they get it). - Set
CLIENT_IP_HEADER(e.g.x-forwarded-for) andTRUSTED_PROXY_COUNT(how many of your proxies append to it) to match your load balancers. Without them, public unauthenticated entry points use an aggregate fallback subject instead of trusting the load balancer's socket address; email-address and token/key buckets still apply where available.
coss.com computes every invoice itself (@coss/billing: plans, contracts, usage, credits); Stripe only stores
customers and payment methods, issues the invoices coss.com pushes and collects them. Without Stripe
(BILLING_PROVIDER=fake, the default) every charge succeeds and nothing leaves coss.com.
- Create a restricted key with write access to Customers, Invoices, Invoice Items, Credit Notes, Checkout
Sessions, Customer Portal and Setup Intents, and read access to Payment Methods and Invoice Payments. Set it as
STRIPE_SECRET_KEYfor the dashboard and the worker, withBILLING_PROVIDER=stripe. - Add a webhook endpoint
https://coss.com/api/billing/webhookforinvoice.paid,invoice.payment_succeeded,invoice.payment_failed,invoice.payment_action_required,invoice.voided,invoice.marked_uncollectible,checkout.session.completed,customer.updatedandcharge.dispute.created; set its signing secret asSTRIPE_WEBHOOK_SECRET(dashboard only). Locally:stripe listen --forward-to localhost:3000/api/billing/webhook. - Customer Portal (Settings → Billing → Customer portal): allow updating payment methods, the billing address and tax IDs; turn off everything about subscriptions and invoices' cancellation (coss.com has no Stripe subscriptions).
- Turn off Stripe's automatic collection features that would duplicate coss.com's: Smart Retries and failed-payment emails for invoices (coss.com retries charges and emails billing contacts itself). Invoice and receipt emails from Stripe are fine to keep.
- Enable the payment method types you offer (cards; ACH Direct Debit, SEPA and Bacs Direct Debit for accounts that
need them; bank transfers for invoices with payment terms). Each account's allowed and required types are set in
the admin console. Stripe Tax is used only for accounts set to
stripe_automatictax.
Sales deals, credits and invoice adjustments are made in the admin console (/admin/organizations/<id>/billing);
deals invoiced off-Stripe use an account's manual provider (coss.com emails the invoice; an admin records payments).
Errors and traces go to the Sentry organization cosscom (@coss/observability, EU region). Create three projects —
web (Next.js), api (Bun) and worker (Bun) — and set each service's SENTRY_DSN to its project's DSN
(Project settings → Client Keys), plus SENTRY_ENVIRONMENT (the stage) and optionally
SENTRY_TRACES_SAMPLE_RATE (default 0.1). Without a DSN nothing is reported, so local development and CI
send nothing unless you set one.
- What is reported: unhandled errors everywhere; every
console.erroron a server (the packages log the failures they handle that way: provider errors, webhook deliveries, failed worker runs…), also as Sentry logs withconsole.warn; failed email-dispatch jobs; browser errors on the dashboard and the site. Events are taggedservice, and withrequest_id+organization_id/environment_id(API),organization_id+ the user's id (dashboard) orloop/email_id(worker). - Privacy: the SDKs collect no request/response bodies (email content), IPs, cookies, query strings, AI
prompts or local variables (
DATA_COLLECTION), and thescrub*hooks mask secret URL segments (tracking and password-reset tokens) in events, spans and logs. There is no session replay. - Browsers (both zones) fetch their configuration from the dashboard's
GET /api/monitoringand post their events to it; it forwards only envelopes for the dashboard's own DSN. The CSP needs no Sentry origin. - Releases are the commit:
NEXT_DEPLOYMENT_IDfor the Next.js images,SENTRY_RELEASE(a build argument) for the API and worker images. Builds with aSENTRY_AUTH_TOKEN(organization token, passed todocker buildas thesentry_auth_tokensecret) upload the Next.js apps' source maps toweband delete them from the image; the API and worker run their TypeScript source, so their stack traces need none.
Production runs in AWS account 294417174674, region us-east-2 (the organization's guardrails allow no other
region for regional services). The repository holds everything it takes: one Dockerfile per app
(apps/{web,site,portal,api,worker,smtp}/Dockerfile, built from the repository root), a machine-readable spec in
infra/ (bun run --cwd infra aws:spec prints every service's port, health check, stop
timeout, size, task-role policy and env vars, the ALB listener rules and the CloudFront behaviours as JSON), the
Terraform that provisions it from that spec (infra/terraform) and the deploy workflow
(.github/workflows/deploy.yml). This section is the runbook around them.
Cloudflare DNS (coss.com zone, DNS only)
coss.com, api.coss.com ─▶ CloudFront + WAF ──(HTTPS, X-Origin-Verify)──▶ ALB ─┬─▶ web (Next, :3000) ─┐
│ ├─▶ site (Next, :3002) ├─▶ RDS Postgres
├─ /ui, /ui/* ─▶ coss ui (Vercel) └─▶ api (Bun, :3001) ─┤ Valkey
└─ /origin, /origin/* ─▶ Origin UI (Vercel) worker (Bun, no load balancer)┘ SES · SNS · SQS
Cloudflare DNS (cosslinks.com zone, self-serve account, proxied)
track.cosslinks.com + customer tracking domains (Cloudflare for SaaS) ──(X-Tracking-Origin-Verify)──▶ ALB /t/* ─▶ api
Cloudflare DNS (cosshooks.com zone, self-serve account, proxied)
portal.cosshooks.com (Hooks portal) ──(X-Portal-Origin-Verify)──▶ ALB ─▶ portal (Next, :3003)
Cloudflare DNS (coss.com zone, DNS only)
smtp.coss.com ──(TCP 465/2465, 587/2587, client IP preserved)──▶ NLB ─▶ smtp (Bun, :2465 TLS, :2587 STARTTLS)
-
Network: a VPC (
10.40.0.0/16) over us-east-2a/b; public subnets for the ALB and one NAT gateway per AZ, private subnets for tasks, RDS and Valkey. Security groups: the ALB takes 443 only from the managed prefix listcom.amazonaws.global.cloudfront.origin-facing(its own group: the list weighs ~55 rules) and from Cloudflare's IPv4 ranges (tracking domains); each service's tasks only from the ALB on their port (the SMTP relay's from the internet through the NLB); RDS 5432 and Valkey 6379 only from web, portal, api, worker and smtp. -
NAT gateways (required): the worker delivers customer webhooks to arbitrary public URLs and ports, and domain verification in web, api and worker queries public DNS (1.1.1.1 / 8.8.8.8, port 53) directly. AWS traffic stays off them through VPC endpoints: interface endpoints for
sqs,secretsmanager,ecr.api,ecr.dkr,logsandemail(the SES API), plus the frees3gateway endpoint (ECR image layers). -
ECS on Fargate (arm64): cluster
coss-production, servicesweb,site,portal,api,worker,smtp, task definitions built fromspec.json(sizes, minimum counts,stopTimeout, env names and kinds; plain values fromproduction.tfvars, secrets from the stage secret), CPU target-tracking autoscaling, deployment circuit breaker with rollback, ECS Exec. ECR repositoriescoss/<service>(immutable commit tags;buildcache-<hash>is the BuildKit cache of the dependency layers, exported only when the lockfile, apackage.jsonor the Dockerfile changed). Logs in/coss/production/<service>(30 days). -
ALB: HTTPS listener with an ACM certificate (us-east-2) for
coss.comandapi.coss.com(CloudFront forwards the viewerHost, and validates the origin certificate against it). Default action: fixed 403. Rules exactly as the spec prints them (generated fromZONE_ROUTESinpackages/auth/src/slugs.ts):Priority Conditions Target group 10 host api.coss.com+X-Origin-Verifyapi 20 path /t/*+X-Tracking-Origin-Verify(any host: customer tracking domains through Cloudflare)api 30–80 the site's paths ( /,/pricing,/docs*,/legal*,/llms*.txt,/site-assets/*, root files,/.well-known*; 3 per rule) +X-Origin-Verifysite 90 host coss.com+X-Origin-Verify(every other path: org slugs,/dashboard,/api/*,/_next/*)web Target groups: health checks
/api/health(web, site, portal) and/health(api), HTTP 200, no database access; deregistration delay 30 s. Keep the idle timeout at 60 s: the apps hold idle connections for 75 s (KEEP_ALIVE_TIMEOUT, BunidleTimeout) so the ALB never reuses one they are closing. -
CloudFront + WAF: two distributions, certificates from ACM in us-east-1, HTTP→HTTPS redirect, a response headers policy that adds HSTS (2 years, subdomains, preload).
coss.com: originsalb(HTTPS only, custom headerX-Origin-Verify: <secret>, origin request policyManaged-AllViewer, soHost, cookies and query strings reach the apps) and one per external zone of the coss ui repository:ui→coss-com-ui.vercel.app,origin→coss-com-origin.vercel.app(Vercel projects serving their base path;Managed-AllViewerExceptHostHeader, so Vercel sees its own hostname). Default behaviourManaged-CachingDisabled(dashboard, auth, server actions, and the landing page, since a path pattern can't match exactly/). The cached behaviours from the spec use the policycoss-production-origin-cache-control, which honours the origin'sCache-Control: min/default TTL 0, max 1 year, all query strings (_rsc), no cookies, and the headersRSC,Next-Router-Prefetch,Next-Router-State-Tree,Next-Router-Segment-Prefetchin the cache key. Docs pages are sent withs-maxage=31536000: the deploy workflow invalidates the site's cached paths after every rollout (cloudFront.invalidateAfterDeployin the spec), never the hashed asset folders, so tabs opened before a deploy can still load their old chunks.api.coss.com: the ALB only, never cached.- WAF web ACLs (scope
CLOUDFRONT): a per-IP rate rule (5,000 / 5 min on coss.com, 20,000 on the API; floods only, the apps rate-limit precisely), AWS IP reputation, common and known-bad-inputs rule groups. Rules that legitimate traffic trips are counted instead of blocking:SizeRestrictions_BODY(both), and on the APICrossSiteScripting_BODY(sends carry whole HTML emails) andNoUserAgent_HEADER(some SDK runtimes).
-
RDS Postgres 18:
coss-production,db.t4g.medium, Multi-AZ, encrypted, 14-day backups, deletion protection, Performance Insights,rds.force_ssl. Apps connect with?sslmode=verify-full&sslrootcert=/etc/ssl/certs/rds-global-bundle.pem(each image ships the RDS CA bundle there). The master password is set through a write-only argument and never stored in the Terraform state. Pool sizing: every task opens up toDATABASE_POOL_MAX(default 10) app connections. Each API task also opens one pg-boss metadata connection; each worker adds up to four pg-boss connections and one LISTEN connection. Budget(max web + max api + max worker tasks) × DATABASE_POOL_MAX + max api tasks + 5 × max worker tasks, plus two connections for migrations and a few for operators (today ≈ 160 of ~410). Keep the total belowmax_connections(SHOW max_connections;— on RDS it scales with instance memory, roughlymemory / 9.5 MB). Lower the pool size or add RDS Proxy before scaling task counts past that. -
ElastiCache Serverless (Valkey): created by
valkey:bootstrap(see Rate limits on ElastiCache Serverless for Valkey) in the private subnets with thecoss-production-valkeysecurity group. -
SES, SNS, SQS, EventBridge: created by
ses:bootstrap(see Running on Amazon SES), run as a one-off task (below). -
DNS (Cloudflare, zone
coss.com):coss.comandapi.coss.comare DNS-only CNAMEs (flattened at the apex) to their distributions; ACM validation CNAMEs; DKIM and MAIL FROM records of coss.com's own sending domain. -
Tracking links (
cosslinks.com, a self-serve Cloudflare account, not the coss.com zone): every tracking link points at a domain of its own, so a sender's abuse flagged by URL blocklists never lands on coss.com.track.cosslinks.comis a proxied record pointing at the ALB's DNS name (terraform output alb_dns_name): it is the default tracking origin (TRACKING_PUBLIC_URL, api + worker) and the Cloudflare for SaaS fallback origin customers CNAME their tracking domains to (TRACKING_CNAME_TARGET). coss.com registers each customer domain as a custom hostname (HTTP certificate validation) withCLOUDFLARE_API_TOKEN(an account token scoped to the zone, SSL and Certificates: Edit) andCLOUDFLARE_ZONE_ID. A request header transform rule setsX-Tracking-Origin-Verify: <secret>(Secrets Managercoss/production/tracking-origin-verify) on every request in the zone: the ALB forwards/t/*with it to the api target group for any host, and nothing else. SSL mode Full (zone-wide; the zone serves nothing else): Cloudflare connects with the caller's hostname as SNI, which the ALB's certificate doesn't cover; the header secret is what proves a request came through Cloudflare. The apex redirects to coss.com. Free plan: 100 custom hostnames included, then $0.10 each per month. -
SMTP relay (
smtp.coss.com): thesmtpservice behind a network load balancer (infra/terraform/nlb.tf): TCP listeners 465 and 2465 → container port 2465 (implicit TLS), 587 and 2587 → 2587 (STARTTLS), client addresses preserved (the relay limits connections and failed logins per address), so the tasks' security group takes 2465/2587 from anywhere (they have no public address; only the NLB reaches them). The relay terminates TLS itself, since STARTTLS can't be offloaded: Terraform requests an exportable ACM certificate forsmtp.coss.com(exportable certificates cost extra; validation CNAME fromterraform output smtp_certificate_validation_records, DNS only), passes its ARN asSMTP_TLS_CERTIFICATE_ARN, and the task role may onlyacm:ExportCertificateit; the relay exports it at start and every 12 hours (ACM renews it) withSMTP_TLS_PASSPHRASE(stage secret, any random value).smtp.coss.comis a DNS-only CNAME toterraform output nlb_dns_name(Cloudflare can't proxy SMTP).SMTP_PUBLIC_HOST=smtp.coss.com(web) shows the settings on environment pages; leave it unset on a stage whose relay isn't reachable yet. No SES permissions: the relay only stores and enqueues, the worker sends. -
Hooks portal (
cosshooks.com, the same self-serve Cloudflare account ascosslinks.com):apps/portal, the page customers embed for their users, runs as theportalservice on a registrable domain of its own (never under coss.com, which would make it same-site with the dashboard).portal.cosshooks.comis a proxied record pointing at the ALB (terraform output alb_dns_name), which serves it with its own ACM certificate (an SNI listener certificate; validation CNAME fromterraform output portal_certificate_validation_records, DNS only), so the zone's SSL mode is Full (strict). A request header transform rule setsX-Portal-Origin-Verify: <secret>(Secrets Managercoss/production/portal-origin-verify) on every request in the zone; the ALB forwardsportal.cosshooks.comwith it to the portal and nothing else. A redirect rule sends the apex tohttps://coss.com.PORTAL_PUBLIC_URL=https://portal.cosshooks.com(web, api) makes portal session links point there.
- Errors and uptime: Sentry (organization
cosscom). Every server and browser reports errors; thewebproject filters errors caused by browser extensions and web crawlers. Uptime monitors checkhttps://coss.com/api/health(web) andhttps://api.coss.com/health(api) every minute from several regions. - Alarms (
infra/terraform/monitoring.tf, notify thecoss-alertsSNS topic →alert_email): per service, fewer running tasks than its minimum, memory above 85 %, CPU above 90 % (autoscaling saturated); load balancer 5xx, per-target 5xx and unhealthy targets; RDS CPU, CPU credits (t4g), free storage, free memory and connections (> 300 of ~410); Valkey throttling (raise--max-ecpu); plus ses:bootstrap's SES event queue backlog and dead letters. Route 53 health checks and CloudFront metrics exist only in us-east-1, which the organization's SCP denies: uptime is Sentry's. - Cost: an AWS budget (
monthly_budget_usd, email at 80 % actual and 100 % forecasted) and Cost Anomaly Detection per service (daily email for anomalies ≥ $25).
One Secrets Manager secret per stage, coss/production (JSON), injected through each task definition's secrets
(one key per secret variable of infra/aws/services.ts): DATABASE_URL, BETTER_AUTH_SECRET, VALKEY_URL,
WEBHOOK_SECRETS_KEY, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, TURNSTILE_SECRET_KEY, GOOGLE_CLIENT_SECRET,
GITHUB_CLIENT_SECRET, CLOUDFLARE_API_TOKEN,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, AI_GATEWAY_API_KEY, SMTP_TLS_PASSPHRASE (any random value: it
only protects the SMTP relay's exported certificate key), RISK_HASH_KEY (changing it resets what
risk assessments remember), EMAIL_LINK_KEYS (marketing email's preference, unsubscribe and confirmation links;
store it with put-secret.sh EMAIL_LINK_KEYS --generate before the deploy that adds it, or api and worker tasks
can't start; see Rotating EMAIL_LINK_KEYS), IPINFO_TOKEN (optional: a free IPinfo account's token for risk's ASN and country
data) and IPINFO_PRIVACY_TOKEN (optional, with RISK_IP_PRIVACY=ipinfo: a paid IPinfo plan's token for VPN and
proxy data per address; IPinfo must be a listed subprocessor first) and APNS_PRIVATE_KEY (optional: the iOS app's
push notifications, see below). Terraform creates only the container; values are
written with infra/terraform/scripts/put-secret.sh NAME --generate | --clipboard | --stdin, which never prints them
(losing WEBHOOK_SECRETS_KEY makes webhook secrets unreadable, see
Rotating WEBHOOK_SECRETS_KEY to change it). Tasks read secrets when they start:
redeploy after changing one. The two origin header values are separate secrets (coss/production/origin-verify,
coss/production/tracking-origin-verify), generated by Terraform for CloudFront, Cloudflare and the ALB rules
(rotate one by adding the new value to the rules, then the sender, then removing the old one).
Plain values live in infra/terraform/production.tfvars (plain_env, per service); the plan fails when a required
plain variable of the spec has no value or tfvars sets one the spec doesn't declare. SENTRY_* DSNs come from the
cosscom Sentry organization (EU region). Receiving adds INBOUND_MANAGED_DOMAIN=inbound.coss.com and
SES_INBOUND_BUCKET (web, api, worker) and SES_INBOUND_QUEUE_URL (worker), printed by
ses:bootstrap --managed-domain. The site needs nothing: everything it needs is in the image.
Images are built once and promoted: builds set SKIP_ENV_VALIDATION and bake in nothing stage-specific (only the
commit: NEXT_DEPLOYMENT_ID, SENTRY_RELEASE). Each server validates its environment at startup and exits if it is
wrong. AWS credentials come from the task role.
- Execution role (
coss-production-ecs-execution, shared): pull from ECR, write logs, read the stage secret. - Task roles: web →
coss-dashboard, api →coss-api, worker →coss-worker: the policiesses:bootstrapcreates under/coss/(defined inapps/worker/scripts/ses-policies.ts), plus ECS Exec. The site gets no permissions; smtp onlyacm:ExportCertificateon its own certificate. Theses-bootstrapone-off task runs withcoss-production-ses-bootstrap(SES, SNS, SQS, EventBridge, S3 (the inbound bucket), CloudWatch alarms, IAM policies under/coss/). - Deploys (
coss-production-deploy): the role of the CodeBuild project whose runners execute the Deploy workflow's AWS jobs (ECR push, task definitions, service updates, the migrate task,iam:PassRoleon the task roles, the CloudFront invalidation, read-only ALB/CloudFront reads for the routing check). The organization's SCP forbids IAM identity providers, so GitHub OIDC isn't available; the runners are started by a webhook (through thecoss-production-githubCodeConnections connection to the AWS Connector app oncosscom) only for jobs of the workflow namedDeploy, and only when the job's actor is ondeploy_actor_ids(production.tfvars: the numeric GitHub ids of the people with write access; add a new maintainer there, or their merges won't deploy). The repository is public, so that allow-list is what keeps a stranger's pull request (with its own workflow namedDeploy) off this role, which can read every production secret (it passes the execution role); verified by excluding an actor and dispatching (no runner started). Fork pull requests also need a maintainer's approval before any workflow runs (Settings → Actions: all external contributors). CodeBuild can't filter workflow jobs by branch (HEAD_REFonly applies to push and pull request events): the workflow's first job refuses anything butdeploy.ymlfrommain, and the AWS jobs need theproductionenvironment, whose deployment branch policy allows onlymain.
Upgrading a stage that still runs v1-only webhook secret readers: use the
one-time coordinated upgrade below instead of step 3's rolling
deployment. This applies even when WEBHOOK_SECRETS_KEY is unchanged.
The Deploy workflow does this for pushes to main in batches: its gate job (on GitHub's runners, which cost
nothing here, unlike CodeBuild's minutes) waits until the push's CI passed and then until main has had no newer
push for 20 minutes or production's last deploy is an hour old, so the first push after a quiet spell ships right
after CI and a busy stretch ships about hourly. A push that's superseded while it waits isn't built (the newer
push's run ships it). Run it by hand from the admin console's Deploys page or the Actions tab (gh workflow run deploy.yml) to ship main at once; with
image_tag it redeploys an earlier commit's images, e.g. a rollback. When CI failed and a re-run passed, re-run
the Deploy run's failed jobs. A deploy job whose commit production already includes (a newer deploy went first)
skips, so production never goes back to an older commit unless you dispatch one:
- Build and push the six images, tagged with the commit (
docker buildx build --platform linux/arm64 -f apps/web/Dockerfile --build-arg NEXT_DEPLOYMENT_ID=<sha> --secret id=sentry_auth_token,env=SENTRY_AUTH_TOKEN .for the Next apps;--build-arg SENTRY_RELEASE=<sha>for the API, worker and SMTP relay), skipping images that exist. Base images never come from Docker Hub there (CodeBuild's shared addresses exhaust its anonymous pull limit): Node from ECR Public (NODE_IMAGE), Bun fromcoss/base/bunin our ECR (BUN_IMAGE), which the workflow fills from Docker Hub once per Bun version. Local builds keep Docker Hub (the Dockerfiles' defaults). - Run migrations as a one-off task from the new worker image (
infra/scripts/ecs-task.sh --image-tag <sha> worker -- bun packages/db/src/migrate.ts; never two at once, the deploy job's concurrency group). Migrations must be backward compatible with the running code. - Roll out all six services at once (
infra/scripts/ecs-deploy.sh <sha> <services…>): each gets a new revision of its latest task definition with the new image, and the script fails if the circuit breaker rolled it back. A service behind the load balancer counts as deployed once its new tasks passed their health checks and the old ones are draining; the worker (no health check) once its old tasks are gone and the new ones ran for 60 s without a failure. Rolling deploys keep 100 % healthy; SIGTERM drains each task (Next and the API finish in-flight requests, the worker finishes each loop's current item, the SMTP relay answers messages in progress and sends idle clients421, so they reconnect to another task). - Record the commit in SSM (
/coss/production/image-tag, read by Terraform) and invalidate the spec'scloudFront.invalidateAfterDeploypaths on the coss.com distribution. - Check routing (
infra/scripts/check-routing.sh, also runnable locally): the ALB's rules and the coss.com distribution's cached behaviours must matchinfra/terraform/spec.json. Routing changes (a newZONE_ROUTESentry, i.e. a new site page or zone) only go live withterraform apply, which deploys never run, so until someone applies them this step fails with "routing drift: run terraform apply" and the differences. The code ships either way.
Infrastructure changes: cd infra/terraform && terraform init && terraform plan -var-file=production.tfvars with
admin credentials for the account, then apply. Services ignore task definition changes; after changing
environment values, run the Deploy workflow by hand so the services pick up the new revision.
/admin/deploys shows the commit production runs, main's merges it doesn't run yet (with their CI) and the Deploy
workflow's latest runs (waiting for their batch, building, deploying with the step it is on, done), and refreshes
itself while the tab is open (every 10 s while a run moves, else every 30 s). Each run has its own page
(/admin/deploys/<run id>): the merges it ships (or, for a redeploy, takes back out) against the deploy before it,
its jobs on a timeline, the deploy job's steps and the end of each failed job's log.
Deploy now dispatches the workflow for main (skipping the batch wait) and cancels the push runs still waiting;
Redeploy on an earlier run's commit dispatches it with image_tag (production stays there until main is
deployed again; migrations aren't undone). Both record an admin audit event with the admin and their reason.
It reads and dispatches through a GitHub App, coss.com Deploy, so no person's token is involved. Setting it up once:
- In the
cosscomorganization: Settings → Developer settings → GitHub Apps → New GitHub App. Namecoss.com Deploy, homepagehttps://coss.com, webhook off, repository permissions Actions: read and write and Contents: read-only (nothing else), installable on this account only. Create it, note its App ID, and generate a private key (a.pemdownload). - Install it on
cosscom, only oncosscom/coss. - Its bot account starts the deploy runner, so it goes on the allow-list:
gh api 'users/coss-com-deploy[bot]' --jq .id(the app's slug +[bot]; ours iscoss-com-deploy, App ID 5340076) →deploy_actor_idsinproduction.tfvars. infra/terraform/scripts/put-secret.sh DEPLOY_GITHUB_APP_PRIVATE_KEY --stdin < coss-com-deploy.*.private-key.pem, then take it out ofunset_secrets, setDEPLOY_GITHUB_APP_IDunderplain_env.web, and apply. Delete the.pem.- Run the Deploy workflow (
gh workflow run deploy.yml) so the dashboard picks up the new task definition.
The dashboard asks GitHub for installation tokens limited to those two permissions on this repository. With them it can dispatch, cancel and re-run this repository's workflows and read its code, never push: the Deploy workflow refuses anything but main, and the release workflow only runs on pushes. Anyone with the private key (the dashboard's task, the stage secret) can start the deploy runner like a maintainer can, so rotate it (generate a new key in the app's settings, store it, redeploy, delete the old one) if it may have leaked.
One-off tasks (from infra/, with admin credentials):
scripts/ecs-task.sh worker -- bun packages/db/src/migrate.ts
scripts/ecs-task.sh --task-role "$(terraform -chdir=terraform output -raw bootstrap_role_arn)" worker -- \
bun apps/worker/scripts/ses-bootstrap.ts --name coss --tenant-prefix coss --alert-email ops@coss.com
scripts/ecs-task.sh api -- bun packages/tenancy/scripts/create-admin.ts --email you@example.com [--verify-email]
scripts/ecs-task.sh worker -- bun apps/worker/scripts/webhooks-reencrypt.ts --dry-runFirst deployment of a stage: Terraform (network, RDS, IAM, secrets, ECR, certificates; validation CNAMEs in
Cloudflare) → valkey:bootstrap → stage secret values (put-secret.sh) → images → Terraform (services, ALB,
CloudFront, WAF) → migrate → ses-bootstrap one-off task → test through CloudFront with
curl --resolve coss.com:443:<distribution IP> → DNS in Cloudflare → sign up on the dashboard → create-admin --verify-email (sign-in needs a verified address, and no verification email can go out yet) →
create coss.com's own organization in the dashboard, add and verify its sending domain (coss.com: reserved for
everyone else, so only a platform admin can add it, from the dashboard; an SES identity for it created outside coss.com
must be deleted first, coss.com never adopts one), and set the worker's
NOTIFICATIONS_ENVIRONMENT_ID (its project's live environment, env_…) and NOTIFICATIONS_FROM (e.g. coss.com <notifications@coss.com>) in production.tfvars, apply, and redeploy. Until then the worker logs a warning at
startup and holds account emails (sign-up verification, password resets, invitations — dropped once their link
expires: an hour, or the invitation's 48 hours) and reputation notification emails (dropped after 3 days), so nobody
can verify their address or create a live key; afterwards, ask for a new verification email from account settings.
They are ordinary live emails of that organization: its dashboard shows their delivery, and its suppressions and
reputation guard apply. Account emails keep no body once sent (their links are secrets).
Push notifications to the iOS app use the APNs key "coss.com push" (APNS_KEY_ID WCQDCU4HUL) of the Cal.com, Inc.
Apple team (ADWKXDJV75), which owns com.coss.ios: Production environment, restricted to that topic. Its .p8 is
the APNS_PRIVATE_KEY key of coss/production (put-secret.sh APNS_PRIVATE_KEY --stdin < AuthKey_….p8); the key
id, team and topic are plain values in production.tfvars. To replace it, create a new key under Certificates,
Identifiers & Profiles → Keys (Apple lets you download it once), store it, change APNS_KEY_ID, apply and redeploy,
then revoke the old one. Without them the worker logs a warning and holds pushes (each is dropped after an hour).
Local development uses its own key (Sandbox) in .env.
The key-ring release reads both v1 and v2 ciphertext, but immediately writes v2 when an endpoint is created
or its secret is rolled. Earlier releases read only v1: mixing these releases breaks deliveries and dashboard
secret reveals. This first format upgrade requires a maintenance window; subsequent deployments between
dual-format releases can follow the normal rolling deploy order. A new stage needs no transition.
- Build the new images and run the backward-compatible migrations (deploy steps 1–2). Keep the existing single
WEBHOOK_SECRETS_KEYunchanged on all services; do not combine this upgrade with a key rotation or runwebhooks:reencryptyet. - Block incoming API and dashboard traffic at the load balancer, including direct origin access. Suspend service autoscaling and automated deployments, and disable automatic rollback to v1-only task revisions (including ECS deployment circuit-breaker/alarm rollback). Record desired counts, and scale web, api and worker to zero. Allow SIGTERM draining to finish, and verify that every old task has stopped, including standalone tasks or operator scripts that access webhook secrets. Do not start any new app task while an old reader remains.
- Set all three services to the new image revisions before restoring their desired counts. Keep traffic blocked until every running task uses a dual-format reader and the API/dashboard health checks pass. Verify a legacy endpoint's secret reveal and delivery, then create or roll a test endpoint and verify its reveal and delivery. Run these checks through controlled operator access during maintenance.
- Restore traffic, autoscaling and automated deployments, pinned to the upgraded revisions. Re-enable automatic
rollback only when its target revision also reads both formats. Queued work resumes
when the workers start; allow for delayed email and webhook deliveries during the maintenance window.
Existing
v1rows remain readable and do not need immediate re-encryption. Only now may the re-encryption script or the key-rotation procedure below run.
Rollback: before any new task or re-encryption script has written v2, the old images can be restored using
the same stop-all/start-all procedure with the original key. Once a v2 secret exists, rollback images must also
read both formats; keeping the same encryption key does not make a v1-only image compatible. Keep traffic blocked
and repair forward if the cutover fails after v2 writes. This release has no v2-to-v1 downgrade command.
Prerequisite: every API, dashboard and worker task already runs a dual-format reader. For a stage still using v1-only readers, complete the format upgrade first.
The variable is a comma-separated list of keys, newest first: the first encrypts new endpoint secrets, every one
decrypts. Each stored secret names its key by an id derived from the key itself (the webhooks:reencrypt script
prints them), so the list needs no labels and its order only decides which key encrypts. Endpoints' own signing
secrets don't change, so customers notice nothing.
- Generate a key (
openssl rand -base64 32) and append it:WEBHOOK_SECRETS_KEY=<old>,<new>. Deploy web, api and worker. Every task can now decrypt with both keys; nothing is encrypted with the new one yet. - Swap the order,
<new>,<old>, and deploy again. New and rolled endpoint secrets are encrypted with the new key. Doing step 1 first means no task that only knows the old key ever reads a secret made with the new one (rolling deploys run both versions at once). - Run the
webhooks-reencryptone-off task from the worker image (bun apps/worker/scripts/webhooks-reencrypt.ts, needsDATABASE_URLand the newWEBHOOK_SECRETS_KEY;--dry-runonly counts). It rewrites every secret still encrypted with an older key, while traffic continues, and exits 1 if anything was left over (a secret no listed key decrypts, or one rolled while it ran: run it again). - Remove the old key,
<new>, and deploy. Destroy the old key.
After a leak of the key, do all four steps right away: with the database alone the key is useless, but with both,
every endpoint's signing secret can be read. Customers should then roll their endpoint
secrets with no grace period. Locally, bun run webhooks:reencrypt does step 3 against DATABASE_URL.
Unsubscribe and preference links in marketing email are encrypted with the first key of EMAIL_LINK_KEYS and work
as long as their key is listed: nothing about them is stored, so a dropped key breaks every link made with it.
Recipients follow links in old emails for years, and the law requires unsubscribe links to work for at least 30 days
after sending: keep retired keys listed for at least two years.
- Generate a key (
openssl rand -base64 32) and append it:EMAIL_LINK_KEYS=<old>,<new>. Deploy api and worker: both now read links made with either key. - Swap the order,
<new>,<old>, and deploy again: new links use the new key. - Years later, remove the old key. After a leak, swap right away (step 1 and 2 at once is fine for the reader side) and accept that anyone holding the old key could forge links that change one recipient's preferences in one environment; drop it once that risk outweighs the broken links.
- Next's data cache is per task (in memory and on local disk). Nothing uses
revalidateTag/ISR today; a sharedcacheHandleris needed before anything does.
coss.com is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only), Copyright (c)
2026 Cal.com, Inc. — except:
packages/sdk(thecossnpm package): MIT, so applications can bundle it freely.packages/mcp(thecoss-mcpnpm package, coss.com's MCP server): MIT, like the SDK it is built on.packages/cli(thecoss-clinpm package, thecosscommand): MIT, likewise.packages/kloq: MIT, vendored fromcosscom/calendar(as is the kloq registry the site serves).- The fonts in
packages/ui/src/fonts: SIL Open Font License 1.1.
Contributions are welcome. Before we can merge your first pull request, you sign our
Contributor License Agreement (CLA.md): the CLA Assistant check on the pull request links to it, and
signing takes one click with your GitHub account. You keep the copyright to your work; the agreement lets Cal.com distribute it under the
project's licenses and under commercial licenses.