How to containerize WordPress with Docker and Docker Compose for local and production parity?

So, you want to run WordPress in Docker, and better yet, have that setup be pretty similar whether you’re developing locally or deploying to production? Great idea! The main takeaway upfront is that Docker and Docker Compose can indeed give you excellent local/production parity for WordPress, provided you set things up thoughtfully. This means using a multi-stage Dockerfile, externalizing configurations, and treating your database and uploads as persistent, separate concerns.

Before we dive into the “how,” let’s quickly touch on the “why.” You might be thinking, “WordPress is easy to install, why add the complexity of Docker?” And you’re right, a basic LAMP stack is straightforward. However, Docker brings some significant advantages:

Consistent Environments

This is the big one. “It works on my machine” becomes “It works in this container.” No more wrestling with differing PHP versions, missing extensions, or database quirks between your development laptop, your team member’s setup, and your production server. Everyone gets the exact same environment bundled into an image.

Simplified Setup for Developers

New team member? Just docker-compose up. They don’t need to manually install Apache, Nginx, PHP, MySQL, or worry about dependencies. It’s all defined and packaged.

Isolation and Resource Management

Each component (Nginx, PHP-FPM, MySQL) runs in its own container. This isolates issues – a problem in your PHP doesn’t tank your database. It also makes resource management clearer.

Easier Scaling (for Production)

While for a single WordPress site, this might be overkill, understanding how to containerize lays the groundwork for scaling in more complex environments using orchestrators like Kubernetes or Swarm.

Version Control for Infrastructure

Your Dockerfile and docker-compose.yml become part of your code repository. This means your infrastructure definitions are versioned, auditable, and repeatable.

If you’re looking to enhance your WordPress deployment strategy, you might find it beneficial to explore related topics such as server migrations. A relevant article that discusses the process of migrating from one CyberPanel server to another can provide valuable insights into managing your WordPress environment effectively. You can read more about it in this article: Migrating to Another CyberPanel Server. This information can be particularly useful when considering how to maintain consistency across different environments, whether local or production.

Crafting Your Docker Setup: The Core Components

To containerize WordPress, we’ll typically be working with several interconnected containers. Think of it like building a small apartment complex for your website.

The WordPress Application Container (PHP & Web Server)

This is where your WordPress files and PHP code live. We’ll use a PHP-FPM image and often pair it with a separate Nginx container for serving requests. While you could run Apache directly in the PHP container, it’s generally cleaner and more performant to separate PHP-FPM and Nginx.

The Database Container

WordPress needs a database, usually MySQL or MariaDB. This will be a separate container, totally independent of your WordPress application. This separation is crucial for persistence and scaling.

The Reverse Proxy / Web Server Container (Nginx)

Nginx acts as the front door to your WordPress site. It handles incoming HTTP requests, serves static files efficiently, and passes dynamic PHP requests to our PHP-FPM container. This setup is common and generally recommended for performance and flexibility.

Setting Up Your Local Development Environment

Let’s start with a solid local setup using Docker Compose. This will be the foundation for our production parity.

The docker-compose.yml File

This is the blueprint for your multi-container application. We’ll define all our services here.

“`yaml

version: ‘3.8’

services:

nginx:

image: nginx:stable-alpine

container_name: wordpress_nginx

ports:

  • “80:80”

volumes:

  • ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
  • ./src:/var/www/html # Mount your WordPress source code

depends_on:

  • wordpress

networks:

  • wordpress_network

wordpress:

build:

context: . # Points to the directory containing the Dockerfile

dockerfile: Dockerfile.prod # We’ll reuse our production Dockerfile here

container_name: wordpress_app

environment:

WORDPRESS_DB_HOST: db:3306

WORDPRESS_DB_USER: ${MYSQL_USER}

WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD}

WORDPRESS_DB_NAME: ${MYSQL_DATABASE}

WORDPRESS_TABLE_PREFIX: wp_ # Good practice to customize

volumes:

  • ./src:/var/www/html # Mount your WordPress source code into the container
  • uploads:/var/www/html/wp-content/uploads # Dedicate a volume for uploads

depends_on:

  • db

restart: always # Keep it running

networks:

  • wordpress_network

db:

image: mariadb:10.6 # Or mysql:8.0, choose your preferred DB

container_name: wordpress_db

environment:

MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} # Root for initial setup

MYSQL_DATABASE: ${MYSQL_DATABASE}

MYSQL_USER: ${MYSQL_USER}

MYSQL_PASSWORD: ${MYSQL_PASSWORD}

volumes:

  • db_data:/var/lib/mysql # Persistent volume for database data

restart: always

networks:

  • wordpress_network

volumes:

db_data: # Declaring the named volume for database persistence

uploads: # Declaring the named volume for WordPress uploads persistence

networks:

wordpress_network:

driver: bridge

“`

