The journey a change takes from your branch to something running: the checks it has to pass, what a deploy builds, and where it lands. Read it before your first pull request rather than learning it from a red check.
Everything runs from .github/workflows/deploy.yml (🚀 Deploy), triggered by every pull request and every push to main or dev, with an in-progress run for the same ref cancelled. Four check jobs start in parallel — ⬣ ESLint, ʦ TypeScript, ⚡ Vitest and 🎭 Playwright — each copying .env.example to .env and running prisma migrate deploy plus prisma generate --sql against a throwaway SQLite file first; npm run validate runs local equivalents of all four (build first, because the Playwright run serves the built app). On a pull request the run ends there. On a push, 📦 Prepare Container runs alongside the checks and builds other/Dockerfile remotely with flyctl deploy --build-only --push, labelling the image with the commit SHA and pushing it to Fly's registry for the <app>-staging app when the branch is dev, or for the app named in fly.toml when it is main (the production build also mounts SENTRY_AUTH_TOKEN as a build secret). The 🚀 Deploy job needs all five jobs, then runs flyctl deploy --image with that exact SHA-tagged image, so what ships is the image built from the commit that passed the checks. On each Fly machine the container starts litefs mount: machines in PRIMARY_REGION, the lease candidates, run prisma migrate deploy and switch both SQLite databases to WAL, every machine regenerates TypedSQL, and then npm start boots Express on 8081 behind the LiteFS proxy on 8080, while fly.toml's service checks poll /resources/healthcheck and /litefs/health.
Everything here is read from a committed file. Production dashboards, log access, rollback procedure and on-call are not in this repository, so they are not here — a boundary rather than a gap.
A Docker image built from other/Dockerfile and tagged with the commit SHA in registry.fly.io: Node 22 with production node_modules, the generated Prisma client, the React Router build/ output, prisma/ with its migrations, a per-image INTERNAL_COMMAND_TOKEN written to .env, and the LiteFS binary with other/litefs.yml, started by litefs mount.
flyctl deploy --build-only --push --image-label <commit-sha> --build-arg COMMIT_SHA=<commit-sha>5 gates · exhaustive — a check that is not here does not run
⬣ ESLintRuns eslint . over the whole repository, after copying .env.example and generating the Prisma client with TypedSQL, and fails on any lint error.
npm run lintʦ TypeScript — 🏗 Buildeach named with the committed file that maps a branch to it
what applies them, and when
LiteFS applies them at container start, before the app boots: the exec list in other/litefs.yml runs npx prisma migrate deploy with if-candidate: true, so only machines in PRIMARY_REGION that can hold the Consul lease migrate, and npm start runs only after the exec steps before it finish. The prisma migrate deploy steps in CI only build throwaway test databases and never touch a deployed one.
npx prisma migrate deploy24 variables · exhaustive, names only — never values
FLY_API_TOKENGitHub Actions secret that lets flyctl build, push and deploy the image; without it the 📦 Prepare Container and 🚀 Deploy jobs cannot authenticate with Fly.
SESSION_SECRETSigns the app's session cookies (a comma-separated list allows rotation); init() in env.server.ts throws at boot without it, and docs/deployment.md sets it as a Fly secret for each app.
npm run buildʦ TypeScript — 🔎 Type checkRuns npm run typecheck — react-router typegen to regenerate the +types route modules, then tsc — against the TypedSQL-generated client, failing on any type error, including loader or action data drifting from what a route component reads.
npm run typecheck⚡ VitestRuns npm run test -- --coverage over app/**/*.test.{ts,tsx} with tests/setup/setup-test-env.ts loaded, where any console.error or console.warn during a test throws, so a noisy test fails as surely as a broken assertion.
npm run test -- --run🎭 PlaywrightInstalls Chromium, restores prisma/data.db from a cache keyed on schema.prisma and the migration files (seeding with prisma migrate reset --force on a miss), builds, then runs npx playwright test against npm run start:mocks with MSW mocks, 2 retries and forbidOnly so a committed test.only fails; the HTML report is uploaded as the playwright-report artifact.
npm run build && npm run test:e2e:runHONEYPOT_SECRETSeeds the encryption of the honeypot timestamp field that rejects bot form submissions; required by the env schema, so the server refuses to boot without it.
AWS_ACCESS_KEY_IDAccess key for the hand-signed S3 requests that store and serve note and profile images in Tigris; created by fly storage create and required at boot.
AWS_SECRET_ACCESS_KEYSecret key that signs every SigV4 request to Tigris; created by fly storage create and required by the env schema at boot.
AWS_REGIONRegion component of the SigV4 signature on Tigris requests; created by fly storage create and required at boot.
AWS_ENDPOINT_URL_S3The Tigris S3 endpoint image uploads and reads are sent to; it must parse as a URL or the env schema fails at boot.
BUCKET_NAMEThe Tigris bucket holding uploaded note and user images; created by fly storage create and required at boot.
INTERNAL_COMMAND_TOKENShared secret a replica sends when it forwards a cache write to the primary (app/routes/admin/cache/sqlite.server.ts); generated with openssl into .env at image build, so every machine running one image agrees on it and nobody sets it by hand.
DATABASE_URLThe Prisma connection string, pointing at sqlite.db inside the LiteFS mount so every write goes through LiteFS replication; required by the env schema.
DATABASE_PATHFilesystem path of the main SQLite database inside the LiteFS mount; litefs.yml switches it to WAL on lease candidates and env.server.ts requires it.
DATABASE_FILENAMEName of the main database file that the LiteFS proxy in other/litefs.yml tracks for write forwarding and that litefs-js reads transaction numbers for.
CACHE_DATABASE_PATHPath of the SQLite cache database opened by app/utils/cache.server.ts, kept inside the LiteFS mount so cache entries replicate; litefs.yml switches it to WAL.
CACHE_DATABASE_FILENAMEFile name the Dockerfile composes CACHE_DATABASE_PATH from, so the cache database sits beside sqlite.db under LITEFS_DIR.
LITEFS_DIRThe LiteFS FUSE mount directory (fuse.dir in litefs.yml) that both database paths live under, and where litefs-js reads the .primary file to tell whether this machine is the primary.
INTERNAL_PORTPort the LiteFS proxy listens on (proxy.addr in litefs.yml), which must match internal_port 8080 in fly.toml, and the port litefs-js addresses sibling instances on.
PORTPort server/index.ts listens on (8081 in the image, 3000 when unset) and the target the LiteFS proxy forwards requests to.
NODE_ENVMust be production in the image: env.server.ts validates it, and server/index.ts uses it to serve the built app instead of Vite and to enforce the real rate limits.
PRISMA_SCHEMA_DISABLE_ADVISORY_LOCKStops prisma migrate deploy from taking an advisory lock, which the Dockerfile's comment ties to running migrations against the SQLite databases in WAL mode.
FLY_CONSUL_URLThe Consul endpoint LiteFS uses for its primary lease; it exists once fly consul attach (docs/deployment.md step 7) has run, and without it no machine can become the primary that accepts writes.
PRIMARY_REGIONRegion whose machines are lease candidates (candidate: FLY_REGION == PRIMARY_REGION), set from primary_region in fly.toml; it decides which machines run migrations and accept writes.
FLY_REGIONRegion of the running machine, provided by the Fly runtime; compared with PRIMARY_REGION for lease candidacy and echoed in the fly-region response header by app/entry.server.tsx.
FLY_APP_NAMEApp name provided by the Fly runtime; it builds the LiteFS advertise URL and Consul key, and litefs-js uses it to discover and address sibling instances over .internal DNS.
HOSTNAMEMachine hostname provided by the runtime, used in the advertise URL other LiteFS nodes replicate from.