L LAB

Docker

The application ships a dockerised stack for both local development and production. The image is role-agnostic — every container runs the same binary, and the compose file is the single source of truth for what each container does.

Topology

ServiceImageRole
caddycaddy:2-alpineWeb server; auto-TLS in prod, plain HTTP in dev
appyour imagePHP-FPM on :9000
queuesamephp artisan queue:work
schedulersamephp artisan schedule:work (replaces cron)
dbpostgres:17-alpinePrimary datastore
redisredis:8-alpineCache, session, queue backend

Image layout

docker/Dockerfile is multi-stage:

StagePurpose
vendorcomposer install --no-dev for the prod vendor tree
assetsnpm run build to populate public/build
basePHP-FPM 8.4 + extensions + shared entrypoint
devAdds Xdebug (off by default), uses host bind mount
prodBakes app + vendor + assets, optimises autoload, runs as www-data

The shared entrypoint (docker/entrypoint/app.sh) does role-independent bootstrap, optionally caches config/routes and runs migrations (only when RUN_MIGRATIONS=1, which is pinned off for queue/scheduler so workers never race the schema), then execs the compose command.

Local development

cp .env.example .env
cp docker/.env.docker.example docker/.env.docker

make up        # build dev image, start the full stack
make migrate   # first time only

App at http://localhost:8080.

TargetAction
make up / make downStart / stop the stack
make shShell into the app container
make tinkerphp artisan tinker inside the container
make migrate / make migrate-freshRun migrations
make testPHPUnit inside the container
make pintPint formatter on dirty files

The dev container bind-mounts the project root, so host edits are picked up immediately. Set WWW_USER_ID/WWW_GROUP_ID to your host id -u/id -g to avoid file-ownership issues. Xdebug is opt-in: XDEBUG_MODE=debug,develop make restart.

Production

A multi-arch image can’t be loaded back into the local docker store, so:

TargetWhat it does
make buildMulti-arch build into buildx cache (CI validation)
make pushMulti-arch build + push — produces the deployable artifact
make build-localSingle-arch build loaded locally
APP_IMAGE=ghcr.io/your-org/laravel-api-boilerplate \
  APP_IMAGE_TAG=2026.05.14 make push

The server never builds — docker/docker-compose.prod.yml only references image:. Deploy loop:

git pull
make prod-pull   # docker compose pull
make prod-up     # recreate changed services

Auto-TLS

Caddy reads ${APP_DOMAIN} from docker/.env.prod and provisions a Let’s Encrypt certificate on first boot (point DNS at the server; open ports 80 + 443). Behind an existing TLS proxy? Replace Caddyfile.prod with a plain :80 block.

Migrations

app runs migrate --force on boot when RUN_MIGRATIONS=1 (the default). For zero-downtime, set RUN_MIGRATIONS=0 and run migrations as a one-shot before swapping containers.

Environment files

FileRead byHolds
.envLaravel + dev composeApp config, mail, API keys
docker/.env.dockerDev compose onlyUID/GID, ports, dev DB/Redis creds
docker/.env.prodProd compose + LaravelEverything + image coords, domain, ACME email

Only the *.example siblings are committed; the real files are .gitignored.