The Nginx Configuration

Create a directory named nginx and inside it, a file nginx.conf:

“`nginx

server {

listen 80;

server_name localhost; # Or your local domain

root /var/www/html;

index index.php index.html index.htm;

location / {

try_files $uri $uri/ /index.php?$args;

}

location ~ \.php$ {

include fastcgi_params;

fastcgi_pass wordpress:9000; # ‘wordpress’ is the service name in docker-compose

fastcgi_index index.php;

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

fastcgi_param PATH_INFO $fastcgi_path_info;

}

Deny access to hidden files

location ~ /\. {

deny all;

}

Deny access to SQL files, etc.

location ~* \.(sql|md|log|sh|bak|zip|rar|tar\.gz)$ {

deny all;

}

Cache static assets

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {

expires max;

log_not_found off;

}

}

“`

The Multi-Stage Dockerfile for WordPress

This file will define how your WordPress application image is built. Using multi-stage builds is key for smaller and more secure production images. Create a file named Dockerfile.prod in your root directory.

“`dockerfile

Stage 1: Build dependencies and composer packages (if you use them)

FROM composer:2.6 as composer_build

WORKDIR /app

COPY composer.json composer.lock ./

RUN composer install –no-dev –no-plugins –no-scripts –prefer-dist –optimize-autoloader –no-progress –ansi

Stage 2: Install WordPress CLI

FROM wodby/wpcli:2.8.1 as wpcli

WORKDIR /usr/local/bin

RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \

&& chmod +x wp-cli.phar \

&& mv wp-cli.phar wp

Stage 3: The actual WordPress application image

FROM wordpress:6.4.3-php8.2-fpm-alpine # Use a specific, stable tag

Set working directory inside the container

WORKDIR /var/www/html

Copy the built dependencies from stage 1 (if any)

COPY –from=composer_build /app/vendor /var/www/html/vendor

Copy wp-cli from stage 2

COPY –from=wpcli /usr/local/bin/wp /usr/local/bin/wp

Copy your local WordPress files into the container

For local dev, you’ll volume mount this. For production, you’ll copy.

We’ll keep this commented for the local dev docker-compose.yml to rely on volume mount.

But for a true production build (e.g., using a CI/CD pipeline), you’d uncomment this.

COPY . /var/www/html/

Handle permissions

The default user for wordpress:fpm-alpine is www-data (UID 82).

We might need to adjust ownership based on your local user mapping for shared volumes,

but for production, this should generally be fine, or you might need a dedicated entrypoint script.

USER www-data

Expose port (PHP-FPM usually listens on 9000)

EXPOSE 9000

CMD [“php-fpm”]

“`

Important note on Dockerfile.prod: For local development, we’re mounting your src directory into the container. This means changes you make locally are immediately reflected. For a true production build, you would typically uncomment COPY . /var/www/html/ in the Dockerfile.prod and build that image in your CI/CD pipeline, omitting runtime volume mounts for the core WordPress files (only for uploads and potentially themes/plugins for specific setups). This ensures a completely self-contained, immutable image.

Environment Variables (.env file)

Create a .env file in the same directory as your docker-compose.yml. This keeps sensitive information out of your version control.

“`

MYSQL_ROOT_PASSWORD=your_super_secret_root_password

MYSQL_DATABASE=wordpress

MYSQL_USER=wp_user

MYSQL_PASSWORD=your_wp_db_password

“`

Initializing WordPress

Once all these files are in place, navigate to your project root in your terminal and run:

“`bash

docker-compose up -d

“`

This will build your images (if not cached), pull official ones, and start all your services.

You should now be able to access your fresh WordPress installation at http://localhost. Run through the standard WordPress 5-minute install.

Achieving Production Parity

Now, how do we make this local setup production-ready and ensure parity? The main differences boil down to how images are built, how volumes are managed, and sometimes, network configuration.

Building Production-Ready Images

