Migrating a PHP/MySQL Website into Docker Containers: A Step‑by‑Step Guide

basanta sapkota
If you still run a PHP site on a classic LAMP stack, you are not alone. Many small businesses in Biratnagar and larger apps in Kathmandu use that setup. Over time the stack can become hard to manage. Different developers may have different versions of PHP, Apache, or MySQL. Deployments may fail because the production server differs from the developer’s laptop. Docker containers solve those problems. This guide shows how to move a PHP/MySQL website into Docker containers. It covers planning, building images, wiring services together, and testing the result.

Why migrate a PHP/MySQL website into Docker containers?

Reason What you gain
Consistent environments One docker-compose up gives the same stack to every developer.
Simpler deployments The same compose file works on staging and production.
Version control Change the PHP version in a Dockerfile, rebuild, and you are done.
Lower resource use Containers share the host kernel, so they use less RAM than full VMs.
Easier scaling Add more PHP workers or a replica MySQL with a single command.

These benefits are real, not marketing hype. They address the “it works on my machine” syndrome that many Nepali developers know well.

Pre‑migration assessment

Before you touch any code, take a snapshot of the current environment.

# Versions
php -v
mysql --version
apache2 -v   

# or nginx -v

# PHP extensions
php -m

# Database size
mysql -u root -p -e "
SELECT table_schema AS Database,
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS Size_MB
FROM information_schema.tables
GROUP BY table_schema;"

Write down:

  • PHP version and required extensions.
  • MySQL version and size of the database.
  • Any custom Apache/Nginx configuration files.
  • Cron jobs, file‑upload directories, and external services.

Look for hard‑coded values such as localhost in database connections or absolute file paths. Those will need to become environment variables.

Project structure

Create a clean directory layout for the Dockerised project.

your-website/
├── docker-compose.yml
├── docker/
│   ├── nginx/
│   │   ├── Dockerfile
│   │   └── default.conf
│   ├── php/
│   │   ├── Dockerfile
│   │   └── php.ini
│   └── mysql/
│       └── init/
│           └── 01-create-database.sql
├── src/               

# existing PHP files
├── data/              

# persistent MySQL data
└── logs/              

# log files

All source files stay in src. Volumes map this folder into the PHP and Nginx containers, so changes appear instantly.

Docker Compose file

The compose file defines the four services we need: nginx, php, mysql, and phpmyadmin for quick DB access.

version: '3.8'

services:
  nginx:
    build:
      context: ./docker/nginx
    container_name: ${PROJECT_NAME}-nginx
    ports:
      - "${NGINX_PORT:-80}:80"
    volumes:
      - ./src:/var/www/html
      - ./logs/nginx:/var/log/nginx
    depends_on:
      - php
    networks:
      - app-net
    restart: unless-stopped

  php:
    build:
      context: ./docker/php
    container_name: ${PROJECT_NAME}-php
    volumes:
      - ./src:/var/www/html
      - ./docker/php/php.ini:/usr/local/etc/php/php.ini
      - ./logs/php:/var/log/php
    environment:
      - DB_HOST=mysql
      - DB_PORT=3306
      - DB_NAME=${DB_NAME:-your_database}
      - DB_USER=${DB_USER:-your_user}
      - DB_PASS=${DB_PASS:-your_password}
    depends_on:
      mysql:
        condition: service_healthy
    networks:
      - app-net
    restart: unless-stopped

  mysql:
    image: mysql:8.0
    container_name: ${PROJECT_NAME}-mysql
    ports:
      - "${MYSQL_PORT:-3306}:3306"
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS:-rootpassword}
      MYSQL_DATABASE: ${DB_NAME:-your_database}
      MYSQL_USER: ${DB_USER:-your_user}
      MYSQL_PASSWORD: ${DB_PASS:-your_password}
    volumes:
      - ./data/mysql:/var/lib/mysql
      - ./docker/mysql/init:/docker-entrypoint-initdb.d
      - ./logs/mysql:/var/log/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10
    networks:
      - app-net
    restart: unless-stopped

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    container_name: ${PROJECT_NAME}-phpmyadmin
    ports:
      - "${PMA_PORT:-8080}:80"
    environment:
      PMA_HOST: mysql
      PMA_USER: ${DB_USER:-your_user}
      PMA_PASSWORD: ${DB_PASS:-your_password}
    depends_on:
      - mysql
    networks:
      - app-net
    restart: unless-stopped

