Dockerizing Laravel for Consistent Deploys
"Works on my machine" dies when staging runs PHP 8.2 and production runs 8.1 with different extensions. Docker gives you reproducible builds—if your Dockerfile is not copying vendor/ from your laptop and calling it done.
Production Dockerfile
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache nginx supervisor mysql-client \
&& docker-php-ext-install pdo_mysql opcache pcntl
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
COPY . .
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache
FROM base AS production
RUN chown -R www-data:www-data storage bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]
Compose for Local Dev
# docker-compose.yml excerpt
services:
app:
build: .
volumes:
- .:/var/www/html
depends_on:
- mysql
- redis
queue:
build: .
command: php artisan queue:work --sleep=3 --tries=3
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: laravel
MYSQL_ROOT_PASSWORD: secret
redis:
image: redis:7-alpine
Deployment Tips
- Multi-stage builds: separate dev dependencies from production image
- Run migrations in init container or deploy hook, not on every pod start racing
- Store
.envin secrets—inject at runtime, never bake into image - Health check endpoint:
/healthhitting DB and Redis
Image size matters for pull times—Alpine-based images often land under 200MB vs 800MB Ubuntu monsters.
CI Image Builds
Build images in GitHub Actions, push to ECR or GHCR, deploy by tag—not git pull on server. Immutable artifacts mean rollback is retagging previous image digest, not guessing which commit was stable.
Scan images with Trivy or Grype in CI. Base image CVEs patch weekly; rebuild pipeline should be automated on schedule, not panic-driven after a blog post about Log4Shell-style headlines.
Document local dev onboarding: docker compose up should get new hires running in under 15 minutes. Mount source for hot reload in dev; never mount production secrets into dev containers shared on Slack screenshots.
Pin base image digests in production Dockerfile, not floating tags like php:8.3-fpm-alpine latest—surprise breaking upstream changes on rebuild Friday afternoon are avoidable. Document why each installed extension exists; orphaned extensions widen attack surface and image size.
Runtime Configuration
PHP-FPM pm.max_children must match container memory limit—each child consumes 40–80MB typical Laravel app. Formula: available memory divided by average child RSS with 20% headroom. OOM kills during traffic spike mean pm settings too aggressive for Kubernetes memory limits.
Run scheduler as separate container or Kubernetes CronJob, not inside web container cron—double scheduling causes duplicate invoice emails and angry customers every month boundary.
Use .dockerignore to exclude node_modules and .git from build context. Order Dockerfile layers so composer install caches when only application code changes—context upload and rebuild time drops dramatically on large repos when layer order is correct.
Local Parity
Match PHP extensions and versions exactly between dev containers and production image—missing ext-intl in prod only manifests when formatting dates in French during demo. Compose override files for Xdebug stay out of production builds via separate compose profiles developers enable explicitly.
Run php artisan optimize in build stage, not at container start—faster cold starts on autoscaling events. Verify storage and bootstrap/cache permissions in entrypoint script idempotently; Kubernetes pods restarting should not require manual chmod on NFS mounts.
Scan images in registry for CVEs on schedule; base image updates are cheaper than emergency fire drills when compliance audit finds critical OpenSSL vulnerability two days before deadline.
Document docker compose commands in README for day-one onboarding—standard commands reduce Slack questions and eliminate PHP version mismatches across macOS and Windows machines before containers unify the environment.
Local vs Production Parity
Match PHP extensions and php.ini settings between Docker and production—a missing intl or redis extension causes deploy surprises that pass CI because nobody runs the prod image locally. Pin base image digests in production Dockerfiles so upstream tag moves do not change behavior silently.
Frequently Asked Questions
Docker vs Laravel Sail?
Sail is excellent for local dev. Production usually needs custom Dockerfile tuned for opcache, nginx, and queue workers.
One container or many?
Separate web, queue worker, and scheduler containers. Scale workers independently from HTTP traffic.
How do we handle storage/uploads?
Mount S3 via Flysystem—do not persist uploads in container filesystem.