Dockerizing and Orchestrating Legacy Laravel Systems on Modern Linode Infrastructure
Assessing Legacy Laravel Application Dependencies
Before embarking on the containerization journey for a legacy Laravel application, a thorough dependency audit is paramount. Legacy systems often carry implicit dependencies on specific OS packages, PHP extensions, and even subtle environmental variables that were assumed to be present. A failure to identify and replicate these in the Docker image will lead to runtime failures that are notoriously difficult to debug within a containerized environment.
Start by examining the application’s composer.json for its PHP version and required packages. Beyond that, scrutinize the server’s current PHP installation for enabled extensions. Common culprits for legacy apps include redis, memcached, imagick, gd, pdo_mysql, and intl. Additionally, check for system-level libraries required by these extensions, such as libpng-dev, libjpeg-dev, freetype-dev, and libzip-dev.
A practical approach is to run a script on the existing production server that lists all installed PHP extensions and their loaded configurations. This can be achieved with a simple PHP script:
<?php
echo "<h2>PHP Version</h2>";
echo "<p>" . phpversion() . "</p>";
echo "<h2>Loaded PHP Extensions</h2>";
echo "<ul>";
foreach (get_loaded_extensions() as $extension) {
echo "<li>" . $extension . "</li>";
}
echo "</ul>";
echo "<h2>PHP Info (relevant sections)</h2>";
phpinfo();
?>
Analyze the output of phpinfo(), paying close attention to the “Configure Command” and “extension_dir” to understand how PHP was compiled and where extensions are loaded from. This information is critical for constructing an accurate Dockerfile.
Crafting the Dockerfile for Legacy Laravel
The Dockerfile needs to be meticulously constructed to mirror the production environment. We’ll opt for an official PHP base image, specifying a version that matches the legacy application’s requirements. For this example, let’s assume PHP 7.4.
The following Dockerfile demonstrates how to install necessary OS packages, PHP extensions, and configure PHP. It also includes steps for setting up Composer and copying the application code.
# Use an official PHP runtime as a parent image
FROM php:7.4-fpm
# Set the working directory in the container
WORKDIR /var/www/html
# Install system dependencies for PHP extensions
# Adjust packages based on your audit (e.g., libzip-dev for zip extension)
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
freetype-dev \
libonig-dev \
libxml2-dev \
libssl-dev \
acl \
vim \
nano \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
# Add or remove extensions based on your audit
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_mysql zip intl opcache exif pcntl bcmath sockets \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& pecl install imagick \
&& docker-php-ext-enable imagick
# Install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Copy the application code
# Ensure you have a .dockerignore file to exclude unnecessary files (e.g., vendor, node_modules)
COPY . /var/www/html
# Install Composer dependencies
# Use --no-dev for production builds if you have a separate build stage
RUN composer install --no-interaction --prefer-dist --optimize-autoloader
# Set permissions for storage and bootstrap/cache directories
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
# Expose port 9000 for PHP-FPM
EXPOSE 9000
# Default command to run PHP-FPM
CMD ["php-fpm"]
A crucial companion to the Dockerfile is the .dockerignore file. This prevents unnecessary files from being copied into the image, reducing build times and image size. Essential entries include:
.git .gitignore .env vendor node_modules npm-debug.log yarn-error.log storage/logs/* storage/framework/sessions/* storage/framework/views/* storage/framework/cache/* bootstrap/cache/*
Containerizing the Database and Cache Services
Legacy Laravel applications typically rely on MySQL and often use Redis or Memcached for caching. These services can also be containerized, simplifying deployment and management. We’ll use docker-compose.yml to define these services.
This docker-compose.yml defines three services: app (our Laravel application), db (MySQL), and redis.
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: legacy_laravel_app
ports:
- "8000:80" # Map host port 8000 to container port 80 for Nginx/Apache
volumes:
- .:/var/www/html # Mount application code for development (remove for production builds)
depends_on:
- db
- redis
environment:
# Ensure these match your .env file or are set appropriately
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: legacy_db
DB_USERNAME: legacy_user
DB_PASSWORD: legacy_password
REDIS_HOST: redis
REDIS_PORT: 6379
APP_ENV: local # Or production
APP_DEBUG: true # Set to false for production
db:
image: mysql:8.0
container_name: legacy_laravel_db
restart: always
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: root_password_here # Change this!
MYSQL_DATABASE: legacy_db
MYSQL_USER: legacy_user
MYSQL_PASSWORD: legacy_password
redis:
image: redis:6.2
container_name: legacy_laravel_redis
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
db_data:
redis_data:
Important Considerations:
- The
volumesfor theappservice are commented out for production builds. For development, mounting the local code allows for live changes without rebuilding the image. For production, the code should be baked into the image during the build process. - Environment variables are crucial. Ensure the
.envfile within your application is correctly configured or that these variables are managed externally (e.g., via Kubernetes Secrets or Linode NodeBalancers). - Database and Redis data persistence is handled by Docker volumes. These should be managed and backed up appropriately.
Web Server Configuration (Nginx)
A separate container for a web server like Nginx is typically used to serve the Laravel application. This container will proxy requests to the PHP-FPM service running in the app container. We’ll define an Nginx service in our docker-compose.yml and create a corresponding Nginx configuration file.
First, create an Nginx configuration file (e.g., nginx/default.conf):
server {
listen 80;
server_name localhost; # Or your domain name
root /var/www/html/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
# Ensure this matches the PHP-FPM service name and port in docker-compose.yml
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
# Optional: Add caching headers for static assets
location ~* \.(css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public";
}
}
Now, update your docker-compose.yml to include the Nginx service:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: legacy_laravel_app
# No ports exposed here for app service, Nginx will handle external access
volumes:
- .:/var/www/html # Mount application code for development (remove for production builds)
depends_on:
- db
- redis
environment:
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: legacy_db
DB_USERNAME: legacy_user
DB_PASSWORD: legacy_password
REDIS_HOST: redis
REDIS_PORT: 6379
APP_ENV: local
APP_DEBUG: true
nginx:
image: nginx:stable-alpine
container_name: legacy_laravel_nginx
ports:
- "80:80" # Map host port 80 to container port 80
- "443:443" # For HTTPS
volumes:
- .:/var/www/html # Mount application code (for development)
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf # Mount Nginx config
# Add SSL certificates here for production
# - ./ssl/your_domain.crt:/etc/nginx/ssl/your_domain.crt
# - ./ssl/your_domain.key:/etc/nginx/ssl/your_domain.key
depends_on:
- app
environment:
# Nginx environment variables can be set here if needed
db:
image: mysql:8.0
container_name: legacy_laravel_db
restart: always
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: root_password_here
MYSQL_DATABASE: legacy_db
MYSQL_USER: legacy_user
MYSQL_PASSWORD: legacy_password
redis:
image: redis:6.2
container_name: legacy_laravel_redis
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
db_data:
redis_data:
With this setup, when you run docker-compose up -d, Nginx will listen on port 80, proxying requests to the app service’s PHP-FPM on port 9000.
Deployment to Linode Kubernetes Engine (LKE)
Orchestrating these containers on a cloud platform like Linode requires a Kubernetes cluster. We’ll outline the process of deploying our Dockerized Laravel application to Linode Kubernetes Engine (LKE).
Prerequisites:
- A running LKE cluster.
kubectlconfigured to communicate with your LKE cluster.- Docker images for your application, Nginx, MySQL, and Redis pushed to a container registry (e.g., Docker Hub, Linode Container Registry).
We’ll define Kubernetes manifests (YAML files) for each component.
Kubernetes Deployment for Application and Nginx
A Deployment object manages the application pods, and a Service object exposes them. For Nginx, we’ll use a Deployment and an Ingress resource to manage external access.
# app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-laravel-app
labels:
app: legacy-laravel
spec:
replicas: 2 # Adjust as needed for scalability
selector:
matchLabels:
app: legacy-laravel
template:
metadata:
labels:
app: legacy-laravel
spec:
containers:
- name: app
image: your-docker-registry/legacy-laravel-app:latest # Replace with your image
ports:
- containerPort: 9000
env:
- name: DB_HOST
value: "legacy-laravel-db-service" # Kubernetes service name for DB
- name: DB_PORT
value: "3306"
- name: DB_DATABASE
value: "legacy_db"
- name: DB_USERNAME
value: "legacy_user"
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: legacy-laravel-secrets
key: db-password
- name: REDIS_HOST
value: "legacy-laravel-redis-service" # Kubernetes service name for Redis
- name: REDIS_PORT
value: "6379"
- name: APP_ENV
value: "production"
- name: APP_DEBUG
value: "false"
volumeMounts:
- name: storage-volume
mountPath: /var/www/html/storage
- name: bootstrap-cache-volume
mountPath: /var/www/html/bootstrap/cache
volumes:
- name: storage-volume
persistentVolumeClaim:
claimName: legacy-laravel-storage-pvc
- name: bootstrap-cache-volume
persistentVolumeClaim:
claimName: legacy-laravel-bootstrap-cache-pvc
---
# app-service.yaml
apiVersion: v1
kind: Service
metadata:
name: legacy-laravel-app-service
spec:
selector:
app: legacy-laravel
ports:
- protocol: TCP
port: 9000
targetPort: 9000
type: ClusterIP # Internal service, accessed by Nginx
---
# nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-laravel-nginx
labels:
app: legacy-laravel-nginx
spec:
replicas: 2
selector:
matchLabels:
app: legacy-laravel-nginx
template:
metadata:
labels:
app: legacy-laravel-nginx
spec:
containers:
- name: nginx
image: your-docker-registry/legacy-laravel-nginx:latest # Replace with your image
ports:
- containerPort: 80
- containerPort: 443
volumeMounts:
- name: nginx-config-volume
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf # Mount specific file
# Add volume mounts for SSL certificates if using HTTPS
# - name: ssl-certs
# mountPath: /etc/nginx/ssl
volumes:
- name: nginx-config-volume
configMap:
name: legacy-laravel-nginx-config
# - name: ssl-certs
# secret:
# secretName: legacy-laravel-ssl-certs
---
# nginx-service.yaml
apiVersion: v1
kind: Service
metadata:
name: legacy-laravel-nginx-service
spec:
selector:
app: legacy-laravel-nginx
ports:
- protocol: TCP
port: 80
targetPort: 80
name: http
- protocol: TCP
port: 443
targetPort: 443
name: https
type: LoadBalancer # Expose Nginx externally via Linode Load Balancer
---
# nginx-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: legacy-laravel-nginx-config
data:
default.conf: |
server {
listen 80;
server_name your_domain.com; # Replace with your domain
root /var/www/html/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass legacy-laravel-app-service:9000; # Use the app service name
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
}
Kubernetes Deployment for Database and Redis
For stateful services like MySQL and Redis, StatefulSets are generally preferred over Deployments to ensure stable network identifiers and persistent storage. We’ll also define PersistentVolumeClaims (PVCs) for data persistence.
# db-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: legacy-laravel-db
spec:
serviceName: legacy-laravel-db-service # Headless service for stable network IDs
replicas: 1
selector:
matchLabels:
app: legacy-laravel-db
template:
metadata:
labels:
app: legacy-laravel-db
spec:
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
name: mysql
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: legacy-laravel-secrets
key: db-root-password
- name: MYSQL_DATABASE
value: "legacy_db"
- name: MYSQL_USER
value: "legacy_user"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: legacy-laravel-secrets
key: db-password
volumeMounts:
- name: db-data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: db-data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 10Gi # Adjust storage size as needed
---
# db-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: legacy-laravel-db-service
spec:
selector:
app: legacy-laravel-db
ports:
- protocol: TCP
port: 3306
targetPort: 3306
clusterIP: None # Headless service
---
# redis-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: legacy-laravel-redis
spec:
serviceName: legacy-laravel-redis-service
replicas: 1
selector:
matchLabels:
app: legacy-laravel-redis
template:
metadata:
labels:
app: legacy-laravel-redis
spec:
containers:
- name: redis
image: redis:6.2
ports:
- containerPort: 6379
name: redis
volumeMounts:
- name: redis-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: redis-data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi # Adjust storage size as needed
---
# redis-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: legacy-laravel-redis-service
spec:
selector:
app: legacy-laravel-redis
ports:
- protocol: TCP
port: 6379
targetPort: 6379
clusterIP: None # Headless service
Kubernetes Secrets
Sensitive information like database passwords should be stored in Kubernetes Secrets. Create a secret manifest:
# legacy-laravel-secrets.yaml apiVersion: v1 kind: Secret metadata: name: legacy-laravel-secrets type: Opaque data: db-root-password: YOUR_ROOT_PASSWORD_BASE64 # Base64 encoded db-password: YOUR_DB_PASSWORD_BASE64 # Base64 encoded
To encode your passwords, use the base64 command: echo -n 'your_password' | base64. Apply these manifests using kubectl apply -f <filename.yaml>.
Finalizing and Monitoring
After deploying all components to LKE, thoroughly test the application. Monitor logs for both the application and Nginx pods using kubectl logs <pod-name>. For production environments, consider setting up Prometheus and Grafana for more robust monitoring of resource utilization and application performance.
Containerizing and orchestrating legacy Laravel applications on modern infrastructure like Linode provides significant benefits in terms of scalability, resilience, and manageability. The key is a meticulous approach to dependency mapping and a clear understanding of both Docker and Kubernetes concepts.