For production, you generally want to COPY your entire application code into the image (excluding sensitive files via .dockerignore) rather than volume-mounting it at runtime. This creates immutable images.

Production Dockerfile Best Practices

  1. Specific Base Images: Always use specific tags (e.g., wordpress:6.4.3-php8.2-fpm-alpine) instead of latest.
  2. Multi-Stage Builds: As shown above, this ensures your final image is small and only contains necessary runtime components, not build tools.
  3. No Dev Dependencies: Use composer install --no-dev if you’re using Composer.
  4. Security: Run as a non-root user (the WordPress FPM images often handle this by default with www-data). Minimize unnecessary software.
  5. Caching: Leverage Docker’s build cache by ordering instructions from least-to-most frequently changing.

Externalizing Configurations for Production

Never hardcode secrets or environment-specific values directly into your Dockerfile or docker-compose.yml for production.

Using Environment Variables

For production, you’ll still use environment variables, but they’ll be managed differently. Instead of a .env file, you might use:

  • Orchestrator Secrets: Kubernetes secrets, Docker Swarm secrets.
  • Cloud Provider Secrets Management: AWS Secrets Manager, Google Secret Manager, Azure Key Vault.
  • Direct Environment Variables: Set during deployment in your CI/CD pipeline or directly on the host/container orchestration.

Persistent Data: Volumes and Backups

Database and user uploads are your persistent data. They must survive container restarts and even container deletion.

Named Volumes vs. Bind Mounts

  • Local Development: Bind mounts are great (./src:/var/www/html) because changes on your host are immediately reflected in the container.
  • Production: Named volumes (db_data:/var/lib/mysql) are preferred for performance, data isolation, and management by the Docker daemon. For uploads, you might also use cloud storage (S3, GCS) via a plugin for better scalability and durability.

Database Backup Strategy

Ensure your production database has a robust backup and restore strategy. This is outside Docker’s direct scope but critical for production. Think automated nightly backups to a different location.

Handling wp-config.php

This file is a tricky one for parity. It contains database credentials, salts, and often other environment-specific configurations.

Dynamic wp-config.php

You can create a wp-config-docker.php that pulls variables from the environment and then includes the standard wp-config.php at the very end. The official WordPress Docker images often handle this by default if you use their environment variables. For custom setups, an entrypoint script can generate or modify wp-config.php dynamically on container start.

“`php

// wp-config.php (or an entrypoint script that generates or modifies it)

// Pull environment variables for database

define(‘DB_NAME’, getenv(‘WORDPRESS_DB_NAME’));

define(‘DB_USER’, getenv(‘WORDPRESS_DB_USER’));

define(‘DB_PASSWORD’, getenv(‘WORDPRESS_DB_PASSWORD’));

define(‘DB_HOST’, getenv(‘WORDPRESS_DB_HOST’));

define(‘DB_CHARSET’, ‘utf8’);

define(‘DB_COLLATE’, ”);

// Unique salts (generate these securely for production!)

define(‘AUTH_KEY’, getenv(‘AUTH_KEY’));

define(‘SECURE_AUTH_KEY’, getenv(‘SECURE_AUTH_KEY’));

define(‘LOGGED_IN_KEY’, getenv(‘LOGGED_IN_KEY’));

define(‘NONCE_KEY’, getenv(‘NONCE_KEY’));

define(‘AUTH_SALT’, getenv(‘AUTH_SALT’));

define(‘SECURE_AUTH_SALT’, getenv(‘SECURE_AUTH_SALT’));

define(‘LOGGED_IN_SALT’, getenv(‘LOGGED_IN_SALT’));

define(‘NONCE_SALT’, getenv(‘NONCE_SALT’));

$table_prefix = getenv(‘WORDPRESS_TABLE_PREFIX’) ? getenv(‘WORDPRESS_TABLE_PREFIX’) : ‘wp_’;

// Example for dynamic URL (useful for development vs. production)

if (getenv(‘WORDPRESS_URL’)) {

define(‘WP_HOME’, getenv(‘WORDPRESS_URL’));

define(‘WP_SITEURL’, getenv(‘WORDPRESS_URL’));

}

// Memory limit (optional, can be in php.ini)

define(‘WP_MEMORY_LIMIT’, ‘256M’);

// Debug mode (set to false for production!)

define(‘WP_DEBUG’, getenv(‘WORDPRESS_DEBUG’) === ‘true’ ? true : false);

/ That’s all, stop editing! Happy publishing. /

/** Absolute path to the WordPress directory. */

if ( !defined(‘ABSPATH’) )

define(‘ABSPATH’, __DIR__ . ‘/’);

/** Sets up WordPress vars and included files. */

require_once ABSPATH . ‘wp-settings.php’;

“`

