diff --git a/.env.example b/.env.example index 1810e98a2..c4a942e49 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,24 @@ RESEND_API_KEY="bongo cat" NEXT_PUBLIC_API_URL="http://localhost:3001" +# Self-hosting: public URL of the basket event-collection service +NEXT_PUBLIC_BASKET_URL="https://basket.databuddy.cc" + +# Self-hosting: URL of the tracking script (JS bundle) served to end users +NEXT_PUBLIC_TRACKER_URL="https://cdn.databuddy.cc/databuddy.js" + +# Self-hosting: public URL of the dashboard app (used by links service for redirects) +APP_URL="https://app.databuddy.cc" + +# Self-hosting: where the links service root / redirects to +LINKS_ROOT_REDIRECT_URL="https://databuddy.cc" + +# Self-hosting: URL of the MaxMind GeoLite2-City MMDB file +GEOIP_DB_URL="https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb" + +# Self-hosting: public URL of the dashboard, added to API CORS allowed origins +DASHBOARD_URL="" + # Not Necessary unless using blog MARBLE_WORKSPACE_KEY= MARBLE_API_URL=https://api.marblecms.com diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md new file mode 100644 index 000000000..fb989eccc --- /dev/null +++ b/SELF_HOSTING.md @@ -0,0 +1,347 @@ +# Self-Hosting Databuddy + +This guide walks you through running Databuddy on your own infrastructure. + +## Architecture Overview + +Databuddy is a monorepo with several independent services: + +| Service | Default Port | Purpose | +|---|---|---| +| **dashboard** | 3000 | Next.js frontend | +| **api** | 3001 | Main analytics API (oRPC) | +| **basket** | 4000 | Event ingestion / tracking endpoint | +| **links** | 2500 | Short-link redirector | +| **uptime** | 4000 | Uptime monitoring | + +Infrastructure dependencies: + +| Dependency | Default Port | Purpose | +|---|---|---| +| PostgreSQL 17 | 5432 | Relational data (users, projects, links) | +| ClickHouse | 8123 | Analytics event storage | +| Redis 7 | 6379 | Caching, rate limiting, queues | + +--- + +## Prerequisites + +- [Bun](https://bun.sh) 1.3.4+ +- [Docker](https://docs.docker.com/get-docker/) + Docker Compose +- Node.js 20+ (optional, for tooling) + +--- + +## Quick Start + +### 1. Clone and install + +```bash +git clone https://github.com/your-org/databuddy.git +cd databuddy +bun install +``` + +### 2. Start infrastructure + +```bash +docker compose up -d +``` + +This starts PostgreSQL, ClickHouse, and Redis with default dev credentials. + +### 3. Configure environment + +```bash +cp .env.example .env +``` + +Edit `.env` with your values (see the full reference below). + +### 4. Initialize databases + +```bash +bun run db:push # PostgreSQL schema +bun run clickhouse:init # ClickHouse tables +``` + +### 5. Start services + +```bash +bun run dev +``` + +Or start individual services: + +```bash +bun run dev:dashboard # dashboard + api together +``` + +--- + +## Environment Variable Reference + +### Core (all services) + +| Variable | Default | Required | Description | +|---|---|---|---| +| `DATABASE_URL` | `postgres://databuddy:databuddy_dev_password@localhost:5432/databuddy` | Yes | PostgreSQL connection string | +| `REDIS_URL` | `redis://localhost:6379` | Yes | Redis connection string | +| `NODE_ENV` | `development` | No | `development` or `production` | + +### API service (`apps/api`) + +| Variable | Default | Required | Description | +|---|---|---|---| +| `CLICKHOUSE_URL` | `http://default:@localhost:8123/databuddy_analytics` | Yes | ClickHouse HTTP endpoint | +| `CLICKHOUSE_USER` | `default` | No | ClickHouse username | +| `CLICKHOUSE_PASSWORD` | _(empty)_ | No | ClickHouse password | +| `BETTER_AUTH_URL` | `http://localhost:3000` | Yes | Public URL of the dashboard (used by auth) | +| `BETTER_AUTH_SECRET` | — | Yes | Random secret for session signing (run `openssl rand -base64 32`) | +| `AI_API_KEY` | _(empty)_ | No | OpenRouter API key — required only for the AI assistant feature | +| `PORT` | `3001` | No | Port the API listens on | +| `DASHBOARD_URL` | _(empty)_ | No | Your dashboard's public URL — added to CORS allowed origins for self-hosting | +| `RESEND_API_KEY` | _(empty)_ | No | [Resend](https://resend.com) API key for transactional email | +| `S3_BUCKET` | _(empty)_ | No | S3/R2 bucket name for file uploads | +| `S3_ACCESS_KEY_ID` | _(empty)_ | No | S3/R2 access key | +| `S3_SECRET_ACCESS_KEY` | _(empty)_ | No | S3/R2 secret key | +| `S3_ENDPOINT` | _(empty)_ | No | S3-compatible endpoint (e.g. Cloudflare R2) | +| `GITHUB_CLIENT_ID` | _(empty)_ | No | GitHub OAuth app client ID | +| `GITHUB_CLIENT_SECRET` | _(empty)_ | No | GitHub OAuth app secret | +| `GOOGLE_CLIENT_ID` | _(empty)_ | No | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | _(empty)_ | No | Google OAuth secret | + +### Dashboard (`apps/dashboard`) + +| Variable | Default | Required | Description | +|---|---|---|---| +| `NEXT_PUBLIC_API_URL` | `http://localhost:3001` | Yes | Public URL of the API service | +| `BETTER_AUTH_URL` | `http://localhost:3000` | Yes | Public URL of the dashboard (must match API) | +| `BETTER_AUTH_SECRET` | — | Yes | Same secret as the API service | +| `AUTUMN_SECRET_KEY` | _(empty)_ | No | Autumn billing integration key | +| `NEXT_PUBLIC_BASKET_URL` | `https://basket.databuddy.cc` | No | Public URL of your basket service — set this so tracking snippets point to your own instance | +| `NEXT_PUBLIC_TRACKER_URL` | `https://cdn.databuddy.cc/databuddy.js` | No | URL where the tracking JS bundle is served — set this if you self-host the tracker script | + +### Links service (`apps/links`) + +| Variable | Default | Required | Description | +|---|---|---|---| +| `APP_URL` | `https://app.databuddy.cc` | No | Public URL of your dashboard app — used for expired/not-found link redirect pages | +| `LINKS_ROOT_REDIRECT_URL` | `https://databuddy.cc` | No | Where the links service root `/` redirects to | +| `GEOIP_DB_URL` | `https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb` | No | URL to fetch the MaxMind GeoLite2-City MMDB file for geolocation | + +### Basket service (`apps/basket`) + +| Variable | Default | Required | Description | +|---|---|---|---| +| `PORT` | `4000` | No | Port the basket service listens on | +| `CLICKHOUSE_URL` | — | Yes | ClickHouse HTTP endpoint (inherited from root `.env`) | +| `STRIPE_SECRET_KEY` | _(empty)_ | No | Stripe secret key for payment webhooks | +| `STRIPE_WEBHOOK_SECRET` | _(empty)_ | No | Stripe webhook signing secret | +| `GEOIP_DB_URL` | `https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb` | No | URL to fetch the GeoLite2-City MMDB file | + +### Uptime service (`apps/uptime`) + +| Variable | Default | Required | Description | +|---|---|---|---| +| `UPSTASH_QSTASH_TOKEN` | — | Yes | [Upstash QStash](https://upstash.com/docs/qstash) token for scheduling uptime checks | +| `RESEND_API_KEY` | _(empty)_ | No | Resend API key for uptime alert emails | + +--- + +## Example `.env` + +```env +# ── Infrastructure ──────────────────────────────────────────────────────────── +DATABASE_URL="postgres://databuddy:change_me@localhost:5432/databuddy" +REDIS_URL="redis://localhost:6379" +CLICKHOUSE_URL="http://default:@localhost:8123/databuddy_analytics" + +# ── Auth ───────────────────────────────────────────────────────────────────── +BETTER_AUTH_SECRET="" +BETTER_AUTH_URL="https://app.example.com" # public URL of your dashboard + +# ── Service URLs (self-hosting) ─────────────────────────────────────────────── +NEXT_PUBLIC_API_URL="https://api.example.com" +NEXT_PUBLIC_BASKET_URL="https://basket.example.com" +NEXT_PUBLIC_TRACKER_URL="https://cdn.example.com/databuddy.js" +APP_URL="https://app.example.com" +LINKS_ROOT_REDIRECT_URL="https://example.com" +DASHBOARD_URL="https://app.example.com" + +# ── Optional ────────────────────────────────────────────────────────────────── +AI_API_KEY="" +RESEND_API_KEY="" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +UPSTASH_QSTASH_TOKEN="" +NODE_ENV=production +``` + +--- + +## Docker Compose (full stack) + +The following example wires all services together. Adjust image tags and domain names to your setup. + +```yaml +services: + # ── Infrastructure ────────────────────────────────────────────────────────── + + postgres: + image: postgres:17 + environment: + POSTGRES_DB: databuddy + POSTGRES_USER: databuddy + POSTGRES_PASSWORD: ${DB_PASSWORD:-change_me} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U databuddy -d databuddy"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + clickhouse: + image: clickhouse/clickhouse-server:25.5.1-alpine + environment: + CLICKHOUSE_DB: databuddy_analytics + CLICKHOUSE_USER: default + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + volumes: + - clickhouse_data:/var/lib/clickhouse + - ./scripts/clickhouse-init.sql:/docker-entrypoint-initdb.d/clickhouse-init.sql + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8123/ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ── Application services ──────────────────────────────────────────────────── + + api: + build: + context: . + dockerfile: api.Dockerfile + ports: + - "3001:3001" + environment: + DATABASE_URL: postgres://databuddy:${DB_PASSWORD:-change_me}@postgres:5432/databuddy + REDIS_URL: redis://redis:6379 + CLICKHOUSE_URL: http://default:@clickhouse:8123/databuddy_analytics + BETTER_AUTH_URL: ${BETTER_AUTH_URL} + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + DASHBOARD_URL: ${DASHBOARD_URL} + AI_API_KEY: ${AI_API_KEY:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} + NODE_ENV: production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + clickhouse: + condition: service_healthy + restart: unless-stopped + + basket: + build: + context: . + dockerfile: basket.Dockerfile + ports: + - "4000:4000" + environment: + DATABASE_URL: postgres://databuddy:${DB_PASSWORD:-change_me}@postgres:5432/databuddy + REDIS_URL: redis://redis:6379 + CLICKHOUSE_URL: http://default:@clickhouse:8123/databuddy_analytics + NODE_ENV: production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + clickhouse: + condition: service_healthy + restart: unless-stopped + + links: + build: + context: . + dockerfile: links.Dockerfile + ports: + - "2500:2500" + environment: + DATABASE_URL: postgres://databuddy:${DB_PASSWORD:-change_me}@postgres:5432/databuddy + REDIS_URL: redis://redis:6379 + APP_URL: ${APP_URL} + LINKS_ROOT_REDIRECT_URL: ${LINKS_ROOT_REDIRECT_URL:-https://databuddy.cc} + GEOIP_DB_URL: ${GEOIP_DB_URL:-} + NODE_ENV: production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + dashboard: + build: + context: . + dockerfile: apps/dashboard/Dockerfile + ports: + - "3000:3000" + environment: + DATABASE_URL: postgres://databuddy:${DB_PASSWORD:-change_me}@postgres:5432/databuddy + REDIS_URL: redis://redis:6379 + BETTER_AUTH_URL: ${BETTER_AUTH_URL} + BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_BASKET_URL: ${NEXT_PUBLIC_BASKET_URL} + NEXT_PUBLIC_TRACKER_URL: ${NEXT_PUBLIC_TRACKER_URL:-} + AUTUMN_SECRET_KEY: ${AUTUMN_SECRET_KEY:-} + NODE_ENV: production + depends_on: + - api + restart: unless-stopped + +volumes: + postgres_data: + clickhouse_data: + redis_data: +``` + +--- + +## Optional Services + +### Email (Resend) + +Set `RESEND_API_KEY` to enable transactional email (password reset, uptime alerts, etc.). Create a free account at [resend.com](https://resend.com). + +### OAuth (GitHub / Google) + +Create OAuth apps in the respective developer consoles and set the `GITHUB_CLIENT_*` / `GOOGLE_CLIENT_*` variables. The callback URL should be `{BETTER_AUTH_URL}/api/auth/callback/{provider}`. + +### Uptime monitoring + +The uptime service uses [Upstash QStash](https://upstash.com/docs/qstash) for scheduling. Set `UPSTASH_QSTASH_TOKEN` to enable it. + +### GeoIP + +By default, geolocation data is fetched from the Databuddy CDN (`cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb`). To use your own copy of the MaxMind GeoLite2-City database, set `GEOIP_DB_URL` to an HTTP URL pointing to your hosted `.mmdb` file. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7a6dfe6a8..b87b50443 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -265,6 +265,7 @@ const app = new Elysia({ precompile: true }) ...(process.env.NODE_ENV === "development" ? ["http://localhost:3000"] : []), + ...(process.env.DASHBOARD_URL ? [process.env.DASHBOARD_URL] : []), ], }) ) diff --git a/apps/cron/geo.ts b/apps/cron/geo.ts index 0db5eae9d..76202c50d 100644 --- a/apps/cron/geo.ts +++ b/apps/cron/geo.ts @@ -6,7 +6,9 @@ import { AddressNotFoundError, Reader } from "@maxmind/geoip2-node"; -const CDN_URL = "https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb"; +const CDN_URL = + process.env.GEOIP_DB_URL || + "https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb"; const REGIONS: Record = { NA: [ diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 573415343..ae309012f 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -18,3 +18,9 @@ RESEND_API_KEY=your_resend_api_key NEXT_PUBLIC_API_URL=http://localhost:3001 AUTUMN_SECRET_KEY=your_autumn_secret_key + +# Self-hosting: public URL of the basket event-collection service +NEXT_PUBLIC_BASKET_URL=https://basket.databuddy.cc + +# Self-hosting: URL of the tracking script (JS bundle) served to end users +NEXT_PUBLIC_TRACKER_URL=https://cdn.databuddy.cc/databuddy.js diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts index 922133799..c41084ba6 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.ts @@ -11,10 +11,10 @@ export function generateScriptTag( const isLocalhost = process.env.NODE_ENV === "development"; const scriptUrl = isLocalhost ? "http://localhost:3000/databuddy.js" - : "https://cdn.databuddy.cc/databuddy.js"; + : (process.env.NEXT_PUBLIC_TRACKER_URL || "https://cdn.databuddy.cc/databuddy.js"); const _apiUrl = isLocalhost ? "http://localhost:4000" - : "https://basket.databuddy.cc"; + : (process.env.NEXT_PUBLIC_BASKET_URL || "https://basket.databuddy.cc"); const options = Object.entries(trackingOptions) .filter(([key, value]) => { diff --git a/apps/dashboard/app/layout.tsx b/apps/dashboard/app/layout.tsx index 190a4f391..052471bbe 100644 --- a/apps/dashboard/app/layout.tsx +++ b/apps/dashboard/app/layout.tsx @@ -133,7 +133,7 @@ export default function RootLayout({ apiUrl={ isLocalhost ? "http://localhost:4000" - : "https://basket.databuddy.cc" + : (process.env.NEXT_PUBLIC_BASKET_URL || "https://basket.databuddy.cc") } clientId={ isLocalhost diff --git a/apps/links/src/index.ts b/apps/links/src/index.ts index dbba74919..0badbc79f 100644 --- a/apps/links/src/index.ts +++ b/apps/links/src/index.ts @@ -21,7 +21,10 @@ initLogger({ const app = new Elysia() .use(evlog({ enrich: enrichLinksWideEvent })) .get("/", function rootRedirect() { - return redirect("https://databuddy.cc", 302); + return redirect( + process.env.LINKS_ROOT_REDIRECT_URL || "https://databuddy.cc", + 302 + ); }) .get("/health/status", async function linksHealthStatus() { const { db, sql } = await import("@databuddy/db"); diff --git a/apps/links/src/routes/redirect.ts b/apps/links/src/routes/redirect.ts index 2e1d24017..1ece5c50c 100644 --- a/apps/links/src/routes/redirect.ts +++ b/apps/links/src/routes/redirect.ts @@ -19,9 +19,10 @@ import { extractIp, getGeo } from "../utils/geo"; import { hashIp } from "../utils/hash"; import { parseUserAgent } from "../utils/user-agent"; -const EXPIRED_URL = "https://app.databuddy.cc/dby/expired"; -const NOT_FOUND_URL = "https://app.databuddy.cc/dby/not-found"; -const PROXY_URL = "https://app.databuddy.cc/dby/l"; +const APP_URL = process.env.APP_URL || "https://app.databuddy.cc"; +const EXPIRED_URL = `${APP_URL}/dby/expired`; +const NOT_FOUND_URL = `${APP_URL}/dby/not-found`; +const PROXY_URL = `${APP_URL}/dby/l`; /** Set to `true` to enforce per-IP Redis rate limits (100 req / 60s). */ const RATE_LIMIT_ENABLED = false; diff --git a/apps/links/src/utils/geo.ts b/apps/links/src/utils/geo.ts index d169ba979..3a49d0ad7 100644 --- a/apps/links/src/utils/geo.ts +++ b/apps/links/src/utils/geo.ts @@ -19,7 +19,9 @@ interface GeoResult { city: string | null; } -const CDN_URL = "https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb"; +const CDN_URL = + process.env.GEOIP_DB_URL || + "https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb"; const EMPTY_GEO: GeoResult = { country: null, region: null, city: null }; let reader: GeoIPReader | null = null;