Day 61: Dockerizing a Laravel app: practical tips and trade-offs
Moving a Laravel application into containers isn't just about running docker build and calling it done. We need to balance image size, security, startup performance, and the operational complexity of managing multiple container orchestration strategies. This article walks through the most common production patterns we've deployed across e-commerce and SaaS systems, including the trade-offs that matter when you're responsible for keeping these applications running.
Why containerize Laravel in the first place?
Before diving into the technical details, let's ground ourselves in why we containerize. Containers give us reproducibility: the same image runs identically on a developer's laptop, in staging, and in production. They simplify dependency management—PHP extensions, system libraries, and application code all bundled together. And they make horizontal scaling straightforward: spin up more container instances as load increases. For teams managing multiple Laravel services, this consistency eliminates the "but it works on my machine" problem.
PHP-FPM image: Installing required extensions safely
PHP-FPM is the de facto standard for running PHP in containerized environments. It separates the PHP process manager from the web server, which gives us flexibility and resilience. When building a PHP-FPM image, the first decision is choosing a base. Official PHP images come in several flavors: php:8.2-fpm-alpine for minimal footprint, or php:8.2-fpm-debian for broader compatibility with system packages.
Alpine-based images are tempting because they're small—often under 150 MB compared to 400+ MB for Debian variants. But they come with trade-offs. Alpine uses musl instead of glibc, which can cause unexpected behavior with certain PHP extensions or native binaries. For production Laravel apps, we typically recommend Debian-based images unless image size is a hard constraint.
Here's a typical starting point for a production Dockerfile:
FROM php:8.2-fpm-debian
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpq-dev \
libonig-dev \
libxml2-dev \
zip \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install \
pdo_mysql \
pdo_pgsql \
mbstring \
exif \
pcntl \
bcmath
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy application
WORKDIR /var/www/html
COPY . .
# Install dependencies
RUN composer install --no-dev --optimize-autoloader
# Set permissions
RUN chown -R www-data:www-data /var/www/html
EXPOSE 9000
CMD ["php-fpm"]Notice a few things here. We install extensions using docker-php-ext-install, which handles compilation and configuration automatically. For extensions with external dependencies (like pdo_pgsql), we install system libraries first. We also use --no-dev with Composer to exclude test dependencies, which reduces the final image size and attack surface.
The EXPOSE 9000 line documents that FPM listens on port 9000, but doesn't actually publish it. That's intentional: FPM shouldn't be exposed directly to the internet. We'll put a web server in front of it.
Nginx in front: Serving static files and proxying to FPM
Nginx is our web server of choice for Laravel deployments. It's lightweight, handles static assets efficiently, and proxies requests to PHP-FPM with minimal overhead. Rather than running Nginx and PHP-FPM in the same container (which complicates process management and makes scaling awkward), we run them in separate containers that communicate over a network.
Here's a production-grade Nginx configuration:
upstream php_fpm {
server fpm:9000;
}
server {
listen 80;
server_name _;
root /var/www/html/public;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Static files (cache aggressively)
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Deny access to sensitive files
location ~ /\. {
deny all;
}
location ~ /config/ {
deny all;
}
# Laravel routing
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM proxy
location ~ \.php$ {
fastcgi_pass php_fpm;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
This configuration handles several critical tasks. It serves static assets with far-future cache headers (30 days), reducing load on FPM. It denies access to dot-files and configuration directories, hardening the attack surface. It uses Laravel's standard routing pattern (try_files) to pass all requests through index.php. And it proxies PHP requests to the FPM container via the fpm:9000 hostname, which Docker's internal DNS resolves automatically.
The Nginx Dockerfile is simpler:
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=php-builder /var/www/html /var/www/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]We use daemon off so Nginx runs in the foreground, which is essential for Docker—containers need a foreground process. The --from=php-builder syntax copies files from the PHP image we built earlier, avoiding duplication.
Separate containers for web, queue, and scheduler
One of the biggest architectural wins in containerization is treating each workload independently. A web request handler has different resource requirements than a long-running queue worker or a scheduled task runner. Running them in separate containers lets us scale and resource-limit them individually.
A typical Laravel application needs three persistent processes:
- Web container: Handles HTTP requests via Nginx + PHP-FPM
- Queue worker: Processes background jobs from Redis or database
- Scheduler: Runs scheduled commands at specified intervals
Here's a docker-compose setup that reflects this separation:
version: '3.9'
services:
web:
build:
context: .
dockerfile: Dockerfile.web
ports:
- "80:80"
depends_on:
- fpm
networks:
- app-network
fpm:
build:
context: .
dockerfile: Dockerfile.fpm
expose:
- 9000
environment:
APP_ENV: production
LOG_CHANNEL: stdout
depends_on:
- postgres
- redis
networks:
- app-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/ping || exit 1"]
interval: 30s
timeout: 10s
retries: 3
queue:
build:
context: .
dockerfile: Dockerfile.fpm
command: php artisan queue:work --queue=default --timeout=60
environment:
APP_ENV: production
LOG_CHANNEL: stdout
depends_on:
- redis
networks:
- app-network
restart: unless-stopped
scheduler:
build:
context: .
dockerfile: Dockerfile.fpm
command: php artisan schedule:work
environment:
APP_ENV: production
LOG_CHANNEL: stdout
depends_on:
- postgres
- redis
networks:
- app-network
restart: unless-stopped
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: laravel
POSTGRES_USER: laravel
POSTGRES_PASSWORD: secret
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
redis:
image: redis:7-alpine
networks:
- app-network
volumes:
postgres_data:
networks:
app-network:
driver: bridge
Notice that queue and scheduler containers use the same Dockerfile.fpm image but override the command. This is efficient: one image, multiple entry points. The health check on the FPM service ensures Nginx knows when the upstream is unhealthy and can serve appropriate error responses. We also set restart: unless-stopped on worker containers so they automatically recover from crashes.
In Kubernetes, this separation becomes even more powerful. You define separate Deployments for web (with 3+ replicas) and workers (with appropriate concurrency), and scale them independently based on load metrics.
FrankenPHP: A modern single-container alternative
FrankenPHP is a relatively new approach that bundles Caddy (a modern HTTP server) with PHP in a single container. Instead of PHP-FPM and a separate web server, FrankenPHP handles both with built-in HTTP/2, automatic HTTPS, and request handling directly in PHP.
The Dockerfile is remarkably simple:
FROM dunglas/frankenphp:latest
WORKDIR /app
COPY composer.* ./
RUN composer install --no-dev --optimize-autoloader
COPY . .
RUN chown -R www-data:www-data /app
EXPOSE 80 443
CMD ["frankenphp", "run", "--bind", "0.0.0.0:80"]FrankenPHP automatically detects Laravel and handles routing. There's no separate Nginx configuration to maintain. Static files are served directly. For simple deployments or when operational simplicity is paramount, FrankenPHP is attractive.
However, there are trade-offs. FrankenPHP is newer than the established Nginx + PHP-FPM pattern, so community support and production battle-testing are still evolving. You lose the architectural flexibility of separating concerns: you can't easily run different numbers of web and worker replicas. If the FrankenPHP process crashes, both your web server and PHP runtime go down. And for very high-traffic applications where Nginx's efficiency at serving static files matters, the separation still has operational value.
We'd recommend FrankenPHP for:
- Startups and early-stage products where deployment simplicity wins
- Internal tools and admin panels with modest traffic
- Development and staging environments
And stick with Nginx + PHP-FPM for:
- High-traffic production systems handling thousands of RPS
- Applications with complex caching and static asset strategies
- Teams comfortable with multi-container architectures
Common mistakes we see in production
Running multiple processes in one container: Some teams add supervisord or use Nginx and PHP-FPM in the same Dockerfile. Docker containers should run a single process (or a tightly coupled pair). Multiple processes complicate logging, health checks, and scaling. Use separate containers.
Not setting resource limits: Without explicit memory and CPU limits in docker-compose or Kubernetes manifests, a runaway PHP process can starve other services. Always define mem_limit and cpus constraints.
Logging to files inside the container: Container filesystems are ephemeral. When the container dies, logs vanish. Configure Laravel to log to stdout (set LOG_CHANNEL=stdout in your environment) so Docker's log driver captures everything.
Including vendor directory in the build: Some teams COPY the entire project including vendor/ and node_modules. This bloats the image. Always run dependency installation inside the Dockerfile and use multi-stage builds to separate build tools from runtime.
Forgetting health checks: Orchestrators like Docker Swarm and Kubernetes need to know when your container is healthy. Add a health check endpoint or use TCP probes so the platform can restart unhealthy instances.
Key takeaways
- PHP-FPM with Nginx is the production workhorse: proven, flexible, and battle-tested across thousands of deployments
- Separate web, queue, and scheduler into distinct containers for independent scaling and resource management
- Use multi-stage Dockerfiles to keep images lean: compile dependencies, then copy only what's needed to the runtime stage
- Always log to stdout and configure health checks so orchestrators can manage your containers reliably
- FrankenPHP offers simplicity for smaller deployments but sacrifices architectural flexibility; choose based on your team's scale and expertise
- Set resource limits and configure restart policies so containers can recover from transient failures automatically






Comments
Loading comments…