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
# ── 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.linkdenGenerate 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 shiprefuses to run while a required value is empty, whileBETTER_AUTH_SECRETis shorter than 32 characters (or is Better Auth's public default), while a URL is nothttps://, whileCORS_ORIGINis more than a bare origin, or (onceSITE_DOMAINis set) whileBETTER_AUTH_URL,CORS_ORIGINorNEXT_PUBLIC_SITE_URLdiffer fromhttps://SITE_DOMAIN.apps/web/.envandapps/server/.envare 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:
- 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 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 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. If you set
SETUP_TOKEN, paste it into the Setup token field. - 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/setupfirst could claim the install, which is whatSETUP_TOKENprevents.
🤖 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 optionalWALLET_*andSETUP_TOKEN. - Variables:
SITE_DOMAIN,NEXT_PUBLIC_SITE_URL, andLINKDEN_DB_NAME/LINKDEN_IMAGES_BUCKETfor 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
productionenvironment a required reviewer and amain-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/orbunx wrangler rollback --name linkden(web). The nextbun shipre-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>isLINKDEN_DB_NAME, defaultlinkden-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
| Command | What it does |
|---|---|
bun ship | Build 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-api | Stream live API logs |
bunx wrangler tail linkden | Stream live web logs |
bun db:generate | Generate a migration from schema changes (contributors only) |
bun reset:password -- --email <you> --remote | Reset 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:
| 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 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.