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
- Get a free Cloudflare account → create an API token.
- Fill in
.envwith your token, account ID, Alchemy state settings, and secrets. - Run
bun ship. (Deploy everything in one shot.) - Copy the real URLs from the output → update
.env→ runbun shipagain. - Set
SITE_DOMAINto your hostname, point the URLs at it,bun shiponce more. - Go to
/admin/setupand create your account. Done.
✅ Step 0 — Prerequisites
Before you start, make sure you have:
- A Cloudflare account (free tier is fine)
- Bun installed (
bun --versionshould 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:
Resource Permission Account → Workers Scripts Edit Account → D1 Edit Account → Workers R2 Storage Edit Zone → Workers Routes Edit Zone → Zone Read Zone → DNS Edit Zone → SSL and Certificates Edit The Zone permissions are what let Alchemy attach your custom domain and the
/api/*and/trpc/*routes. If you only ever deploy toworkers.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
# ── 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.linkdenGenerate a secret (run it twice, once for ALCHEMY_PASSWORD and once for BETTER_AUTH_SECRET):
openssl rand -base64 32About 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:
- Deploy your own
alchemy-stateworker in your account (see the Alchemy docs forCloudflareStateStore) and setALCHEMY_STATE_TOKENto the token you gave it, or - Switch the
stateStoreinpackages/infra/alchemy.run.tsto something local, for example the default file-system store, by removing thestateStoreoption. ThenALCHEMY_STATE_TOKENis not needed and state lives inpackages/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-runnerIn 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.
myname→linkden-api.myname.workers.dev). You can also find it after the first deploy in the command output.
🚀 Step 3 — First Deploy
bun shipThis single command:
- Creates your D1 database (
linkden-db) and runs schema migrations. - Creates the R2 buckets
linkden-images(uploads) andlinkden-backups(daily D1 exports, 30-day expiry). - Deploys the API Worker (
linkden-api). - 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.
- Copy the URLs from the Step 3 output.
- Update
.envwith 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- Run deploy again:
bun ship⚠️ Admin login does not work on
workers.dev. The web and API Workers sit on two differentworkers.devorigins, and the session cookie cannot cross them, so/adminredirects back to the login page. The public page and/api/healthwork fine, which is what aworkers.devdeploy is for (staging). For a working admin, finish Step 5.
🌐 Step 5 — Custom Domain (Recommended)
With a custom domain LinkDen runs same-origin: the web app owns the hostname and the API is routed under it.
| What | Where |
|---|---|
Web Worker linkden | Custom Domain on SITE_DOMAIN |
API Worker linkden-api | Routes 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.
- 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).
- Set
SITE_DOMAINand 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- Run
bun shipone 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
- Open
https://links.yourdomain.com/admin/setup. - Fill in your email and password.
- 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
| Command | What it does |
|---|---|
bun ship | Build and deploy (safe to run repeatedly) |
bun run destroy | ⚠️ Irreversible. Deletes all Cloudflare resources |
bunx wrangler tail linkden-api | Stream live API logs |
bunx wrangler tail linkden | Stream live web logs |
bun db:generate | Generate a migration from schema changes |
bun db:push | Push schema to local dev D1 |
💰 Cost Check
For a typical single-user LinkDen instance, you will stay 100% within Cloudflare's free tier:
| Resource | Free Tier Limit | Typical Usage |
|---|---|---|
| Workers requests | 100,000 / day | ~100s / day |
| D1 reads | 5,000,000 / day | ~1,000s / day |
| D1 writes | 100,000 / day | ~10s / day |
| R2 storage | 10 GB | Depends on uploads |
| R2 requests | 10,000,000 / month | Low |
🧯 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.