diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..68fce6d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +*.md +!README.md +docker-compose.override.yml diff --git a/.gitignore b/.gitignore index 44b9d545..4f0dc6b4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ yarn-error.log* # env files .env* !.env.example +!.env.docker # vercel .vercel diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ef80756f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +# ── Stage 1: Install dependencies ───────────────────────────────── +FROM oven/bun:1 AS deps + +WORKDIR /app + +COPY package.json bun.lock bunfig.toml ./ +COPY apps/web/package.json apps/web/ +COPY apps/web/prisma.config.ts apps/web/ +COPY apps/web/prisma apps/web/prisma + +RUN bun install --frozen-lockfile + +# ── Stage 2: Build the Next.js app ─────────────────────────────── +FROM oven/bun:1 AS builder + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Copy generated Prisma client from deps stage (output goes to apps/web/src/generated/prisma +# per schema.prisma output config, and is gitignored so COPY . . does not include it) +COPY --from=deps /app/apps/web/src/generated/prisma ./apps/web/src/generated/prisma + +# Build the Next.js app (Prisma client already generated by postinstall in deps stage) +WORKDIR /app/apps/web +RUN bun run build + +WORKDIR /app + +# ── Stage 3: Production runner ─────────────────────────────────── +FROM oven/bun:1-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 + +# Copy standalone Next.js output (includes server + minimal node_modules) +COPY --from=builder /app/apps/web/.next/standalone ./ +COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder /app/apps/web/public ./apps/web/public + +# Copy Prisma schema + migrations for runtime migrate deploy +COPY --from=builder /app/apps/web/prisma ./apps/web/prisma + +# Copy generated Prisma client (custom output: apps/web/src/generated/prisma) +COPY --from=builder /app/apps/web/src/generated/prisma ./apps/web/src/generated/prisma + +# Install Prisma CLI with full dependency tree for runtime migrations. +# Installed to /prisma-cli to avoid conflicts with standalone Next.js output. +COPY prisma.docker.config.ts /app/prisma.config.ts +RUN mkdir /prisma-cli && cd /prisma-cli \ + && echo '{"dependencies":{"prisma":"^7.4.1"}}' > package.json \ + && bun install \ + && rm -f bun.lock + +# Copy entrypoint script +COPY docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +EXPOSE 3000 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD ["bun", "apps/web/server.js"] diff --git a/apps/web/.env.docker b/apps/web/.env.docker new file mode 100644 index 00000000..c3842ba4 --- /dev/null +++ b/apps/web/.env.docker @@ -0,0 +1,39 @@ +# ────────────────────────────────────────────── +# Docker Compose environment template +# ────────────────────────────────────────────── +# Copy this file to apps/web/.env before running docker compose up: +# cp apps/web/.env.docker apps/web/.env +# Then fill in the placeholder values below. + +# ────────────────────────────────────────────── +# Database (pre-configured for Docker networking) +# ────────────────────────────────────────────── +DATABASE_URL=postgresql://postgres:postgres@pgbouncer:6432/postgres?statement_cache_capacity=100 + +# ────────────────────────────────────────────── +# Redis (pre-configured for Docker networking) +# ────────────────────────────────────────────── +UPSTASH_REDIS_REST_URL=http://redis-rest:80 +UPSTASH_REDIS_REST_TOKEN=local_token + +# ────────────────────────────────────────────── +# Authentication (required — fill these in) +# ────────────────────────────────────────────── +# Create a GitHub OAuth App: https://github.com/settings/developers +# Set the callback URL to http://localhost:3000/api/auth/callback/github +GITHUB_CLIENT_ID=your_github_oauth_client_id +GITHUB_CLIENT_SECRET=your_github_oauth_client_secret + +# Random 32-char string for session encryption +# Generate with: openssl rand -hex 16 +BETTER_AUTH_SECRET=change_me_to_a_random_secret + +# Base URL of the app +BETTER_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# ────────────────────────────────────────────── +# AI — Ghost assistant (optional) +# ────────────────────────────────────────────── +# OPEN_ROUTER_API_KEY=your_open_router_api_key +# ANTHROPIC_API_KEY=your_anthropic_api_key diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index ded790ca..10ac8f50 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -26,12 +26,13 @@ const KNOWN_ROUTES = [ ]; const nextConfig: NextConfig = { + output: "standalone", devIndicators: false, serverExternalPackages: ["@prisma/client"], experimental: { staleTimes: { - dynamic: 300, - static: 180, + dynamic: 600, + static: 600, }, // serverComponentsHmrCache: true, imgOptTimeoutInSeconds: 3, diff --git a/apps/web/src/lib/db.ts b/apps/web/src/lib/db.ts index 7f909fe9..a557c515 100644 --- a/apps/web/src/lib/db.ts +++ b/apps/web/src/lib/db.ts @@ -16,22 +16,20 @@ function getOrCreatePool(): Pool { const isDev = process.env.NODE_ENV !== "production"; const pool = new Pool({ connectionString: process.env.DATABASE_URL, - // In dev, Next.js spawns 10-15 child processes each with its own pool. - // A single page load (e.g. PR detail) can fire 15+ parallel DB queries - // via concurrent server-component renders. docker-compose.yml sets - // max_connections=300 so each process can safely hold 20 connections. - // In production a managed pooler (PgBouncer / Neon) sits in front of - // PG, so a small pool is fine. - max: isDev ? 20 : 5, - idleTimeoutMillis: isDev ? 10_000 : 30_000, - // Docker Desktop on macOS introduces significant latency for new TCP - // connections (5-15 s observed). In dev, disable the timeout so these - // slow-but-valid connections aren't killed prematurely. In production - // behind a managed pooler, 5 s is more than enough. + // With PgBouncer handling connection pooling, we need fewer connections + // from the application side. Each Next.js instance can maintain a small pool. + max: isDev ? 10 : 3, + idleTimeoutMillis: isDev ? 10_000 : 60_000, connectionTimeoutMillis: isDev ? 0 : 5_000, allowExitOnIdle: true, }); + // Enable prepared statement caching for better query performance + pool.on("connect", (client) => { + client.query("SET statement_timeout = '30s'"); + client.query("SET lock_timeout = '10s'"); + }); + _proc.__dbPool = pool; attachDatabasePool(pool); return pool; diff --git a/deploy.md b/deploy.md new file mode 100644 index 00000000..179a6d49 --- /dev/null +++ b/deploy.md @@ -0,0 +1,85 @@ +# Self-Hosted Deployment + +Run Better Hub locally with Docker Compose. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) (v2+) +- A [GitHub OAuth App](https://github.com/settings/developers) + +## 1. Create a GitHub OAuth App + +1. Go to **Settings → Developer settings → OAuth Apps → New OAuth App** +2. Set the fields: + - **Application name**: anything (e.g. "Better Hub Local") + - **Homepage URL**: `http://localhost:3000` + - **Authorization callback URL**: `http://localhost:3000/api/auth/callback/github` +3. After creating, copy the **Client ID** and generate a **Client Secret** + +## 2. Configure environment + +```sh +cp apps/web/.env.docker apps/web/.env +``` + +Edit `apps/web/.env` and fill in: + +| Variable | Value | +|---|---| +| `GITHUB_CLIENT_ID` | Your OAuth App Client ID | +| `GITHUB_CLIENT_SECRET` | Your OAuth App Client Secret | +| `BETTER_AUTH_SECRET` | A random string (`openssl rand -hex 16`) | + +The database and Redis URLs are pre-configured for Docker networking — no changes needed. + +## 3. Start the stack + +```sh +docker compose up --build +``` + +This starts: + +- **postgres** — PostgreSQL 16 database +- **redis** + **redis-rest** — Redis with HTTP REST API (Upstash-compatible) +- **app** — Better Hub Next.js application + +On first boot, `prisma db push` syncs the database schema before the app starts. + +## 4. Open the app + +Visit [http://localhost:3000](http://localhost:3000) and sign in with GitHub. + +## Architecture + +The Docker setup uses `docker-compose.override.yml` to add the `app` service on top of the base `docker-compose.yml`. This keeps the base file identical to upstream so you can sync without merge conflicts. + +``` +docker-compose.yml ← upstream (postgres, redis, redis-rest) +docker-compose.override.yml ← self-hosted additions (app service, healthchecks) +Dockerfile ← multi-stage build (deps → build → slim runner) +docker-entrypoint.sh ← runs prisma db push on boot +apps/web/.env.docker ← env template for Docker networking +``` + +## Rebuilding + +After pulling new changes: + +```sh +docker compose up --build +``` + +The database schema is synced automatically on each boot via `prisma db push`, so schema changes are applied without extra steps. + +## Stopping + +```sh +docker compose down +``` + +To also remove the database volume: + +```sh +docker compose down -v +``` diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 00000000..ed504326 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,56 @@ +# Override file for self-hosted deployment. +# Docker Compose merges this automatically with docker-compose.yml, +# keeping the base file untouched for clean upstream syncs. +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: better-hub-app + restart: unless-stopped + ports: + - "3000:3000" + env_file: ./apps/web/.env + depends_on: + pgbouncer: + condition: service_healthy + redis-rest: + condition: service_started + + pgbouncer: + image: public.ecr.aws/bitnami/pgbouncer:latest + container_name: better-hub-pgbouncer + restart: unless-stopped + environment: + - POSTGRESQL_HOST=postgres + - POSTGRESQL_PORT=5432 + - POSTGRESQL_DATABASE_NAME=better_hub + - POSTGRESQL_USERNAME=postgres + - POSTGRESQL_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - PGBOUNCER_DEFAULT_POOL_SIZE=30 + - PGBOUNCER_MIN_POOL_SIZE=10 + - PGBOUNCER_RESERVE_POOL_SIZE=5 + - PGBOUNCER_MAX_CLIENT_CONNECTIONS=100 + - PGBOUNCER_POOL_MODE=transaction + healthcheck: + test: ["CMD-SHELL", "bash -c '(echo > /dev/tcp/localhost/6432) 2>/dev/null || exit 1'"] + interval: 10s + timeout: 5s + retries: 10 + + postgres: + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + command: > + postgres + -c max_connections=300 + -c shared_buffers=256MB + -c effective_cache_size=512MB + -c work_mem=16MB + -c maintenance_work_mem=128MB + -c random_page_cost=1.1 + -c synchronous_commit=off + -c full_page_writes=off diff --git a/docker-compose.yml b/docker-compose.yml index c20b4bd0..f15e266b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,10 +8,16 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: better_hub - ports: - - "127.0.0.1:54320:5432" volumes: - postgres_data:/var/lib/postgresql/data + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + reservations: + cpus: "1.0" + memory: 1G redis: image: redis:7-alpine @@ -19,6 +25,14 @@ services: restart: unless-stopped volumes: - redis_data:/data + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + reservations: + cpus: "0.25" + memory: 256M redis-rest: image: hiett/serverless-redis-http:latest @@ -32,6 +46,14 @@ services: SRH_CONNECTION_STRING: redis://redis:6379 depends_on: - redis + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + cpus: "0.25" + memory: 128M volumes: postgres_data: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..3fbd9f09 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Sync database schema on startup. +# Uses prisma db push instead of migrate deploy because the project's +# migrations are incomplete — many tables/columns only exist in schema.prisma. +echo "Syncing database schema..." +cd /app +NODE_PATH=/prisma-cli/node_modules /prisma-cli/node_modules/.bin/prisma db push --accept-data-loss + +echo "Starting application..." +exec "$@" diff --git a/prisma.docker.config.ts b/prisma.docker.config.ts new file mode 100644 index 00000000..3e647cfb --- /dev/null +++ b/prisma.docker.config.ts @@ -0,0 +1,14 @@ +// Minimal Prisma config for Docker runtime (prisma migrate deploy). +// Unlike apps/web/prisma.config.ts, this does not import dotenv +// since env vars are already provided by docker-compose env_file. +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "apps/web/prisma/schema.prisma", + migrations: { + path: "apps/web/prisma/migrations", + }, + datasource: { + url: process.env.DATABASE_URL!, + }, +});