Self-Hosting Buildd
Complete guide for self-hosting Buildd on your own infrastructure
Self-Hosting Buildd
Complete guide for self-hosting Buildd on your own infrastructure.
Overview
Buildd consists of:
- Web server (Next.js) - Dashboard, API, and authentication
- Database (PostgreSQL) - Neon or any Postgres instance
- Cron trigger (External) - Calls
/api/cron/scheduleshourly, on the hour - Workers (External) - Run on laptops, VMs, or CI runners
Prerequisites
- Node.js 18+ or Bun
- PostgreSQL database (Neon, local, or Docker)
- Domain name (optional, but recommended)
- SSL certificate (Let's Encrypt recommended)
Quick Start with Docker Compose
1. Clone and Configure
git clone https://github.com/buildd-ai/buildd.git
cd buildd
cp .env.example .env.local2. Edit .env.local
.env.example in the repo is not a complete list. Several variables the
app reads are absent from it — most importantly ENCRYPTION_KEY, which the
entire secrets system derives its key from. Without
it the app boots and looks healthy, and then credential delivery silently
no-ops. Set the variables below, not just the ones in the example file.
# Database
DATABASE_URL=postgresql://postgres:postgres@db:5432/buildd
# Auth (generate with: openssl rand -base64 32)
AUTH_SECRET=your-secret-here
# Secret encryption — REQUIRED, min 32 characters, and NOT in .env.example.
# Generate with: openssl rand -base64 48
# Store it verbatim. Any transformation (a stray trailing newline, a shell
# escape) changes the scrypt input and makes every existing secret
# undecryptable, with no error at startup.
ENCRYPTION_KEY=your-32-plus-char-key
# Public base URL. Every link the app generates falls back to
# https://buildd.dev when this is unset.
NEXT_PUBLIC_APP_URL=https://your-domain.com
# Google OAuth
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
# Cron (generate with: openssl rand -base64 32)
CRON_SECRET=your-cron-secret
# Pusher (optional - for realtime updates)
PUSHER_APP_ID=
PUSHER_KEY=
PUSHER_SECRET=
PUSHER_CLUSTER=us2
NEXT_PUBLIC_PUSHER_KEY=
NEXT_PUBLIC_PUSHER_CLUSTER=us23. Create docker-compose.yml
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: buildd
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
web:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/buildd
- AUTH_SECRET=${AUTH_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- CRON_SECRET=${CRON_SECRET}
depends_on:
- db
restart: unless-stopped
volumes:
postgres_data:4. Start Services
docker-compose up -dAccess at http://localhost:3000
Database Setup
Option 1: Neon (Recommended)
- Sign up at neon.tech
- Create database
- Copy connection string to
DATABASE_URL - Migrations run automatically on deploy
Option 2: Local PostgreSQL
# Install PostgreSQL
sudo apt install postgresql postgresql-contrib
# Create database
sudo -u postgres createdb buildd
# Run migrations
cd packages/core
bun run db:migrateAlternative Cron Trigger Services
If you don't want to manage cron yourself:
Hourly is deliberate, not a minimum viable setting. The tick only needs to be as fine as the finest schedule you actually run, and a finer tick wakes the database on every call for nothing. If you add a schedule finer than hourly, make the tick at least that fine or it will fire late — see Task Schedules.
Option 1: cron-job.org (Free)
- Sign up at cron-job.org
- Create new job:
- URL:
https://your-domain.com/api/cron/schedules - Interval: Hourly, on the hour (
0 * * * *) - HTTP Method: GET
- Headers:
Authorization: Bearer YOUR_CRON_SECRET
- URL:
Option 2: System Crontab
crontab -eAdd entry:
0 * * * * curl -s -H "Authorization: Bearer YOUR_CRON_SECRET" http://localhost:3000/api/cron/schedulesEnvironment Variables Reference
Required
| Variable | Description |
|---|---|
DATABASE_URL | PostgreSQL connection string. The first DB access throws without it |
AUTH_SECRET | NextAuth secret (32+ random chars). Read implicitly by NextAuth |
ENCRYPTION_KEY | Master key for the secrets system — AES-256-GCM via scrypt. Minimum 32 characters; encrypt()/decrypt() throw below that. Not present in .env.example |
GOOGLE_CLIENT_ID | Google OAuth client ID (required for Google sign-in) |
GOOGLE_CLIENT_SECRET | Google OAuth secret |
Effectively required for a real deployment
| Variable | Description |
|---|---|
NEXT_PUBLIC_APP_URL | Public base URL. Nothing throws without it, but every generated link falls back to VERCEL_URL and then to https://buildd.dev — so share links, callbacks and dashboard URLs point at the wrong host |
CRON_SECRET | Authenticates /api/cron/*. The routes return 500 CRON_SECRET not configured without it, so all scheduled work stops |
Optional — each gates one feature
| Variable | Without it |
|---|---|
AUTH_URL / NEXTAUTH_URL | Nothing breaks. auth.ts sets trustHost: true, so NextAuth infers the host from the request; every other read is a fallback chain ending in http://localhost:3000. NEXTAUTH_URL is checked first where both are read |
PUSHER_APP_ID, PUSHER_KEY, PUSHER_SECRET, PUSHER_CLUSTER, NEXT_PUBLIC_PUSHER_KEY, NEXT_PUBLIC_PUSHER_CLUSTER | Realtime dashboard updates are disabled; the Pusher client is null unless the set is complete |
VOYAGE_API_KEY | Semantic embedding and reranking are unavailable — knowledge retrieval degrades to a lexical-only fallback with no error |
GITHUB_APP_ID, GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY (or GITHUB_APP_PRIVATE_KEY_BASE64) | The whole GitHub integration is off — isGitHubAppConfigured() is false, so no PR creation, no merges, no repository_dispatch |
GITHUB_APP_CLIENT_SECRET | GitHub sign-in fails. There is no AUTH_GITHUB_ID/AUTH_GITHUB_SECRET — the GitHub App's OAuth credentials double as the login provider's |
GITHUB_APP_WEBHOOK_SECRET | Fails open. verifyWebhookSignature() logs a warning and returns true, so any unauthenticated caller can forge a GitHub webhook. Set it |
GITHUB_APP_SLUG | The install URL defaults to the buildd app slug — wrong for your own app |
SLACK_SIGNING_SECRET | Fails closed — the Slack integration route rejects every request |
DISCORD_PUBLIC_KEY | Fails closed — the Discord integration route rejects every request |
ANTHROPIC_API_KEY | Optional for the web app; workers supply their own credentials. Only the task classifier throws without it |
STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY, STORAGE_PUBLIC_URL | File-artifact upload and download are disabled (isStorageConfigured() gates them). STORAGE_REGION defaults to auto |
BUILDD_SIGNING_KEY_TEAM_ID | JWKS key storage and rotation throw. Only needed if you use signed tokens |
ALLOWED_EMAILS | Sign-in is open to any email that completes OAuth |
Two footguns in the private-key handling. GITHUB_APP_PRIVATE_KEY is read with
.replace(/\\n/g, '\n'), so it must contain literal backslash-n
sequences, not real newlines — GITHUB_APP_PRIVATE_KEY_BASE64 avoids the
question and is preferred. And ENCRYPTION_KEY is only length-checked
(< 32 throws): a same-length corruption passes the check and then decrypts
nothing, which surfaces as "no credential stored" rather than an error.
packages/core/config.ts defines its required() helper as
process.env[key] || '' so that Vercel builds don't fail. A missing value
becomes an empty string, not an error — do not rely on startup to tell you
what you forgot.
Troubleshooting
Migrations won't run
# Check database connection
psql $DATABASE_URL -c "SELECT 1"
# Run migrations manually
cd packages/core
bun run db:migrateSchedules not triggering
- Check
CRON_SECRETis set correctly - Verify cron job is running:
curl -H "Authorization: Bearer SECRET" URL - Ensure schedule is
enabled=true
Security Checklist
- Use strong
AUTH_SECRET,ENCRYPTION_KEYandCRON_SECRET(32+ chars) - Set
GITHUB_APP_WEBHOOK_SECRET— webhook signature verification is skipped without it - Back up
ENCRYPTION_KEYoutside the deployment; losing it means every stored secret is unrecoverable - Enable SSL/TLS with valid certificate
- Use firewall to restrict database access
- Keep dependencies updated:
bun update - Monitor logs for unauthorized access attempts