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:
- MySQL container — holds the WordPress database, backed by a named volume so data survives restarts.
- WordPress container — runs PHP-FPM/Apache, serves the site, and connects to MySQL over the network by container name.
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
- Never expose MySQL’s port publicly. In all the examples above, MySQL has no
-p 3306:3306published to the host — it’s only reachable from WordPress over the internal network, which is exactly how it should be in production. - Use environment-injected secrets, not hardcoded passwords, especially in any Compose file or manifest that ends up in version control. Use a
.envfile excluded via.gitignorefor Compose, andSecretobjects for Kubernetes. - Keep WordPress and MySQL images updated — both are common attack targets, and stale images accumulate known CVEs.
- Isolate networks per project. Don’t put unrelated stacks on the same Docker network unless they genuinely need to talk to each other; network segmentation limits blast radius if one container is compromised.
Best Practices
- Always use named volumes for both the database and the
wp-content/wp-includesfiles — losing uploaded media is just as bad as losing the database. - Set
restart: unless-stopped(oralways) so the stack survives host reboots without manual intervention. - Use
depends_onin Compose, but don’t treat it as a readiness check — MySQL can take a few seconds to accept connections after the container starts, and WordPress’s own retry logic on first boot generally handles this gracefully, but for custom entrypoints, add an explicit wait-for-it style check. - Pin image versions (
mysql:8.4,wordpress:6.6-apache) rather than usinglatest, so upgrades are deliberate, not accidental.
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
- Docker Hub — Official WordPress Image
- Docker Hub — Official MySQL Image
- Docker Docs — Networking overview
- Docker Docs — Compose file reference
- Kubernetes Docs — Services
- Kubernetes Docs — DNS for Services and Pods
- Let’s Encrypt — How It Works