Self-hosting Bookly
Bookly is one container plus Postgres. Optional: a mail provider, and a bucket for uploads (otherwise they live on a volume).
1. Docker Compose (recommended)#
git clone https://github.com/AhmadHakroosh/bookly.git && cd bookly
cp .env.example .env
Edit .env:
| Variable | Set to |
|---|---|
APP_URL |
The public URL, e.g. https://bookly.example.com |
AUTH_SECRET |
openssl rand -base64 32 |
EMAIL_DRIVER, EMAIL_FROM + provider keys |
resend or smtp; without email, sign-in links and notifications only print to the container log |
RECALL_API_KEY, RECALL_WEBHOOK_SECRET |
Optional: auto-capture on Google Meet, Teams and Zoom through a notetaker bot (docs/integrations.md) |
Then:
docker compose --profile app up -d # app + Postgres
docker compose --profile app --profile tls up -d # …plus Caddy with automatic HTTPS
With the tls profile, point your domain's A/AAAA record at the server and set ACME_EMAIL in .env. Caddy obtains certificates for APP_URL and for every custom domain you verify in the admin (on-demand TLS asks the app at /api/domains/check).
Open APP_URL: the first visit shows the setup wizard, which creates your account and your workspace. Sign-up is closed afterwards; add people through invitations.
To use a prebuilt image instead of building locally, replace build: . in docker-compose.yml with image: ghcr.io/ahmadhakroosh/bookly:latest (or a version tag).
2. Vercel + managed services#
The same code runs on Vercel:
- Root Directory
apps/web, Node 24. - Postgres: any provider (Neon works well).
DATABASE_URL= pooled connection string. - Only production builds:
apps/web/vercel.jsonsets anignoreCommandthat skips preview deployments, because a preview without its own database, auth secret and job worker cannot run. To get previews, give the Preview environment the same variables as Production (ideally against a database branch) and remove theignoreCommand. - Set
JOBS_WORKER=falseand configure QStash (QSTASH_TOKENand the two signing keys) so reminders, retries, transcripts and schedules run through it; see docs/cloud.md. Without QStash,CRON_SECRETplusapps/web/vercel.json(daily on Hobby) or the GitHub Actions tick is the fallback. - Run migrations from your machine:
DATABASE_URL='…' pnpm db:migrate.
3. Updating#
git pull
docker compose --profile app build app
docker compose --profile app up -d
The container applies pending migrations on start (a bundled migrate.cjs runs before the server), and migrations are always additive within a minor version. Read CHANGELOG.md before major versions.
4. Backups#
Everything lives in two places: the Postgres database and the uploads (the local volume or your bucket).
docker compose exec postgres pg_dump -U bookly bookly | gzip > bookly-$(date +%F).sql.gz
Restore with gunzip -c file.sql.gz | docker compose exec -T postgres psql -U bookly bookly.
5. Operations#
- Logs:
docker compose logs -f app. Email inconsolemode prints here. - Background jobs: the container runs a pg-boss worker (reminders, retries) using the same database; nothing else to deploy. Set
JOBS_WORKER=falseonly on serverless. - Scaling: the app is stateless; run several replicas behind a load balancer with one Postgres.
- Health:
GET /api/domains/check?host=<APP_URL host>returns 200 when the app and database are up.
6. Environment reference#
See .env.example; every variable is documented inline and validated at startup by packages/config.
Uploads#
Profile photos and workspace logos can be uploaded (Admin → Booking page, Admin → Settings →
Branding) or linked by URL as before. Uploads are resized once on the server (512×512 photos,
logos up to 1024 px, metadata stripped), recorded in the files table and served at
/files/<id> with a year-long cache. Where the bytes live is STORAGE_DRIVER:
local(default): a directory,STORAGE_LOCAL_DIR(./data/uploads; the compose stack mounts theuploads-datavolume at/app/data/uploads). Back it up with the database.s3: any S3-compatible bucket: Cloudflare R2, AWS S3, MinIO, Backblaze. SetS3_BUCKET,S3_ENDPOINT,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY(S3_REGIONisautofor R2,S3_FORCE_PATH_STYLE=truefor R2 and MinIO). Uploads go straight to the bucket with a presigned PUT URL; downloads of private files are streamed by the app from/files/<id>, which checks the viewer on every request, so no bucket URL ever reaches a browser.
Self-hosted installs have no storage quota. Every upload and deletion is in the activity log.
Desktop notifications (Web Push)#
The bell and browser notifications in an open tab work out of the box. For notifications with every Bookly tab closed (and in the installed app), give the install a VAPID key pair once:
npx web-push generate-vapid-keys
Set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and VAPID_SUBJECT (mailto:you@example.com) and
restart. Keep the pair: new keys invalidate every browser's subscription. Browsers need HTTPS
(localhost is exempt). See notifications.md.
Guest replies (optional)#
To have guests' replies land on the contact timeline instead of the host's inbox, give the
install an inbound subdomain on Resend: add receiving for e.g. reply.example.com in Resend
and its MX record in your DNS, create a webhook for email.received to
https://<your host>/api/webhooks/resend, and set INBOUND_EMAIL_DOMAIN and
RESEND_WEBHOOK_SECRET (with RESEND_API_KEY). Details in
contacts.md.
Export and deletion#
Admin → Settings → Data lets the workspace owner download everything as one JSON file (workspace, members, event types, availability, bookings, contacts and timeline, tasks, routing forms, transcripts, recaps; tokens and keys excluded) and delete the workspace permanently. Deletion removes the owning organization, which cascades through every table; on a single-tenant install the app returns to the setup wizard. Users delete their own account from Admin → Account (the menu under their email) once they own no workspace. Neither action touches your backups, so keep their retention in mind when someone asks for erasure.
Accounts and passwords#
Sign-in works with a password or an emailed link. On the hosted service a new account confirms its email address before the password can be used (the sign-up form sends the link); on a self-hosted install the first owner and every invited member count as verified, because the invitation itself went to their address. Better Auth removes the password of an unverified account the first time that person signs in with an emailed link, so keep it that way. A forgotten password is reset from "Forgot your password?" on the sign-in page: Bookly emails a link (valid for one hour) to /reset-password, so email must be configured (RESEND_API_KEY or SMTP; in development links print to the console). Signed-in users change their password under Admin → Booking page; changing it signs out every other session.
Sign in with Google, Microsoft or GitHub#
Optional. Each provider appears as a "Continue with …" button on the sign-in, sign-up and invitation pages as soon as its OAuth client is configured; nothing changes for installs without one.
| Provider | Env | Callback URL to register |
|---|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
<APP_URL>/api/auth/callback/google |
|
| Microsoft | MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, MICROSOFT_TENANT |
<APP_URL>/api/auth/callback/microsoft |
| GitHub | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
<APP_URL>/api/auth/callback/github |
Google and Microsoft reuse the OAuth clients from docs/integrations.md: add the callback URL above next to the calendar one. Sign-in asks the provider for identity only (name, email, picture); calendar access stays a separate connection under Admin → Calendars, with its own scopes. GitHub needs an OAuth app (Settings → Developer settings → OAuth Apps) with the callback URL above.
Rules that apply on every install:
- A provider button never creates an account on its own. New accounts come from the sign-up page (cloud) or an invitation link (self-hosted, where sign-up is closed once the workspace exists), where the button is offered explicitly.
- Passkeys (WebAuthn) work out of the box: the relying-party id is the app's host (
APP_URL), or the root domain on a multi-tenant install so one passkey covers every workspace subdomain. Hosts add them under Admin → Booking page → Passkeys and sign in with "Sign in with a passkey" or the email field's autofill. HTTPS is required except onlocalhost. - Google and Microsoft sign-ins attach to an existing account with the same email address, provided that account's own email is verified (signing in once with an emailed link verifies it). Otherwise, or for GitHub, sign in with the password or an email link and connect the provider under Admin → Booking page → Sign-in methods.
- An account created through a provider can set a password there too; the last remaining sign-in method cannot be disconnected.
Rate limiting across replicas#
One container needs nothing: limits are counted in memory. If you run several replicas behind
a load balancer, set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN so booking-form,
API and sign-in limits are shared; otherwise each replica allows the full quota on its own.
Update check and telemetry#
Once a day (on the job tick) the install POSTs to TELEMETRY_URL and gets back the latest
released version. When it is newer than what you run, the admin shows a notice with a link to the
release notes.
What is sent, always: the app version, TENANCY, the Node version and a random install id
generated once and stored in platform_state. It is not derived from your hostname, IP, users or
anything else about you.
What is sent only if you opt in under Admin → Settings → General → Help improve Bookly: counts of workspaces, hosts, event types, bookings in the last 30 days and contacts, plus which integration providers are connected and whether video capture and payments are configured. Never names, emails, transcripts or content.
Set TELEMETRY=off to disable the check entirely; the admin then never learns about new
releases. GET /api/telemetry on the platform returns the latest version if you want to check by
hand.
Monitoring and error tracking#
- Errors: set
SENTRY_DSN(server) andNEXT_PUBLIC_SENTRY_DSN(browser) and Bookly reports exceptions from pages, API routes, the cron tick and the browser to Sentry. AddSENTRY_ORG,SENTRY_PROJECTandSENTRY_AUTH_TOKENat build time to upload source maps. Leave them unset and nothing is sent anywhere. - Metrics: set
METRICS_TOKENand scrapeGET /api/metrics(seedocs/cloud.md). - Security headers: every response carries a Content-Security-Policy limited to Bookly's own origin plus Daily, Stripe and Sentry,
X-Content-Type-Options,Referrer-Policy, aPermissions-Policythat grants camera and microphone only to the call page, and HSTS in production. Booking pages may be embedded on HTTPS sites; the admin and console sendX-Frame-Options: DENY. The origin ofS3_ENDPOINT(read at build time) is added toconnect-srcso browsers can upload to the bucket with presigned URLs; the bucket's CORS must allowPUTfrom your admin host./files/<id>responses carry their own headers (frame-ancestors 'self',nosniff,no-referrer) so the browser's PDF viewer works. If you serve avatars or uploads from another host, add it toimg-srcinapps/web/next.config.ts.