Operations
Deployment Guide
This project ships as a self-contained Docker Compose stack — the same one this API was built and
tested against. This guide covers running it locally, what belongs in .env,
and what to change for a production host.
The stack
docker-compose.yml defines eight services, all built from the
same PHP image (docker/php/Dockerfile):
migrate
One-shot job: runs php artisan migrate --force, then exits. Every other service waits on it finishing successfully before starting — this is what makes a bare docker compose up produce a fully migrated database with no manual step.
app
PHP-FPM process running the Laravel application code.
nginx
Reverse proxy in front of app, exposed on the host at APP_PORT (default 8000). This is the only service you actually need to reach from outside the Docker network.
horizon
Runs php artisan horizon — supervises the Redis-backed queue workers (payments, notifications, webhooks, calendar, outbox, default).
scheduler
Runs php artisan schedule:work — fires the scheduled commands (expire holds/pending bookings, send reminders, refresh calendar busy periods, cleanup idempotency keys) on their configured cadence.
outbox
Runs php artisan outbox:relay — polls the transactional outbox table and dispatches queued domain events (this is what turns a DB write into a webhook delivery, notification, etc).
pgsql
PostgreSQL 16. Data persists in the pgsql-data named volume.
redis
Redis 7 — backs the cache, session, queue connections, and the distributed locks the booking/hold concurrency logic relies on. Data persists in redis-data.
Quick start (local / self-hosted)
The container entrypoint (docker/php/entrypoint.sh) installs
Composer dependencies, copies .env.example to
.env and generates APP_KEY
automatically the first time it runs — there is no manual setup step for a first boot.
git clone https://github.com/incatswetrust/booking-engine.git cd booking-engine docker compose up --build
Once the stack is healthy, the API is reachable at http://localhost:8000
(or whatever APP_PORT you set). Verify with:
curl http://localhost:8000/health
If you want real values before the first boot (e.g. to skip re-entering Stripe/Google credentials later),
copy .env.example to .env
yourself first and edit it — the entrypoint only creates it when it's missing, it never overwrites an existing one.
Environment variables
Everything below lives in .env.example. Groups marked
required for production must have real values before you expose the stack publicly;
everything else has a safe local default.
Application
required for production| Variable | Default | Notes |
|---|---|---|
| APP_NAME | "Booking Engine" | Used in mail templates, Swagger UI title, OpenTelemetry service name. |
| APP_ENV | local | Set to production on a real deployment — changes error verbosity and a few framework optimizations. |
| APP_KEY | auto-generated | Laravel's encryption key. Auto-generated on first boot; never rotate it without a migration plan — anything encrypted (webhook secrets, calendar OAuth tokens) becomes unreadable. |
| APP_DEBUG | true | Must be false in production — a true value leaks stack traces in error responses. |
| APP_URL | http://localhost:8000 | Public base URL. Used to build the Google OAuth redirect URI and the Swagger UI server entry — set this to your real domain in production. |
Database (PostgreSQL)
required for production| Variable | Default | Notes |
|---|---|---|
| DB_CONNECTION | pgsql | Only pgsql is tested — the schema uses PostgreSQL-specific features (GiST exclusion constraints for booking overlap/capacity). |
| DB_HOST / DB_PORT | pgsql / 5432 | Points at the pgsql service by its Compose service name. For a managed/external Postgres, point this at that host instead. |
| DB_DATABASE / DB_USERNAME / DB_PASSWORD | booking_engine / booking_engine / secret | Change the password before any non-local deployment. |
| FORWARD_DB_PORT | 5432 | Which host port pgsql's 5432 is published on — only matters if you need to reach Postgres directly from the host. |
Redis (cache, session, queue, locks)
required for production| Variable | Default | Notes |
|---|---|---|
| REDIS_HOST / REDIS_PORT | redis / 6379 | Points at the redis service. Every queue worker (Horizon), the availability cache, and the per-resource booking locks depend on this being reachable from every container. |
| REDIS_PASSWORD | null | Set a real password if Redis is ever reachable outside the Docker network. |
| CACHE_STORE / SESSION_DRIVER / QUEUE_CONNECTION | redis / redis / redis | All three must point at Redis — the availability cache, distributed locks, and Horizon queues assume this. |
| Variable | Default | Notes |
|---|---|---|
| MAIL_MAILER | log | Defaults to writing emails to the log instead of sending them — safe for local dev. |
| MAIL_HOST / MAIL_PORT / MAIL_USERNAME / MAIL_PASSWORD | — | Set these to a real SMTP provider (Postmark, SES, Resend, etc.) to actually deliver booking confirmation/reminder emails. |
| MAIL_FROM_ADDRESS / MAIL_FROM_NAME | hello@example.com / "${APP_NAME}" | From header on outgoing mail. |
Stripe (payments)
| Variable | Default | Notes |
|---|---|---|
| STRIPE_SECRET | — | Secret key from dashboard.stripe.com/test/apikeys (or live keys in production). Required for any payment_mode other than "none". |
| STRIPE_WEBHOOK_SECRET | — | Signing secret for the webhook endpoint at POST /api/v1/webhooks/stripe — get it from `stripe listen --forward-to <host>/api/v1/webhooks/stripe` locally, or the Stripe Dashboard's webhook config in production. |
Telegram notifications
| Variable | Default | Notes |
|---|---|---|
| TELEGRAM_BOT_TOKEN | — | Bot token from @BotFather. Only needed if you want booking notifications delivered over Telegram in addition to email. |
Google Calendar integration
| Variable | Default | Notes |
|---|---|---|
| GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | — | OAuth2 client from console.cloud.google.com/apis/credentials with the Calendar API enabled. |
| GOOGLE_CALENDAR_REDIRECT_URI | "${APP_URL}/api/v1/calendar-connections/callback" | Must exactly match a redirect URI registered on the OAuth client — update APP_URL first and this follows automatically. |
Observability
| Variable | Default | Notes |
|---|---|---|
| SENTRY_LARAVEL_DSN | (empty) | Empty disables Sentry entirely. Set a real DSN to get error tracking. |
| OTEL_SDK_DISABLED | true | OpenTelemetry is off by default. Set to false and point OTEL_EXPORTER_OTLP_ENDPOINT at a real collector to get traces/metrics/logs (HTTP duration, booking rate, webhook latency, queue size, etc). |
API documentation (L5-Swagger)
| Variable | Default | Notes |
|---|---|---|
| L5_SWAGGER_CONST_HOST | "${APP_URL}" | Server URL baked into the generated OpenAPI spec at /docs-assets. |
| L5_SWAGGER_GENERATE_ALWAYS | true | Regenerates the spec on every request in dev. Consider false in production and running `php artisan l5-swagger:generate` in your deploy step instead, to avoid the generation cost on every /docs hit. |
Going to production
APP_ENV=production,APP_DEBUG=false, and a real, never-committedAPP_KEY.- Put a TLS-terminating reverse proxy (or a managed load balancer) in front of the
nginxservice — it only serves plain HTTP itself. - Back up the
pgsql-datavolume (or pointDB_HOSTat a managed Postgres with its own backups) — it's the only service holding durable state besides Redis. - Redis persistence matters too: the transactional outbox depends on jobs actually running, and losing in-flight queue state mid-deploy can delay (not lose — outbox rows are the source of truth) webhook/notification delivery.
- Scale
horizonandapp/nginxindependently — Horizon's supervisor config inconfig/horizon.phpalready balances workers across thedefault/outbox/payments/notifications/webhooks/calendarqueues. - Point your orchestrator's health/readiness probes at
GET /health/live(process is up) andGET /health/ready(checks Postgres + Redis connectivity).