You’d then add these environment variables to your .env file (local) or your deployment environment (production).

Dealing with Themes, Plugins, and Uploads

How you handle these can significantly impact your workflow and production parity.

Core WordPress Files & Standard Plugins/Themes

For production, these are ideally identical across environments. WordPress core files, and any pre-selected plugins/themes (not client-specific ones that might change frequently) should be baked into your Docker image. This means they are present when the container starts.

Custom Themes and Plugins

If you have custom themes or plugins that are part of your application’s source code, they should be included in the COPY . /var/www/html/ step of your production Dockerfile.

User-Installed Plugins/Themes and Uploads

These are usually written to by the WordPress application.

  • Uploads (wp-content/uploads): Always use a named volume for persistence, or even better for production, integrate with cloud storage (AWS S3, Google Cloud Storage) using a plugin like WP Offload Media. This decouples uploads from your server and provides better scalability and durability.
  • Plugins/Themes installed via WP Admin: This is generally discouraged for containerized environments. If a user installs a plugin via the admin, it’s written inside that specific container instance. If the container restarts or a new instance spins up, that change is lost. The ideal approach is to manage plugins and themes via your build process (Composer, Git submodules) and bake them into the image. If you absolutely must allow in-admin installs, you’d need a persistent volume for wp-content but this comes with complexities around image immutability and rebuilds.

If you’re looking to enhance your understanding of containerization, you might find it helpful to explore a related article that delves into the best practices for deploying applications using Docker. This resource provides valuable insights that can complement your knowledge on how to containerize WordPress with Docker and Docker Compose for local and production parity. For more information, check out this informative piece at The Sheryar.

Deploying to Production

The principles we’ve discussed apply, but the tooling changes.

Orchestration Tools (Docker Compose, Swarm, Kubernetes)

  • Docker Compose: Excellent for single-server deployments (e.g., a simple VPS). Your docker-compose.yml can be mostly reused, with environmental variables externalized as discussed.
  • Docker Swarm: For slightly larger, but still relatively simple, multi-server deployments. Similar concepts to Compose.
  • Kubernetes: The industry standard for complex, scalable, distributed applications. Here, docker-compose.yml translates into Kubernetes manifests (Deployments, Services, PersistentVolumeClaims, Secrets, ConfigMaps). This has a steeper learning curve but offers immense power.

CI/CD Pipeline

For production, you’ll want an automated process:

  1. Code Commit: Push your changes (WordPress code, Dockerfiles, Nginx configs) to Git.
  2. Build Image: Your CI pipeline builds the Docker image(s) for your WordPress application and Nginx.
  3. Tag and Push: Tag the images with a unique version (e.g., Git commit hash) and push them to a Docker Registry (Docker Hub, AWS ECR, GCR).
  4. Deployment: Your CD pipeline pulls the new images and updates your running containers on your production server(s) using your chosen orchestrator.

If you’re looking to enhance the performance of your containerized WordPress site, you might find it beneficial to explore optimization techniques. A related article that can help you understand how to improve your website’s speed is available at Google PageSpeed Insights. This resource provides valuable insights into optimizing your site for better performance, which can be particularly useful when working with Docker and Docker Compose for both local and production environments.

Final Thoughts on Parity and Maintenance

Achieving true 100% production parity is often an aspirational goal, not always fully attainable or even necessary. The key is sufficient parity to prevent “works on my machine” issues.

Iteration and Refinement

Your Docker setup will evolve. Start simple, then add complexity as needed. Don’t over-engineer from day one.

Monitoring and Logging

Ensure you have proper monitoring for your containers (CPU, memory, network) and centralized logging. Containers often write logs to stdout/stderr, which can be collected by your orchestration platform or a dedicated logging solution.

Security Updates

Regularly update your base images (wordpress, nginx, mariadb). Baking these into your CI/CD process ensures you’re always running secure, up-to-date environments.

By following these guidelines, you’ll create a robust, consistent, and maintainable WordPress environment using Docker, bridging the gap between your local development and your production deployments.