Self-hosting
Run your own instance: Postgres, environment variables, migrations, and DNS.
How Feedex gets from a local checkout to https://feedex.rianfernando.com, with
DNS on Cloudflare and the site verified in Google Search Console.
Work through the parts in order. Each one is independent enough to stop and resume between them.
What Feedex needs to run
| Requirement | Why | Where it comes from |
|---|---|---|
| Node.js 20.9+ | Runtime | Vercel provides it |
| PostgreSQL | All persistent data | You must provision this — see below |
DATABASE_URL | Connection string | From the database provider |
AUTH_SECRET | Derives API key digests, signs tokens | You generate it |
APP_URL | Canonical origin for absolute URLs and cookies | https://feedex.rianfernando.com |
Nothing else. No Redis, no object storage, no email provider in this version.
Part 1 — The database
Is it local right now?
Yes. In development, with DATABASE_URL unset, Feedex runs an embedded
PGlite database — PostgreSQL compiled to WebAssembly — stored in .data/ in
the project folder. That is why npm run dev works with nothing installed.
That embedded database is development only. Serverless functions on Vercel get an ephemeral, per-instance filesystem, so a file-backed database there would be empty on some requests, stale on others, and wiped on every deploy. Production needs a real PostgreSQL server.
Do you need Supabase? Does Vercel provide one?
You need a Postgres. Several options work, and Feedex does not care which — it only needs a connection string.
| Option | Free tier | Notes |
|---|---|---|
| Neon (recommended) | 0.5 GB, scales to zero | Serverless Postgres, built for this shape of workload. Available directly in the Vercel dashboard under Storage. |
| Supabase | 500 MB | Works fine. You would use only the Postgres part — Feedex has its own auth, so Supabase Auth stays unused. |
| Vercel Postgres | — | Now a thin layer over Neon; provisioning through the Vercel Storage tab gives you the same thing with the env var wired in automatically. |
| Railway / Render / your own | Varies | Any reachable Postgres 14+ works. |
Recommendation: Neon, provisioned from inside Vercel. It sets
DATABASE_URL on the project for you, scales to zero when idle, and there is
no second dashboard to manage.
Supabase is not needed. If you already use it elsewhere and would rather keep one provider, it works equally well — just take the connection string from Project Settings → Database → Connection string → URI and use the pooled (port 6543) variant.
Provision it
- In the Vercel dashboard, open your project → Storage → Create Database → Neon.
- Pick a region close to your users.
iad1(Washington) is a reasonable default for a mostly-US audience. - Connect it to the project. Vercel injects
DATABASE_URLinto all environments.
If you provision outside Vercel, add DATABASE_URL manually in Part 3.
Run the migrations
Feedex ships plain SQL migrations in drizzle/. Against production they run as
a deliberate step, not implicitly on a request.
# From your machine, pointed at the production database
DATABASE_URL="postgres://..." npm run db:migrate
Run this once after the first deploy, and again after any deploy that includes
new files in drizzle/.
Do not run
npm run db:seedagainst production — it creates a demo account with a published password.
Part 2 — Deploy to Vercel
Push the repository first
git remote add origin https://github.com/Rian-Fernando/Feedex.git
git push -u origin main
Import it
- Go to vercel.com/new.
- Import
Rian-Fernando/Feedex. - Vercel detects Next.js. Leave the build settings alone — the
buildscript already bundles the widget beforenext build. - Do not deploy yet. Add the environment variables first (Part 3), or the
first deploy will build fine and then fail at runtime on a missing
DATABASE_URL.
Build settings (for reference)
| Setting | Value |
|---|---|
| Framework preset | Next.js |
| Build command | npm run build (default) |
| Output directory | .next (default) |
| Install command | npm install (default) |
| Node version | 20.x or 22.x |
Part 3 — Environment variables
In Vercel: Settings → Environment Variables. Add these for Production (and Preview, if you want preview deployments to work).
| Name | Value | Notes |
|---|---|---|
DATABASE_URL | postgres://… | Auto-set if you provisioned through Vercel Storage. |
AUTH_SECRET | Generate — see below | Required. Rotating it invalidates every API key digest. |
APP_URL | https://feedex.rianfernando.com | Set this after the domain is attached (Part 4). |
DISABLE_SIGNUP | true | Optional. Set it once your own account exists so the instance stops accepting new registrations. |
Generate the secret:
openssl rand -base64 48
AUTH_SECRETkeys the HMAC used for secret API keys. If you change it later, existingsk_fdx_…keys stop validating and have to be rotated from the dashboard. Set it once and leave it.
Redeploy after adding variables — Vercel does not apply them to an existing build.
Part 4 — Domain and Cloudflare DNS
Your apex rianfernando.com is on Cloudflare. You are adding
feedex.rianfernando.com as a subdomain pointing at Vercel.
Add the domain in Vercel
- Project → Settings → Domains.
- Add
feedex.rianfernando.com. - Vercel shows the DNS record it wants — a
CNAMEtocname.vercel-dns.com.
Add the record in Cloudflare
Cloudflare dashboard →
rianfernando.com→ DNS → Records → Add record.Fill in:
Field Value Type CNAMEName feedexTarget cname.vercel-dns.comProxy status DNS only (grey cloud) TTL Auto Save.
The proxy status matters. Leave it grey (DNS only), at least initially:
- Vercel issues and renews the TLS certificate itself, and it validates over HTTP. With Cloudflare's orange-cloud proxy on, that validation can fail and the domain sits in "Invalid Configuration".
- Orange-cloud also puts Cloudflare's cache in front of Vercel's, which means two caches to reason about and stale HTML after deploys unless you configure page rules carefully.
- You gain little: Vercel already fronts the app with its own CDN.
If you later want Cloudflare's WAF in front, switch to orange cloud after the certificate is issued, and set the SSL/TLS mode to Full (strict) — anything less will loop or serve the wrong certificate.
Verify
DNS usually propagates in a minute or two.
dig +short feedex.rianfernando.com
curl -sI https://feedex.rianfernando.com | head -1
curl -s https://feedex.rianfernando.com/api/health
The health endpoint should return {"status":"ok","driver":"postgres",…}. If
driver says pglite, DATABASE_URL did not reach the runtime.
Update APP_URL
Set APP_URL=https://feedex.rianfernando.com in Vercel and redeploy. This
affects:
- absolute URLs in metadata and the sitemap,
- the
Secureflag on the session cookie, - the install snippet shown on each project page.
Part 5 — First run
Visit
https://feedex.rianfernando.com/registerand create your account. The first registration also creates your workspace.Set
DISABLE_SIGNUP=truein Vercel and redeploy, so the instance stops accepting new registrations.Create a project, copy the public key, and add the snippet to a real site:
<script src="https://feedex.rianfernando.com/widget.js" data-feedex-key="pk_fdx_..." defer ></script>Submit a test report and confirm it lands in the dashboard.
Part 6 — Google Search Console
Verify the property
Use a Domain property if you want one entry covering the apex and every subdomain, or a URL prefix property for just this subdomain. The domain property is the better long-term choice given you will add more subdomains.
Domain property (recommended):
Search Console → Add property → Domain →
rianfernando.com.Google gives you a
TXTrecord.Cloudflare → DNS → Add record:
Field Value Type TXTName @Content google-site-verification=…Back in Search Console, click Verify.
URL prefix property (subdomain only):
Add property → URL prefix →
https://feedex.rianfernando.com.Choose HTML tag verification and copy the
contentvalue.Add it to
src/config/site.ts, then surface it in the root layout's metadata:// src/app/layout.tsx export const metadata: Metadata = { // … verification: { google: 'YOUR_VERIFICATION_CODE' }, };Deploy, then click Verify.
Submit the sitemap
Search Console → Sitemaps → enter sitemap.xml → Submit.
The full URL is https://feedex.rianfernando.com/sitemap.xml.
Request indexing
Search Console → URL Inspection → paste
https://feedex.rianfernando.com/ → Request indexing. This nudges the
first crawl; it is not required.
What to expect
- Verification is immediate once the DNS record propagates.
- First crawl typically lands within a few days.
- The dashboard and API are
Disallowed inrobots.txtand markednoindex, so Search Console will report them as excluded. That is correct, not an error.
Also worth doing
- Bing Webmaster Tools — supports importing directly from Search Console, so it is a two-minute job. Bing's index also feeds several AI assistants.
- Confirm
https://feedex.rianfernando.com/llms.txtis reachable. It is not part of Search Console, but it is what AI crawlers read.
Ongoing operations
Migrations on later deploys
npm run db:generate # after editing src/lib/db/schema.ts
DATABASE_URL="postgres://..." npm run db:migrate
Commit the generated SQL in drizzle/. Run the migration before or immediately
after the deploy that needs it.
Backups
Neon and Supabase both keep automatic point-in-time backups on their free tiers. Confirm the retention window in the provider dashboard — it is usually 7 days on free plans.
Monitoring
GET /api/health returns 200 with the active driver and query latency, or
503 if the database is unreachable. Point an uptime check at it.
Rotating a leaked key
Dashboard → project → Install → Rotate. The old key stops working immediately. Rotating the public key means updating the snippet wherever it is installed.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Build succeeds, runtime 500s | DATABASE_URL missing | Add it in Vercel, redeploy |
/api/health reports driver: pglite | DATABASE_URL not visible to the runtime | Check it is set for the right environment, redeploy |
relation "users" does not exist | Migrations never ran | DATABASE_URL=… npm run db:migrate |
| Domain stuck on "Invalid Configuration" | Cloudflare proxy is on | Set the record to DNS only (grey cloud) |
| Redirect loop on the subdomain | Cloudflare SSL mode is Flexible | Set it to Full (strict) |
| Widget gets a CORS error | Project domain does not match the host | Clear the project's Domain field, or set it to the right host |
| Sessions drop on every request | APP_URL scheme is http in production | Set it to the https:// origin |
| Secret keys stop validating | AUTH_SECRET changed | Rotate the secret keys from the dashboard |
Appendix A — Neon, step by step
The database walkthrough referenced in Part 1, in full.
1. Create it from inside Vercel
- Vercel dashboard → your Feedex project → Storage tab.
- Create Database → Neon → Continue.
- Region: pick the one nearest your users.
us-east-1(Washington) pairs with Vercel's defaultiad1and keeps query latency in single-digit milliseconds. - Name it
feedex-db. - Create, then Connect it to the Feedex project, with all three environments (Production, Preview, Development) ticked.
Vercel now injects DATABASE_URL — plus a few POSTGRES_* aliases Feedex does
not use — into the project. You do not need to copy anything by hand.
2. Confirm the connection string
Vercel → Settings → Environment Variables → DATABASE_URL. It looks
like:
postgresql://neondb_owner:PASSWORD@ep-something-123456-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require
Note the -pooler in the host. That is the pooled endpoint, which is the right
one for serverless functions — each invocation opens its own connection, and
the pooler is what stops that exhausting the database's connection limit.
3. Run the migrations
Once, from your machine, against the production database:
DATABASE_URL="postgresql://neondb_owner:...-pooler...neon.tech/neondb?sslmode=require" \
npm run db:migrate
Expected output:
[migrate] driver: postgres
[migrate] up to date
Copy the string from Vercel exactly, quotes included — it contains ? and &,
which your shell will otherwise interpret.
4. Verify
curl -s https://feedex.rianfernando.com/api/health
{ "status": "ok", "driver": "postgres", "latencyMs": 12, "timestamp": "..." }
"driver": "postgres" is the thing to check. If it says pglite, the runtime
never saw DATABASE_URL — confirm it is set for Production and redeploy.
5. Create your account
Visit /register, sign up, then set DISABLE_SIGNUP=true in Vercel and
redeploy so the instance stops accepting new registrations.
Notes
- Free tier: 0.5 GB storage, which at roughly 2 kB per feedback item is on the order of a quarter of a million reports.
- Scale to zero: Neon suspends an idle database after five minutes. The first request afterwards pays a cold start of a few hundred milliseconds.
- Backups: Neon keeps point-in-time restore. Check the retention window in its dashboard — it is typically 7 days on the free plan.
- Later migrations: after editing
src/lib/db/schema.ts, runnpm run db:generate, commit the SQL indrizzle/, and rundb:migrateagainst production with the same command as above.
Appendix B — Google and GitHub sign-in
Both are optional and independent. A provider without both an id and a secret configured simply does not appear on the sign-in page.
- Google Cloud Console → create a project (or pick an existing one).
- APIs & Services → OAuth consent screen:
- User type: External
- App name:
Feedex - Support email and developer contact: your address
- Authorised domain:
rianfernando.com - Scopes: the defaults are enough. Feedex asks only for
openid email profile. - While the app is in Testing, only accounts on the test-users list can sign in. Publish it once you are happy. With only these three scopes Google does not require a verification review.
- Credentials → Create credentials → OAuth client ID:
- Application type: Web application
- Name:
Feedex web - Authorised JavaScript origins:
http://localhost:3000 https://feedex.rianfernando.com - Authorised redirect URIs:
http://localhost:3000/api/auth/google/callback https://feedex.rianfernando.com/api/auth/google/callback
- Copy the client ID and secret into Vercel as
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET, then redeploy.
The redirect URI must match exactly — scheme, host, port, path, no trailing
slash. A mismatch produces Google's redirect_uri_mismatch error, which is by
far the most common thing to get wrong here.
GitHub
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Fill in:
- Application name:
Feedex - Homepage URL:
https://feedex.rianfernando.com - Authorization callback URL:
https://feedex.rianfernando.com/api/auth/github/callback
- Application name:
- Register application, then Generate a new client secret. The secret is shown once.
- Copy both into Vercel as
GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET, then redeploy.
A GitHub OAuth App accepts only one callback URL. For local development,
register a second app (Feedex dev) pointing at
http://localhost:3000/api/auth/github/callback and use its credentials in
.env.local.
How accounts are matched
When someone signs in with a provider, Feedex resolves them in this order:
- The provider account is already linked → sign that user in.
- No link, but a user exists with the same email → link them, only if the provider says the email is verified. Without that condition, registering at a provider with someone else's address would take over their workspace.
- Otherwise → create the user and their first workspace, exactly as password registration does.
So an account created with a password can later sign in with Google, and the same person arriving via Google and GitHub ends up as one account rather than two — provided the email is verified at both.
Users created through a provider have no password. They can set one from Settings → Account to gain a second way in; Feedex refuses to unlink the last remaining sign-in method.
Testing locally
# .env.local
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
npm run dev
The buttons appear on /login and /register as soon as both values are
present. No restart beyond the dev server is needed.
Troubleshooting
| Symptom | Cause |
|---|---|
redirect_uri_mismatch | The registered URI differs from APP_URL + /api/auth/<provider>/callback. They must match exactly. |
| Buttons do not appear | One of the pair is missing, or the deploy predates the variables being set. |
| "That sign-in link has expired" | The state cookie is older than ten minutes, or was dropped. Start again. |
| "That sign-in could not be verified" | The state did not match — usually a stale tab. Start again. |
| GitHub: "no verified email address" | The GitHub account has no verified address. Add one in GitHub's email settings. |
| Sign-up blocked | DISABLE_SIGNUP=true also blocks first-time provider sign-up, by design. |