Configuration
Where each kind of setting lives — the config files in src/config, the environment variables, wrangler.jsonc, and the settings operators edit in the admin console.
ShipKit keeps configuration in four places, and each one answers a different question:
| Where | What goes there | Changed by |
|---|---|---|
src/config/*.ts | Product decisions: name, URLs, plans, landing page, theme | Editing code and deploying |
.dev.vars / Worker secrets | Credentials: auth secret, OAuth, payment and email keys | .dev.vars locally, wrangler secret put in production |
wrangler.jsonc | Cloudflare resources: Worker name, D1, R2, rate limits, cron | Editing the file and deploying |
| Admin console, Settings | Operational numbers: retention windows, grace periods, limits | An operator, at runtime, no deploy |
A secret is an environment variable; a value someone running the business should tune without a developer is an operator setting; the rest is code.
Config files in src/config
| File | What it holds |
|---|---|
app-config.ts | App name, description, canonical URL, email addresses, legal entity, Google client id, analytics key, waitlist switch, media URL |
plans.ts | Subscription plans and credit packs: prices, monthly credit grants, the Stripe price ids and Creem product ids |
landing.ts | The landing page as an array of blocks. See Landing page and themes |
themes.ts | The four visual themes and defaultTheme, the one visitors see first |
private-paths.ts | Top-level paths that are not marketing pages. One list feeds the prerender filter and the Disallow lines in robots.txt |
Components import these files directly rather than reading
import.meta.env. Most user-facing copy is not here but in messages/en.json
and messages/zh.json (see i18n); the app name reaches that
copy as a parameter, so renaming the app in the UI is one edit.
app-config.ts
Everything in it ships as a placeholder. Go through it before the first deploy.
| Field | Used for |
|---|---|
name, description | The product name in the UI, emails and page titles; the tagline in the marketing footer |
url | The canonical production origin: canonical and hreflang links, the social card URL, links inside emails |
emailFrom | Sender of transactional email. Put it on a sending subdomain you have verified in Resend, such as noreply@send.yourdomain.com |
supportEmail | Reply-To on every email, the support link in Help and feedback, the marketing footer, the legal pages. Must reach a real inbox |
googleClientId | The Google OAuth client id, for One Tap in the browser. It must be the same client as GOOGLE_CLIENT_ID; the file has one value for dev and one for production |
legalEntity | Company name, jurisdiction, address and DMCA contact, printed on Terms, Privacy and the DMCA page |
analytics.posthog | PostHog project key and cluster hosts. An empty key turns analytics off |
waitlist | While true, new users land on /waitlist until an admin approves them. Build-time, so the prerendered pages show the matching call to action |
mediaUrl | Public origin for marketing images and clips. Empty means placeholders instead of broken images |
url and the APP_URL environment variable both hold your origin but do
different jobs: url is compiled into the pages, APP_URL is what the server
uses for auth callbacks and checkout return URLs. In production both should be
your domain.
A few branding files sit outside src/config: public/manifest.json,
the icons in public/, and public/og.png, the social card, which
bun run og redraws from scripts/og-card.html.
plans.ts
The single source of truth for pricing: webhooks resolve an incoming Stripe
price id or Creem product id against this file, so an unlisted id is not
recognized. The ids ship as REPLACE_ME placeholders until the /stripe or
/creem skill writes the real ones in. See Payments and
Credits.
Environment variables
Secrets are read from .dev.vars locally and from Worker secrets in
production. .dev.vars.example is the list, and src/core/env.ts validates
it with zod. Required keys are checked on first use and a missing one stops
the request with an error naming it; optional keys are checked only by the
code that needs them, so deleting a feature never breaks startup. Empty
strings count as unset.
| Variable | Required | What it does |
|---|---|---|
APP_URL | Yes | The app's origin, a full URL. http://localhost:3000 locally |
BETTER_AUTH_SECRET | Yes | Signs session cookies and encrypts secret-type operator settings. At least 16 characters; generate with openssl rand -base64 32 |
GOOGLE_CLIENT_ID | Yes | Google OAuth client id |
GOOGLE_CLIENT_SECRET | Yes | Google OAuth client secret |
GITHUB_CLIENT_ID | No | With its secret, adds "Continue with GitHub" to the login page |
GITHUB_CLIENT_SECRET | No | Set both GitHub keys or neither |
PAYMENT_PROVIDER | No | stripe or creem: who takes new checkouts. Empty means Stripe if STRIPE_SECRET_KEY is set, otherwise Creem |
STRIPE_SECRET_KEY | No | Stripe secret API key |
STRIPE_WEBHOOK_SECRET | No | Verifies /api/webhooks/stripe. Locally it comes from stripe listen |
CREEM_API_KEY | No | Creem API key |
CREEM_WEBHOOK_SECRET | No | Verifies /api/webhooks/creem |
RESEND_API_KEY | No | Sends email through Resend. Without it every send is a logged no-op and sign-in codes print to the dev log |
DEMO_MODE | No | true turns a deployment into the public demo. Never set it on a real deployment |
Purchase buttons read the active provider's key: until it is set they show "coming soon" instead of starting a checkout, so the app is usable before payments exist.
Environment is read at boot, so restart the dev server after editing
.dev.vars. Changing BETTER_AUTH_SECRET signs everyone out and makes any
stored secret-type setting unreadable, so treat it as permanent once you are
live. Deploy covers pushing secrets to production.
When you add a variable, add it to both .dev.vars.example and the schema in
src/core/env.ts. D1 and R2 need no variables at all: they are bindings.
wrangler.jsonc
The Cloudflare side of the app. The fields you are most likely to touch:
| Field | Default | Notes |
|---|---|---|
name | shipkit | The Worker's name, and its workers.dev subdomain |
d1_databases[0].database_id | REPLACE_ME | Filled in when you create the production database. Harmless locally |
d1_databases[0].database_name | shipkit | Must match the database you create |
r2_buckets[0].bucket_name | shipkit-files | Private file storage |
ratelimits | 5/min and 200/min | PUBLIC_RATE_LIMIT for public forms, ANALYTICS_RATE_LIMIT for the analytics proxy |
triggers.crons | 0 0 * * * | One daily run that executes every registered job |
Keep the binding names (DB, BUCKET and the rate limiter names): the code
refers to them. After changing this file, run bun run cf-typegen to
regenerate the Env types.
Operator settings
Some values belong to whoever runs the business, not to the codebase:
how long to keep audit logs, how many days a subscription survives a missed
webhook. These live at /admin/settings (Settings → Settings in the console)
and change without a deploy.
| Setting | Default | What it controls |
|---|---|---|
| Deleted account retention | 30 days | How long a closed account is kept, restorable, before it is erased |
| Subscription grace period | 3 days | How long past its period a subscription survives without a renewal event |
| Low balance threshold | 50 credits | Below this, the header balance chip becomes a top-up prompt |
| Webhook payload retention | 90 days | How long delivered payment webhooks are kept |
| Audit log retention | 180 days | Older entries are purged nightly (minimum 7) |
| Email log retention | 90 days | Older delivery records are purged nightly |
| Commission rate | 2000 bps (20%) | Affiliate commission on referred orders; new orders only |
| Refund holdback | 30 days | How long a commission is held before it can be withdrawn |
| Minimum withdrawal | 5000 cents | Balance an affiliate needs to request a payout |
| Attribution window | 30 days | How long a referral click counts |
| Screenshots per report | 3 | Images allowed on one piece of feedback |
| Screenshot size limit | 5 MB | Per image |
How they behave:
- Defaults live in code. The database stores only overrides. A key nobody has changed follows the default in the feature's code, so a new default you ship reaches every deployment that never touched it. Each row can be reset to its default.
- Changes take up to a minute. Values are cached for 60 seconds per Worker isolate.
- Ranges are enforced. Each numeric setting has a minimum and maximum, checked in the form and again on the server.
- Permissions apply. Viewing needs
settings.read, editingsettings.write; see Permissions. - Deleted features take their settings with them. Each group belongs to a feature and disappears when you delete it.
Adding a setting
A feature declares its settings in its own settings.ts with
registerSetting(): a namespaced key such as billing.grace_days, a kind
(number, boolean, string or secret), the code default, an optional
range and unit, and a label and hint as message functions so the console
localizes them. src/core/settings/handlers.ts imports that file with one
line. src/features/billing/settings.ts is a short model to copy.
Server code reads the value with getNumberSetting('billing.grace_days') (or
getSetting, getSecretSetting) from src/core/settings/store.ts. A
secret is encrypted at rest and never sent back to the browser; a setting
marked visibility: 'public' is readable by any signed-in user, for limits
the browser has to enforce.
One constraint: a settings.ts file is loaded by the browser bundle too, so
it must import nothing server-only. Keep the default in a client-safe
config.ts beside it, as billing does. src/core/settings/handlers.test.ts
fails if you get this wrong. Adding features covers
the other registries a new feature can plug into.