Skip to main content
fewer
Deployment & Self-Hosting

Deployment & Self-Hosting

Deploy Fewer to production: Docker, Netlify, Caddy reverse proxy, and the standalone build.

Fewer is a Next.js 16 app that can be deployed anywhere Node.js runs. This guide covers the supported deployment paths.

Prerequisites

  • Bun 1.3+ (or Node.js 18+)
  • A server with Node.js runtime

Local Production Build

bun install
bun run build
bun run start

The build script produces a standalone output (.next/standalone) with public/ copied in, so the production server is fully self-contained:

NODE_ENV=production bun .next/standalone/server.js

Docker

docker build -t fewer .
docker run -p 3000:3000 fewer

Then open http://localhost:3000.

Netlify

The repo includes netlify.toml with the Next.js plugin preconfigured:

[build]
  command = "bun run build:netlify"

[[plugins]] package = "@netlify/plugin-nextjs" ```

Deploy by connecting your GitHub repo to Netlify, or via the CLI:

bunx netlify deploy --prod

The build:netlify script skips the standalone output step (Netlify handles its own serverless output). The COMMIT_REF environment variable is injected automatically during Netlify builds to version the app.

Domains: homepage vs app

Fewer splits its public surface across two domains on the same Netlify site, and the app lives at a sub-path:

  • `fewer.directory` — the marketing homepage, privacy policy, docs, and blog. Visiting / on any host serves this page (src/app/page.tsx); the visitor does not need to sign in.
  • `app.fewer.directory` — the interactive app. The app itself lives at `/app` (src/app/app/page.tsx); app.fewer.directory and the Netlify .app subdomain redirect their root to /app (Netlify redirect rules with a Host header condition), so the app domain lands directly on the app.

In Netlify, add app.fewer.directory (and optionally www.fewer.directory) as domain aliases on the same site; with Netlify DNS the records and TLS are provisioned automatically. The NEXT_PUBLIC_APP_URL env var (used for OAuth callbacks at /api/cloud/callback and share links) must point at the app origin: https://app.fewer.directory.

Caddy Reverse Proxy

The repo includes a Caddyfile that proxies port 81 to the app on port 3000:

:81 {
	handle {
		reverse_proxy localhost:3000 {
			header_up Host {host}
			header_up X-Forwarded-For {remote_host}
			header_up X-Forwarded-Proto {scheme}
			header_up X-Real-IP {remote_host}
		}
	}
}

It also supports a ?XTransformPort= query parameter for port forwarding during development.

Local Filesystem Features (build-time flags)

A few features reach outside the browser tab: Open in File Explorer (Alt+O), Open File (Enter), dragging a folder from your OS onto the canvas (import on an empty canvas, expand-from-disk otherwise), and the File System Access directory picker. They depend on server-side OS openers (/api/open-folder, /api/open-file, /api/list-directory — all restricted to localhost requests) or on browser APIs that do not exist in every webview, so they are switched off by default in src/lib/fewer/features.ts:

export const LOCAL_FS_FEATURES = {
  openInOs: false, // "Open in File Explorer" (folder menu + Alt+O)
  openFileInOs: false, // "Open File" (file menu + Enter)
  dragDropImport: false, // OS folder drop on an empty canvas → import
  dropToExpand: false, // OS folder drop on a canvas → expand from disk
  fsaDirectoryPicker: false, // showDirectoryPicker()
} as const;

Set a flag to true and rebuild to restore that feature (useful for a self-hosted instance you access over localhost, or a native/Tauri build that replaces the OS openers with native commands). These are build-time constants, not environment variables — nothing to add to .env.

Folder import itself is never gated: it uses the legacy webkitdirectory picker, which works in every browser and webview. The same applies to file/URL/cloud imports and in-app node dragging.

PWA & Static Assets

The public/ directory ships:

  • manifest.json: PWA manifest with 192x192 and 512x512 icons
  • robots.txt: search engine rules, points at the XML sitemaps below
  • sitemap.xml (marketing site) and app/sitemap.xml (app host): generated
  • logo*.png/svg: brand assets

The standalone build copies public/ automatically. If you self-host with a custom server, make sure public/ is served so the manifest resolves correctly.

Supabase (accounts, saved graphs, share links)

Accounts, saved graphs, crawl caching, and server-backed share links use Supabase. The app works fully without it (local import/export/theme all function), but these features require a Supabase project.

Setup

  1. Create a project at supabase.com
  2. Run the migrations in supabase/migrations/ (via the Supabase CLI: supabase db push, or the SQL editor)
  3. Set the two environment variables below
Production vs non-production databases. Fewer ships with two Supabase projects so developers never touch production data from a local or preview build: | Environment | Project | Env file | NEXT_PUBLIC_SUPABASE_URL | | ----------- | ------- | -------- | --------------------------- | | Production | fewer (rzzbhboedvezamqjjuoe, eu-west-1) | .env | https://rzzbhboedvezamqjjuoe.supabase.co | | Dev / previews / local | fewer-dev (aorhvfihnjhpxgjiacfg, ap-south-1) | .env.local | https://aorhvfihnjhpxgjiacfg.supabase.co | The dev project must run the same supabase/migrations/ so its schema stays in lock-step with prod. Never put the service-role key in a client file. Each project needs its own, server-side only: - Prod SUPABASE_SERVICE_ROLE_KEY → Netlify env (accounts, watch digest, account deletion). - Dev SUPABASE_SERVICE_ROLE_KEY → only your local .env.local (never commit it) or the non-prod deploy environment. Both projects need their own dashboard auth settings (Email provider, confirm-email, Site URL + redirect URLs) — the values below are production-specific.

Production go-live checklist

One-time settings to verify before real users arrive:

  1. Supabase dashboard → Authentication → Providers → Email: make sure Email is enabled (it is the only sign-in provider).
  2. Supabase dashboard → Authentication → Sign In / Up: turn on Confirm email so new accounts must verify their address (blocks fake sign-ups). The app's sign-up dialog already prompts "Check your email".
  3. Supabase dashboard → Authentication → URL Configuration: set Site URL to your production app origin (e.g. https://app.fewer.directory) and add your origin + https://app.fewer.directory/auth/callback to Redirect URLs — required for password-reset and email-confirmation links.
  4. Netlify env vars: linking the Netlify ⇄ Supabase integration sets NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY automatically. Add the rest manually: NEXT_PUBLIC_APP_URL (production origin, used in emailed links), RESEND_API_KEY, RESEND_FROM_EMAIL, SUPABASE_SERVICE_ROLE_KEY, CRON_SECRET, CONNECTIONS_ENCRYPTION_KEY (openssl rand -base64 32), and the per-provider OAuth keys below.
  5. Run migrations on the hosted project: supabase link --project-ref <ref> then supabase db push, so hardening migrations (e.g. 0012_harden_share_rls.sql) are applied before launch.
  6. Verify invite-only sharing in an incognito window: open an invite link signed out → 403 + sign-in prompt → sign in as the invited email → the graph loads.

Environment Variables

VariablePurpose
NEXT_PUBLIC_SUPABASE_URLYour Supabase project URL
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYThe publishable (anon) key. RLS protects data; auth is enforced via the user's JWT
NEXT_PUBLIC_APP_URLThe app origin, used for emailed links and OAuth redirect URIs (https://app.fewer.directory in production)
NEXT_PUBLIC_HOME_URLThe marketing homepage origin used by the navbar's home link (https://fewer.directory); leave empty in dev to fall back to /
NEXT_PUBLIC_WEB3FORMS_ACCESS_KEYWeb3Forms access key for the bug-report/contact form
NEXT_PUBLIC_APP_VERSIONInjected at build time from package.json version + git commit SHA
COMMIT_REFGit commit SHA (Netlify provides this automatically)
NETLIFYSet by Netlify builds; disables standalone output
CONTEXTSet by Netlify; production hides deploy preview overlays
Note: NEXT_PUBLIC_* variables are inlined at build time, so they must be set when you build, not just at runtime.

Mail & scheduled digests (Watch File Indexes)

Watching file indexes and emailing daily change digests uses Resend for email and a nightly GitHub Actions cron workflow for the crawl.

VariablePurpose
RESEND_API_KEYResend API key for sending digest emails
RESEND_FROM_EMAILSender address for digest emails (defaults to fewer <onboarding@resend.dev>)
SUPABASE_SERVICE_ROLE_KEYService-role key (server-side only) so the nightly job can read every user's watchlist
CRON_SECRETSecret sent with the x-cron-secret header that authorizes the /api/watch/run route (manual/debug trigger)

The nightly job runs in .github/workflows/watch-digest.yml (schedule 59 23 * * *, plus a workflow_dispatch button for manual runs). It runs scripts/watch-digest.ts, which calls the shared job in src/lib/fewer/watchDigest.ts — crawling watched indexes, diffing against the previous crawl, and sending one consolidated email per user only when something changed. Set the required secrets/variables on the repo with bun scripts/env-sync.ts github (the digest job reads NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, RESEND_API_KEY, and RESEND_FROM_EMAIL from GitHub Actions secrets/variables — same names as everywhere else). The /api/watch/run route remains as a cron-secret-protected manual trigger over the same code.

Email Templates

All outbound emails (auth flows + digest + invite) share one shell defined in src/lib/fewer/emailTemplate.ts. The shell uses the app's brand: dark background, fewer.directory wordmark, orange CTA, table-based layout for Outlook compatibility.

Local dev: supabase/templates/*.html are generated from the shared shell by bun run email:templates. The config.toml points at these files, so local supabase start uses them automatically.

Hosted project: 1. Run bun run email:templates to regenerate supabase/templates/*.html. 2. Open Dashboard → Auth → Email Templates. 3. Paste each file's content into the corresponding template (Confirmation, Magic Link, Reset Password, Change Email, Invite, Reauthentication). 4. Set subjects to match the generated filenames.

Optional — custom SMTP (recommended): Enable custom SMTP in Dashboard → Authentication → Email → SMTP Settings, pointing at your Resend SMTP credentials. This makes auth emails come from your domain instead of noreply@mail.app.supabase.io, matching the digest/invite sender.

Billing (Stripe) — off by default

Self-serve Pro checkout, the billing portal, and the Stripe webhook all sit behind a feature flag and ship disabled: while BILLING_ENABLED=false, every /api/billing/* route returns 503 and the client hides the upgrade UI, so plans are assigned directly in the database (profiles.plan — seed the tier table with python3 scripts/gen-seed-plans.py; the Plans docs page stays unpublished while billing is untested).

VariablePurpose
BILLING_ENABLEDServer-side gate for /api/billing/*. Keep false until checkout is tested end to end
NEXT_PUBLIC_BILLING_ENABLEDClient-side gate: hides upgrade UI while off. Must match BILLING_ENABLED
STRIPE_SECRET_KEYServer-side Stripe key (sk_test_… in dev). Only read when billing is enabled
STRIPE_WEBHOOK_SECRETSigning secret for POST /api/billing/webhook (whsec_… — from stripe listen in dev)
STRIPE_PRO_PRICE_IDRecurring monthly price ID of the "fewer Pro" product (price_…)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYAccepted but unused — every Stripe call is server-side

Never grant plan changes through the client. profiles.plan and stripe_customer_id are service-role-only columns; migrations 0022/0026 keep them out of the authenticated user's update grant.

Cloud Connections (OAuth)

Cloud storage browsing (GitHub private repos, Google Drive, OneDrive, SharePoint, Azure) uses OAuth. Each provider needs its own app credentials. The redirect URI for every provider is:

http://localhost:3000/api/cloud/callback      (dev)
https://your-domain.com/api/cloud/callback   (prod)

Shared environment variables

VariablePurpose
CONNECTIONS_ENCRYPTION_KEY32-byte base64 key used to encrypt OAuth tokens at rest. Generate with openssl rand -base64 32
NEXT_PUBLIC_APP_URLYour app origin (used to build the OAuth redirect URI)

GitHub (private repos)

  1. Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
  2. Homepage URL: https://your-domain.com (or http://localhost:3000).
  3. Authorization callback URL: https://your-domain.com/api/cloud/callback.
  4. Copy the Client ID and Client Secret into:

Google Drive

  1. Go to Google Cloud Console → your project → APIs & Services → OAuth consent screen.
  2. Set the app to External and add the drive.metadata.readonly scope in Data Access.
  3. APIs & Services → Credentials → Create Credentials → OAuth client ID → Web application.
  4. Add the redirect URI: https://your-domain.com/api/cloud/callback.
  5. Copy the Client ID and Client Secret into:

OneDrive, SharePoint, Azure DevOps, Azure Blob (Microsoft)

All four Microsoft-backed providers use a single Azure AD app.

  1. Go to Azure Portal → App registrations → New registration.
  2. Supported account types: choose "Any Entra ID Tenant + Personal Microsoft accounts" (the common tenant). This allows both personal OneDrive accounts and work/school accounts (SharePoint, Azure DevOps, Azure Blob). If you only need your own organization, "Single tenant" also works — set MICROSOFT_TENANT to your tenant ID instead of common.
  3. Set the Redirect URI to a Web platform: https://your-domain.com/api/cloud/callback.
  4. Under Certificates & secrets → New client secret, create one and copy it.
  5. Under API permissions → Add a permission → Microsoft Graph → Delegated, add:
  6. For Azure DevOps, add a separate permission: Azure DevOps → user_impersonation (or add the 499b84ac-... resource scope).
  7. For Azure Blob, add the Azure Storage delegated permission (https://storage.azure.com/user_impersonation), and assign the Storage Blob Data Reader role to the app on your storage account.
  8. Copy the values into:

Troubleshooting Microsoft registration

"Could not grant admin consent. Your organization does not have a subscription (or service principal) for: Microsoft Graph, Azure DevOps, Azure Storage"

This is expected on free/personal Entra tenants — they have no Azure DevOps org or Azure Storage subscription, so those service principals can't exist.

Fix: 1. Remove the Azure DevOps and Azure Storage API permissions from the registration. They're only needed with real Azure resources. 2. Skip "Grant admin consent" — it's optional for delegated scopes. Consent happens at first sign-in. 3. Keep only User.Read + Files.Read for personal OneDrive. Drop Files.Read.All / Sites.Read.All if they also fail consent.

Account-type reality check: - Personal Microsoft account → OneDrive works. SharePoint / Azure DevOps / Azure Blob do not (org-only). - Work/school account → SharePoint/DevOps/Blob possible, if the org has the services and you hold the roles.

Next Steps