Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 0 additions & 29 deletions .env.example

This file was deleted.

102 changes: 102 additions & 0 deletions .env.schema
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# This env file uses @env-spec - see https://varlock.dev/env-spec for more info
Copy link
Author

@theoephraim theoephraim Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This schema could be much more thorough, adding more descriptions / links, better validation logic, adding new vars to replace MODE checks.

Huge benefits, and is less overwhelming since the user only needs to set items that differ, rather than copy/pasting all items.

Also note that it is much more legible with code highlighting provided by our VSCode Plugin.

(what it looks like with syntax highlighting)
image

#
# @defaultRequired=true @defaultSensitive=false
# @currentEnv=$NODE_ENV
# @generateTypes(lang=ts, path=types/env-vars.d.ts)
# ----------

# @type=enum(development, production, test)
NODE_ENV=development
# @type=enum(development, production, test)
MODE=$NODE_ENV
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved this into the schema itself, and has a default value set, so additional ?? 'development' can be removed in a few places. Ideally we'd remove one of these since there is a mix of checks using one or the other throughout the codebase. Personally we recommend not relying on NODE_ENV.


# @type=port
PORT=3000

LITEFS_DIR="/litefs/data"
DATABASE_PATH="./prisma/data.db"
DATABASE_URL="file:./data.db?connection_limit=1"
CACHE_DATABASE_PATH="./other/cache.db"

# used to secure sessions
# @sensitive
# @docs(https://stack-staging.epicweb.dev/topic/deployment)
SESSION_SECRET="super-duper-s3cret"

# encryption seed for honeypot server
# @sensitive
# @docs(https://stack-staging.epicweb.dev/topic/deployment)
HONEYPOT_SECRET="super-duper-s3cret"

# this is set to a random value in the Dockerfile
# @sensitive
INTERNAL_COMMAND_TOKEN="some-made-up-token"

# set to false to prevent search engines from indexing the website (defaults to allow)
ALLOW_INDEXING=true

# enables mocks for external services
MOCKS=forEnv(development, test)

# will be set to curent commit sha in deployments
# @optional
COMMIT_SHA=

# API key for Resend (email service)
# @type=string(startsWith=re_)
# @sensitive
# @optional # remove this if using resend
# @docs(https://resend.com/docs/dashboard/api-keys/introduction#what-is-an-api-key)
RESEND_API_KEY=

# will be set to true when running in CI
CI=false

# Sentry settings (error tracking)
# note that SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT are optional
# but enable @sentry/react-router integration and release tagging
# ---
# @type=url
# @optional # remove this if using sentry
# @example=https://examplePublicKey@o0.ingest.sentry.io/0
# @docs(https://docs.sentry.io/concepts/key-terms/dsn-explainer/)
SENTRY_DSN=
# @optional @sensitive
SENTRY_AUTH_TOKEN=
# @required=if($SENTRY_AUTH_TOKEN)
SENTRY_ORG=
# @required=if($SENTRY_AUTH_TOKEN)
SENTRY_PROJECT=

# GitHub settings
#
# the mocks and some code rely on these being prefixed with "MOCK_"
# if they aren't then the real github api will be attempted
# ---
GITHUB_CLIENT_ID="MOCK_GITHUB_CLIENT_ID"
# @sensitive
GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET"
# @sensitive
GITHUB_TOKEN="MOCK_GITHUB_TOKEN"
# @type=url
GITHUB_REDIRECT_URI="https://example.com/auth/github/callback"


# Tigris Object Storage (S3-compatible) Configuration
# ---
AWS_ACCESS_KEY_ID="mock-access-key"
# @sensitive
AWS_SECRET_ACCESS_KEY="mock-secret-key"
AWS_REGION="auto"
# @type=url
AWS_ENDPOINT_URL_S3="https://fly.storage.tigris.dev"
BUCKET_NAME="mock-bucket"

# Populated by fly.io
# ---
# current fly.io region
# @optional
FLY_REGION=
# app name as set in fly.io
# @optional
FLY_APP_NAME=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ node_modules
/build
/server-build
.env
.env.local
.env.*.local
.cache

/prisma/data.db
Expand All @@ -26,3 +28,4 @@ node_modules
# generated files
/app/components/ui/icons
.react-router/
/types/env-vars.d.ts
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ node_modules
/public/build
/server-build
.env
.env.*

