Locally, one .env at the repository root configures everything; copy .env.example to
start. The API checks its variables at startup and names every one that is wrong.
- Empty means not set.
STRIPE_SECRET_KEY= is the same as leaving the line out.
PORT stands in for API_PORT when API_PORT is not set. When both are, API_PORT
wins.
CORS_ORIGINS defaults to WEB_URL. Only set it if something else calls the API from a
browser. It can be set in .env.railway too.
- One provider at a time.
BILLING_PROVIDER is stripe or polar, and the other’s key
must be empty.
- Stripe’s two values go together. With
STRIPE_SECRET_KEY set, STRIPE_WEBHOOK_SECRET
is required.
- OAuth providers need both halves:
GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, or
neither. Same for GitHub.
- The example
AUTH_SECRET is refused in production.
| Variable |
What |
Default |
Production |
NODE_ENV |
development, test or production |
development |
The API image sets it |
API_PORT |
Where the API listens |
PORT, then 3000 |
Leave it to PORT |
LOG_LEVEL |
fatal to trace, or silent |
debug dev, info production, silent tests |
Optional |
CORS_ORIGINS |
Browser origins allowed to call the API, comma-separated |
WEB_URL |
Rarely |
See Traces and metrics.
| Variable |
What |
Default |
Production |
OTEL_EXPORTER_OTLP_ENDPOINT |
Base URL of an OpenTelemetry backend. Empty: telemetry off |
None |
Optional |
OTEL_EXPORTER_OTLP_HEADERS |
name=value pairs it needs, comma-separated: its key |
None |
With it |
OTEL_SERVICE_NAME |
The name traces and metrics are filed under |
api, or worker |
Rarely |
| Variable |
What |
Default |
Production |
WEB_URL |
Public address of the app; emails and OAuth use it |
http://localhost:5173 |
Yes, https:// |
AUTH_SECRET |
Signs sessions. 32+ characters |
None: API won’t start |
Yes, generated |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Sign-in with Google |
No Google button |
If offered |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
Sign-in with GitHub |
No GitHub button |
If offered |
ADMIN_EMAILS |
Who can open the admin panel, comma-separated |
Nobody |
Yes |
MOBILE_SCHEME |
The mobile app’s URL scheme, as in its app.json |
saasstack |
If you changed the scheme |
EXPO_ACCESS_TOKEN |
Push, with enhanced security on in Expo |
None: not needed |
Rarely |
| Variable |
What |
Default |
Production |
STRIPE_SECRET_KEY |
sk_… or rk_…, test or live |
Billing off, all on Free |
If you charge |
STRIPE_WEBHOOK_SECRET |
whsec_…, proves webhooks are Stripe’s |
Billing off |
With the key |
BILLING_PROVIDER |
stripe or polar |
stripe |
If you use Polar |
POLAR_ACCESS_TOKEN |
An organization access token, polar_oat_… |
Billing off, all on Free |
With Polar |
POLAR_WEBHOOK_SECRET |
Proves webhooks are Polar’s |
Billing off |
With the token |
POLAR_SERVER |
sandbox or production |
production |
production |
| Variable |
What |
Default |
Production |
AI_GATEWAY_API_KEY |
Vercel AI Gateway key |
Assistant is off |
If offered |
AI_MODEL |
provider/model |
deepseek/deepseek-v4.1-flash |
Optional |
See Product analytics, and
Mixpanel or PostHog to pick one. The web and mobile apps read it
from the API, so it is set on the API only.
| Variable |
What |
Default |
Production |
ANALYTICS_PROVIDER |
mixpanel or posthog |
mixpanel |
If PostHog |
MIXPANEL_TOKEN |
Mixpanel project token |
Analytics is off |
If you want it |
MIXPANEL_REGION |
Where its data is kept: us, eu, in |
us |
If not in the US |
POSTHOG_KEY |
PostHog project API key, phc_… |
Analytics is off |
If you want it |
POSTHOG_HOST |
US cloud, EU cloud (https://eu.i.posthog.com), own |
https://us.i.posthog.com |
If not the US one |
See Error reporting. The web and mobile apps read their DSNs
from the API.
| Variable |
What |
Default |
Production |
SENTRY_DSN |
A Sentry project’s DSN. Empty: nothing is reported |
None |
Recommended |
SENTRY_WEB_DSN |
The web app’s own project |
SENTRY_DSN |
Optional |
SENTRY_MOBILE_DSN |
The mobile app’s own project |
SENTRY_DSN |
Optional |
SENTRY_RELEASE |
The version errors are filed under |
RAILWAY_GIT_COMMIT_SHA |
Rarely |
SENTRY_AUTH_TOKEN |
Building the web app only: sends its source maps |
None: no maps are made |
Optional |
SENTRY_ORG |
Building the web app only: your Sentry organization |
None |
With it |
SENTRY_WEB_PROJECT |
Building the web app only: the project they go to |
None |
With it |
| Variable |
What |
Default |
Production |
JOBS_WORKER |
Whether the API runs jobs itself |
true |
false only with a separate worker |
| Variable |
What |
Default |
Production |
DEMO_MODE |
true on a public demo: every page says it is one |
false |
true only on a demo |
DEMO_RETURN_URL |
On a demo, where the banner’s “Back to …” link goes |
none |
Only on a demo |
DEMO_ALL_FEATURES |
On a demo, true gives everyone every feature, whatever their plan |
false |
Only on a demo |
PUBLIC_DEMO_RETURN_URL |
The same, for the bar on the marketing site |
none |
Site image build argument |
| Variable |
What |
Default |
Production |
STAND_INS |
Stand-ins for Stripe and the model, for the journeys. Only on localhost |
false |
Never: deploy:check fails on it |
| Variable |
What |
Default |
Production |
DATABASE_URL |
postgres://… |
None: API won’t start |
Yes, from host |
PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE |
The database in parts, without DATABASE_URL |
None |
From host, on AWS |
DATABASE_ORM |
drizzle, prisma or typeorm |
drizzle |
Same as dev |
REDIS_URL |
redis://… |
redis://localhost:6379 |
Yes, from host |
REDIS_PORT |
Local Docker Redis |
6379 |
Not used |
POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_PORT |
Local Docker database |
saas, saas, saas, 5432 |
Not used |
Change a POSTGRES_* value and change DATABASE_URL to match. Choose DATABASE_ORM once, before
the first migration: see Database and ORMs.
| Variable |
What |
Default |
Production |
SMTP_HOST |
Mail server |
localhost |
Yes |
SMTP_PORT |
Its port; also Mailpit’s locally |
1025 |
Yes, usually 587 |
SMTP_USER, SMTP_PASSWORD |
Login |
None |
Yes |
MAIL_FROM |
Sender |
SaaS Stack <hello@example.com> |
Yes, on a verified domain |
CONTACT_EMAIL |
Where the contact form sends messages |
none: the form is off |
If you want the form |
MAILPIT_UI_PORT |
Where you read caught email |
8025 |
Not used |
| Variable |
What |
Default |
Production |
WEB_PORT |
Web dev server port |
5173 |
Not used |
VITE_API_URL |
Where the dev server sends /api |
http://localhost:3000 |
Not used; Caddy uses API_URL |
SITE_PORT |
Site dev server port |
4321 |
Not used |
SITE_URL |
The site’s address, for canonical links and sitemap |
http://localhost:4321 |
Site image build argument |
PUBLIC_APP_URL |
Where the site’s “Sign in” goes |
http://localhost:5173 |
Site image build argument |
EXPO_PUBLIC_API_URL |
Where the mobile app finds the API |
http://localhost:3000 |
Your web app’s address |
VITE_API_BASE_URL |
The API’s address, if it is not under /api of the web app |
none: the same address |
Only without the web image’s proxy |
On a phone, localhost is the phone: use your machine’s network address.
| Variable |
Read by |
What |
Default |
PORT |
api, web, site |
Where to listen |
3000 api, 8080 web and site |
API_URL |
web, site |
The API’s private address, for /api and the contact form |
http://localhost:3000 |
Besides the API’s variables above, Railway reads:
| Variable |
What |
Default |
RAILWAY_PROJECT |
Project it may change |
Required |
RAILWAY_REPO |
owner/name on GitHub |
This folder’s origin |
RAILWAY_BRANCH |
Branch deployed |
main |
WEB_DOMAIN, SITE_DOMAIN |
Your own domains, without https:// |
Railway’s addresses |
RAILWAY_WORKER |
true for a separate worker service |
Jobs run in the API |
Empty values there are left as they are on Railway.
- Add it to
apps/api/src/config/env.ts and .env.example, with a comment.
- Read it with
@Inject(ENV) env: Env, never process.env.
- If production needs it, add it to
YOURS in .railway/railway.ts and to
.env.railway.example.