networks:
  app-net:
    driver: bridge

All secrets are stored in a .env file at the project root.

PROJECT_NAME=your-website
NGINX_PORT=80
MYSQL_PORT=3306
PMA_PORT=8080

DB_NAME=your_database
DB_USER=your_user
DB_PASS=your_secure_password
DB_ROOT_PASS=your_root_password

PHP container

The PHP Dockerfile installs the required extensions and Composer.

# docker/php/Dockerfile
FROM php:8.1-fpm

RUN apt-get update && apt-get install -y \
    git curl libpng-dev libonig-dev libxml2-dev libzip-dev zip unzip \
    && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html

COPY php.ini /usr/local/etc/php/

# Create non‑root user
RUN groupadd -g 1000 www && \
    useradd -u 1000 -ms /bin/bash -g www www && \
    chown -R www:www /var/www/html

USER www

EXPOSE 9000
CMD ["php-fpm"]

php.ini (essential settings)

memory_limit = 256M
max_execution_time = 300
post_max_size = 64M
upload_max_filesize = 32M
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 4000
opcache.validate_timestamps = 0

Nginx container

The Nginx Dockerfile is tiny because the base image already contains the web server.

# docker/nginx/Dockerfile
FROM nginx:alpine

COPY default.conf /etc/nginx/conf.d/

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

default.conf

