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:

WhereWhat goes thereChanged by
src/config/*.tsProduct decisions: name, URLs, plans, landing page, themeEditing code and deploying
.dev.vars / Worker secretsCredentials: auth secret, OAuth, payment and email keys.dev.vars locally, wrangler secret put in production
wrangler.jsoncCloudflare resources: Worker name, D1, R2, rate limits, cronEditing the file and deploying
Admin console, SettingsOperational numbers: retention windows, grace periods, limitsAn 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

FileWhat it holds
app-config.tsApp name, description, canonical URL, email addresses, legal entity, Google client id, analytics key, waitlist switch, media URL
plans.tsSubscription plans and credit packs: prices, monthly credit grants, the Stripe price ids and Creem product ids
landing.tsThe landing page as an array of blocks. See Landing page and themes
themes.tsThe four visual themes and defaultTheme, the one visitors see first
private-paths.tsTop-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.

FieldUsed for
name, descriptionThe product name in the UI, emails and page titles; the tagline in the marketing footer
urlThe canonical production origin: canonical and hreflang links, the social card URL, links inside emails
emailFromSender of transactional email. Put it on a sending subdomain you have verified in Resend, such as noreply@send.yourdomain.com
supportEmailReply-To on every email, the support link in Help and feedback, the marketing footer, the legal pages. Must reach a real inbox
googleClientIdThe 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
legalEntityCompany name, jurisdiction, address and DMCA contact, printed on Terms, Privacy and the DMCA page
analytics.posthogPostHog project key and cluster hosts. An empty key turns analytics off
waitlistWhile true, new users land on /waitlist until an admin approves them. Build-time, so the prerendered pages show the matching call to action
mediaUrlPublic 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.

VariableRequiredWhat it does
APP_URLYesThe app's origin, a full URL. http://localhost:3000 locally
BETTER_AUTH_SECRETYesSigns session cookies and encrypts secret-type operator settings. At least 16 characters; generate with openssl rand -base64 32
GOOGLE_CLIENT_IDYesGoogle OAuth client id
GOOGLE_CLIENT_SECRETYesGoogle OAuth client secret
GITHUB_CLIENT_IDNoWith its secret, adds "Continue with GitHub" to the login page
GITHUB_CLIENT_SECRETNoSet both GitHub keys or neither
PAYMENT_PROVIDERNostripe or creem: who takes new checkouts. Empty means Stripe if STRIPE_SECRET_KEY is set, otherwise Creem
STRIPE_SECRET_KEYNoStripe secret API key
STRIPE_WEBHOOK_SECRETNoVerifies /api/webhooks/stripe. Locally it comes from stripe listen
CREEM_API_KEYNoCreem API key
CREEM_WEBHOOK_SECRETNoVerifies /api/webhooks/creem
RESEND_API_KEYNoSends email through Resend. Without it every send is a logged no-op and sign-in codes print to the dev log
DEMO_MODENotrue 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:

FieldDefaultNotes
nameshipkitThe Worker's name, and its workers.dev subdomain
d1_databases[0].database_idREPLACE_MEFilled in when you create the production database. Harmless locally
d1_databases[0].database_nameshipkitMust match the database you create
r2_buckets[0].bucket_nameshipkit-filesPrivate file storage
ratelimits5/min and 200/minPUBLIC_RATE_LIMIT for public forms, ANALYTICS_RATE_LIMIT for the analytics proxy
triggers.crons0 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.

SettingDefaultWhat it controls
Deleted account retention30 daysHow long a closed account is kept, restorable, before it is erased
Subscription grace period3 daysHow long past its period a subscription survives without a renewal event
Low balance threshold50 creditsBelow this, the header balance chip becomes a top-up prompt
Webhook payload retention90 daysHow long delivered payment webhooks are kept
Audit log retention180 daysOlder entries are purged nightly (minimum 7)
Email log retention90 daysOlder delivery records are purged nightly
Commission rate2000 bps (20%)Affiliate commission on referred orders; new orders only
Refund holdback30 daysHow long a commission is held before it can be withdrawn
Minimum withdrawal5000 centsBalance an affiliate needs to request a payout
Attribution window30 daysHow long a referral click counts
Screenshots per report3Images allowed on one piece of feedback
Screenshot size limit5 MBPer 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, editing settings.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.