botttle
A self hosted client portal for freelancers to manage projects, clients, and payments in one place.
Freelancers and small studios are often stuck between heavyweight agency tools and rigid SaaS billing apps. botttle gives you your own client portal that you control, with a modern UI and a focus on day to day work.
-
All your work in one place
Projects, milestones, tasks, invoices, time tracking, comments, and file uploads are scoped per project and client. -
Clear, professional invoicing
Create project linked invoices with line items, tax and currency support, track payments and download polished PDFs you can send to clients. -
Client friendly portal
Invite clients to log in, see only their projects and invoices, and keep everyone aligned without endless email threads. -
Designed for self hosting
Keep your data where you want it, and customize the portal as your freelance practice grows.
botttle is in active development. The current build includes:
- Authentication, clients, projects, milestones, and tasks
- Invoicing with payments, PDF export, and Lemon Squeezy webhook hooks (checkout custom data
invoice_id) - Time tracking with billable flags, per-project CSV export, and reports (
/api/reports/summary,/api/reports/time) - Dashboard charts (Recharts): workload, revenue snapshot, time split and trend
- Project comments and file uploads (local disk; API ready for S3-style storage later)
- Client portal: draft invoices hidden from clients, read-only project milestones, optional Pay online link via
INVOICE_PAYMENT_LINK_TEMPLATE - Per-project time reports (admin): date range, billable vs non-billable, chart, CSV export (
/projects/:id/reports) - Admin audit log (who changed what): in-app
/audit-logs, APIGET /api/audit-logs
PostgreSQL is required. From the repo root:
docker compose up -d postgres
cp apps/api/.env.example apps/api/.env
# Set JWT_SECRET and REFRESH_SECRET in apps/api/.env, then:
cd packages/db && DATABASE_URL="postgresql://botttle:botttle@127.0.0.1:5432/botttle" bunx prisma migrate deploy
cd ../..
bun run dev:api # terminal 1
bun run dev:web # terminal 2Migrations on every environment: run prisma migrate deploy against the target database whenever you deploy (new migration = required before the new code runs). The Docker API image runs this automatically via docker/entrypoint-api.sh before starting the server. For local API only, you can set RUN_MIGRATIONS_ON_BOOT=true in apps/api/.env so bun run dev:api applies pending migrations first (optional).
If a user forgets their password and you do not yet have email reset, an operator can set a new bcrypt hash in PostgreSQL or use a one-off script against users.password_hash (same rounds as the API’s hashPassword).
| Feature | What to configure |
|---|---|
| Transactional email | REDIS_URL, RESEND_API_KEY, EMAIL_FROM, APP_PUBLIC_URL. Run bun run dev:worker (or the Docker worker service) to process the queue. |
| Lemon checkout links | Webhook: LEMONSQUEEZY_WEBHOOK_SECRET + dashboard webhook URL. Dynamic checkouts: LEMONSQUEEZY_API_KEY + LEMONSQUEEZY_DEFAULT_VARIANT_ID (or per-invoice variant in the UI). |
| S3 file uploads | FILE_STORAGE=s3, S3_BUCKET, AWS_REGION, credentials. Web keeps uploading via the API only. |
| Audit: PDF views | GET /invoices/:id/pdf records INVOICE_PDF_VIEWED. |
| Password reset emails | Same email config as transactional email. Enables /forgot-password → /reset-password flow. |
# Optional: export JWT_SECRET=... REFRESH_SECRET=... (defaults are insecure)
docker compose up --build- Web UI: http://localhost:8080 (Nginx proxies
/apito the API) - API: http://localhost:3001
- Postgres: localhost
5432(user/password/db:botttle) - Redis: localhost
6379— used by the email worker (BullMQ) whenREDIS_URLis set; optional if you do not use transactional email.
The API container runs bunx prisma migrate deploy in packages/db on startup so schema changes (including notifications and audit_logs) are applied before traffic hits the app.
Compose also defines a worker service (same API image, runs bun apps/api/dist/worker.js) for the email queue when REDIS_URL / Resend are set.
There is a separate apps/marketing site; upcoming work may include richer client-only UX and deeper analytics in the main app.
GET /health always returns a basic response. To enable deeper checks:
- Set
HEALTH_CHECK_DB=trueto validate the API can reach PostgreSQL. - Set
HEALTH_CHECK_REDIS=trueto validate Redis is configured (for the email worker).