server {
    listen 80;
    server_name localhost;
    root /var/www/html;
    index index.php index.html;

    

# Static files
    location ~* \.(js|css|png|jpe?g|gif|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    

# Main request handling
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    

# PHP processing
    location ~ \.php$ {
        fastcgi_pass php:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    

# Deny hidden files
    location ~ /\. {
        deny all;
        return 404;
    }
}

Updating the PHP code

Database connection

Replace hard‑coded values with environment variables.

<?php
$host = $_ENV['DB_HOST'] ?? 'mysql';
$db   = $_ENV['DB_NAME'] ?? 'your_database';
$user = $_ENV['DB_USER'] ?? 'your_user';
$pass = $_ENV['DB_PASS'] ?? 'your_password';

try {
    $pdo = new PDO("mysql:host=$host;dbname=$db", $user, $pass);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
    error_log('DB connection error: ' . $e->getMessage());
    die('Database error');
}
?>

File uploads

Map the upload folder to a persistent volume if required.

<?php
$uploadDir = $_ENV['UPLOAD_DIR'] ?? './uploads/';

if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

// Continue with upload handling...
?>

Data migration

Small databases (under 1 GB)

# Export from the old server
mysqldump -u root -p your_database > backup.sql

# Start containers
docker-compose up -d

# Import into containerised MySQL
docker-compose exec mysql mysql -u root -p your_database < backup.sql

Large databases (minimal downtime)

  1. Set up MySQL replication from the existing server to the container.
  2. Verify the replica works.
  3. Switch DNS or load balancer to the new MySQL.
  4. Stop replication and remove the old server.

Testing the containerised site

# Build and start
docker-compose up -d --build

# Check status
docker-compose ps

# Follow logs
docker-compose logs -f php
docker-compose logs -f nginx
docker-compose logs -f mysql

Validation checklist

Test Expected result
Open http://localhost Home page loads
Submit a form that writes to DB No DB errors
Access phpinfo.php Shows PHP 8.1 and installed extensions
Upload a file File saved in the mapped directory
Log in / log out Session persists across pages

Common migration issues and fixes

Issue Symptom Fix
Permission errors “Permission denied” when writing files Ensure the PHP container runs as user www and that the host directory is owned by the same UID.
DB connection timeout “Can’t connect to MySQL” Verify depends_on healthcheck, and that DB_HOST points to mysql.
Missing PHP extension “Call to undefined function” Add the extension to the docker/php/Dockerfile with docker-php-ext-install.
Session loss Users logged out after a page refresh Store sessions in a shared volume or in the database.

Performance optimisation

PHP‑FPM tuning

Add to php.ini:

max_children = 20
process_control_timeout = 10

MySQL configuration

Add command arguments to the MySQL service in docker-compose.yml:

command: >
  --innodb-buffer-pool-size=256M
  --max-connections=150
  --query-cache-type=1
  --query-cache-size=32M

Nginx tweaks

gzip on;
gzip_types text/css application/javascript text/xml application/json;
keepalive_timeout 30;

Monitoring and maintenance

Health‑check script (scripts/health-check.sh)

# !/bin/bash
services=("nginx" "php" "mysql")
for s in "${services[@]}"; do
    if docker-compose ps "$s" | grep -q "Up"; then
        echo "✓ $s running"
    else
        echo "✗ $s stopped"
        docker-compose logs "$s"
    fi
done
docker system df
docker-compose exec mysql mysql -u root -p${DB_ROOT_PASS} -e "SHOW PROCESSLIST;"

Make it executable and run it daily with a cron job.

Backup script (scripts/backup.sh)

# !/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
DIR="./backups/$DATE"
mkdir -p "$DIR"

docker-compose exec mysql mysqldump -u root -p${DB_ROOT_PASS} ${DB_NAME} > "$DIR/database.sql"
tar -czf "$DIR/files.tar.gz" ./src

find ./backups -type d -mtime +7 -exec rm -rf {} +
echo "Backup saved to $DIR"

Deploying to production

Replace the development docker-compose.yml with a production version that:

  • Uses fixed image tags (e.g., php:8.1-fpm-alpine).
  • Sets restart: always.
  • Limits resources:
deploy:
  resources:
    limits:
      memory: 512M
      cpus: "0.5"
  • Stores secrets in Docker Swarm or Kubernetes secrets instead of .env.

Security hardening

  • Run containers as non‑root users (already done).
  • Keep base images up to date (docker pull before each rebuild).
  • Use a firewall to restrict MySQL port to internal network only.
  • Enable HTTPS in Nginx with Let’s Encrypt certificates.

Scaling considerations

Horizontal scaling of PHP workers

docker-compose up -d --scale php=3

Update default.conf to use an upstream pool:

upstream php_backend {
    server php_1:9000;
    server php_2:9000;
    server php_3:9000;
}

Database scaling

For read‑heavy workloads add a replica:

mysql-replica:
  image: mysql:8.0
  environment:
    - MYSQL_REPLICATION_MODE=slave
    - MYSQL_MASTER_HOST=mysql

Configure the application to read from the replica where appropriate.

Best practices for migrating a PHP/MySQL website into Docker containers

  1. Keep the Dockerfile small. Install only the extensions you need.
  2. Use environment variables for all configuration that may change between environments.
  3. Store persistent data in named volumes, never in the container’s writable layer.
  4. Version‑control the docker-compose.yml and all Dockerfiles.
  5. Run automated tests against the containerised stack before releasing.
  6. Document any manual steps, such as data import, in the repo’s README.

Conclusion

Moving a PHP/MySQL website into Docker containers takes effort, but the payoff is clear. You get a reproducible environment, easier deployments, and a path to scale when traffic grows. The steps above—assessment, structuring the project, writing Dockerfiles, updating code, migrating data, testing, and hardening security—form a repeatable process. Start with a development setup, verify every feature works, then roll the same compose file out to staging and production.

Whether your site serves a small shop in Pokhara or a large portal in Kathmandu, Docker gives you the tools to deploy with confidence. Follow this guide, adapt it to your own codebase, and you will see fewer “works on my machine” problems and smoother releases.

Ready to try it? Set up the directory structure, copy the files, and run docker-compose up -d. If you hit a snag, revisit the common‑issue table or the health‑check script. Good luck, and enjoy the stability that containerisation brings.



Post a Comment