DreamLake

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

TargetRepoProviderStagingProduction
Docslakeshore-docs (this one)Netlifypnpm stagingpnpm prod
Control planelakeshore-controlplaneHerokupnpm stagingpnpm 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.

Merging to main does not deploy

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.

bash
pnpm staging   # → netlify-staging (no version check)
pnpm prod      # version check → v/<version> snapshot → netlify-production

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 — stack heroku-24, the heroku/nodejs buildpack, the required config vars, and a postdeploy hook that runs pnpm run prisma:generate on a freshly provisioned app.
bash
# from inside the lakeshore-controlplane repo:
pnpm staging   # force-pushes HEAD to heroku-staging
pnpm prod      # force-pushes HEAD to heroku-production

Set each Heroku app to auto-deploy from its matching branch.

Env-var contract

VarRequiredRead byNotes
DATABASE_URLyesPrisma (provider = "mongodb")The only Mongo URI the server actually reads.
MONGODB_URIdeclarednothing 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_TOKENyes in prodsrc/auth.tsAdmin 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_KEYyes in prodsrc/secrets.tsBase64 32 bytes — the AES-256-GCM master key for the Secret table.
REDIS_URLnosrc/log-cache.tsDefaults to redis://localhost:6379. Backs exec log streams, result journals, and the daemon replay-nonce store.
LAKESHORE_BLOBS_BUCKETnosrc/blob.tsGates 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_KEYwith the bucketAWS SDKCredentials for the blobs bucket.
LAKESHORE_PUBLIC_URLrecommendedsrc/routes/daemons-launch.tsThe absolute URL baked into a launched daemon's bootstrap script. Falls back to LAKESHORE_SERVER_URL, then LAKESHORE_URL, then http://localhost:$PORT.
ELASTICITY_ENABLEDnosrc/server.tsThe elasticity controller is built but off unless this is exactly "true". ELASTICITY_TICK_S sets the interval (default 5).
LAKESHORE_REQUIRE_DAEMON_SIGNATURESnosrc/daemon-auth.ts"true" rejects daemons that send no X-LS-* signature headers instead of falling back to the legacy bearer.
LOG_LEVELnoFastifyDefaults to info.
NODE_ENVyesseveralapp.json sets production.
PORTnoFastifyHeroku injects it.

Generate the two production-required secrets:

bash
# LAKESHORE_ADMIN_TOKEN — the admin-tier bearer.
openssl rand -base64 24 | tr '+/' '-_' | tr -d '='

# SECRETS_KEY — base64 32 bytes for AES-256-GCM.
openssl rand -base64 32
The dev fallbacks are dev-only

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

bootstrap.shbash
heroku create lakeshore-staging --stack heroku-24
heroku buildpacks:set heroku/nodejs --app lakeshore-staging
heroku config:set \
  DATABASE_URL="mongodb+srv://…/lakeshore-staging?retryWrites=true&w=majority" \
  MONGODB_URI="mongodb+srv://…/lakeshore-staging?retryWrites=true&w=majority" \
  LAKESHORE_ADMIN_TOKEN="$(openssl rand -base64 24 | tr '+/' '-_' | tr -d '=')" \
  SECRETS_KEY="$(openssl rand -base64 32)" \
  NODE_ENV=production \
  --app lakeshore-staging

Then deploy by force-pushing the matching branch from the control-plane repo (pnpm staging / pnpm prod).

Verifying a deploy

bash
curl -s https://<app>.herokuapp.com/healthz   # {"ok":true} — liveness, no I/O
curl -s https://<app>.herokuapp.com/readyz    # readiness — pings Mongo + Redis

/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.

EnvDatabase nameWhere the URI lives
local devlakeshorelakeshore-controlplane/.env (gitignored)
staginglakeshore-stagingHeroku staging app config vars
productionlakeshore-prodHeroku production app config vars
Never commit credentials

.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:

dev      → readWrite on lakeshore
staging  → readWrite on lakeshore-staging
prod     → readWrite on lakeshore-prod

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:

mongodb+srv://<user>:<pass>@<cluster-host>/<dbname>?retryWrites=true&w=majority

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:

js
db.Invocation.createIndex(
  { namespaceId: 1, idempotencyKey: 1 },
  { unique: true, partialFilterExpression: { idempotencyKey: { $exists: true } } })
db.Event.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })

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.

Force-push semantics

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.