• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 12+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » Dockerizing and Orchestrating Legacy PHP Systems on Modern DigitalOcean Infrastructure

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 .env file managed securely, or a secrets manager).
  • Persistent Storage: Ensure database data is stored in a persistent volume. The db_data volume 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.

Primary Sidebar

A little about the Author

Having 12+ Years of Experience in Software Development, Vinay is a principal software architect, senior systems engineer, and elite technical consultant. He specializes in bespoke PHP/WordPress development, high-performance Magento 2 & Shopify architectures, custom plugin/theme development from scratch, and legacy code modernization (including VB6, VB.NET, PyQt, and Crystal Reports). Known for solving complex database bottlenecks, speed optimization (Core Web Vitals), and advanced security code auditing, Vinay engineers production-ready systems designed to scale under heavy concurrent load conditions.



Chat on WhatsApp

Recent Posts

  • Go Goroutines vs. Node.js Event Loop: Scaling I/O-Bound Microservices Under High Load
  • Elixir Phoenix vs. Go Gin: Concurrency Models and Fault Tolerance Under Peak Request Volume
  • Python Celery vs. Go Channels: Distributed Task Queue Overhead and Memory Reliability
  • Scala Pekko vs. Go Goroutines: Actor Model vs. CSP for Event-Driven Reactive Systems
  • Java Loom Virtual Threads vs. Go Goroutines: Under-the-Hood Scheduler and Thread Overhead Comparison

Categories

  • apache (1)
  • Business & Monetization (390)
  • Centos (4)
  • Comparisons & Decision Making (55)
  • Debian (2)
  • Debugging & Troubleshooting (584)
  • Desktop Applications (14)
  • DevOps (7)
  • DevOps & Cloud Scaling (962)
  • Django (1)
  • Laravel (4)
  • Migration & Architecture (192)
  • Mobile Applications (24)
  • MySQL (1)
  • Performance & Optimization (806)
  • PHP (5)
  • PHP Development (21)
  • Plugins & Themes (244)
  • Programming Languages (9)
  • Python (19)
  • Ruby on Rails (1)
  • Security & Compliance (543)
  • SEO & Growth (491)
  • Server (23)
  • Ubuntu (9)
  • VB6 & VB.NET (8)
  • Web Applications & Frontend (19)
  • Web Assembly (Wasm) (2)
  • WordPress (22)
  • WordPress Plugin Development (7)
  • WordPress Theme Development (357)

Recent Posts

  • Go Goroutines vs. Node.js Event Loop: Scaling I/O-Bound Microservices Under High Load
  • Elixir Phoenix vs. Go Gin: Concurrency Models and Fault Tolerance Under Peak Request Volume
  • Python Celery vs. Go Channels: Distributed Task Queue Overhead and Memory Reliability

Top Categories

  • DevOps & Cloud Scaling (962)
  • Performance & Optimization (806)
  • Debugging & Troubleshooting (584)
  • Security & Compliance (543)
  • SEO & Growth (491)
  • Business & Monetization (390)

Our Products

  • ERP & LMS Systems (4)
  • Directories & Marketplaces (4)
  • Healthcare Portals (3)
  • Point of Sale (POS) (2)
  • E-Commerce Engines (2)

Our Services

  • E-Commerce Development (10)
  • WordPress Development (8)
  • Python & Desktop GUI (7)
  • General Consulting (7)
  • Legacy Modernization (5)
  • Mobile App Development (4)

Copyright © 2026 · Vinay Vengala