How to Run a WordPress Blog Using Two Linked Docker Containers: MySQL and WordPress Setup

How to Run a WordPress Blog Using Two Linked Docker Containers

How to Run a WordPress Blog Using Two Linked Docker Containers

WordPress and MySQL are one of the most common two-container patterns I set up, and they’re a great way to actually internalize how Docker networking and persistent storage work together, because if you get either one wrong, the site simply won’t come up. In this article, I’ll build the whole thing from scratch — a custom network, a MySQL container, a WordPress container, persistent volumes for both, and then take it further with Compose, reverse proxy/SSL, and a Kubernetes deployment.

Architecture Overview

The setup has two containers:

They communicate over a user-defined bridge network, which is what makes container-name-based DNS resolution work. This is the modern replacement for the old --link flag, which is deprecated and shouldn’t be used in new setups.

[ Browser ] --> :8080 --> [ WordPress container ] --> internal network --> [ MySQL container ]

Docker Networking Fundamentals (Quick Refresher)

When you create a user-defined bridge network, Docker runs an embedded DNS server for that network. Any container attached to it can resolve any other container’s name to its internal IP address automatically — no manual IP configuration, no /etc/hosts editing. This is different from the default bridge network, where containers can only talk to each other by IP address unless you use legacy links.

docker network create wp-network
docker network inspect wp-network --format '{{ .Driver }}'

Expected output:

bridge

Step 1: Create Persistent Volumes

docker volume create wp-db-data
docker volume create wp-content-data

Step 2: Run the MySQL Container

docker run -d \
  --name wp-mysql \
  --network wp-network \
  -e MYSQL_ROOT_PASSWORD=rootpass \
  -e MYSQL_DATABASE=wordpress \
  -e MYSQL_USER=wpuser \
  -e MYSQL_PASSWORD=wppass \
  -v wp-db-data:/var/lib/mysql \
  mysql:8.4

Confirm it’s healthy:

docker logs wp-mysql --tail 5

Expected output (near the end of startup):

[Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.0' socket: '/var/run/mysqld/mysqld.sock' port: 3306

Step 3: Run the WordPress Container

docker run -d \
  --name wp-app \
  --network wp-network \
  -e WORDPRESS_DB_HOST=wp-mysql \
  -e WORDPRESS_DB_NAME=wordpress \
  -e WORDPRESS_DB_USER=wpuser \
  -e WORDPRESS_DB_PASSWORD=wppass \
  -v wp-content-data:/var/www/html \
  -p 8080:80 \
  wordpress:6.6-apache

Note that WORDPRESS_DB_HOST is set to wp-mysql — the container name, not an IP address. This only resolves because both containers are attached to the same user-defined network, wp-network.

Verify:

curl -sI http://localhost:8080 | head -n 1

Expected output:

HTTP/1.1 200 OK

Visiting http://localhost:8080 in a browser should now show the WordPress installation wizard.

Step 4: Verify Container-to-Container DNS Resolution

docker exec wp-app getent hosts wp-mysql

Expected output:

172.19.0.2      wp-mysql

This confirms the WordPress container is resolving the MySQL container by name through Docker’s embedded DNS — this is the mechanism that replaced the old --link flag.

Docker Compose: The Practical Way to Run This

In real usage, I never run these as two separate docker run commands — I define the whole stack in Compose:

version: "3.9"

services:
  db:
    image: mysql:8.4
    container_name: wp-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wpuser
      MYSQL_PASSWORD: wppass
    volumes:
      - wp-db-data:/var/lib/mysql
    networks:
      - wp-network

  wordpress:
    image: wordpress:6.6-apache
    container_name: wp-app
    restart: unless-stopped
    depends_on:
      - db
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wpuser
      WORDPRESS_DB_PASSWORD: wppass
    volumes:
      - wp-content-data:/var/www/html
    ports:
      - "8080:80"
    networks:
      - wp-network

networks:
  wp-network:
    driver: bridge

volumes:
  wp-db-data:
  wp-content-data:

Notice WORDPRESS_DB_HOST here is db — the service name in Compose, which Compose’s own DNS resolves automatically within the project’s default network, same principle as the raw docker network create example above.

docker compose up -d
docker compose ps

Expected output:

NAME       IMAGE                  STATUS         PORTS
wp-app     wordpress:6.6-apache   Up 10 seconds  0.0.0.0:8080->80/tcp
wp-mysql   mysql:8.4              Up 12 seconds  3306/tcp

Internal Working: Bridge Networking and Container Linking

A user-defined bridge network creates a Linux bridge interface on the host (visible via ip link as something like br-xxxxxxxx), and each container gets a virtual ethernet pair connecting its network namespace to that bridge. Docker’s embedded DNS server, running inside the Docker daemon on 127.0.0.11 within each container’s network namespace, intercepts DNS queries for container/service names and resolves them to the correct internal IP.

This is a meaningful architectural improvement over the deprecated --link flag, which worked by injecting static /etc/hosts entries and environment variables at container start time — brittle, one-directional, and unable to handle IP changes on container restart. With a user-defined network, name resolution is dynamic and updates automatically if a container is recreated with a new IP.

You can see the bridge and the container’s attachment to it directly:

docker network inspect wp-network --format '{{ range .Containers }}{{ .Name }} {{ .IPv4Address }}{{ "\n" }}{{ end }}'

Expected output:

wp-mysql 172.19.0.2/16
wp-app 172.19.0.3/16

Real-World Extension: Adding a Reverse Proxy with SSL

For an actual production blog, I don’t expose WordPress on a raw port — I put a reverse proxy in front for TLS termination and a proper domain name. Here’s the same stack extended with Nginx and a Let’s Encrypt companion (using the nginx-proxy + acme-companion pattern):

version: "3.9"

services:
  proxy:
    image: nginxproxy/nginx-proxy:1.6
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - certs:/etc/nginx/certs
      - vhost:/etc/nginx/vhost.d
      - html:/usr/share/nginx/html

  acme:
    image: nginxproxy/acme-companion:2.5
    volumes_from:
      - proxy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - acme:/etc/acme.sh
    environment:
      DEFAULT_EMAIL: admin@example.com

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wpuser
      MYSQL_PASSWORD: wppass
    volumes:
      - wp-db-data:/var/lib/mysql
    networks:
      - wp-network

  wordpress:
    image: wordpress:6.6-apache
    depends_on:
      - db
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wpuser
      WORDPRESS_DB_PASSWORD: wppass
      VIRTUAL_HOST: blog.example.com
      LETSENCRYPT_HOST: blog.example.com
    volumes:
      - wp-content-data:/var/www/html
    networks:
      - wp-network
      - default

networks:
  wp-network:

volumes:
  wp-db-data:
  wp-content-data:
  certs:
  vhost:
  html:
  acme:

The proxy container watches the Docker socket for containers with VIRTUAL_HOST set, and automatically configures Nginx routing plus a Let’s Encrypt certificate for that domain — no manual Nginx config file editing required. Note this mounts the Docker socket into the proxy container, which is a meaningful trust boundary; only do this with proxy images you trust, on hosts where you control what else runs.

Kubernetes Deployment

For running this at scale, or just to compare the model, here’s the equivalent as Kubernetes objects — a Deployment for MySQL backed by a PersistentVolumeClaim, a Service for internal DNS, and a matching setup for WordPress:

apiVersion: v1
kind: Secret
metadata:
  name: wp-mysql-secret
type: Opaque
stringData:
  MYSQL_ROOT_PASSWORD: rootpass
  MYSQL_PASSWORD: wppass
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wp-db-pvc
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wp-mysql
spec:
  replicas: 1
  selector:
    matchLabels:
      app: wp-mysql
  template:
    metadata:
      labels:
        app: wp-mysql
    spec:
      containers:
        - name: mysql
          image: mysql:8.4
          envFrom:
            - secretRef:
                name: wp-mysql-secret
          env:
            - name: MYSQL_DATABASE
              value: wordpress
            - name: MYSQL_USER
              value: wpuser
          volumeMounts:
            - name: db-storage
              mountPath: /var/lib/mysql
      volumes:
        - name: db-storage
          persistentVolumeClaim:
            claimName: wp-db-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: wp-mysql
spec:
  selector:
    app: wp-mysql
  ports:
    - port: 3306
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: wp-content-pvc
spec:
  accessModes: ["ReadWriteOnce"]
  resources:
    requests:
      storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wordpress
spec:
  replicas: 1
  selector:
    matchLabels:
      app: wordpress
  template:
    metadata:
      labels:
        app: wordpress
    spec:
      containers:
        - name: wordpress
          image: wordpress:6.6-apache
          env:
            - name: WORDPRESS_DB_HOST
              value: wp-mysql
            - name: WORDPRESS_DB_NAME
              value: wordpress
            - name: WORDPRESS_DB_USER
              value: wpuser
            - name: WORDPRESS_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: wp-mysql-secret
                  key: MYSQL_PASSWORD
          ports:
            - containerPort: 80
          volumeMounts:
            - name: wp-content
              mountPath: /var/www/html
      volumes:
        - name: wp-content
          persistentVolumeClaim:
            claimName: wp-content-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: wordpress
spec:
  type: LoadBalancer
  selector:
    app: wordpress
  ports:
    - port: 80
      targetPort: 80

The wp-mysql Kubernetes Service gives WordPress the same name-based DNS resolution that the Docker bridge network provided — WORDPRESS_DB_HOST: wp-mysql resolves through Kubernetes’ internal DNS (CoreDNS) exactly the way it resolved through Docker’s embedded DNS.

Security Considerations

Best Practices

Troubleshooting

WordPress shows “Error establishing a database connection” Check that WORDPRESS_DB_HOST matches the exact container/service name, and that both containers are on the same network:

docker network inspect wp-network
docker exec wp-app getent hosts wp-mysql

MySQL container exits immediately after start Check logs for a common first-run issue — mismatched or missing required environment variables:

docker logs wp-mysql

WordPress can’t write uploaded files Check ownership inside the container; the Apache/PHP process usually runs as www-data and needs write access to /var/www/html/wp-content/uploads:

docker exec wp-app ls -la /var/www/html/wp-content

Site loads but images/CSS are broken after moving to a new domain This is almost always the WordPress siteurl/home options still pointing at the old address, not a Docker issue — update them via wp-cli or directly in the wp_options table.

Monitoring

docker stats wp-app wp-mysql
docker compose logs -f --tail 50

For anything beyond a hobby blog, I’d add a healthcheck to both services in Compose so orchestration tools (and docker compose ps) can report actual application health, not just “container process is running”:

    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

Summary

Running WordPress on two linked containers is a genuinely good way to internalize core Docker concepts: user-defined bridge networks give you automatic, name-based service discovery between containers, and named volumes keep both the database and the site’s content durable across restarts and redeployments. From there, the same architecture scales cleanly — add a reverse proxy for TLS in a small Compose stack, or translate the same two-service pattern into Kubernetes Deployments and Services when you need more resilience and scale. The underlying idea doesn’t change: keep state in volumes, let the platform’s DNS handle service discovery, and never expose the database directly to the outside world.

References

Exit mobile version