Compose production
The production Compose file runs a single published image against a remote libSQL database — Bunny Database, Turso, or your own sqld. This is the self-hosted prod recipe (VPS or your own k8s); Magic Containers runs the HTTP app + installer only, not these daemons (ADR 0009). There is no second build; the same image serves dev and prod. The difference is the env file and the compose file, not the binaries inside the container.
Services
docker-compose.prod.yml defines these services, all derived from one hardened image (read_only: true, non-root uid 1000, no-new-privileges):
| Service | Role |
|---|---|
migrate |
One-shot. Waits for the remote DB to accept connections, runs php artisan migrate --force, then exits. restart: "no". |
app |
FrankenPHP serving HTTP on :8080. Starts only after migrate completes successfully. |
queue |
php artisan queue:work --tries=3 --timeout=60. Same dependency on migrate. |
scheduler |
php artisan schedule:work. Same dependency on migrate. |
reverb |
php artisan reverb:start on :8081. Default fan-out is in-memory. |
app, queue, scheduler, and reverb all share the image and wait on migrate: service_completed_successfully, so schema is current before any of them serve traffic.
Run against a remote libSQL
cp .env.docker.prod.example .env.docker.prod
# set APP_KEY, IMAGE_NAME, DB_URL, DB_AUTH_TOKEN
docker compose -f docker-compose.prod.yml --env-file .env.docker.prod up -d
DB_URL is the libSQL endpoint (libsql://[id].lite.bunnydb.net for Bunny Database, the Turso URL, or your own sqld address). DB_AUTH_TOKEN is the access token the managed provider issues. With both set, the migrate sidecar connects, runs migrations once, and the long-running services follow.
Optional profiles
Two profiles ship disabled by default and opt in with a flag:
--profile bundled-db— starts a localghcr.io/tursodatabase/libsql-servercontainer. Use it to test the prod image on your own machine without a managed DB; leaveDB_URLempty and compose defaults it tohttp://db:8080.--profile redis— startsredis:7-alpine. Needed only when you setREVERB_SCALING_ENABLED=trueor run more than one app replica; Laravel has no database persister for Reverb fan-out, so horizontal scale-out requires Redis pub/sub. Leave it off to keep the in-memory server.
One-shot migrate
The migrate service polls DB_URL until the host accepts a TCP/TLS connection, then runs php artisan migrate --force and exits. Because app, queue, scheduler, and reverb declare depends_on: migrate: condition: service_completed_successfully, a failed migration blocks the rest of the stack from starting — the app never serves against a half-migrated schema.
On Magic Containers prefer the web installer (/install) over the compose sidecar: set INSTALLER_ENABLED=true and explicit DB_URL / DB_AUTH_TOKEN, run migrations through the UI, then flip INSTALLER_ENABLED=false and redeploy.