/test-results/
/playwright-report/
/playwright/.cache/
/tests/fixtures/email/*.json
/coverage
/prisma/migrations
/types/env-vars.d.ts

package-lock.json
3 changes: 2 additions & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"prisma.prisma",
"qwtel.sqlite-viewer",
"yoavbls.pretty-ts-errors",
"github.vscode-github-actions"
"github.vscode-github-actions",
"varlock.env-spec-language"
]
}
11 changes: 5 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ instructions:

1. Fork repo
2. clone the repo
3. Copy `.env.example` into `.env`
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer necessary. Now users only need to manage values that are different than the defaults in .env.schema.

4. Run `npm install && npm run setup -s` to install dependencies and run
3. Run `npm install && npm run setup -s` to install dependencies and run
validation
5. Create a branch for your PR with `git checkout -b pr/your-branch-name`
4. Create a branch for your PR with `git checkout -b pr/your-branch-name`

> Tip: Keep your `main` branch pointing at the original repository and make pull
> requests from branches on your fork. To do this, run:
Expand All @@ -44,10 +43,10 @@ If the setup script doesn't work, you can try to run the commands manually:
git clone <your-fork>
cd ./epic-stack

# copy the .env.example to .env
# create a file for gitignored .env overrides
# everything's mocked out during development so you shouldn't need to
# change any of these values unless you want to hit real environments.
cp .env.example .env
# set anything unless you want to hit real environments.
touch .env.local
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that .env will also be loaded, so using .env.local instead is not strictly necessary, but is fairly common and helps clarify that it is for local overrides.


# Install deps
npm install
Expand Down
1 change: 1 addition & 0 deletions app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { HydratedRouter } from 'react-router/dom'
import { ENV } from 'varlock/env'

if (ENV.MODE === 'production' && ENV.SENTRY_DSN) {
void import('./utils/monitoring.client.tsx').then(({ init }) => init())
Expand Down
20 changes: 8 additions & 12 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,26 @@ import {
type ActionFunctionArgs,
type HandleDocumentRequestFunction,
} from 'react-router'
import { getEnv, init } from './utils/env.server.ts'
import { ENV } from 'varlock/env'
import { getInstanceInfo } from './utils/litefs.server.ts'
import { NonceProvider } from './utils/nonce-provider.ts'
import { makeTimings } from './utils/timing.server.ts'

export const streamTimeout = 5000

init()
global.ENV = getEnv()

const MODE = process.env.NODE_ENV ?? 'development'

type DocRequestArgs = Parameters<HandleDocumentRequestFunction>

export default async function handleRequest(...args: DocRequestArgs) {
const [request, responseStatusCode, responseHeaders, reactRouterContext] =
args
const { currentInstance, primaryInstance } = await getInstanceInfo()
responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown')
responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
responseHeaders.set('fly-region', ENV.FLY_REGION ?? 'unknown')
responseHeaders.set('fly-app', ENV.FLY_APP_NAME ?? 'unknown')
responseHeaders.set('fly-primary-instance', primaryInstance)
responseHeaders.set('fly-instance', currentInstance)

if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) {
if (ENV.NODE_ENV === 'production' && ENV.SENTRY_DSN) {
responseHeaders.append('Document-Policy', 'js-profiling')
}

Expand Down Expand Up @@ -72,8 +68,8 @@ export default async function handleRequest(...args: DocRequestArgs) {
directives: {
fetch: {
'connect-src': [
MODE === 'development' ? 'ws:' : undefined,
process.env.SENTRY_DSN ? '*.sentry.io' : undefined,
ENV.MODE === 'development' ? 'ws:' : undefined,
ENV.SENTRY_DSN ? '*.sentry.io' : undefined,
"'self'",
],
'font-src': ["'self'"],
Expand Down Expand Up @@ -114,8 +110,8 @@ export default async function handleRequest(...args: DocRequestArgs) {

export async function handleDataRequest(response: Response) {
const { currentInstance, primaryInstance } = await getInstanceInfo()
response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown')
response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown')
response.headers.set('fly-region', ENV.FLY_REGION ?? 'unknown')
response.headers.set('fly-app', ENV.FLY_APP_NAME ?? 'unknown')
response.headers.set('fly-primary-instance', primaryInstance)
response.headers.set('fly-instance', currentInstance)

Expand Down
18 changes: 3 additions & 15 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
useMatches,
} from 'react-router'
import { HoneypotProvider } from 'remix-utils/honeypot/react'
import { ENV } from 'varlock/env'
import { type Route } from './+types/root.ts'
import appleTouchIconAssetUrl from './assets/favicons/apple-touch-icon.png'
import faviconAssetUrl from './assets/favicons/favicon.svg'
Expand All @@ -31,7 +32,6 @@ import tailwindStyleSheetUrl from './styles/tailwind.css?url'
import { getUserId, logout } from './utils/auth.server.ts'
import { ClientHintCheck, getHints } from './utils/client-hints.tsx'
import { prisma } from './utils/db.server.ts'
import { getEnv } from './utils/env.server.ts'
import { pipeHeaders } from './utils/headers.server.ts'
import { honeypot } from './utils/honeypot.server.ts'
import { combineHeaders, getDomainUrl, getImgSrc } from './utils/misc.tsx'
Expand Down Expand Up @@ -119,7 +119,6 @@ export async function loader({ request }: Route.LoaderArgs) {
theme: getTheme(request),
},
},
ENV: getEnv(),
toast,
honeyProps,
},
Expand All @@ -138,34 +137,25 @@ function Document({
children,
nonce,
theme = 'light',
env = {},
}: {
children: React.ReactNode
nonce: string
theme?: Theme
env?: Record<string, string | undefined>
}) {
const allowIndexing = ENV.ALLOW_INDEXING !== 'false'
return (
<html lang="en" className={`${theme} h-full overflow-x-hidden`}>
<head>
<ClientHintCheck nonce={nonce} />
<Meta />
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
{allowIndexing ? null : (
{ENV.ALLOW_INDEXING ? null : (
<meta name="robots" content="noindex, nofollow" />
)}
<Links />
</head>
<body className="bg-background text-foreground">
{children}
<script
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of this is necessary anymore. ENV is just available in both backend and front-end and we are protected from leaking any config items that are marked as sensitive.

nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(env)}`,
}}
/>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
Expand All @@ -174,12 +164,10 @@ function Document({
}

export function Layout({ children }: { children: React.ReactNode }) {
// if there was an error running the loader, data could be missing
const data = useLoaderData<typeof loader | null>()
const nonce = useNonce()
const theme = useOptionalTheme()
return (
<Document nonce={nonce} theme={theme} env={data?.ENV}>
<Document nonce={nonce} theme={theme}>
{children}
</Document>
)
Expand Down
3 changes: 2 additions & 1 deletion app/routes/_auth/auth.$provider/callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { invariant } from '@epic-web/invariant'
import { faker } from '@faker-js/faker'
import { SetCookie } from '@mjackson/headers'
import { http } from 'msw'
import { ENV } from 'varlock/env'
import { afterEach, expect, test } from 'vitest'
import { twoFAVerificationType } from '#app/routes/settings/profile/two-factor/_layout.tsx'
import { getSessionExpirationDate, sessionKey } from '#app/utils/auth.server.ts'
Expand Down Expand Up @@ -231,7 +232,7 @@ async function setupRequest({
sameSite: 'Lax',
httpOnly: true,
maxAge: 60 * 10,
secure: process.env.NODE_ENV === 'production' || undefined,
secure: ENV.NODE_ENV === 'production' || undefined,
})
const request = new Request(url.toString(), {
method: 'GET',
Expand Down
5 changes: 3 additions & 2 deletions app/routes/_auth/webauthn/utils.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type RegistrationResponseJSON,
} from '@simplewebauthn/server'
import { createCookie } from 'react-router'
import { ENV } from 'varlock/env'
import { z } from 'zod'
import { getDomainUrl } from '#app/utils/misc.tsx'

Expand All @@ -11,8 +12,8 @@ export const passkeyCookie = createCookie('webauthn-challenge', {
sameSite: 'lax',
httpOnly: true,
maxAge: 60 * 60 * 2,
secure: process.env.NODE_ENV === 'production',
secrets: [process.env.SESSION_SECRET],
secure: ENV.NODE_ENV === 'production',
secrets: [ENV.SESSION_SECRET],
})

export const PasskeyCookieSchema = z.object({
Expand Down
5 changes: 3 additions & 2 deletions app/routes/admin/cache/sqlite.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { redirect } from 'react-router'
import { ENV } from 'varlock/env'
import { z } from 'zod'
import { cache } from '#app/utils/cache.server.ts'
import {
Expand All @@ -21,7 +22,7 @@ export async function updatePrimaryCacheValue({
)
}
const domain = getInternalInstanceDomain(primaryInstance)
const token = process.env.INTERNAL_COMMAND_TOKEN
const token = ENV.INTERNAL_COMMAND_TOKEN
return fetch(`${domain}/admin/cache/sqlite`, {
method: 'POST',
headers: {
Expand All @@ -39,7 +40,7 @@ export async function action({ request }: Route.ActionArgs) {
`${request.url} should only be called on the primary instance (${primaryInstance})}`,
)
}
const token = process.env.INTERNAL_COMMAND_TOKEN
const token = ENV.INTERNAL_COMMAND_TOKEN
const isAuthorized =
request.headers.get('Authorization') === `Bearer ${token}`
if (!isAuthorized) {
Expand Down
Loading