Configuration
Every environment variable and admin setting in LinkNest, which ones are build-time, how payment keys are resolved, and the values that only live in code.
9 min readUpdated Oct 17, 2026Applies to LinkNest 1.0.0
On this page
LinkNest is configured in three places:
- Environment variables bootstrap the app: the database, sign-in, admins, the encryption key for payment secrets, your public hostname and the optional AI and email keys.
- Admin settings are stored in the
site_settingstable and edited in the admin panel: branding and SEO under Admin → Brand, gateway keys under Admin → Payment settings. Plans have their own table, edited under Admin → Plans. - Code constants cover a few limits that have no setting yet. They are listed at the end of this page.
Environment variables
Core
| Variable | Required | Default | What it does |
|---|---|---|---|
DATABASE_URL |
Production | Embedded in-memory PGLite | Postgres connection string. Empty or whitespace counts as unset. Without it, all data is lost on restart and the migration script skips itself. |
BETTER_AUTH_SECRET |
Production | Random per process | Signs sessions. Without it, everyone is signed out whenever the server restarts. Generate with openssl rand -base64 32. |
BETTER_AUTH_URL |
Recommended | Worked out per request | Your public origin with the scheme, for example https://links.example.com. Used as the sign-in base URL and added to the trusted origins. |
VITE_PUBLIC_HOSTNAME |
Production | Unset | Your public host without the scheme, for example links.example.com. Read at build time for the share-image URL in every page's head, and at runtime by the server for trusted sign-in origins, the checkout return allow-list, the custom-domain target and the default broadcast sender. |
ADMIN_EMAILS |
Production | Unset: nobody new becomes admin | Comma-separated email addresses, for example you@example.com,ops@example.com. A signed-in account with a listed address is seated as a platform admin; nobody else is. Read at runtime. Email addresses are not verified, so register every listed address yourself before the site is public. See Admins. |
SETTINGS_ENCRYPTION_KEY |
Recommended | BETTER_AUTH_SECRET |
A long random string. Encrypts the payment secrets saved under Admin → Payment settings. See Encrypted payment secrets. |
.env.example fills ADMIN_EMAILS with you@example.com and the two secrets with placeholder text. Replace all three before you deploy: a placeholder secret is public, and an unregistered example address can be claimed by anyone.
Build time
These are compiled into the browser bundle. Changing them means rebuilding (in Docker, pass them with --build-arg).
| Variable | Default | What it does |
|---|---|---|
VITE_PUBLIC_HOSTNAME |
Unset | See above. Pass it at build time and set it at runtime. |
VITE_SOCIAL_AUTH |
Off | Exactly true shows "Continue with Google" and "Continue with X" in the sign-in modal. Leave it false: those buttons go through a hosted sign-in broker that a self-hosted install does not have credentials for. See Social sign-in. |
VITE_AUTH_ENABLED |
Sign-in on | Exactly false turns sign-in off and runs every request as one shared development user. The server also reads it at runtime and refuses every request when a DATABASE_URL is set. Development only. |
NITRO_PRESET |
vercel |
Set to node-server for Docker or a VM. The Dockerfile sets it for you. |
Server
| Variable | Default | What it does |
|---|---|---|
PORT |
3000 for the built server (the Docker image sets 3000); the dev server is always 8080 |
Listening port. |
HOST |
All interfaces (the Docker image sets 0.0.0.0) |
Listening address. |
AI and email
| Variable | Default | What it does |
|---|---|---|
XAI_API_KEY |
Unset | Server-only. Turns on model-written plans in the AI co-pilot. Without it the co-pilot uses its rule-based planner. Never give it a VITE_ name. |
RESEND_API_KEY |
Unset | Sends creators' email broadcasts through Resend. Without it a broadcast is saved as "ready" for the creator to copy. |
RESEND_FROM |
hello@ plus VITE_PUBLIC_HOSTNAME |
The sender address. It must be on a domain verified in Resend. |
Payment fallbacks
Every gateway key can be set here or in Admin → Payment settings. Read how they are resolved before choosing.
| Variable | Admin field |
|---|---|
STRIPE_SECRET_KEY |
Stripe secret key |
STRIPE_PUBLISHABLE_KEY (also VITE_STRIPE_PUBLISHABLE_KEY or NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) |
Stripe publishable key |
STRIPE_WEBHOOK_SECRET |
Stripe webhook secret |
PAYPAL_CLIENT_ID |
PayPal client ID |
PAYPAL_CLIENT_SECRET (also PAYPAL_SECRET) |
PayPal secret |
PAYPAL_WEBHOOK_ID |
PayPal webhook ID |
RAZORPAY_KEY_ID |
Razorpay key ID |
RAZORPAY_KEY_SECRET |
Razorpay key secret |
RAZORPAY_WEBHOOK_SECRET |
Razorpay webhook secret |
.env.example lists most of these but not PAYPAL_WEBHOOK_ID or RAZORPAY_WEBHOOK_SECRET.
Social sign-in
| Variable | Default | What it does |
|---|---|---|
GROK_AUTH_ISSUER |
https://auth.grok.me |
The hosted sign-in broker behind the Google and X buttons. |
GROK_AUTH_CLIENT_ID, GROK_AUTH_CLIENT_SECRET |
A built-in preview client | Credentials for that broker. The built-in client only accepts preview hosts, so on your own domain the buttons fail. |
Email and password is the supported way to sign in on a self-hosted install. Google and X sign-in cannot be pointed at your own OAuth apps without changing src/lib/auth/server.ts.
Set by the platform
| Variable | What it does |
|---|---|
VERCEL_URL, VERCEL_PROJECT_PRODUCTION_URL |
Set by Vercel. Their hosts are added to the trusted sign-in hosts. |
Demo mode
| Variable | Required | Default | What it does |
|---|---|---|---|
DEMO_MODE |
No | Off | true adds Explore the creator studio and Explore the admin panel to the sign-in dialog and seeds a sample Premium page, /u/solandclay, with sample customers for the admin panel. Demo accounts use the reserved demo.invalid domain and are read-only on the server: no saves, broadcasts, checkouts, payouts or xAI calls. Real accounts are unaffected. BETTER_AUTH_SECRET must be set. |
Note
The demo admin sees only demo data and fully masked payment keys. Real admins still see the demo pages under People, but the demo payments and payouts are left out of Finance.
Admin settings
Brand and SEO
Edited under Admin → Brand, stored in site_settings. There is no environment fallback for these.
| Field | Key | Default | Where it shows |
|---|---|---|---|
| Product name | brand_name |
LinkNest |
Page titles, the public page's tab title, PayPal and Razorpay plan names, tip checkout line items. Up to 40 characters. |
| Tagline | brand_tagline |
Built-in line | Under the name in the admin sidebar only. |
| Logo | brand_logo |
The built-in mark | Site header, studio and admin sidebars. Uploaded, cropped square to 256 px and stored in the database. |
| Favicon | brand_favicon |
/favicon.svg |
Browser tab. Uploaded, cropped to 64 px and stored in the database. |
| SEO title | seo_title |
The product name | <title>, Open Graph and Twitter titles. Up to 70 characters. |
| SEO description | seo_description |
Built-in description | Meta, Open Graph and Twitter descriptions. Up to 180 characters. |
| Share image | seo_og_image |
https://<VITE_PUBLIC_HOSTNAME>/og.jpg |
og:image. |
| X handle | seo_twitter |
Empty | twitter:site. |
| Hide from search engines | seo_noindex |
Off | Adds robots: noindex, nofollow. |
Tip
An uploaded share image is stored as a data: URL, which social networks do not fetch. For link previews that work, leave the share image empty and replace public/og.jpg (1200 by 630) before you build.
Rebranding through Admin → Brand does not reach every string. Several screens print linknest.app/<username> or "LinkNest" from the source; see Known limitations.
Payment settings
Edited under Admin → Payment settings. Each gateway has a Test connection button.
| Gateway | Fields | Good to know |
|---|---|---|
| Stripe | Publishable key, secret key, webhook secret, test mode | Keys are checked for the pk_, sk_ or rk_ and whsec_ prefixes. The test-mode switch is a label: the key you paste decides test or live. |
| PayPal | Client ID, secret, webhook ID, sandbox | Sandbox is on until you turn it off. It decides whether LinkNest talks to api-m.sandbox.paypal.com or api-m.paypal.com. |
| Razorpay | Key ID, key secret, webhook secret, test mode, currency, INR per 1 USD | Currency is USD (default) or INR. Prices are in USD; INR charges convert them with the INR per 1 USD rate, which appears when INR is chosen. Without a rate, Razorpay charges USD. The test-mode switch is a label: the key decides. See Razorpay. |
A gateway counts as connected when its two keys are present: Stripe's secret and publishable keys, PayPal's client ID and secret, Razorpay's key ID and secret. Each screen shows the webhook URL to paste into the provider; Selling it as SaaS has the steps.
How payment settings are resolved
For each payment key, LinkNest reads the database first:
- If a value is saved in
site_settings, it wins. - If nothing is saved and an environment variable is set, the environment value is used and copied into
site_settingson that first read. - Otherwise the gateway is not configured.
Because of step 2, changing a payment variable in the environment after the app has read it has no effect. Change it in Admin → Payment settings instead, or remove the saved copy so the new environment value is copied on the next read: Clear under a saved secret does this for secrets, and for the other keys delete the row from site_settings.
Saving an empty field in the admin panel leaves the old value in place. To remove a saved secret, use Clear next to it (shown for the Stripe secret key and webhook secret, the PayPal secret, and the Razorpay key secret and webhook secret). Checkout through that gateway stops until a new one is saved. If an environment variable also sets that secret, the screen says so, and the environment value is used and saved again on the next read.
Encrypted payment secrets
These five settings are stored encrypted with AES-256-GCM: the Stripe secret key, the Stripe webhook secret, the PayPal secret, the Razorpay key secret and the Razorpay webhook secret. The publishable key, PayPal client ID, PayPal webhook ID and Razorpay key ID are stored as plain text, as are the other settings. The admin screens and Admin → Export mask the secrets.
The encryption key is derived from SETTINGS_ENCRYPTION_KEY, or from BETTER_AUTH_SECRET when SETTINGS_ENCRYPTION_KEY is empty. Secrets saved as plain text by an older build are still read, and are saved again encrypted the first time they are read.
Warning
Keep the key unchanged once secrets are saved. If SETTINGS_ENCRYPTION_KEY changes, or BETTER_AUTH_SECRET changes while it is the fallback, or you first set SETTINGS_ENCRYPTION_KEY after secrets were saved with the fallback, the saved secrets can no longer be read. LinkNest then treats them as not set: the gateway stops working, the log shows [settings] <key> is encrypted but could not be decrypted, and you must enter the secrets again under Admin → Payment settings. Secrets that also come from environment variables are picked up and saved again on their own.
With neither SETTINGS_ENCRYPTION_KEY nor BETTER_AUTH_SECRET set, saving a payment secret fails with "Set SETTINGS_ENCRYPTION_KEY (or BETTER_AUTH_SECRET) before saving payment secrets." Secrets from environment variables still work but are not saved to the database.
Encryption protects a copied database or backup on its own. Anyone who also has the key can read the secrets, so keep the key apart from the backups and restrict database access.
Values that live in code
| What | Where | Value |
|---|---|---|
| AI co-pilot runs per page | COPILOT_CAP in src/lib/grove/copilot.ts |
40, for the life of the page |
| xAI model used by the co-pilot | src/lib/grove/copilot.ts |
grok-4-1-fast-non-reasoning |
| Minimum payout request | src/lib/grove/earn.ts |
1000 cents ($10) |
| Minimum tip or product price at checkout | src/lib/payments/earn-checkout.ts |
100 cents ($1) |
| Themes, typefaces and which ones are free | src/lib/grove/themes.ts, src/routes/dashboard/appearance.tsx |
18 themes (first 6 free), 9 typefaces (3 free) |
| Insight rules | src/lib/grove/insights.ts |
See Analytics |
| Hosts that can never be claimed as custom domains | src/lib/grove/domain-format.ts |
localhost, *.vercel.app, *.localhost and a few product hosts |
| Broadcast rate limit | src/lib/grove/broadcasts.ts |
One broadcast per page every five minutes |
| Subscribe field limit | assertPublicFormLimit in src/lib/http/rate-limit.ts |
5 per visitor IP per page every 10 minutes, and 300 per page per hour |
| Message form limit (notes and booking requests) | src/lib/http/rate-limit.ts |
5 per visitor IP per page every 10 minutes, and 100 per page per hour |
| Password and email unlock limit | src/lib/http/rate-limit.ts |
20 per visitor IP per page every 10 minutes, and 600 per page per hour |
| Link shortener limit | createShortLink in src/lib/grove/tools.ts |
30 new short links per account per hour |
The form and shortener limits are fixed windows counted in the rate_limits table (migration 0016). A visitor over a limit gets "Too many tries. Wait a few minutes and try again."; a creator over the shortener limit gets "You've made a lot of short links. Try again in an hour." If the database cannot be reached, the limits are skipped rather than blocking the page. The visitor IP comes from the proxy's headers; see Client IP for rate limits.