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 TokensCreate TokenCustom 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 summaryCreate 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

# ── Apple Wallet (optional — skip if you don't need Wallet passes) ──
# WALLET_SIGNER_CERT=base64encodedcert...
# WALLET_SIGNER_KEY=base64encodedkey...
# WALLET_WWDR_CERT=base64encodedwwdr...
# WALLET_TEAM_ID=ABC12DEF34
# WALLET_PASS_TYPE_ID=pass.com.yourname.linkden

Generate a secret (run it twice, once for ALCHEMY_PASSWORD and once for BETTER_AUTH_SECRET):

openssl rand -base64 32

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 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. mynamelinkden-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.
  3. Click Create Account.

First user is admin. Registration permanently locks after this — no one else can sign up.


🤖 Deploying from GitHub Actions

The repo ships .github/workflows/deploy.yml, which runs the same bun run deploy after CI passes on main. Put the values from Step 2 into the repo's production environment: everything sensitive as secrets, and SITE_DOMAIN plus NEXT_PUBLIC_SITE_URL as variables. A separate backup-db.yml workflow exports D1 to linkden-backups daily.


🛠️ Common Commands

CommandWhat it does
bun shipBuild and deploy (safe to run repeatedly)
bun run destroy⚠️ Irreversible. Deletes all Cloudflare resources
bunx wrangler tail linkden-apiStream live API logs
bunx wrangler tail linkdenStream live web logs
bun db:generateGenerate a migration from schema changes
bun db:pushPush schema to local dev D1

💰 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 bun reset:password locally.

Worker returns 500 on all routes

Check your BETTER_AUTH_SECRET — it must be set and match what was used on the previous deploy. Run bunx wrangler tail linkden-api to see the error; errors are logged as JSON with the request path.

D1 binding not found

Alchemy provisions bindings at deploy time. If you deleted and re-created resources manually, run bun run destroy then bun ship to start fresh.

More in the Troubleshooting guide.

On this page