.jpg)
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)
- Set up MySQL replication from the existing server to the container.
- Verify the replica works.
- Switch DNS or load balancer to the new MySQL.
- 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 pullbefore 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
- Keep the Dockerfile small. Install only the extensions you need.
- Use environment variables for all configuration that may change between environments.
- Store persistent data in named volumes, never in the container’s writable layer.
- Version‑control the
docker-compose.ymland all Dockerfiles. - Run automated tests against the containerised stack before releasing.
- 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.