Deployment
Two deploy targets, in two different repos. Each has its own command and its own staging/production branch pair. Neither blocks the other — push only what changed.
TL;DR
| Target | Repo | Provider | Staging | Production |
|---|---|---|---|---|
| Docs | lakeshore-docs (this one) | Netlify | pnpm staging | pnpm prod |
| Control plane | lakeshore-controlplane | Heroku | pnpm staging | pnpm prod |
The two pnpm prod scripts are unrelated despite the shared name. In
the control-plane repo it is literally
git push -f origin HEAD:heroku-production. In this repo it cuts the
v/<version> snapshot branch, builds the docs, uploads, and force-pushes
netlify-production.
Netlify's production branch for the docs site is netlify-production.
A push to main produces a branch-deploy preview only. Shipping docs to
production means running pnpm prod.
Docs (Netlify)
Already wired; build config lives at the repo root in netlify.toml.
pnpm prod refuses to deploy when the root package.json and
docs/package.json versions disagree. Bump them together with
pnpm set-version <x.y.z>, run pnpm install, and commit both
package.json files plus pnpm-lock.yaml — Netlify installs with
--frozen-lockfile, so a stale lockfile is a failed build.
Control plane (Heroku)
A Node 20+ / Fastify 5 / Prisma 5 process. Heroku builds it with the
heroku/nodejs buildpack straight from the lakeshore-controlplane
repo. Two files carry the contract:
Procfile— one line,web: node dist/server.js.app.json— stackheroku-24, theheroku/nodejsbuildpack, the required config vars, and apostdeployhook that runspnpm run prisma:generateon a freshly provisioned app.
Set each Heroku app to auto-deploy from its matching branch.
Env-var contract
| Var | Required | Read by | Notes |
|---|---|---|---|
DATABASE_URL | yes | Prisma (provider = "mongodb") | The only Mongo URI the server actually reads. |
MONGODB_URI | declared | nothing in src/ | app.json and .env.example still declare it required. Set it to the same value so a fresh heroku create from app.json succeeds. |
LAKESHORE_ADMIN_TOKEN | yes in prod | src/auth.ts | Admin bearer. The server refuses to boot without it when NODE_ENV=production. Legacy name ADMIN_TOKEN is still read as a fallback with a one-time stderr deprecation warning. |
SECRETS_KEY | yes in prod | src/secrets.ts | Base64 32 bytes — the AES-256-GCM master key for the Secret table. |
REDIS_URL | no | src/log-cache.ts | Defaults to redis://localhost:6379. Backs exec log streams, result journals, and the daemon replay-nonce store. |
LAKESHORE_BLOBS_BUCKET | no | src/blob.ts | Gates the whole control-plane-owned S3 path. Unset means exec logs and payloads stay on the inline-bytes wire. Requires AWS_REGION when set. |
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | with the bucket | AWS SDK | Credentials for the blobs bucket. |
LAKESHORE_PUBLIC_URL | recommended | src/routes/daemons-launch.ts | The absolute URL baked into a launched daemon's bootstrap script. Falls back to LAKESHORE_SERVER_URL, then LAKESHORE_URL, then http://localhost:$PORT. |
ELASTICITY_ENABLED | no | src/server.ts | The elasticity controller is built but off unless this is exactly "true". ELASTICITY_TICK_S sets the interval (default 5). |
LAKESHORE_REQUIRE_DAEMON_SIGNATURES | no | src/daemon-auth.ts | "true" rejects daemons that send no X-LS-* signature headers instead of falling back to the legacy bearer. |
LOG_LEVEL | no | Fastify | Defaults to info. |
NODE_ENV | yes | several | app.json sets production. |
PORT | no | Fastify | Heroku injects it. |
Generate the two production-required secrets:
With LAKESHORE_ADMIN_TOKEN unset outside production the server runs in
open mode — every /v1/namespaces/:ns/* and /v1/admin/* request
passes with authMode = "open", with a loud warning. With SECRETS_KEY
unset it falls back to a hard-coded development key, also with a
warning. Production refuses both.
Provisioning a new app
Then deploy by force-pushing the matching branch from the control-plane
repo (pnpm staging / pnpm prod).
Verifying a deploy
/readyz returns { ok, db, redis, activePollCount, adminSubscribers }
and answers 503 when Mongo or Redis is unreachable, with the driver
error under dbError. db is "up" or "down"; redis also reports
"unconfigured" when REDIS_URL is unset.
Database
Mongo is the sole durable store. Redis is a latency cache — losing it costs streaming and the nonce window, never data.
| Env | Database name | Where the URI lives |
|---|---|---|
| local dev | lakeshore | lakeshore-controlplane/.env (gitignored) |
| staging | lakeshore-staging | Heroku staging app config vars |
| production | lakeshore-prod | Heroku production app config vars |
.env is gitignored and .env.example carries placeholders only. Real
connection strings live on Heroku.
Atlas setup
One Atlas project per deployment lineage, one database per environment inside it. The cluster can be shared across environments to keep costs down — isolation lives at the database + user layer.
1. Create the cluster. Staging and dev are fine on M0. Production
wants M10 or larger: M0 has no backups, hard connection caps, and shared
CPU. Pick a region near the Heroku app (Common Runtime defaults to
us-east-1).
2. One database user per environment, each scoped to its own database, so staging credentials cannot read production:
3. IP allowlist. Heroku dynos use rotating shared egress IPs, so a
strict allowlist is impractical without paid add-ons. Either 0.0.0.0/0
(the standard hobby/staging posture — the DB password becomes the only
control) or an Atlas Private Endpoint over AWS PrivateLink (M10+, the
production answer).
4. Build the URI. Take the SRV template from Atlas's Connect panel and substitute user, password, and database name:
The <dbname> segment is load-bearing. Omit it and the driver connects
to test.
Schema changes
Heroku's release phase does not run migrations. This project uses
schema-push, not migration files: after deploying a prisma/schema.prisma
change, run pnpm prisma db push against the target database yourself.
Three indexes cannot be expressed in Prisma's Mongo provider and must be created by hand if you rely on them:
Troubleshooting
Heroku and pnpm. The heroku/nodejs buildpack picks its package
manager from the lockfile it finds; pnpm-lock.yaml is not native to
it. The control-plane package.json declares
"packageManager": "pnpm@10.33.0" so Node 20's bundled corepack shells
out to pnpm at install time. If a fresh deploy still fails, add a
community pnpm buildpack ahead of heroku/nodejs.
Atlas refuses connections from dynos. Rotating egress IPs against a
default-deny allowlist. Widen to 0.0.0.0/0 for dev/staging, or move to
a Private Endpoint for production.
DATABASE_URL vs MONGODB_URI. Prisma reads DATABASE_URL; no
source file reads MONGODB_URI, but app.json still declares it
required, so a fresh provision from the manifest fails without it. Set
both to the same value.
All four deploy commands force-push. Treat the deploy branches as
write-only — never check one out, never merge into one. The HEAD they
receive is whatever you have locally when you run the command.