diff --git a/.env.example b/.env.example
index 5429a7a34d..9bd79356bd 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,5 @@
+# Local development. For Docker self-hosting, use selfhost.env.example.
+
CLICKHOUSE_URL="http://default:@localhost:8123/databuddy_analytics"
# Required by DQL. This must use the separately provisioned dql_user account;
# never point it at the application/admin ClickHouse credentials.
@@ -6,13 +8,6 @@ DATABASE_URL="postgres://databuddy:databuddy_dev_password@localhost:5432/databud
REDIS_URL="redis://localhost:6379"
BULLMQ_REDIS_URL="redis://localhost:6379"
-# Required only by docker-compose.selfhost.yml. Use URL-safe passwords and make
-# the local database URLs above use the same credentials before initialization.
-IMAGE_TAG=""
-POSTGRES_PASSWORD=""
-CLICKHOUSE_PASSWORD=""
-REDIS_PASSWORD=""
-
# Public URLs. Local development uses built-in localhost defaults when blank.
DASHBOARD_URL=""
API_URL=""
@@ -31,7 +26,7 @@ FIRECRAWL_API_KEY=""
SUPERMEMORY_API_KEY=""
BETTER_AUTH_URL="http://localhost:3000"
-# Required by docker-compose.selfhost.yml. Generate independent random values.
+# Generate independent random values for sessions and encryption.
BETTER_AUTH_SECRET="generate-a-random-32-byte-base64-secret"
DATABUDDY_ENCRYPTION_KEY=""
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 86dd13240c..37f79fb5fc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -186,6 +186,7 @@ jobs:
run: >-
bun test --no-env-file apps/dashboard/next-config.test.ts
apps/dashboard/app/layout.test.ts
+ apps/dashboard/lib/app-url.test.ts
'apps/dashboard/app/(main)/websites/[id]/_components/utils/code-generators.test.ts'
'apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.test.ts'
packages/auth/src/selfhost-config.test.ts
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index f4f93f52d4..06a9a5571c 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -49,7 +49,7 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
run: |
- ALL='["api","basket","dashboard","insights","links","uptime"]'
+ ALL='["api","basket","dashboard","init","insights","links","uptime"]'
if [[ "$EVENT_NAME" != "push" ]]; then
echo "services=$ALL" >> "$GITHUB_OUTPUT"
exit 0
@@ -60,19 +60,16 @@ jobs:
fi
export TURBO_SCM_BASE="$BEFORE_SHA"
export TURBO_SCM_HEAD="HEAD"
- affected=()
+ # The init image ships the schema and tooling for every release.
+ affected=('"init"')
for svc in api basket dashboard insights links uptime; do
count=$(bunx turbo ls --affected --filter="@databuddy/$svc" --output=json | jq -r '.packages.count')
if [[ "$count" != "0" ]]; then
affected+=("\"$svc\"")
fi
done
- if [[ ${#affected[@]} -eq 0 ]]; then
- echo "services=[]" >> "$GITHUB_OUTPUT"
- else
- IFS=,
- echo "services=[${affected[*]}]" >> "$GITHUB_OUTPUT"
- fi
+ IFS=,
+ echo "services=[${affected[*]}]" >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.service }} (${{ matrix.platform.arch }})
@@ -136,6 +133,7 @@ jobs:
api) text="Databuddy API service - analytics backend" ;;
basket) text="Databuddy Basket service - event ingestion" ;;
dashboard) text="Databuddy Dashboard service - web analytics UI" ;;
+ init) text="Databuddy database initialization and schema tools" ;;
insights) text="Databuddy Insights service - queued insight generation" ;;
links) text="Databuddy Links service - URL shortening and tracking" ;;
uptime) text="Databuddy Uptime service - availability monitoring" ;;
diff --git a/.github/workflows/health-check.yml b/.github/workflows/health-check.yml
index 8deaef1510..a9732ed045 100644
--- a/.github/workflows/health-check.yml
+++ b/.github/workflows/health-check.yml
@@ -5,6 +5,9 @@ on:
branches: [main]
paths:
- "*.Dockerfile"
+ - "docker-compose.selfhost.yml"
+ - "selfhost.env.example"
+ - "scripts/test-selfhost-init.sh"
- ".dockerignore"
- "apps/api/**"
- "apps/basket/**"
@@ -15,11 +18,15 @@ on:
- "bun.lock"
- "package.json"
- "turbo.json"
+ - "tsconfig/**"
- ".github/workflows/health-check.yml"
pull_request:
branches: [main, staging]
paths:
- "*.Dockerfile"
+ - "docker-compose.selfhost.yml"
+ - "selfhost.env.example"
+ - "scripts/test-selfhost-init.sh"
- ".dockerignore"
- "apps/api/**"
- "apps/basket/**"
@@ -30,6 +37,7 @@ on:
- "bun.lock"
- "package.json"
- "turbo.json"
+ - "tsconfig/**"
- ".github/workflows/health-check.yml"
permissions:
@@ -40,6 +48,14 @@ concurrency:
cancel-in-progress: true
jobs:
+ selfhost-init:
+ name: Self-host Database Initialization
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ - run: bash scripts/test-selfhost-init.sh
+
vector-config-check:
name: Vector Delivery Canary
runs-on: blacksmith-2vcpu-ubuntu-2404
diff --git a/apps/dashboard/app/(main)/monitors/status-pages/[id]/page.tsx b/apps/dashboard/app/(main)/monitors/status-pages/[id]/page.tsx
index 20632444ef..ea3755fbf4 100644
--- a/apps/dashboard/app/(main)/monitors/status-pages/[id]/page.tsx
+++ b/apps/dashboard/app/(main)/monitors/status-pages/[id]/page.tsx
@@ -76,6 +76,7 @@ export default function StatusPageDetailsPage() {
});
const statusPage = statusPageQuery.data;
+ const statusPageUrl = statusPage && getStatusPageUrl(statusPage.slug);
const monitorToRemoveData = statusPage?.monitors.find(
(m: StatusPageMonitor) => m.id === monitorToRemove
@@ -215,17 +216,19 @@ export default function StatusPageDetailsPage() {
{statusPage ? (
<>
-
- View Page
-
+ {statusPageUrl && (
+
+ View Page
+
+ )}
Edit Details
-
copyToClipboard(url)}
- >
-
- Copy URL
-
-
+
- }
- >
-
- View Page
-
+ onClick={() => copyToClipboard(url)}
+ >
+
+ Copy URL
+
+
+ }
+ >
+
+ View Page
+
+ >
+ )}
setIsTransferOpen(true)}
diff --git a/apps/dashboard/lib/app-url.test.ts b/apps/dashboard/lib/app-url.test.ts
new file mode 100644
index 0000000000..ef454387fa
--- /dev/null
+++ b/apps/dashboard/lib/app-url.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from "bun:test";
+
+test.each([
+ [false, undefined, "https://status.databuddy.cc/example"],
+ [false, "", "https://status.databuddy.cc/example"],
+ [true, undefined, null],
+ [true, "", null],
+ [true, " ", null],
+ [true, "https://status.example.com", "https://status.example.com/example"],
+ [false, "https://status.example.com", "https://status.example.com/example"],
+] as const)("status links with self-host=%s and URL %s", (selfhost, url, expected) => {
+ const result = Bun.spawnSync({
+ cmd: [
+ process.execPath,
+ "-e",
+ 'import { getStatusPageUrl } from "./app-url"; console.log(JSON.stringify(getStatusPageUrl("example")));',
+ ],
+ cwd: import.meta.dir,
+ env: {
+ ...process.env,
+ NODE_ENV: "production",
+ SELFHOST: String(selfhost),
+ NEXT_PUBLIC_SELFHOST: String(selfhost),
+ STATUS_URL: "",
+ NEXT_PUBLIC_STATUS_URL: url,
+ },
+ });
+ expect(result.exitCode).toBe(0);
+ expect(JSON.parse(result.stdout.toString())).toBe(expected);
+});
diff --git a/apps/dashboard/lib/app-url.ts b/apps/dashboard/lib/app-url.ts
index 0d8255c494..7ee5f8a383 100644
--- a/apps/dashboard/lib/app-url.ts
+++ b/apps/dashboard/lib/app-url.ts
@@ -1,9 +1,11 @@
-import { publicConfig } from "@databuddy/env/public";
+import { isSelfHosted, publicConfig } from "@databuddy/env/public";
export const APP_URL = publicConfig.urls.dashboard;
const STATUS_URL = publicConfig.urls.status;
-export function getStatusPageUrl(slug: string): string {
- return `${STATUS_URL}/${slug}`;
+export function getStatusPageUrl(slug: string): string | null {
+ return isSelfHosted && !process.env.NEXT_PUBLIC_STATUS_URL?.trim()
+ ? null
+ : `${STATUS_URL}/${slug}`;
}
diff --git a/dashboard.Dockerfile b/dashboard.Dockerfile
index bbddba2fc9..76001a63c3 100644
--- a/dashboard.Dockerfile
+++ b/dashboard.Dockerfile
@@ -19,6 +19,8 @@ COPY turbo.json turbo.json
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
+ARG SELFHOST=false
+ENV SELFHOST=$SELFHOST
# Build-time defaults keep the image buildable. Override these with real public
# URLs when building environment-specific dashboard images.
ARG NEXT_PUBLIC_API_URL=https://api.databuddy.cc
@@ -26,7 +28,6 @@ ARG NEXT_PUBLIC_APP_URL=https://app.databuddy.cc
ARG NEXT_PUBLIC_BASKET_URL=https://basket.databuddy.cc
ARG NEXT_PUBLIC_LINKS_URL=https://dby.sh
ARG NEXT_PUBLIC_STATUS_URL=https://status.databuddy.cc
-ARG SELFHOST=false
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml
index 324f667eaf..6c34cc0cee 100644
--- a/docker-compose.selfhost.yml
+++ b/docker-compose.selfhost.yml
@@ -5,14 +5,49 @@ x-logging: &logging
max-size: "10m"
max-file: "3"
+x-environment: &environment
+ NODE_ENV: production
+ SELFHOST: "true"
+ DATABASE_URL: &database-url "postgres://${POSTGRES_USER:-databuddy}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-databuddy}"
+ REDIS_URL: &redis-url "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379"
+ BULLMQ_REDIS_URL: *redis-url
+ CLICKHOUSE_URL: &clickhouse-url "http://${CLICKHOUSE_USER:-default}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD}@clickhouse:8123/${CLICKHOUSE_DB:-databuddy_analytics}"
+ DB_POOL_MAX: ${DB_POOL_MAX:-10}
+ DASHBOARD_URL: &dashboard-url ${DASHBOARD_URL:?Set DASHBOARD_URL}
+
+x-auth-environment: &auth-environment
+ <<: *environment
+ BETTER_AUTH_URL: *dashboard-url
+ BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET}
+ BETTER_AUTH_COOKIE_DOMAIN: ${BETTER_AUTH_COOKIE_DOMAIN:-}
+ DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY}
+ API_URL: &api-url ${API_URL:?Set API_URL}
+ BASKET_URL: &basket-url ${BASKET_URL:?Set BASKET_URL}
+ LINKS_URL: &links-url ${LINKS_URL:-http://localhost:2500}
+ STATUS_URL: &status-url ${STATUS_URL:-}
+ EMAIL_FROM: ${EMAIL_FROM:-}
+ ALERTS_EMAIL_FROM: ${ALERTS_EMAIL_FROM:-}
+ RESEND_API_KEY: ${RESEND_API_KEY:-}
+
+x-backend: &backend
+ <<: *logging
+ restart: unless-stopped
+ depends_on:
+ postgres:
+ condition: service_healthy
+ clickhouse:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
services:
postgres:
+ <<: *logging
image: postgres:17-alpine
- container_name: databuddy-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-databuddy}
POSTGRES_USER: ${POSTGRES_USER:-databuddy}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your environment}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
volumes:
@@ -23,17 +58,14 @@ services:
timeout: 5s
retries: 5
restart: unless-stopped
- networks:
- - databuddy
- <<: *logging
clickhouse:
+ <<: *logging
image: clickhouse/clickhouse-server:25.5.1-alpine
- container_name: databuddy-clickhouse
environment:
CLICKHOUSE_DB: ${CLICKHOUSE_DB:-databuddy_analytics}
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default}
- CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD in your environment}
+ CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD}
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
ports:
- "127.0.0.1:${CLICKHOUSE_PORT:-8123}:8123"
@@ -44,198 +76,142 @@ services:
soft: 262144
hard: 262144
healthcheck:
- test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8123/ping" ]
+ test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8123/ping" ]
interval: 10s
timeout: 5s
+ start_period: 30s
retries: 5
restart: unless-stopped
- networks:
- - databuddy
- <<: *logging
redis:
+ <<: *logging
image: redis:7-alpine
- container_name: databuddy-redis
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
environment:
- REDISCLI_AUTH: ${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}
- command: >
- redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction --requirepass ${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}
+ REDISCLI_AUTH: ${REDIS_PASSWORD:?Set REDIS_PASSWORD}
+ command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy noeviction --requirepass ${REDIS_PASSWORD:?Set REDIS_PASSWORD}
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
- networks:
- - databuddy
+
+ init:
<<: *logging
+ image: ghcr.io/databuddy-analytics/databuddy-init:${IMAGE_TAG:?Set IMAGE_TAG to a release tag}
+ profiles: [ "tools" ]
+ environment:
+ DATABASE_URL: *database-url
+ CLICKHOUSE_URL: *clickhouse-url
+ depends_on:
+ postgres:
+ condition: service_healthy
+ clickhouse:
+ condition: service_healthy
+ restart: "no"
+
+ dashboard:
+ <<: *backend
+ build:
+ context: .
+ dockerfile: dashboard.Dockerfile
+ args:
+ SELFHOST: "true"
+ NEXT_PUBLIC_APP_URL: *dashboard-url
+ NEXT_PUBLIC_API_URL: *api-url
+ NEXT_PUBLIC_BASKET_URL: *basket-url
+ NEXT_PUBLIC_LINKS_URL: *links-url
+ NEXT_PUBLIC_STATUS_URL: *status-url
+ ports:
+ - "${DASHBOARD_PORT:-3000}:3000"
+ environment: *auth-environment
+ healthcheck:
+ test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ]
+ interval: 30s
+ timeout: 3s
+ start_period: 30s
+ retries: 3
api:
- image: ghcr.io/databuddy-analytics/databuddy-api:${IMAGE_TAG:?Set IMAGE_TAG to a release tag, for example v1.0.0}
- container_name: databuddy-api
+ <<: *backend
+ image: ghcr.io/databuddy-analytics/databuddy-api:${IMAGE_TAG:?Set IMAGE_TAG to a release tag}
ports:
- "${API_PORT:-3001}:3001"
environment:
- NODE_ENV: production
+ <<: *auth-environment
PORT: "3001"
- DATABASE_URL: "postgres://${POSTGRES_USER:-databuddy}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your environment}@postgres:5432/${POSTGRES_DB:-databuddy}"
- DB_POOL_MAX: ${DB_POOL_MAX:-10}
SERVICE_NAME: databuddy-api
- REDIS_URL: "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}@redis:6379"
- BULLMQ_REDIS_URL: "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}@redis:6379"
- CLICKHOUSE_URL: "http://${CLICKHOUSE_USER:-default}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD in your environment}@clickhouse:8123/${CLICKHOUSE_DB:-databuddy_analytics}"
- BETTER_AUTH_URL: ${DASHBOARD_URL:?Set DASHBOARD_URL in your environment}
- BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET in your environment}
- DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY in your environment}
- SELFHOST: "true"
- DASHBOARD_URL: ${DASHBOARD_URL:?Set DASHBOARD_URL in your environment}
- API_URL: ${API_URL:?Set API_URL in your environment}
- BASKET_URL: ${BASKET_URL:?Set BASKET_URL in your environment}
- EMAIL_FROM: ${EMAIL_FROM:-}
- ALERTS_EMAIL_FROM: ${ALERTS_EMAIL_FROM:-}
AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY:-}
FIRECRAWL_API_KEY: ${FIRECRAWL_API_KEY:-}
SUPERMEMORY_API_KEY: ${SUPERMEMORY_API_KEY:-}
SUPERLOG_API_KEY: ${SUPERLOG_API_KEY:-}
- RESEND_API_KEY: ${RESEND_API_KEY:-}
- healthcheck:
- test: [ "CMD", "bun", "-e", "fetch('http://localhost:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ]
+ healthcheck: &http-healthcheck
+ test: [ "CMD", "bun", "-e", "fetch('http://127.0.0.1:3001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
- depends_on:
- postgres:
- condition: service_healthy
- clickhouse:
- condition: service_healthy
- redis:
- condition: service_healthy
- restart: unless-stopped
- networks:
- - databuddy
- <<: *logging
basket:
- image: ghcr.io/databuddy-analytics/databuddy-basket:${IMAGE_TAG:?Set IMAGE_TAG to a release tag, for example v1.0.0}
- container_name: databuddy-basket
+ <<: *backend
+ image: ghcr.io/databuddy-analytics/databuddy-basket:${IMAGE_TAG:?Set IMAGE_TAG to a release tag}
ports:
- "${BASKET_PORT:-4000}:4000"
environment:
- NODE_ENV: production
+ <<: *environment
PORT: "4000"
- DATABASE_URL: "postgres://${POSTGRES_USER:-databuddy}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your environment}@postgres:5432/${POSTGRES_DB:-databuddy}"
- DB_POOL_MAX: ${DB_POOL_MAX:-10}
SERVICE_NAME: databuddy-basket
- REDIS_URL: "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}@redis:6379"
- CLICKHOUSE_URL: "http://${CLICKHOUSE_USER:-default}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD in your environment}@clickhouse:8123/${CLICKHOUSE_DB:-databuddy_analytics}"
- DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY in your environment}
- IP_HASH_SALT: ${IP_HASH_SALT:?Set IP_HASH_SALT in your environment}
+ DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY}
+ EMAIL_FROM: ${EMAIL_FROM:-}
+ ALERTS_EMAIL_FROM: ${ALERTS_EMAIL_FROM:-}
+ RESEND_API_KEY: ${RESEND_API_KEY:-}
SUPERLOG_API_KEY: ${SUPERLOG_API_KEY:-}
- SELFHOST: "true"
healthcheck:
- test: [ "CMD", "bun", "-e", "fetch('http://localhost:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ]
- interval: 30s
- timeout: 3s
- start_period: 10s
- retries: 3
- depends_on:
- postgres:
- condition: service_healthy
- clickhouse:
- condition: service_healthy
- redis:
- condition: service_healthy
- restart: unless-stopped
- networks:
- - databuddy
- <<: *logging
+ <<: *http-healthcheck
+ test: [ "CMD", "bun", "-e", "fetch('http://127.0.0.1:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ]
insights:
- image: ghcr.io/databuddy-analytics/databuddy-insights:${IMAGE_TAG:?Set IMAGE_TAG to a release tag, for example v1.0.0}
- container_name: databuddy-insights
+ <<: *backend
+ image: ghcr.io/databuddy-analytics/databuddy-insights:${IMAGE_TAG:?Set IMAGE_TAG to a release tag}
+ profiles: [ "insights" ]
ports:
- "${INSIGHTS_PORT:-4002}:4002"
environment:
- NODE_ENV: production
+ <<: *environment
PORT: "4002"
- DATABASE_URL: "postgres://${POSTGRES_USER:-databuddy}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your environment}@postgres:5432/${POSTGRES_DB:-databuddy}"
- DB_POOL_MAX: ${DB_POOL_MAX:-10}
SERVICE_NAME: databuddy-insights
- REDIS_URL: "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}@redis:6379"
- BULLMQ_REDIS_URL: "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}@redis:6379"
INSIGHTS_WORKER_CONCURRENCY: ${INSIGHTS_WORKER_CONCURRENCY:-2}
- INSIGHTS_WORKER_ENABLED: "true"
- AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY:?Set AI_GATEWAY_API_KEY in your environment}
+ INSIGHTS_WORKER_ENABLED: "${AI_GATEWAY_API_KEY:+true}"
+ AI_GATEWAY_API_KEY: ${AI_GATEWAY_API_KEY:-}
FIRECRAWL_API_KEY: ${FIRECRAWL_API_KEY:-}
SUPERMEMORY_API_KEY: ${SUPERMEMORY_API_KEY:-}
SUPERLOG_API_KEY: ${SUPERLOG_API_KEY:-}
- CLICKHOUSE_URL: "http://${CLICKHOUSE_USER:-default}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD in your environment}@clickhouse:8123/${CLICKHOUSE_DB:-databuddy_analytics}"
- DASHBOARD_URL: ${DASHBOARD_URL:?Set DASHBOARD_URL in your environment}
- DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY in your environment}
- BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET in your environment}
+ DATABUDDY_ENCRYPTION_KEY: ${DATABUDDY_ENCRYPTION_KEY:?Set DATABUDDY_ENCRYPTION_KEY}
+ BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET}
healthcheck:
- test: [ "CMD", "bun", "-e", "fetch('http://localhost:4002/health/status').then(async r=>process.exit(r.ok&&(await r.json()).status==='ok'?0:1)).catch(()=>process.exit(1))" ]
- interval: 30s
- timeout: 3s
- start_period: 10s
- retries: 3
- depends_on:
- postgres:
- condition: service_healthy
- clickhouse:
- condition: service_healthy
- redis:
- condition: service_healthy
- restart: unless-stopped
- networks:
- - databuddy
- <<: *logging
+ <<: *http-healthcheck
+ test: [ "CMD", "bun", "-e", "fetch('http://127.0.0.1:4002/health/status').then(async r=>process.exit(r.ok&&(await r.json()).status==='ok'?0:1)).catch(()=>process.exit(1))" ]
links:
- image: ghcr.io/databuddy-analytics/databuddy-links:${IMAGE_TAG:?Set IMAGE_TAG to a release tag, for example v1.0.0}
- container_name: databuddy-links
+ <<: *backend
+ image: ghcr.io/databuddy-analytics/databuddy-links:${IMAGE_TAG:?Set IMAGE_TAG to a release tag}
ports:
- "${LINKS_PORT:-2500}:2500"
environment:
- NODE_ENV: production
- DATABASE_URL: "postgres://${POSTGRES_USER:-databuddy}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your environment}@postgres:5432/${POSTGRES_DB:-databuddy}"
- DB_POOL_MAX: ${DB_POOL_MAX:-10}
+ <<: *environment
SERVICE_NAME: databuddy-links
- REDIS_URL: "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}@redis:6379"
- BULLMQ_REDIS_URL: "redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD in your environment}@redis:6379"
- CLICKHOUSE_URL: "http://${CLICKHOUSE_USER:-default}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD in your environment}@clickhouse:8123/${CLICKHOUSE_DB:-databuddy_analytics}"
- DASHBOARD_URL: ${DASHBOARD_URL:?Set DASHBOARD_URL in your environment}
SUPERLOG_API_KEY: ${SUPERLOG_API_KEY:-}
- LINKS_ROOT_REDIRECT_URL: ${LINKS_ROOT_REDIRECT_URL:-https://databuddy.cc}
+ LINKS_ROOT_REDIRECT_URL: ${LINKS_ROOT_REDIRECT_URL:-${DASHBOARD_URL}}
GEOIP_DB_URL: ${GEOIP_DB_URL:-https://cdn.databuddy.cc/mmdb/GeoLite2-City.mmdb}
healthcheck:
- test: [ "CMD", "bun", "-e", "fetch('http://localhost:2500/health/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ]
- interval: 30s
- timeout: 3s
- start_period: 10s
- retries: 3
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- clickhouse:
- condition: service_healthy
- restart: unless-stopped
- networks:
- - databuddy
- <<: *logging
+ <<: *http-healthcheck
+ test: [ "CMD", "bun", "-e", "fetch('http://127.0.0.1:2500/health/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ]
+
volumes:
postgres_data:
clickhouse_data:
redis_data:
-
-
-networks:
- databuddy:
- driver: bridge
diff --git a/infra/railway-template.md b/infra/railway-template.md
index bcbb22b35c..5ac223d065 100644
--- a/infra/railway-template.md
+++ b/infra/railway-template.md
@@ -54,7 +54,6 @@ NODE_ENV=production
SELFHOST=true
BETTER_AUTH_SECRET=${{secret(64)}}
DATABUDDY_ENCRYPTION_KEY=${{secret(64)}}
-IP_HASH_SALT=${{secret(64)}}
AI_GATEWAY_API_KEY=
```
@@ -124,7 +123,6 @@ DATABASE_URL=${{Postgres.DATABASE_URL}}
REDIS_URL=${{Redis.REDIS_URL}}
CLICKHOUSE_URL=${{ClickHouse.DATABASE_URL}}
DATABUDDY_ENCRYPTION_KEY=${{shared.DATABUDDY_ENCRYPTION_KEY}}
-IP_HASH_SALT=${{shared.IP_HASH_SALT}}
SELFHOST=${{shared.SELFHOST}}
```
diff --git a/init.Dockerfile b/init.Dockerfile
index b9867b7d7d..edb1e4dcce 100644
--- a/init.Dockerfile
+++ b/init.Dockerfile
@@ -5,8 +5,9 @@ WORKDIR /app
COPY package.json bun.lock turbo.json ./
COPY packages ./packages
COPY apps ./apps
+COPY tsconfig ./tsconfig
-RUN bun install --ignore-scripts
+RUN bun install --frozen-lockfile --ignore-scripts
ENV NODE_ENV=production
diff --git a/scripts/test-selfhost-init.sh b/scripts/test-selfhost-init.sh
new file mode 100644
index 0000000000..78861123ae
--- /dev/null
+++ b/scripts/test-selfhost-init.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+test_compose=$(mktemp)
+test_env=$(mktemp)
+project="databuddy-init-test-$$"
+
+sed -E \
+ -e 's/^IMAGE_TAG=$/IMAGE_TAG=selfhost-test/' \
+ -e 's/^(POSTGRES_PASSWORD|CLICKHOUSE_PASSWORD|REDIS_PASSWORD|BETTER_AUTH_SECRET|DATABUDDY_ENCRYPTION_KEY)=$/\1=init_test_password/' \
+ selfhost.env.example > "$test_env"
+
+cat > "$test_compose" <<'EOF'
+services:
+ postgres:
+ ports: !reset []
+ clickhouse:
+ ports: !reset []
+ init:
+ image: databuddy-init:selfhost-test
+ pull_policy: never
+EOF
+
+compose() {
+ local docker_env=("PATH=$PATH" "HOME=$HOME") name
+ for name in "${!DOCKER_@}"; do
+ docker_env+=("$name=${!name}")
+ done
+ # Keep Docker connection settings without leaking Compose overrides into the fixture.
+ env -i "${docker_env[@]}" \
+ docker compose --project-name "$project" --env-file "$test_env" \
+ -f docker-compose.selfhost.yml -f "$test_compose" "$@"
+}
+cleanup() {
+ compose down --volumes --remove-orphans
+ rm -f "$test_compose" "$test_env"
+}
+trap cleanup EXIT
+
+docker build -f init.Dockerfile -t databuddy-init:selfhost-test .
+compose run --rm init
+test "$(compose exec -T postgres psql -U databuddy -d databuddy -Atc \
+ "SELECT to_regclass('public.websites') IS NOT NULL AND to_regclass('public.user') IS NOT NULL")" = t
+test "$(compose exec -T clickhouse clickhouse-client --password init_test_password --query \
+ "SELECT count() FROM system.tables WHERE (database, name) IN (('analytics', 'events'), ('analytics', 'daily_pageviews_mv'), ('uptime', 'uptime_monitor'))")" = 3
+
+# Reapplying the schema must also succeed.
+compose run --rm init
+
+# A database connection failure must fail the init command.
+if compose run --rm --no-deps -e DATABASE_URL=postgres://databuddy:wrong@postgres:5432/databuddy init; then
+ echo "Initialization incorrectly succeeded with invalid database credentials" >&2
+ exit 1
+fi
+echo "Self-host initialization smoke test passed"
diff --git a/selfhost.env.example b/selfhost.env.example
new file mode 100644
index 0000000000..e7737138fe
--- /dev/null
+++ b/selfhost.env.example
@@ -0,0 +1,29 @@
+# Copy to .env. Use the same release tag as your checkout.
+IMAGE_TAG=
+
+# Generate each value separately with: openssl rand -hex 32
+POSTGRES_PASSWORD=
+CLICKHOUSE_PASSWORD=
+REDIS_PASSWORD=
+BETTER_AUTH_SECRET=
+DATABUDDY_ENCRYPTION_KEY=
+
+# Local defaults. For a public instance, use your HTTPS URLs.
+DASHBOARD_URL=http://localhost:3000
+API_URL=http://localhost:3001
+BASKET_URL=http://localhost:4000
+LINKS_URL=http://localhost:2500
+
+# Optional: URL of your separately deployed status app. Empty hides public links.
+STATUS_URL=
+
+# Shared dashboard/API parent domain (e.g. .example.com); empty for localhost.
+BETTER_AUTH_COOKIE_DOMAIN=
+
+# Optional email: set both values and use a verified sender domain.
+# RESEND_API_KEY=
+# EMAIL_FROM="Databuddy "
+
+# Optional AI: README.md#optional-services
+# AI_GATEWAY_API_KEY=
+# COMPOSE_PROFILES=insights