Environment Variables
Complete reference for all LinkDen environment variables.
Environment Variables
A quick-reference for every environment variable used by LinkDen.
Quick Start
cp .env.example .envLinkDen uses a single .env file at the monorepo root. Five variables are required to run the app; a few more are needed to deploy it.
Required Variables
| Variable | Description | Example |
|---|---|---|
BETTER_AUTH_SECRET | Secret key for session signing. Generate with openssl rand -base64 32. Must be at least 32 characters (and not a placeholder) whenever BETTER_AUTH_URL is not a localhost URL, otherwise sign-in and the admin API answer 503 | k3jf8s... |
BETTER_AUTH_URL | Base URL of the API server | http://localhost:3000 |
CORS_ORIGIN | Frontend URL (must match exactly, including protocol, no trailing slash) | http://localhost:3001 |
NEXT_PUBLIC_SERVER_URL | Public URL of the API server, used by the Next.js frontend | http://localhost:3000 |
NEXT_PUBLIC_SITE_URL | Public URL of the site itself. Used for robots.txt, sitemap.xml, and Open Graph URLs. Required for a production build and deploy (the deploy fails early when it is empty); only local dev falls back to http://localhost:3001 | http://localhost:3001 |
In production these are all the same hostname. LinkDen runs same-origin: the web Worker owns SITE_DOMAIN and the API Worker is routed under it at /api/* and /trpc/*. So with SITE_DOMAIN=links.yourdomain.com:
BETTER_AUTH_URL=https://links.yourdomain.com
NEXT_PUBLIC_SERVER_URL=https://links.yourdomain.com
CORS_ORIGIN=https://links.yourdomain.com
NEXT_PUBLIC_SITE_URL=https://links.yourdomain.comLocally the web app (:3001) and API (:3000) are different origins, which is why the local examples differ.
Deploy-time validation
bun ship (and the GitHub deploy) refuses to start, before touching any Cloudflare resource, when:
- any of the five required variables is empty or has surrounding whitespace (an unset GitHub secret arrives as an empty string);
BETTER_AUTH_SECRETis shorter than 32 characters or is Better Auth's public default;BETTER_AUTH_URL,CORS_ORIGIN,NEXT_PUBLIC_SERVER_URLorNEXT_PUBLIC_SITE_URLis not anhttps://URL;CORS_ORIGINis more than a bare origin (no path, no trailing slash);SITE_DOMAINis set andBETTER_AUTH_URL,CORS_ORIGINorNEXT_PUBLIC_SITE_URLis not exactlyhttps://SITE_DOMAIN.
A deploy reads the root .env (or the CI environment) only. apps/web/.env and apps/server/.env are loaded by bun dev alone and are never read by a deploy.
Cloudflare Deployment
Only needed when deploying with bun ship or the GitHub Actions deploy workflow:
| Variable | Description |
|---|---|
CLOUDFLARE_API_TOKEN | API token with Workers Scripts:Edit, Workers Routes:Edit, D1:Edit, R2:Edit, Zone:Read, DNS:Edit, SSL and Certificates:Edit. The Zone permissions are only exercised when SITE_DOMAIN is set |
CLOUDFLARE_ACCOUNT_ID | Your Cloudflare account ID |
ALCHEMY_PASSWORD | Encrypts secrets inside Alchemy's deploy state. Generate with openssl rand -base64 32 and never change it once deployed |
ALCHEMY_STATE_TOKEN | Auth token for the alchemy-state CloudflareStateStore Worker. The one LinkDen is configured for is MrDemonWolf's; third-party self-hosters deploy their own state worker or switch stateStore in packages/infra/alchemy.run.ts. See the Cloudflare guide |
ALCHEMY_STAGE | Alchemy stage name. Defaults to prod. Any other value (for example dev) deploys a second, independent copy whose resources carry the stage as a suffix (linkden-db-dev, linkden-images-dev, linkden-api-dev, linkden-dev) so it can never adopt, overwrite, or destroy the production ones |
LINKDEN_DB_NAME / LINKDEN_IMAGES_BUCKET | Upgrades and restores only, prod stage only. Deploys made before 0.5.0 let Alchemy generate the D1/R2 names (a CI deploy produced linkden-database-runner and linkden-images-runner). Set these to those existing names so the prod stage adopts your data instead of creating an empty linkden-db / linkden-images. Any other stage ignores them (with a warning). The backup workflow and bun reset:password --remote read LINKDEN_DB_NAME too. See upgrading an older deploy |
LINKDEN_CONFIRM_DESTROY_PROD | Set to yes to let bun run destroy tear down the prod stage. Without it the command refuses. Even then the production D1 database and R2 buckets are kept (delete: false) once a deploy from this version has recorded that |
SETUP_TOKEN | Optional, recommended on a public deploy. While no admin exists, sign-up must send this value in the x-setup-token header (the setup wizard's Setup token field does that); a wrong or missing token gets 403 "Invalid setup token". Unset = whoever reaches /admin/setup first becomes the admin. Generate with openssl rand -base64 32; store it as a production environment secret in GitHub |
SITE_DOMAIN | Your hostname, for example links.yourdomain.com. When set, Alchemy attaches a Custom Domain to the web Worker and /api/* + /trpc/* routes to the API Worker. Leave unset to deploy to workers.dev (staging; admin login does not work there) |
Runtime Configuration
Most settings that were previously environment variables are now managed through the admin UI at /admin/settings and stored in the database:
| Setting | Location |
|---|---|
| Email provider, API key, sender, contact-form delivery | Settings → Email (Resend; Cloudflare Email Workers coming soon) |
| CAPTCHA provider and keys, MapKit | Settings → Integrations |
| Apple Wallet pass design, Team ID, Pass Type ID, signing keys | Settings → Wallet |
| Branding / whitelabel, vCard, consent banner | Design → Branding |
| Magic link, two-factor, timezone | Settings → Account |
This keeps the .env file minimal and lets you change settings without redeploying.
Apple Wallet (Optional)
Only required if you want to generate Apple Wallet passes. All five values are needed together — if any one is missing, pass downloads answer 503 and Settings → Wallet shows what is missing. You can also upload the same material in Settings → Wallet instead; a value saved there overrides the environment variable.
Certificates and the key can be raw PEM, base64 of the PEM (one line, easiest for .env and GitHub secrets), or, for certificates, base64 of a DER .cer file. The key must be an RSA key (PKCS#1 or PKCS#8); EC keys and .p12 bundles are not supported.
| Variable | Description |
|---|---|
WALLET_SIGNER_CERT | Your Pass Type signing certificate |
WALLET_SIGNER_KEY | Private key for the signing certificate |
WALLET_SIGNER_KEY_PASSPHRASE | Only if the key is encrypted (-----BEGIN ENCRYPTED PRIVATE KEY-----) |
WALLET_WWDR_CERT | Apple's WWDR intermediate certificate |
WALLET_TEAM_ID | Your Apple Developer Team ID (e.g. ABC12DEF34) |
WALLET_PASS_TYPE_ID | Your Pass Type Identifier (e.g. pass.com.yourname.linkden) |
These are secrets — keep them in
.envlocally or in GitHub environment secrets. Never commit them to git.See the Apple Wallet guide for step-by-step certificate setup.
Complete .env Example
# Required
BETTER_AUTH_SECRET=your-generated-secret-key
BETTER_AUTH_URL=http://localhost:3000
CORS_ORIGIN=http://localhost:3001
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
NEXT_PUBLIC_SITE_URL=http://localhost:3001
# Cloudflare deployment only
# CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
# CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id
# ALCHEMY_PASSWORD=your-generated-state-secret
# ALCHEMY_STATE_TOKEN=your-state-worker-token
# ALCHEMY_STAGE=prod
# SITE_DOMAIN=links.yourdomain.com
# SETUP_TOKEN=your-generated-setup-token
# LINKDEN_CONFIRM_DESTROY_PROD=yes # only when you really mean to destroy prod
# Apple Wallet (optional — all 5 required together)
# WALLET_SIGNER_CERT=base64-of-signer_cert.pem
# WALLET_SIGNER_KEY=base64-of-signer_key.pem
# WALLET_WWDR_CERT=base64-of-AppleWWDRCAG4.cer
# WALLET_SIGNER_KEY_PASSPHRASE=only-if-the-key-is-encrypted
# WALLET_TEAM_ID=ABC12DEF34
# WALLET_PASS_TYPE_ID=pass.com.yourname.linkdenSetting Variables
Alchemy binds these to the Workers at deploy time from your .env (local bun ship) or from the GitHub production environment (deploy.yml). In GitHub, store SITE_DOMAIN, NEXT_PUBLIC_SITE_URL (and LINKDEN_DB_NAME / LINKDEN_IMAGES_BUCKET if you need them) as variables and everything else as secrets. Values set by hand in the Cloudflare dashboard are overwritten on the next deploy.
First-Time Setup
After deployment, navigate to /admin/setup to create your admin account. The first user becomes the admin and registration locks automatically (a database trigger enforces a single admin). Until then the setup page is open to anyone who finds it, so on a public deploy set SETUP_TOKEN and enter it in the wizard's Setup token field.
Local development only
| Variable | Where | Description |
|---|---|---|
DEV_LOGIN | apps/server/.dev.vars | "true" registers a credential-free POST /api/auth/dev-login. It works only when BETTER_AUTH_URL is plain http:// on localhost / 127.0.0.1 / [::1], and the endpoint 404s for any non-loopback Host, so it can't work through a tunnel or in production. Never bound by the deploy |
NEXT_PUBLIC_DEV_LOGIN | apps/web/.env | Shows the matching button on /admin/login |
CLOUDFLARE_D1_DATABASE_ID, CLOUDFLARE_D1_TOKEN (+ CLOUDFLARE_ACCOUNT_ID) | shell | Only for bunx drizzle-kit push / studio against a throwaway remote D1. Push bypasses the migration history, so never point it at production |
Troubleshooting
CORS errors
CORS_ORIGIN must exactly match the web app URL — including protocol (https://), no trailing slash. In production it equals https://SITE_DOMAIN.
Authentication not working
- Verify
BETTER_AUTH_SECRETis set (32+ characters);/api/healthshows"auth": "misconfigured"otherwise - Verify
BETTER_AUTH_URLmatches the URL the browser actually reaches the API on (https://SITE_DOMAINin production) - If you are on
workers.dev, the web and API Workers are different origins and the session cookie cannot cross them. SetSITE_DOMAINand redeploy
Changes not taking effect
NEXT_PUBLIC_SERVER_URLandNEXT_PUBLIC_SITE_URLare baked into the frontend at build time. Changing either requires a redeploy (bun shiprebuilds).- Server-side variables take effect on the next request after a redeploy.