Integrations
June 8, 2026 · View on GitHub
The boilerplate ships the wiring — endpoints, ports, env-validated secrets, hooks. To run at full potential, a few things must be configured outside the codebase: Resend templates, a scheduler, a storage bucket, DNS records.
This document is the punch list. Walk it top-to-bottom before going live.
For per-feature internals, see FEATURES.md. For the rationale
behind each choice, see HISTORY.md. For cron wiring details,
see CRON.md.
1. Resend — email templates
Resend templates are managed in the dashboard, not in code. The API only references them by ID. Each template must exist with the exact variables the codebase passes — Resend returns a 422 on mismatch.
Template IDs live in a single hashmap at the top of
apps/api/src/shared/services/email.service.ts (TEMPLATE_IDS). Edit it
once when cloning. They aren't secrets — just opaque dashboard handles — so
keeping them in code is simpler than 8 env vars.
Template inventory
Key in TEMPLATE_IDS | Used for | Variables |
|---|---|---|
verify_email | Email confirmation on sign-up | name, verifyUrl |
reset_password | Forgot-password flow | name, resetUrl |
magic_link | Passwordless sign-in | magicUrl |
org_invitation | Inviting a member to an organization | inviterName, orgName, role, inviteUrl |
data_export_ready | RGPD data export ready | name, downloadUrl, expiresAt |
delete_requested | Account-deletion grace started | name, cancelUrl, expiresAt |
delete_cancelled | User cancelled deletion in time | name |
delete_completed | Account anonymized after grace | name |
Authoring rules
- Variable names are case-sensitive and must match the table above
exactly. The single source of truth is
EmailTemplatesinapps/api/src/shared/ports/email.port.ts. - All URLs point at
APP_URL(the front), never the API. The server hooks build them; the front consumes the token. - Brand the visible label, never embed the raw URL — Outlook/Gmail
re-autolink visible URL text and break
?token=.... expiresAtis an ISO string (new Date(...).toISOString()); render it with the user's locale on the template side.- Boot fails hard in production if
RESEND_API_KEYis missing or anyTEMPLATE_IDSentry is empty — seeResendEmailServiceconstructor.
DNS hardening (mandatory before sending in production)
Gmail / Yahoo / Outlook reject unauthenticated bulk senders since 2024-2025. Three records to add on the sending domain:
- SPF — TXT record published by Resend (one-line
v=spf1 include:...). - DKIM — three CNAMEs from the Resend dashboard.
- DMARC — TXT
_dmarc.<domain>with at minimumv=DMARC1; p=none; rua=mailto:dmarc@<domain>. Tighten top=quarantineorp=rejectonce reports are clean.
Resend's dashboard verifies all three — don't ship without the green check.
2. Scheduler — cron jobs
The boilerplate stays scheduler-agnostic. It exposes protected internal endpoints; you wire your own scheduler.
Jobs to wire
| Endpoint | Recommended cadence | Purpose |
|---|---|---|
POST /internal/rgpd-sweep | Daily, e.g. 0 3 * * * UTC | Wipes accounts whose 7-day grace window has elapsed. Idempotent — safe to over-schedule. |
All /internal/* endpoints are protected by HMAC-signed requests
(X-Internal-Signature: t=<unix>,v1=<hex> over a canonical message — see
CRON.md). The signing key never travels on the wire. On
infra with a private mesh (Railway, Fly), stack private-network on top
via INTERNAL_AUTH_LAYERS=signature,private-network.
Choose one scheduler
Implementation snippets for each option (GitHub Actions, Vercel Cron, Railway,
K8s CronJob, Inngest, BullMQ) are in CRON.md. Pick by infra:
- Serverless / managed PaaS → Vercel Cron or Railway Cron (zero infra).
- GitHub-only ops → GitHub Actions cron (free, no extra service).
- Kubernetes → CronJob (native, observable via
kubectl). - Already running a queue → Inngest or BullMQ (reuse infra, retries built-in).
When to add a job
Each new business job follows the same pattern:
- Add an internal endpoint under
apps/api/src/modules/<context>/routes.ts (internal sub-app). - Document it in
CRON.mdand in the table above. - Wire it in your chosen scheduler.
Keep payloads small ({} or a tiny config object) — the work happens
server-side, the scheduler is just a trigger.
3. Storage — S3-compatible bucket
Dev uses SeaweedFS (opt-in via Docker Compose profile storage —
docker compose --profile storage up -d; bucket auto-created by seaweedfs-init).
Host port is pinned to 8333; inside the compose network it's reachable as
seaweedfs:8333. Production uses any S3-compatible provider. Cloudflare R2 is the recommended default (zero egress
fees, S3 API compatibility verified for the patterns this stack uses — see
HISTORY.md for the verified-2026 list).
Why SeaweedFS over MinIO? MinIO was archived in April 2026 (maintenance mode + features moved behind enterprise license). SeaweedFS is Apache 2.0, ~96 MB image, single-process in dev (
weed server -s3), full S3 compat (presigned URLs, multipart). Same SDK, zero code change.
Provisioning checklist (production)
- Create the bucket in the chosen region/jurisdiction. Once created, R2 buckets typically can't be moved between jurisdictions — pick the right one for your user base (EU for RGPD-only customers).
- Generate API credentials scoped to the bucket (least-privilege: read + write + delete + list, no admin).
- Disable public access on the bucket. Public reads happen via
presigned-GET (
presignDownload), not public objects. - CORS policy: allow the front origin (
APP_URL) forPUT,GET,HEADwith the headers the SDK signs (Content-Type,Content-Length). - Set the env vars:
S3_ENDPOINT,S3_REGION(autofor R2),S3_BUCKETS3_ACCESS_KEY,S3_SECRET_KEYS3_FORCE_PATH_STYLE=trueS3_PUBLIC_URL— base URL prefix for public asset reads (only used when the object is intentionally public).
Boot fails hard if NODE_ENV=production and the endpoint is localhost or
credentials are the dev defaults (dev/dev).
Optional follow-ups (not yet shipped)
- Orphan GC cron — sweep storage objects with no DB row referencing the key. Deferred until the first business table stores keys (rule 14).
- Lifecycle rules on the bucket — archive
<userId>/exports/to cold storage after 7 days (the export presigned URL TTL), or delete after 30 days if you don't want long-term retention.
4. Production secrets — checklist
Every secret below must be set in production. Boot validates via Zod and fails fast on missing/invalid values.
Required
DATABASE_URL— Postgres connection string (managed Postgres recommended).BETTER_AUTH_URL,BETTER_AUTH_SECRET(min 32, generate withopenssl rand -base64 32).APP_URL— front origin used in every email URL.CORS_ORIGIN— comma-separated allow-list for the API.INTERNAL_SIGNING_KEY(min 32,openssl rand -hex 32) — HMAC key for/internal/*request signing. Never sent on the wire.INTERNAL_AUTH_LAYERS—signature(default) orsignature,private-networkon Railway/Fly for defense-in-depth.RESEND_API_KEY. Template IDs live inTEMPLATE_IDSin code (see §1), not env.S3_*env vars (see §3).
RGPD knobs (defaults work)
RGPD_GRACE_PERIOD_DAYS(default7)RGPD_EXPORT_RATE_LIMIT_HOURS(default24)RGPD_SWEEP_BATCH_SIZE(default50)
Optional
RESEND_FROM— defaults toonboarding@resend.dev; switch to your authenticated domain once DNS is green.
The full schema lives in apps/api/common/env.ts. .env.example at the API
root is the up-to-date template.
5. Database
- First-time provisioning:
pnpm db:push(dev) orpnpm db:generate && pnpm db:migrate(prod-style — ship migrations as artifacts).db:pushrunsdrizzle-kit push --forcebecause it executes under Turbo's non-TTY pipe; drizzle-kit's interactive data-loss prompt would otherwise hang. Safe in dev (push is dev-only); prod usesdb:migratewhich doesn't prompt. - Backups: configured at the managed-Postgres level. Test restore procedure before going live — a backup you can't restore is not a backup.
- Pool sizing: defaults match Bun's connection pooling; tune
DATABASE_URLquery params (?max=10) only after measuring contention.
6. Optional follow-ups
These aren't blockers for launch but pay off quickly:
- Uptime monitoring: external probe on
GET /readyz(returns 200 once DB reachable)./livezis the liveness counterpart (no external deps). - Stripe customer deletion: when
@better-auth/stripelands (Phase B.1), wire the customer cleanup intoRgpdService.executeAccountWipe— same TX as the rest of the wipe. (Error tracking + the RGPD audit trail are already shipped: Sentry in Phase 0.4, and every RGPD transition auto-audits via the outboxAuditEventSubscriber— no TODO hooks left in the service.)
TL;DR — pre-production checklist
- 8 Resend templates created with exact variable names; IDs filled in
TEMPLATE_IDSinapps/api/src/shared/services/email.service.ts - DNS records for sending domain (SPF, DKIM, DMARC) — green in Resend
- S3 bucket provisioned, scoped credentials, CORS configured
-
INTERNAL_SIGNING_KEYgenerated (≥32 chars);INTERNAL_AUTH_LAYERSset per infra (signatureeverywhere, addprivate-networkon Railway/Fly) -
BETTER_AUTH_SECRETgenerated (≥32 chars) - All env vars set per §4
- Cron service chosen and wired to
/internal/rgpd-sweep(daily) - DB migrations applied; backups verified
-
pnpm ci:checkgreen; smoke test on staging (signup → invite → upload → export → request deletion → cancel → expire grace → sweep)