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.

Mail

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-committed APP_KEY.
  • Put a TLS-terminating reverse proxy (or a managed load balancer) in front of the nginx service — it only serves plain HTTP itself.
  • Back up the pgsql-data volume (or point DB_HOST at 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 horizon and app/nginx independently — Horizon's supervisor config in config/horizon.php already balances workers across the default/outbox/payments/notifications/webhooks/calendar queues.
  • Point your orchestrator's health/readiness probes at GET /health/live (process is up) and GET /health/ready (checks Postgres + Redis connectivity).