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

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

terminal
git clone https://github.com/winston/wnstn.git
cd wnstn
pnpm install

Copy the environment template and fill in the two required values:

terminal + .env
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

VariableRequiredDescription
DATABASE_URLyesPostgres connection string.
BETTER_AUTH_SECRETyesSigning secret for auth sessions. Use 32+ random chars in production.
BETTER_AUTH_URLnoCanonical origin for auth. Defaults to NEXT_PUBLIC_CANOPY_URL, then http://localhost:3002.
NEXT_PUBLIC_CANOPY_URLnoPublic origin of your Canopy instance — used in install snippets and auth. Default http://localhost:3002.
NEXT_PUBLIC_CANOPY_SITE_IDnoSite ID used when Canopy tracks its own dashboard. Default canopy_dashboard in development.
CANOPY_IP_SALTnoSalt for IP hashing. Defaults to a dev value — always set a strong one in production (see privacy).
WINSTON_OIDC_ISSUERnoOptional SSO — enables OIDC sign-in when set together with the two vars below.
WINSTON_OIDC_CLIENT_IDnoOIDC client ID.
WINSTON_OIDC_CLIENT_SECRETnoOIDC 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):

terminal
pnpm --filter @wnstn/canopy build

Then create and apply the database schema with Drizzle's migrations:

terminal
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
upgrading
On update: 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:

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

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

reverse proxy (nginx)
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

1. Strong CANOPY_IP_SALT — the default is for local development only, and rotating it re-pseudonymizes all future IP hashes.
2. Unique BETTER_AUTH_SECRET, generated once and kept out of version control.
3. NEXT_PUBLIC_CANOPY_URL and BETTER_AUTH_URL set to your real origin so snippets and auth redirects are correct.
4. HTTPS in front of the dashboard — session cookies and sendBeacon payloads both expect it.
5. Postgres backups — the 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.

Own your analytics end to end

Postgres, one Node process, and a script tag — that's the whole stack.