LinkDen
Self-Hosting

Cloudflare Deployment

Deploy LinkDen on Cloudflare Workers and D1 — the recommended hosting option.

☁️ Cloudflare Deployment

Deploy LinkDen to Cloudflare's free tier with two Workers, a D1 database, and two R2 buckets. LinkDen was built for this. One command provisions everything.

⚡ TL;DR

  1. Get a free Cloudflare account → create an API token.
  2. Fill in .env with your token, account ID, Alchemy state settings, and secrets.
  3. Run bun ship. (Deploy everything in one shot.)
  4. Copy the real URLs from the output → update .env → run bun ship again.
  5. Set SITE_DOMAIN to your hostname, point the URLs at it, bun ship once more.
  6. Go to /admin/setup and create your account. Done.

✅ Step 0 — Prerequisites

Before you start, make sure you have:

  • A Cloudflare account (free tier is fine)
  • Bun installed (bun --version should work)
  • Node.js 24 installed (the deploy runs Alchemy under Node via tsx; Bun crashes on it)
  • The repo cloned and dependencies installed:
git clone https://github.com/mrdemonwolf/linkden.git
cd linkden
bun install

🔑 Step 1 — Create a Cloudflare API Token

LinkDen uses Alchemy (IaC) to provision your Workers, D1 database, R2 buckets, custom domain, and routes automatically. It needs an API token to do that.

  • Log in to the Cloudflare dashboard.

  • Click your profile icon (top right) → My Profile.

  • Go to API Tokens → Create Token → Custom token.

  • Add the following permissions:

    ResourcePermission
    Account → Workers ScriptsEdit
    Account → D1Edit
    Account → Workers R2 StorageEdit
    Zone → Workers RoutesEdit
    Zone → ZoneRead
    Zone → DNSEdit
    Zone → SSL and CertificatesEdit

    The Zone permissions are what let Alchemy attach your custom domain and the /api/* and /trpc/* routes. If you only ever deploy to workers.dev, the Account permissions alone are enough.

  • Under Account Resources, select your account. Under Zone Resources, select the zone that will host your site (or all zones).

  • Click Continue to summary → Create Token.

  • Copy the token immediately — it's only shown once.

💡 Where's my Account ID? Find it on the main Cloudflare dashboard in the right sidebar, or run npx wrangler whoami.


⚙️ Step 2 — Configure .env

Create a .env file in the project root (not inside apps/):

# ── Cloudflare ─────────────────────────────────────────────
CLOUDFLARE_API_TOKEN=paste-your-token-here
CLOUDFLARE_ACCOUNT_ID=paste-your-account-id-here

# ── Alchemy (deploy state) ──────────────────────────────────
ALCHEMY_PASSWORD=paste-a-random-secret-here
ALCHEMY_STATE_TOKEN=paste-your-state-worker-token-here
# ALCHEMY_STAGE=prod

# ── App URLs ────────────────────────────────────────────────
# Use placeholder values for now — you'll get real URLs after first deploy.
# Your subdomain is your Cloudflare account name (shown in the dashboard).
NEXT_PUBLIC_SERVER_URL=https://linkden-api.<your-subdomain>.workers.dev
CORS_ORIGIN=https://linkden.<your-subdomain>.workers.dev
NEXT_PUBLIC_SITE_URL=https://linkden.<your-subdomain>.workers.dev

# ── Better Auth ─────────────────────────────────────────────
BETTER_AUTH_SECRET=paste-a-random-secret-here
BETTER_AUTH_URL=https://linkden-api.<your-subdomain>.workers.dev

# ── Custom domain (leave unset until you are ready) ────────
# SITE_DOMAIN=links.yourdomain.com

# ── First-run guard (recommended on a public deploy) ───────
# SETUP_TOKEN=paste-a-random-secret-here

# ── Apple Wallet (optional — skip if you don't need Wallet passes) ──
# One line each: base64 of the PEM file (raw PEM also works) — see the Apple Wallet guide.
# 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

Generate a secret (once each for ALCHEMY_PASSWORD, BETTER_AUTH_SECRET and SETUP_TOKEN):

openssl rand -base64 32

🛡️ The deploy checks these before it touches anything. bun ship refuses to run while a required value is empty, while BETTER_AUTH_SECRET is shorter than 32 characters (or is Better Auth's public default), while a URL is not https://, while CORS_ORIGIN is more than a bare origin, or (once SITE_DOMAIN is set) while BETTER_AUTH_URL, CORS_ORIGIN or NEXT_PUBLIC_SITE_URL differ from https://SITE_DOMAIN. apps/web/.env and apps/server/.env are dev-only and never read by a deploy.

About the Alchemy state store

Alchemy keeps a record of which Cloudflare resources it created. LinkDen's packages/infra/alchemy.run.ts points that record at a shared alchemy-state Worker (a CloudflareStateStore) that the MrDemonWolf apps all use, authenticated by ALCHEMY_STATE_TOKEN. ALCHEMY_PASSWORD encrypts the secrets inside that state.

That shared worker is specific to the MrDemonWolf account. As a third-party self-hoster you have two options:

  1. Deploy your own alchemy-state worker in your account (see the Alchemy docs for CloudflareStateStore) and set ALCHEMY_STATE_TOKEN to the token you gave it, or
  2. Switch the stateStore in packages/infra/alchemy.run.ts to something local, for example the default file-system store, by removing the stateStore option. Then ALCHEMY_STATE_TOKEN is not needed and state lives in packages/infra/.alchemy/ (keep it out of git, but do not lose it: it is how Alchemy knows what it already deployed).

ALCHEMY_STAGE separates deployments of the same app. It defaults to prod; set it to dev for a throwaway staging copy. Every resource of a non-prod stage gets the stage as a name suffix (linkden-db-dev, linkden-images-dev, linkden-backups-dev, linkden-api-dev, linkden-dev), so a staging deploy — and ALCHEMY_STAGE=dev bun run destroy — can never touch the production database, buckets, or Workers.

Upgrading a deploy made before 0.5.0

Before 0.5.0 the D1 database and R2 bucket had no fixed names, so Alchemy generated them: a GitHub Actions deploy created linkden-database-runner and linkden-images-runner (a local bun ship used your login name instead of runner). 0.5.0 pins the names to linkden-db / linkden-images and moves state to the prod stage. On its own that would create a brand-new, empty database and bucket, put the public page back on the Welcome screen, and reopen /admin/setup to anyone — so before you deploy 0.5.0 over an existing install, set two variables to the names you already have (Cloudflare dashboard → Storage & Databases → D1 / R2):

LINKDEN_DB_NAME=linkden-database-runner
LINKDEN_IMAGES_BUCKET=linkden-images-runner

In GitHub Actions these are variables on the production environment (next to SITE_DOMAIN). The overrides apply to the prod stage only: any other stage ignores them (with a warning), so a staging deploy can never adopt your production data. The prod stage then adopts your existing resources by name, runs any pending migrations against them, and nothing is re-created. The old runner (or <login>) stage's state is simply left behind. Do not try to keep the old stage by setting ALCHEMY_STAGE to it instead: in the same stage Alchemy treats the new name as a rename, which creates the new resource and deletes the old one.

💡 What's my subdomain? It's the account name shown in the Cloudflare dashboard sidebar (e.g. myname → linkden-api.myname.workers.dev). You can also find it after the first deploy in the command output.


🚀 Step 3 — First Deploy

bun ship

This single command:

  1. Creates your D1 database (linkden-db) and runs schema migrations.
  2. Creates the R2 buckets linkden-images (uploads) and linkden-backups (daily D1 exports, 30-day expiry).
  3. Deploys the API Worker (linkden-api).
  4. Deploys the Web Worker (linkden) with a service binding to the API.

At the end, the terminal prints your real Worker URLs, like:

Web    -> https://linkden.myname.workers.dev
Server -> https://linkden-api.myname.workers.dev

🔄 Step 4 — Update URLs and Redeploy

This step is required. Workers need to know their own URLs to handle auth and CORS correctly.

  1. Copy the URLs from the Step 3 output.
  2. Update .env with the real URLs (replace the placeholder <your-subdomain> values):
NEXT_PUBLIC_SERVER_URL=https://linkden-api.myname.workers.dev
CORS_ORIGIN=https://linkden.myname.workers.dev
NEXT_PUBLIC_SITE_URL=https://linkden.myname.workers.dev
BETTER_AUTH_URL=https://linkden-api.myname.workers.dev
  1. Run deploy again:
bun ship

⚠️ Admin login does not work on workers.dev. The web and API Workers sit on two different workers.dev origins, and the session cookie cannot cross them, so /admin redirects back to the login page. The public page and /api/health work fine, which is what a workers.dev deploy is for (staging). For a working admin, finish Step 5.


With a custom domain LinkDen runs same-origin: the web app owns the hostname and the API is routed under it.

WhatWhere
Web Worker linkdenCustom Domain on SITE_DOMAIN
API Worker linkden-apiRoutes SITE_DOMAIN/api/* and SITE_DOMAIN/trpc/*

Routes are matched before the custom-domain Worker, so /api/* and /trpc/* reach the API and everything else reaches Next.js. No CORS, no cookie domain tricks.

  1. The domain must be a zone in your Cloudflare account, and the hostname must not already have a DNS record (Cloudflare creates it for you and refuses to overwrite an existing one).
  2. Set SITE_DOMAIN and point every URL at it:
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
  1. Run bun ship one more time. The deploy log shows the custom domain and two routes being created.

See the Custom Domain guide for DNS details and rollback.


👤 Step 6 — Create Your Admin Account

  1. Open https://links.yourdomain.com/admin/setup.
  2. Fill in your email and password. If you set SETUP_TOKEN, paste it into the Setup token field.
  3. Click Create Account.

First user is admin. Registration permanently locks after this — no one else can sign up. Until you finish this step, anyone who reaches /admin/setup first could claim the install, which is what SETUP_TOKEN prevents.


🤖 Deploying from GitHub Actions

The repo ships .github/workflows/deploy.yml, which runs the same bun run deploy after CI passes on a push to this repository's main (a fork's pull request never deploys, even from a branch named main). A running deploy is never cancelled by a newer one.

Put the values from Step 2 into the repo's production environment:

  • Secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, ALCHEMY_PASSWORD, ALCHEMY_STATE_TOKEN, BETTER_AUTH_SECRET, BETTER_AUTH_URL, CORS_ORIGIN, NEXT_PUBLIC_SERVER_URL, plus the optional WALLET_* and SETUP_TOKEN.
  • Variables: SITE_DOMAIN, NEXT_PUBLIC_SITE_URL, and LINKDEN_DB_NAME / LINKDEN_IMAGES_BUCKET for pre-0.5.0 installs.

A preflight step lists any missing secret or variable by name, and the deploy refuses empty, weak or mismatched auth values (same rules as above). Every deploy's job summary records a D1 Time Travel bookmark taken just before the deploy (see Rollback below), and a smoke test probes the web app afterwards.

A separate backup-db.yml workflow exports D1 to linkden-backups daily. It only runs once the production environment secrets are set; run it once by hand (workflow_dispatch) and check that linkden-backups/d1/<date>.sql exists.

🔐 Recommended GitHub settings: give the production environment a required reviewer and a main-only deployment branch policy, keep the Alchemy secrets on the environment (not the repository), and require approval for workflows from external contributors.


⏪ Rollback

Every deploy uploads both Workers and applies any pending migrations.

  • Undo a bad code release: bunx wrangler rollback --name linkden-api (API) and/or bunx wrangler rollback --name linkden (web). The next bun ship re-uploads both Workers, so nothing needs reconciling in Alchemy state.
  • Undo a migration (only when a migration itself has to go): restore D1 to the bookmark printed in that deploy's job summary — bunx wrangler d1 time-travel restore <db> --bookmark=<id> (run from the repo root; <db> is LINKDEN_DB_NAME, default linkden-db). This rewinds all data to that moment.
  • Migrations are written additive-only (expand, then contract in a later release), so rolling back code alone is always safe.

🛠️ Common Commands

CommandWhat it does
bun shipBuild and deploy (safe to run repeatedly; applies pending migrations)
bun run destroy⚠️ Tears down the Workers. On prod it refuses unless LINKDEN_CONFIRM_DESTROY_PROD=yes, and keeps D1/R2 (delete: false) once a deploy from this version has recorded it. Staging: ALCHEMY_STAGE=<stage> bun run destroy
bunx wrangler tail linkden-apiStream live API logs
bunx wrangler tail linkdenStream live web logs
bun db:generateGenerate a migration from schema changes (contributors only)
bun reset:password -- --email <you> --remoteReset the admin password on production (prompted, never on the command line)

💰 Cost Check

For a typical single-user LinkDen instance, you will stay 100% within Cloudflare's free tier:

ResourceFree Tier LimitTypical Usage
Workers requests100,000 / day~100s / day
D1 reads5,000,000 / day~1,000s / day
D1 writes100,000 / day~10s / day
R2 storage10 GBDepends on uploads
R2 requests10,000,000 / monthLow

🧯 Troubleshooting

"Missing CORS_ORIGIN" or auth errors after deploy

You skipped Step 4. Update .env with the real URLs and run bun ship again.

Admin redirects to login in a loop

You are on workers.dev, where web and API are different origins. Set SITE_DOMAIN (Step 5) so both run on one hostname.

"Registration is closed" on /admin/setup

An account already exists. Go to /admin/login instead. If you've lost access, run this from the repo root (you are prompted for the new password; add --disable-2fa if you also lost your authenticator and backup codes):

bun reset:password -- --email you@example.com --remote

"Invalid setup token" on /admin/setup

SETUP_TOKEN is set on the API Worker. Paste the same value into the Setup token field.

API answers 503 "authentication is misconfigured"

BETTER_AUTH_SECRET is empty, shorter than 32 characters, or a placeholder. /api/health reports "auth": "misconfigured". Set a real secret (openssl rand -base64 32) and redeploy. Changing it signs everyone out.

Worker returns 500 on all routes

Run bunx wrangler tail linkden-api to see the error; errors are logged as JSON with the request path (never query strings or tokens).

D1 binding not found

Re-run bun ship. Alchemy adopts the existing linkden-db / linkden-images by their pinned names (adopt: true) and re-binds them. If your resources have other names (a pre-0.5.0 install), set LINKDEN_DB_NAME / LINKDEN_IMAGES_BUCKET first. Do not run bun run destroy to "start fresh".

More in the Troubleshooting guide.

On this page