Getting started
This page runs the whole stack on your machine: database, one collector run, and the dashboard. There is no published package or hosted instance yet. You work from a clone of the (private) repository.
Prerequisites
- Node.js 24+ and pnpm 10 (the repo pins
pnpm@10.33.2viapackageManager). - A real Postgres (16 is what the tests use).
packages/db's SQL creates roles, so run migrations as a user that canCREATE ROLE. - Docker, only if you want to run the test suites. They start their own throwaway
postgres:16containers. - For the collector: the
env-syncbinary onPATH(the TS or Go build; either satisfies the JSON contract), plus everythingenv-syncitself needs to read real targets: theaws,gh, andopCLIs,OP_SERVICE_ACCOUNT_TOKEN, and each target platform's credentials. You can skip all of that for a first run. See a no-credential smoke run. - For the viewer: a Supabase project for Auth, with email confirmation turned on. The app
checks
email_confirmed_at, but it cannot turn that setting on for you.
git clone https://github.com/catesworks/env-sync-viewer.git
cd env-sync-viewer
pnpm install
pnpm build
1. Create the schema and roles
packages/db applies Drizzle migrations first (db:migrate), then the hand-written RLS/role SQL in
packages/db/sql/*.sql (db:post). db:deploy runs both in order. Use an admin connection
here. The app roles come next.
export DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/env_sync_viewer
pnpm db:deploy
This creates everything in the env_sync schema (never public). It also creates two NOLOGIN
group roles, collector and viewer. db:post is safe to re-run.
Create one login role per app, out of band, and keep the passwords in your secret store:
CREATE ROLE env_sync_collector LOGIN PASSWORD '<secret>' IN ROLE collector; -- INSERT-only
CREATE ROLE env_sync_viewer_app LOGIN PASSWORD '<secret>' IN ROLE viewer; -- SELECT-only
Never point either app at a superuser, the table owner, or Supabase's service_role.
2. Run the collector once
The collector reads an inventory: a JSON list of (manifestDir, env, services?) entries. Each
entry becomes one env-sync diff ... --format json subprocess, with manifestDir as its working
directory. Relative paths resolve against the inventory file's own directory.
[{"manifestDir": "/path/to/a/repo/with/secrets.manifest.yml", "env": "dev", "services": ["api"]}]
List only the (manifest, env, service) combinations that are expected to exist. If you omit
services, the collector runs env-sync diff --all. Do that only when every service in the
manifest is deployed to that env.
Option A: against a real manifest
Write the inventory above to a scratch file (for example /tmp/inventory.json), then:
DATABASE_URL=postgresql://env_sync_collector:<secret>@127.0.0.1:5432/env_sync_viewer \
node apps/collector/dist/main.js /tmp/inventory.json
This reads live platform state through env-sync, so it needs real credentials. Start with a
disposable, non-production manifest.
Option B: no-credential smoke run
The collector's test suite ships a fake env-sync (apps/collector/test/fake-bin/env-sync). It
accepts only the collector's exact invocation shape and replies from files in its working
directory: fake-stdout, fake-exit, and fake-stderr. You can use it to fill the dashboard with
no credentials at all:
mkdir -p /tmp/esv-demo
cat > /tmp/esv-demo/fake-stdout <<'EOF'
{"schemaVersion":1,"generatedAt":"2026-09-28T10:00:00.000Z","env":"dev","services":{"api":{"targets":[
{"target":"vercel my-app","platform":"vercel","diffMode":"fingerprint","status":"ok","keys":[
{"key":"API_KEY","status":"MATCH","expectedFingerprint":"0123456789ab","actualFingerprint":"0123456789ab"},
{"key":"DB_URL","status":"DRIFT","expectedFingerprint":"0123456789ab","actualFingerprint":"ba9876543210"}]}]}}}
EOF
echo '[{"manifestDir": "/tmp/esv-demo", "env": "dev", "services": ["api"]}]' > /tmp/esv-demo/inventory.json
PATH="$PWD/apps/collector/test/fake-bin:$PATH" \
DATABASE_URL=postgresql://env_sync_collector:<secret>@127.0.0.1:5432/env_sync_viewer \
node apps/collector/dist/main.js /tmp/esv-demo/inventory.json
Each invocation writes one collector_runs row, with outcome success, partial, or failed.
It also writes that run's snapshot rows, all in one transaction. The process exits 1 if any
invocation failed, so a scheduler sees the failure.
The subprocess never sees your DATABASE_* variables. The collector strips them before it spawns
env-sync.
3. Run the dashboard
Put the viewer's settings in apps/web/.env.local:
DATABASE_URL=postgresql://env_sync_viewer_app:<secret>@127.0.0.1:5432/env_sync_viewer
NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<publishable key>
VIEWER_ALLOWLIST=you@example.com,teammate@example.com
# STALE_AFTER_HOURS=26 # default
Then start it:
pnpm --filter @env-sync-viewer/web dev # http://localhost:3054
Sign in with a confirmed Supabase account whose email is on VIEWER_ALLOWLIST. If there is no
session, you are redirected to /login. If you are signed in but not allowlisted (or not
confirmed), you get a 403. If VIEWER_ALLOWLIST is empty or unset, nobody gets in.
Both at once
pnpm dev at the repo root runs process-compose. It runs the collector once, then starts the web
app. It reads DATABASE_URL and env-sync from the shell you launch it from.
Run the tests
pnpm --filter @env-sync-viewer/db test:unit # RLS suite, real Postgres (Docker)
pnpm --filter @env-sync-viewer/collector test:unit # real Postgres + fake env-sync (Docker)
pnpm --filter @env-sync-viewer/web test:unit
Each Docker-backed suite starts postgres:16 on a random localhost port, keeps its data on tmpfs,
and removes the container on exit, whether the tests pass or fail.
Running it for real
The collector is designed to run on a schedule in GitHub Actions, not on Vercel cron or any
serverless platform, because env-sync needs the aws/gh/op binaries on its host. That host
holds credentials for every platform at once. Run it in a dedicated repo/CI environment with
protected branches and required reviewers, and never on a shared self-hosted runner. See
Architecture → credentials.