Every account DailyTM can use, what it costs you to skip, and the exact steps to configure it.
Three variables are required. Everything else on this page is optional, and skipping something hides its feature rather than breaking the app. That is a guarantee, not an aspiration: a missing required variable fails at startup with a named error listing exactly what to set, and no optional group can bring the app down by being absent.
The short version
cp .env.example .env.local
# fill in DATABASE_URL and BETTER_AUTH_SECRET
pnpm install
pnpm db:generate
pnpm exec prisma migrate deploy
pnpm db:seed # optional: a populated demo workspace
pnpm dev
Open http://localhost:3000/status to confirm the app, the API, and the wire format are all wired up. Then sign up — with no email provider configured, the verification link is printed to the terminal.
Required
1. PostgreSQL — DATABASE_URL
Any Postgres 14 or newer. Neon is what DailyTM is developed against; Supabase, RDS, Railway, Fly, and a plain local server all work.
DATABASE_URL="postgresql://user:password@host:5432/dailytm?sslmode=require"
Locally, if you would rather not sign up for anything:
docker run --name dailytm-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:17
# DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
Use prisma migrate deploy, never db push. Several indexes and constraints in this schema — the board's partial index, the two full-text tsvector columns and their GIN indexes, the attachment size CHECK — cannot be expressed in the Prisma schema and exist only inside migration files. db push skips them silently, and you find out when search does a sequential scan.
2. BETTER_AUTH_SECRET
Signs sessions and tokens. Minimum 32 characters, and the app refuses to start below that.
openssl rand -hex 32
Use a different secret in production from the one on your laptop. Rotating it signs every existing session out, which is the intended behaviour if you ever suspect it leaked.
3. BETTER_AUTH_URL
The absolute origin the app is served from. No trailing slash.
BETTER_AUTH_URL="http://localhost:3000" # development
BETTER_AUTH_URL="https://app.example.com" # production
It must include the scheme. z.url() alone accepts localhost:3000 — it parses as a URL whose protocol is localhost: — so the schema checks for http/https explicitly and rejects the typo at startup rather than at the first OAuth redirect.
This is also what the marketing pages use as their canonical origin and metadataBase, so a wrong value shows up as broken social cards as well as broken redirects.
Optional
Each block below is independent. Skip any of them.
Tests — DATABASE_URL_TEST
The suites truncate every table. Point this at a database that is not DATABASE_URL; the app refuses to start if the two match. Leave it unset and the database-backed suites skip themselves by name rather than failing.
# apply migrations to the test database too
DATABASE_URL="<your test url>" pnpm exec prisma migrate deploy
Email — RESEND_API_KEY, EMAIL_FROM
| Configured | Not configured |
|---|---|
| Verification, password reset, invitations, and notification emails are delivered | Every send is logged to the console and skipped. Verification and invitation links appear in your terminal, so a fresh clone can complete the whole sign-up flow with no account at all |
- Create an account at resend.com.
- Add and verify your sending domain (DNS records — SPF and DKIM). Until it is verified you can only send to your own address.
- Create an API key with Sending access.
- Set
EMAIL_FROMto an address on the verified domain:DailyTM <hello@example.com>.
Google sign-in — GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
Both or neither. A half-configured pair renders no button rather than a broken one.
- Google Cloud Console → APIs & Services → Credentials.
- Create credentials → OAuth client ID → Web application.
- Authorised redirect URI — exactly:
https://your-app.com/api/auth/callback/google(http://localhost:3000/api/auth/callback/googlefor development) - Copy the client ID and secret.
GitHub sign-in — GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Authorization callback URL:
https://your-app.com/api/auth/callback/github - Generate a client secret. GitHub shows it once.
Scheduled jobs — CRON_SECRET
| Configured | Not configured |
|---|---|
| Due-date reminders send, the storage orphan sweep runs | /api/cron/* returns 404 and reminders are simply off. Every other feature works |
The 404 is deliberate — an unconfigured deployment is indistinguishable from one that never had the feature, so probing for it tells an attacker nothing.
openssl rand -hex 32 # minimum 16 characters; a shorter value is refused at startup
On Vercel, vercel.json already declares the schedules and Vercel sends CRON_SECRET as a bearer token automatically. Nothing else to do. Both are daily because the free plan refuses to deploy a cron that runs more than once a day — see DEPLOYMENT.md.
Anywhere else, any scheduler that can send a header will do:
# crontab, systemd timer, GitHub Actions — all equivalent
0 8 * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
https://your-app.com/api/cron/due-date-reminder
30 3 * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
https://your-app.com/api/cron/orphan-sweep
Jobs are idempotent. Running the reminder twice in a day sends one email per task — the guarantee is a unique index, not a flag the job checks, so overlapping runs are safe too.
Object storage — the five S3_* variables
| Configured | Not configured |
|---|---|
| File attachments on tasks, and avatar uploads | The Files tab and the avatar uploader are absent, not disabled. Everything else works |
All five or none. A partial group fails at startup naming what is missing — deliberately different from how a half-set OAuth pair behaves, because somebody who set four of five is trying to enable uploads, and silently hiding the feature hands them a working app with a missing button and no explanation.
Any S3-compatible provider works. There is no adapter interface, because there is one implementation and the provider is a configuration value.
Cloudflare R2
S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
S3_REGION="auto"
S3_BUCKET="dailytm"
S3_ACCESS_KEY_ID="…"
S3_SECRET_ACCESS_KEY="…"
S3_FORCE_PATH_STYLE="false"
MinIO, locally
docker run -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address ":9001"
S3_ENDPOINT="http://localhost:9000"
S3_REGION="us-east-1"
S3_BUCKET="dailytm"
S3_ACCESS_KEY_ID="minioadmin"
S3_SECRET_ACCESS_KEY="minioadmin"
S3_FORCE_PATH_STYLE="true" # MinIO addresses buckets by path
AWS S3, Backblaze B2, DigitalOcean Spaces, and Tigris all work the same way; only the endpoint, region, and path-style flag change.
The bucket stays private
Do not add a public-read policy. Downloads and avatars are served through short-lived presigned URLs, so there is nothing to make public, and a public bucket would make every attachment in every organization readable by anyone with a key.
Bucket CORS — required, and only for PUT
The browser uploads straight to the bucket, and both a PUT and a Content-Type of image/png trigger a preflight. Without this, uploads fail in the browser with a CORS error while every server-side test passes.
On R2: bucket → Settings → CORS policy. On S3: bucket → Permissions → CORS.
[
{
"AllowedOrigins": ["http://localhost:3000", "https://your-app.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type"],
"MaxAgeSeconds": 3600
}
]
Three things about that policy, because each one is a rule people get wrong:
PUTonly. Downloads do not need CORS —window.openis a navigation, not a fetch. Neither do avatars: the<img>points at/api/avatars/<id>on your own origin, which redirects.content-typeand nothing else. The signature travels in the URL's query string, so there is noAuthorizationheader to allow.Content-Lengthis a forbidden header name the browser sets itself and never asks permission for.- Name your origins.
"*"works and is worse: it lets any page on the internet upload to your bucket with a URL it happened to obtain.
src/lib/s3.ts signs ContentLength into the upload URL, so the store itself rejects a body of a different length with a 403. That closes "presign for 1 KB, upload 1 GB" at the storage layer, and finalize additionally reads the real size back with HeadObject and writes that over whatever the client claimed.
Billing — the POLAR_* variables
| Configured | Not configured |
|---|---|
Checkout, a customer portal, and webhooks. New organizations start on FREE |
Every organization is SELF_HOSTED with unlimited limits, and the billing interface is absent. The Polar plugin is never registered, so /api/auth/polar/* does not exist |
Not configured is the right default for a buyer running this for their own team: they have no Polar account and must never reach a limit they have no way to lift.
- Create an organization at polar.sh. Use sandbox first.
- Create two products — Pro and Enterprise — and copy their product IDs.
- Create an access token with product and subscription scopes.
- Register the webhook endpoint. It is:
Copy the signing secret it gives you intohttps://your-app.com/api/auth/polar/webhooksPOLAR_WEBHOOK_SECRET.
POLAR_ACCESS_TOKEN="…"
POLAR_WEBHOOK_SECRET="…"
POLAR_SERVER="sandbox" # "production" when you are charging people
POLAR_PRODUCT_ID_PRO="…"
POLAR_PRODUCT_ID_ENTERPRISE="…"
The two product IDs are optional even with a token set: without them nobody can upgrade, which is a half-finished setup rather than a broken app.
Checkout passes the organization ID as Polar's referenceId, so the team gets the plan rather than whoever held the card. Webhooks are signature-verified and idempotent — a redelivery produces one state change.
Branding — NEXT_PUBLIC_APP_NAME
Defaults to DailyTM. It is used in the browser title, email subjects, the 2FA issuer, the social card, and the web manifest, so setting it here rebrands all of them at once. The mark itself is one SVG path in , drawn by the sidebar logo, the favicon, the Apple icon, and the OG image — change it there and every surface follows.src/lib/brand.ts
Extra origins — BETTER_AUTH_TRUSTED_ORIGINS
Comma-separated. BETTER_AUTH_URL is trusted automatically; this is for preview deployments or a separate marketing domain. Unset is correct for local development.
The public demo — NEXT_PUBLIC_DEMO_MODE
Only for a throwaway deployment. See DEPLOYMENT.md — it makes a cron job willing to delete the demo organization on a schedule.
Production security checklist
Signed off before the v2.0.0 tag, from the better-auth-security-best-practices skill. Every line is something to verify on your deployment, not something to take on trust.
-
BETTER_AUTH_SECRETis 32+ random characters and differs from every other environment.openssl rand -hex 32, not a passphrase. -
BETTER_AUTH_URLishttps://— now enforced, not trusted. Since 2026-08-28 the environment schema refuses anhttp://value for a real domain whenNODE_ENV=production, so a deployment cannot start with one. Loopback is exempt —next buildsetsNODE_ENV=production, so without that exemptionpnpm buildon a development machine would fail, and a loopback address is not reachable from a hostile network anyway. It is checked rather than remembered because Better Auth derives secure-cookie issuance from this protocol: anhttp://production URL shipped the session cookie without theSecureattribute, silently, in an application that otherwise worked. You still have to confirm it matches the domain users actually reach — a mismatch breaks OAuth callbacks in ways that look like random sign-outs, and no schema can check that. -
BETTER_AUTH_TRUSTED_ORIGINSlists every origin that legitimately calls the API — preview deployments, a separate marketing domain — and nothing else. - Rate limiting is on. It is enabled in
src/lib/auth.tsand database-backed, with tightened per-endpoint rules on sign-in, sign-up, and password reset. Do not loosen it to make a test pass. - CSRF protection is on. Better Auth handles it; it depends on the trusted-origins list above being correct.
- Cookies are secure. Automatic when
BETTER_AUTH_URLishttps://, which the line above now guarantees in production. Still worth one look in the browser's application panel —Secure,HttpOnly,SameSite=Lax— because a proxy that terminates TLS and forwardshttpinternally can undo it. -
encryptOAuthTokensis on. Provider access tokens are encrypted at rest. -
session.cookieCacheis deliberately off. Do not turn it on to save a query. A staleactiveOrganizationIdscopes queries to the wrong tenant, and a Server Component cannot set a cookie — so the server-side organization switch never takes effect and the shell redirects to itself forever. One indexed read per request is the right price; the reasoning is in the comment onsessioninsrc/lib/auth.ts. - Background tasks are configured via Next's own
after(), so no@vercel/functionsdependency and it works on any Node host. - Audit logging is live.
databaseHookswriteActivityEventrows for sign-ins and account links; every shared-state mutation writes one in its own transaction. Confirm the organization audit timeline at/<org>/settings/audithas rows in it. - IP headers are correct behind your proxy. Rate limiting is per-IP. If every request arrives with the proxy's address, one abusive client locks out everyone — check that
x-forwarded-forreaches the app. - Account enumeration is not defeated. Sign-in with an unknown email and with a wrong password must return identical responses. If you customise the auth pages, do not "improve" the error message.
- The database is not publicly reachable, and
DATABASE_URLuses TLS (sslmode=requireor stricter). -
CRON_SECRETis set if you want reminders, and is not a short string. - The storage bucket is private, with CORS restricted to your real origins and
PUTonly. -
NEXT_PUBLIC_DEMO_MODEis unset or"false". It is the one flag that lets a scheduled job delete data.
Troubleshooting
| Symptom | Cause |
|---|---|
EnvironmentError naming variables at startup |
Exactly what it says — the named variables are missing or malformed. It lists all of them at once. |
| The app starts but OAuth fails on redirect | BETTER_AUTH_URL does not match the origin the browser is on, or the provider's callback URL is not exactly <origin>/api/auth/callback/<provider>. |
| Search returns nothing for text that clearly exists | The database was prepared with db push. The tsvector columns and their GIN indexes only exist in migration files. Run prisma migrate deploy against a fresh database. |
| Uploads work in tests but fail in the browser | Bucket CORS. See the policy above — PUT, content-type, your origins. |
pnpm test destroys your development data |
DATABASE_URL_TEST was pointed at the development database. The app refuses when they are identical, so they differed by a query string or a trailing character. |
npx crashes with an arborist error |
Use pnpm dlx. npm's arborist cannot walk pnpm's symlinked node_modules. |
prisma migrate dev applies and then hangs |
Known. Kill it, and check for orphaned migrate dev processes — they hold connections and will make later test runs fail on fixtures they just created. Use migrate deploy, which never prompts. |
pnpm test seems to hang for a very long time |
It is probably slow rather than stuck. The database suites truncate every table before each test, so against a hosted Postgres the whole run takes tens of minutes — measured at 24 minutes for 489 tests against a Neon instance a continent away, or roughly seven seconds per database-backed test. Point DATABASE_URL_TEST at a local container and the same run takes minutes. Piping the runner through tail also buffers every line until it exits, which makes slow look identical to hung. |
| A database suite fails on rows it just created | Two vitest run invocations are going at once. They share one test database, and one run's TRUNCATE lands between the other's insert and its assertion. Kill every vitest process and run once. |