LinkDen
Self-Hosting

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 .env

LinkDen 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

VariableDescriptionExample
BETTER_AUTH_SECRETSecret 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 503k3jf8s...
BETTER_AUTH_URLBase URL of the API serverhttp://localhost:3000
CORS_ORIGINFrontend URL (must match exactly, including protocol, no trailing slash)http://localhost:3001
NEXT_PUBLIC_SERVER_URLPublic URL of the API server, used by the Next.js frontendhttp://localhost:3000
NEXT_PUBLIC_SITE_URLPublic 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:3001http://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.com

Locally 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_SECRET is shorter than 32 characters or is Better Auth's public default;
  • BETTER_AUTH_URL, CORS_ORIGIN, NEXT_PUBLIC_SERVER_URL or NEXT_PUBLIC_SITE_URL is not an https:// URL;
  • CORS_ORIGIN is more than a bare origin (no path, no trailing slash);
  • SITE_DOMAIN is set and BETTER_AUTH_URL, CORS_ORIGIN or NEXT_PUBLIC_SITE_URL is not exactly https://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:

VariableDescription
CLOUDFLARE_API_TOKENAPI 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_IDYour Cloudflare account ID
ALCHEMY_PASSWORDEncrypts secrets inside Alchemy's deploy state. Generate with openssl rand -base64 32 and never change it once deployed
ALCHEMY_STATE_TOKENAuth 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_STAGEAlchemy 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_BUCKETUpgrades 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_PRODSet 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_TOKENOptional, 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_DOMAINYour 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:

SettingLocation
Email provider, API key, sender, contact-form deliverySettings → Email (Resend; Cloudflare Email Workers coming soon)
CAPTCHA provider and keys, MapKitSettings → Integrations
Apple Wallet pass design, Team ID, Pass Type ID, signing keysSettings → Wallet
Branding / whitelabel, vCard, consent bannerDesign → Branding
Magic link, two-factor, timezoneSettings → 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.

VariableDescription
WALLET_SIGNER_CERTYour Pass Type signing certificate
WALLET_SIGNER_KEYPrivate key for the signing certificate
WALLET_SIGNER_KEY_PASSPHRASEOnly if the key is encrypted (-----BEGIN ENCRYPTED PRIVATE KEY-----)
WALLET_WWDR_CERTApple's WWDR intermediate certificate
WALLET_TEAM_IDYour Apple Developer Team ID (e.g. ABC12DEF34)
WALLET_PASS_TYPE_IDYour Pass Type Identifier (e.g. pass.com.yourname.linkden)

These are secrets — keep them in .env locally 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.linkden

Setting 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

VariableWhereDescription
DEV_LOGINapps/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_LOGINapps/web/.envShows the matching button on /admin/login
CLOUDFLARE_D1_DATABASE_ID, CLOUDFLARE_D1_TOKEN (+ CLOUDFLARE_ACCOUNT_ID)shellOnly 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_SECRET is set (32+ characters); /api/health shows "auth": "misconfigured" otherwise
  • Verify BETTER_AUTH_URL matches the URL the browser actually reaches the API on (https://SITE_DOMAIN in production)
  • If you are on workers.dev, the web and API Workers are different origins and the session cookie cannot cross them. Set SITE_DOMAIN and redeploy

Changes not taking effect

  • NEXT_PUBLIC_SERVER_URL and NEXT_PUBLIC_SITE_URL are baked into the frontend at build time. Changing either requires a redeploy (bun ship rebuilds).
  • Server-side variables take effect on the next request after a redeploy.

On this page