Self-hosting
Canopy is a monorepo of ordinary Node packages: a Next.js dashboard, a Postgres database managed with Drizzle, and a tiny tracker that the dashboard serves from its own origin. There is no special runtime and no vendor lock-in — if you can run Next.js and Postgres, you can run Canopy, and every byte of analytics data lands in a database you own.
1Prerequisites
| Requirement | Notes |
|---|---|
| Node.js 20+ | Any LTS release the Next.js version supports. |
| pnpm 9+ | The monorepo uses pnpm workspaces and Turborepo. |
| PostgreSQL 14+ | The only stateful dependency. Managed or self-hosted both work. |
2Set up the project
Clone the repository and install workspace dependencies:
git clone https://github.com/winston/wnstn.git cd wnstn pnpm install
Copy the environment template and fill in the two required values:
cp apps/canopy/.env.example apps/canopy/.env DATABASE_URL=postgresql://user:pass@host:5432/canopy BETTER_AUTH_SECRET=<generate-a-long-random-string>
DATABASE_URL points at your Postgres instance and BETTER_AUTH_SECRET signs session cookies — generate it with openssl rand -base64 32. Everything else in the table below has a working default for local development but deserves a look before going live.
Environment variables
| Variable | Required | Description |
|---|---|---|
| DATABASE_URL | yes | Postgres connection string. |
| BETTER_AUTH_SECRET | yes | Signing secret for auth sessions. Use 32+ random chars in production. |
| BETTER_AUTH_URL | no | Canonical origin for auth. Defaults to NEXT_PUBLIC_CANOPY_URL, then http://localhost:3002. |
| NEXT_PUBLIC_CANOPY_URL | no | Public origin of your Canopy instance — used in install snippets and auth. Default http://localhost:3002. |
| NEXT_PUBLIC_CANOPY_SITE_ID | no | Site ID used when Canopy tracks its own dashboard. Default canopy_dashboard in development. |
| CANOPY_IP_SALT | no | Salt for IP hashing. Defaults to a dev value — always set a strong one in production (see privacy). |
| WINSTON_OIDC_ISSUER | no | Optional SSO — enables OIDC sign-in when set together with the two vars below. |
| WINSTON_OIDC_CLIENT_ID | no | OIDC client ID. |
| WINSTON_OIDC_CLIENT_SECRET | no | OIDC client secret. |
3Build and migrate
Build the tracker first. The dashboard serves /p.js from the built SDK output, and without this step it falls back to a minimal inline tracker (pageviews only, no custom events):
pnpm --filter @wnstn/canopy build
Then create and apply the database schema with Drizzle's migrations:
pnpm --filter @wnstn/canopy-client db:generate # write migration SQL from the schema pnpm --filter @wnstn/canopy-client db:migrate # apply it to DATABASE_URL
git pull, pnpm install, rebuild the tracker, and run db:generate + db:migrate again. Migrations are plain SQL files in apps/canopy/drizzle/ — review them like any other change.4Run it
For development, the root command starts everything — dashboard on port 3002 plus SDK watch mode:
pnpm dev # dashboard → http://localhost:3002
For production, build and start the dashboard the standard Next.js way. Any Node host works — a VM, a container, or a platform like Vercel:
pnpm --filter @wnstn/canopy-client build pnpm --filter @wnstn/canopy-client start
5How the script is served
The tracker lives at /p.js on your Canopy origin — a rewrite to an internal route that reads the built SDK, sets a long cache window (5 minutes client-side, an hour at the edge, with stale-while-revalidate), and never 404s: if the dist output is missing, a minimal fallback tracker is served instead. Verify which one you are getting with the X-Canopy-Source response header — dist is the full tracker, fallback means you skipped the SDK build.
Keep the tracker on the same origin as your sites when you can (via reverse proxy) for the cleanest first-party setup. Cross-origin also works — the ingestion endpoint reflects any origin in CORS — but a proxy avoids the extra preflight entirely.
location /p.js {
proxy_pass https://canopy.internal.your-app.com/p.js;
}
location /api/canopy/ {
proxy_pass https://canopy.internal.your-app.com/api/canopy/;
}6Deployment notes
Country attribution reads platform headers: on Vercel it uses x-vercel-ip-country, on Cloudflare cf-ipcountry. Behind a plain reverse proxy there is no such header, so country shows as unknown — front Canopy with Vercel or Cloudflare if country data matters to you.
Production behavior changes with NODE_ENV=production: unknown site IDs are rejected at ingestion instead of auto-created, so register each site in the dashboard (or via POST /api/sites) before pointing traffic at it.
Production checklist
CANOPY_IP_SALT — the default is for local development only, and rotating it re-pseudonymizes all future IP hashes.BETTER_AUTH_SECRET, generated once and kept out of version control.NEXT_PUBLIC_CANOPY_URL and BETTER_AUTH_URL set to your real origin so snippets and auth redirects are correct.sendBeacon payloads both expect it.event table is your analytics history; the export endpoint is a convenient extra copy.7Optional: SSO
Setting all three WINSTON_OIDC_* variables enables OIDC single sign-on alongside email and password. Point the issuer at your identity provider, register <your-origin>/api/auth/callback/winston as the redirect URI, and the login page picks up the SSO option automatically. Leave them unset and the feature stays completely disabled.