Dockerizing and Orchestrating Legacy PHP Systems on Modern DigitalOcean Infrastructure
Understanding the Challenges of Legacy PHP Applications
Migrating legacy PHP applications to modern infrastructure, particularly containerized environments like Docker, presents a unique set of challenges. These systems often have tightly coupled dependencies, implicit configurations, and a lack of standardized deployment practices. Common issues include:
- Dependency Hell: PHP extensions, PEAR packages, and system libraries that are difficult to version and manage consistently across environments.
- Configuration Drift: Environment-specific settings hardcoded or scattered across various configuration files (e.g.,
php.ini, Apache/Nginx vhosts, application-level configs). - Stateful Components: Reliance on local file system state, session files, or specific directory structures that are not designed for ephemeral containers.
- Database Coupling: Direct connections to monolithic databases without clear abstraction layers, making scaling and isolation difficult.
- Monolithic Architecture: Large, single-unit applications that are hard to break down into microservices or even smaller, manageable deployable units.
This post will guide you through a practical approach to containerizing a typical legacy PHP application and orchestrating it on DigitalOcean using Docker and Docker Compose, focusing on production-readiness.
Phase 1: Containerizing the PHP Application
The first step is to create a Dockerfile that encapsulates your PHP application and its runtime environment. We’ll aim for a multi-stage build to keep the final image lean.
Dockerfile for a Typical PHP Application
Consider an application with Apache, PHP, and a few common extensions. We’ll use an official PHP-FPM image as a base, as it’s generally more performant and flexible for web applications than a direct Apache image with mod_php.
# Stage 1: Builder - Install build dependencies and compile extensions
FROM php:8.1-fpm-alpine AS builder
# Install system dependencies for building PHP extensions
RUN apk update && apk add --no-cache \
autoconf \
g++ \
make \
libzip-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
icu-dev \
imagemagick-dev \
git \
unzip \
supervisor \
# Add any other build dependencies your app might need
&& rm -rf /var/cache/apk/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install -j$(nproc) zip \
&& docker-php-ext-install -j$(nproc) intl \
&& pecl install imagick \
&& docker-php-ext-enable imagick \
# Add other extensions as needed (e.g., pdo_mysql, redis)
&& docker-php-ext-install pdo_mysql \
&& pecl install redis \
&& docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Copy application code
WORKDIR /var/www/html
COPY . .
# Install Composer dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Stage 2: Production - Copy artifacts from builder stage
FROM php:8.1-fpm-alpine AS production
# Install runtime dependencies
RUN apk update && apk add --no-cache \
nginx \
libzip \
libpng \
libjpeg-turbo \
freetype \
icu \
imagemagick \
# Add any other runtime dependencies
&& rm -rf /var/cache/apk/*
# Copy PHP extensions from builder stage
COPY --from=builder /usr/local/lib/php/extensions/no-debug-non-zts-20210902/ /usr/local/lib/php/extensions/no-debug-non-zts-20210902/
# Copy application code and composer dependencies from builder stage
WORKDIR /var/www/html
COPY --from=builder /var/www/html /var/www/html
# Configure PHP-FPM
COPY docker/php-fpm/zz-docker.conf /usr/local/etc/php-fpm.d/zz-docker.conf
COPY docker/php-fpm/php.ini /usr/local/etc/php/php.ini
# Configure Nginx
COPY docker/nginx/default.conf /etc/nginx/sites-available/default
# Configure Supervisor for process management (PHP-FPM and potentially cron jobs)
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Expose port and define entrypoint
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
Configuration Files for the Dockerfile
You’ll need to create a docker/ directory in your project root with the following subdirectories and files:
docker/php-fpm/zz-docker.conf
; This file is a template for the PHP-FPM configuration. ; It is automatically generated by Docker. ; See https://github.com/docker-library/php/blob/master/fpm/zz-docker.conf [global] error_log = /var/log/php-fpm.log daemonize = no [www] ; Use a different socket for PHP-FPM to avoid conflicts with Nginx listen = /var/run/php/php8.1-fpm.sock listen.owner = www-data listen.group = www-data listen.mode = 0660 ; Adjust process management settings based on your expected load pm = dynamic pm.max_children = 50 pm.min_spare_servers = 5 pm.max_spare_servers = 10 pm.start_servers = 2 pm.max_requests = 500 ; Other settings access.log = /var/log/php-fpm-access.log catch_workers_output = yes decorate_workers_output = no slowlog = /var/log/php-fpm-slow.log request_slowlog_timeout = 10s ; Set memory limit if needed ; memory_limit = 256M
docker/php-fpm/php.ini
; Basic PHP.ini settings for production ; You might want to copy and modify the default php.ini from your PHP installation ; or use a more comprehensive template. date.timezone = UTC memory_limit = 256M upload_max_filesize = 64M post_max_size = 64M max_execution_time = 300 error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT display_errors = Off log_errors = On error_log = /var/log/php-fpm.log session.save_handler = files session.save_path = /var/lib/php/sessions session.gc_maxlifetime = 1440 session.cookie_httponly = 1 session.use_strict_mode = 1 opcache.enable=1 opcache.memory_consumption=128 opcache.interned_strings_buffer=16 opcache.max_accelerated_files=10000 opcache.revalidate_freq=2 opcache.validate_timestamps=1 opcache.enable_cli=1
docker/nginx/default.conf
server {
listen 80;
server_name localhost; # Replace with your domain if needed
root /var/www/html/public; # Adjust if your public directory is different
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# Use the PHP-FPM socket defined in php-fpm/zz-docker.conf
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Deny access to hidden files
location ~ /\.ht {
deny all;
}
# Serve static files directly
location ~* \.(css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public";
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
docker/supervisor/supervisord.conf
[supervisord] nodaemon=true user=root [program:php-fpm] command=/usr/sbin/php-fpm8.1 --nodaemonize --fpm-config /usr/local/etc/php-fpm.conf autostart=true autorestart=true priority=10 stdout_logfile=/var/log/supervisor/php-fpm.log stderr_logfile=/var/log/supervisor/php-fpm.err.log ; Uncomment and configure if you have cron jobs to run ;[program:cron] ;command=/usr/sbin/crond -f -l 8 -L /var/log/supervisor/cron.log ;autostart=true ;autorestart=true ;priority=20 ;stdout_logfile=/var/log/supervisor/cron.log ;stderr_logfile=/var/log/supervisor/cron.err.log
After creating these files, you can build your Docker image:
docker build -t my-legacy-php-app:latest .
And test it locally:
docker run -d -p 8080:80 --name legacy-app-test my-legacy-php-app:latest # Access your app at http://localhost:8080 # Stop and remove the container: # docker stop legacy-app-test && docker rm legacy-app-test
Phase 2: Orchestrating with Docker Compose
For a typical web application, you’ll likely need a database service. Docker Compose is ideal for defining and running multi-container Docker applications. We’ll set up a PHP application service and a MySQL database service.
docker-compose.yml for Development and Staging
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: legacy_php_app
ports:
- "80:80"
volumes:
# Mount application code for live development (use with caution in production)
- .:/var/www/html
# Mount logs for easier inspection
- ./docker-logs/php-fpm:/var/log/php-fpm
- ./docker-logs/nginx:/var/log/nginx
- ./docker-logs/supervisor:/var/log/supervisor
environment:
# Database connection details - these should ideally come from a .env file
MYSQL_HOST: db
MYSQL_DATABASE: legacy_db
MYSQL_USER: legacy_user
MYSQL_PASSWORD: secure_password
depends_on:
- db
networks:
- app-network
db:
image: mysql:8.0
container_name: legacy_db
ports:
- "3306:3306" # Expose to host for local debugging if needed
volumes:
- db_data:/var/lib/mysql
# Optional: Mount custom MySQL config
# - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: legacy_db
MYSQL_USER: legacy_user
MYSQL_PASSWORD: secure_password
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
db_data:
To run this locally:
# Create log directories if they don't exist mkdir -p docker-logs/php-fpm docker-logs/nginx docker-logs/supervisor # Start services in detached mode docker-compose up -d # View logs docker-compose logs -f # Stop services docker-compose down
Phase 3: Deploying to DigitalOcean with Docker Machine/Swarm or Kubernetes
For production deployments on DigitalOcean, Docker Compose alone is insufficient. You need an orchestration platform. While DigitalOcean offers managed Kubernetes (DOKS), for simpler setups or to gradually introduce orchestration, Docker Swarm or even a single DigitalOcean Droplet running Docker and Docker Compose can be a starting point.
Option A: Single Droplet with Docker Compose (for simpler legacy apps)
This is the most straightforward approach for applications that don’t require high availability or complex scaling. You’ll provision a Droplet, install Docker and Docker Compose, and then deploy your application using the docker-compose.yml file.
1. Provision a DigitalOcean Droplet
Create a Droplet (e.g., Ubuntu 22.04 LTS) with SSH access. Ensure you have a firewall configured (e.g., UFW) to allow HTTP/HTTPS traffic.
2. Install Docker and Docker Compose on the Droplet
# SSH into your Droplet
ssh root@your_droplet_ip
# Update package list and install prerequisites
apt update && apt upgrade -y
apt install -y apt-transport-https ca-certificates curl software-properties-common gnupg lsb-release
# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Set up the stable repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine
apt update
apt install -y docker-ce docker-ce-cli containerd.io
# Add your user to the docker group (optional, but convenient)
usermod -aG docker $USER
newgrp docker # Apply group changes to the current session
# Install Docker Compose
LATEST_COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep 'tag_name' | cut -d\" -f4)
curl -L "https://github.com/docker/compose/releases/download/${LATEST_COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
docker-compose --version
3. Deploy the Application
Transfer your application code and the docker-compose.yml file to the Droplet. It’s recommended to use a CI/CD pipeline to automate this. For manual deployment:
# On your Droplet, navigate to your project directory cd /path/to/your/project # Pull the latest Docker image (if you've pushed it to a registry like Docker Hub or DigitalOcean Container Registry) # docker pull your-docker-registry/my-legacy-php-app:latest # If not using a registry, build the image directly on the Droplet # docker build -t my-legacy-php-app:latest . # Ensure your docker-compose.yml is configured for production (e.g., no host volume mounts for code, proper secrets) # For production, you'd typically build the image and push it to a registry, then pull it on the server. # For simplicity here, we'll assume you've transferred the code and are using the local build. # Start the services docker-compose up -d # Monitor logs docker-compose logs -f
Important Considerations for Production:
- Secrets Management: Never hardcode database passwords or API keys in
docker-compose.yml. Use Docker secrets or environment variables loaded from a secure source (e.g., a.envfile managed securely, or a secrets manager). - Persistent Storage: Ensure database data is stored in a persistent volume. The
db_datavolume in the example handles this. For file uploads or other persistent data, use named volumes or bind mounts to persistent storage on the host. - Logging: Configure Docker to send logs to a centralized logging system (e.g., ELK stack, Splunk, or DigitalOcean’s Logpush).
- Backups: Implement regular backups for your database and any persistent file storage.
- HTTPS: Set up a reverse proxy (like Nginx on another Droplet or a Load Balancer) with SSL certificates (e.g., Let’s Encrypt) to handle HTTPS traffic.
- Health Checks: Implement health checks in your Dockerfile and Docker Compose to ensure services are running correctly.
Option B: DigitalOcean Kubernetes (DOKS)
For more robust, scalable, and resilient deployments, DOKS is the recommended path. This involves converting your docker-compose.yml into Kubernetes manifests (Deployments, Services, StatefulSets, Ingress).
1. Create a DOKS Cluster
Use the DigitalOcean control panel or `doctl` CLI to create a Kubernetes cluster. This will provision master nodes and worker nodes (Droplets).
2. Convert Docker Compose to Kubernetes Manifests
This is the most complex part. You’ll need to create YAML files for:
- Deployment for the PHP App: Defines how to run your containerized PHP application.
- Service for the PHP App: Exposes your PHP app internally within the cluster.
- StatefulSet for MySQL: Manages the MySQL database, ensuring persistent storage and stable network identities.
- Service for MySQL: Exposes the MySQL database internally.
- Ingress: Manages external access to your PHP application, handling routing, SSL termination, etc.
- PersistentVolumeClaims (PVCs): For both the database and potentially file uploads.
Here’s a simplified example of what these might look like:
php-app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: legacy-php-app
labels:
app: legacy-php-app
spec:
replicas: 2 # Scale as needed
selector:
matchLabels:
app: legacy-php-app
template:
metadata:
labels:
app: legacy-php-app
spec:
containers:
- name: app
image: your-docker-registry/my-legacy-php-app:latest # Replace with your image
ports:
- containerPort: 80
env:
- name: MYSQL_HOST
value: "legacy-mysql-service" # Kubernetes service name
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: mysql-credentials
key: database
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: mysql-credentials
key: username
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-credentials
key: password
# Add readiness and liveness probes
livenessProbe:
httpGet:
path: /healthz # Create a health check endpoint in your app
port: 80
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 5
periodSeconds: 10
# Consider resource requests and limits
# resources:
# requests:
# memory: "64Mi"
# cpu: "250m"
# limits:
# memory: "128Mi"
# cpu: "500m"
php-app-service.yaml
apiVersion: v1
kind: Service
metadata:
name: legacy-php-app-service
spec:
selector:
app: legacy-php-app
ports:
- protocol: TCP
port: 80
targetPort: 80
type: ClusterIP # Internal service, exposed via Ingress
mysql-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: legacy-mysql
spec:
serviceName: "legacy-mysql-service" # Headless service for stable network IDs
replicas: 1
selector:
matchLabels:
app: legacy-mysql
template:
metadata:
labels:
app: legacy-mysql
spec:
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
name: mysql
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-credentials
key: root-password
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: mysql-credentials
key: database
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: mysql-credentials
key: username
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-credentials
key: password
volumeMounts:
- name: mysql-persistent-storage
mountPath: /var/lib/mysql
# Add readiness and liveness probes for MySQL
volumeClaimTemplates:
- metadata:
name: mysql-persistent-storage
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: "do-block-storage" # Or your preferred StorageClass
resources:
requests:
storage: 10Gi # Adjust size as needed
mysql-service.yaml
apiVersion: v1
kind: Service
metadata:
name: legacy-mysql-service
spec:
selector:
app: legacy-mysql
ports:
- protocol: TCP
port: 3306
targetPort: 3306
clusterIP: None # Headless service
mysql-credentials-secret.yaml
apiVersion: v1 kind: Secret metadata: name: mysql-credentials type: Opaque data: # Base64 encoded values database: bG... # base64 encode 'legacy_db' username: bG... # base64 encode 'legacy_user' password: bG... # base64 encode 'secure_password' root-password: bG... # base64 encode 'root_password'
ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: legacy-app-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: / # If needed for URL rewriting
# Add other Nginx Ingress controller annotations as required
spec:
rules:
- host: your.domain.com # Replace with your domain
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: legacy-php-app-service
port:
number: 80
# Optional: TLS configuration for HTTPS
# tls:
# - hosts:
# - your.domain.com
# secretName: your-tls-secret # Kubernetes secret containing your TLS certificate
Apply these manifests to your DOKS cluster:
# Ensure your kubectl is configured to point to your DOKS cluster kubectl apply -f mysql-credentials-secret.yaml kubectl apply -f mysql-statefulset.yaml kubectl apply -f mysql-service.yaml kubectl apply -f php-app-deployment.yaml kubectl apply -f php-app-service.yaml kubectl apply -f ingress.yaml
You’ll need to configure DNS for your.domain.com to point to the IP address of your DigitalOcean Load Balancer (which the Ingress controller typically provisions).
Conclusion and Next Steps
Containerizing and orchestrating legacy PHP applications is a journey. This guide provides a solid foundation for moving your application from a monolithic server to a modern, scalable cloud infrastructure on DigitalOcean. The key is to:
- Thoroughly analyze your application’s dependencies and configurations.
- Build lean, multi-stage Docker images.
- Leverage Docker Compose for defining local and staging environments.
- Choose an appropriate orchestration strategy (Docker Compose on a single Droplet, or DOKS for production).
- Implement robust practices for secrets management, logging, backups, and security.
For truly legacy systems, consider a phased approach: first containerize, then gradually refactor parts of the application into more manageable services, potentially moving towards a microservices architecture over time.