Skip to content

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

RequirementWhyWhere it comes from
Node.js 20.9+RuntimeVercel provides it
PostgreSQLAll persistent dataYou must provision this — see below
DATABASE_URLConnection stringFrom the database provider
AUTH_SECRETDerives API key digests, signs tokensYou generate it
APP_URLCanonical origin for absolute URLs and cookieshttps://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.

OptionFree tierNotes
Neon (recommended)0.5 GB, scales to zeroServerless Postgres, built for this shape of workload. Available directly in the Vercel dashboard under Storage.
Supabase500 MBWorks fine. You would use only the Postgres part — Feedex has its own auth, so Supabase Auth stays unused.
Vercel PostgresNow 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 ownVariesAny 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

  1. In the Vercel dashboard, open your project → StorageCreate DatabaseNeon.
  2. Pick a region close to your users. iad1 (Washington) is a reasonable default for a mostly-US audience.
  3. Connect it to the project. Vercel injects DATABASE_URL into 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:seed against 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

  1. Go to vercel.com/new.
  2. Import Rian-Fernando/Feedex.
  3. Vercel detects Next.js. Leave the build settings alone — the build script already bundles the widget before next build.
  4. 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)

SettingValue
Framework presetNext.js
Build commandnpm run build (default)
Output directory.next (default)
Install commandnpm install (default)
Node version20.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).

NameValueNotes
DATABASE_URLpostgres://…Auto-set if you provisioned through Vercel Storage.
AUTH_SECRETGenerate — see belowRequired. Rotating it invalidates every API key digest.
APP_URLhttps://feedex.rianfernando.comSet this after the domain is attached (Part 4).
DISABLE_SIGNUPtrueOptional. Set it once your own account exists so the instance stops accepting new registrations.

Generate the secret:

openssl rand -base64 48

AUTH_SECRET keys the HMAC used for secret API keys. If you change it later, existing sk_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

  1. Project → SettingsDomains.
  2. Add feedex.rianfernando.com.
  3. Vercel shows the DNS record it wants — a CNAME to cname.vercel-dns.com.

Add the record in Cloudflare

  1. Cloudflare dashboard → rianfernando.comDNSRecordsAdd record.

  2. Fill in:

    FieldValue
    TypeCNAME
    Namefeedex
    Targetcname.vercel-dns.com
    Proxy statusDNS only (grey cloud)
    TTLAuto
  3. 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 Secure flag on the session cookie,
  • the install snippet shown on each project page.

Part 5 — First run

  1. Visit https://feedex.rianfernando.com/register and create your account. The first registration also creates your workspace.

  2. Set DISABLE_SIGNUP=true in Vercel and redeploy, so the instance stops accepting new registrations.

  3. 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>
    
  4. 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):

  1. Search Console → Add propertyDomainrianfernando.com.

  2. Google gives you a TXT record.

  3. Cloudflare → DNS → Add record:

    FieldValue
    TypeTXT
    Name@
    Contentgoogle-site-verification=…
  4. Back in Search Console, click Verify.

URL prefix property (subdomain only):

  1. Add property → URL prefixhttps://feedex.rianfernando.com.

  2. Choose HTML tag verification and copy the content value.

  3. 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' },
    };
    
  4. Deploy, then click Verify.

Submit the sitemap

Search Console → Sitemaps → enter sitemap.xmlSubmit.

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 in robots.txt and marked noindex, 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.txt is 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 → InstallRotate. The old key stops working immediately. Rotating the public key means updating the snippet wherever it is installed.


Troubleshooting

SymptomCauseFix
Build succeeds, runtime 500sDATABASE_URL missingAdd it in Vercel, redeploy
/api/health reports driver: pgliteDATABASE_URL not visible to the runtimeCheck it is set for the right environment, redeploy
relation "users" does not existMigrations never ranDATABASE_URL=… npm run db:migrate
Domain stuck on "Invalid Configuration"Cloudflare proxy is onSet the record to DNS only (grey cloud)
Redirect loop on the subdomainCloudflare SSL mode is FlexibleSet it to Full (strict)
Widget gets a CORS errorProject domain does not match the hostClear the project's Domain field, or set it to the right host
Sessions drop on every requestAPP_URL scheme is http in productionSet it to the https:// origin
Secret keys stop validatingAUTH_SECRET changedRotate 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

  1. Vercel dashboard → your Feedex project → Storage tab.
  2. Create DatabaseNeonContinue.
  3. Region: pick the one nearest your users. us-east-1 (Washington) pairs with Vercel's default iad1 and keeps query latency in single-digit milliseconds.
  4. Name it feedex-db.
  5. 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 → SettingsEnvironment VariablesDATABASE_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, run npm run db:generate, commit the SQL in drizzle/, and run db:migrate against 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

  1. Google Cloud Console → create a project (or pick an existing one).
  2. APIs & ServicesOAuth 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.
  3. CredentialsCreate credentialsOAuth 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
      
  4. Copy the client ID and secret into Vercel as GOOGLE_CLIENT_ID and GOOGLE_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

  1. GitHub → SettingsDeveloper settingsOAuth AppsNew OAuth App.
  2. Fill in:
    • Application name: Feedex
    • Homepage URL: https://feedex.rianfernando.com
    • Authorization callback URL: https://feedex.rianfernando.com/api/auth/github/callback
  3. Register application, then Generate a new client secret. The secret is shown once.
  4. Copy both into Vercel as GITHUB_CLIENT_ID and GITHUB_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:

  1. The provider account is already linked → sign that user in.
  2. 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.
  3. 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

SymptomCause
redirect_uri_mismatchThe registered URI differs from APP_URL + /api/auth/<provider>/callback. They must match exactly.
Buttons do not appearOne 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 blockedDISABLE_SIGNUP=true also blocks first-time provider sign-up, by design.