This time it's Multisite - Migrating locally installed Wordpress to a Traefik Dockerised stack
I still have a locally installed Wordpress Multisite instance on my old server, and I will need to migrate it to a docker stack too, before I can decommission.
I have migrated my locally installed standalone Wordpress to a Docker stack, and so far it's been a great success. I still have a locally installed Wordpress Multisite instance on my old server, and I will need to migrate it to a docker stack too, before I can decommission.
I'm going to do it a little differently this time. The first time, it was a bigger site, and maximum caution was needed, I created a clone, assigned a new domain / blog name, preconfigured everything, then switched over the domain and at the same time, renamed the clone server to production. This time, I am going to try big bang. Install the WP Multisite in a Docker stack, and without any renaming, switch over the production domain. This stack is a scrappy pile of small sites with little traffic so it's no big risk to me.
Wordpress Multisite in Docker - the target infrastructure
This article applies to the right-hand container stack being Nginx, Wordpress-fpm, and MariaDB. The other containers already exist from the previous article.

There is a dummy stack already in place which was built as a "WP2" instance to prove the Nginx component. It's in default state, sitting at the Wordpress install prompt, so I will clean the data and use those containers. If you retrieve that example Docker Compose file, it's a good place to start.
If this is the first time you have visited this site, or the first time you've built something like this, I would recommend starting with this launch article, "Building Ghost in a docker container with Ubuntu, Traefik, and SQLite".
Exporting the Database and Wordpress files
I covered configuring Nginx, exporting the Database and Wordpress files at great length in the huge previous migration post, so I won't go into it here. Our starting point should be:
- A SQL dump file from the multisite database
- A tarball from the multisite html directory tree
- An nginx.conf.new file or equivalent
- A default.conf, with updated server_name, logs and PHP target (e.g. wp2:9000)
- A fastcgi-php.inc, if set up as per the previous post
- Optionally - I have an ipfilter and restrictions file
If you don't have those last five files, refer to the previous article, as you will need the first three as a minimum, nginx.conf for an http block, default.conf for a server block, and a fastcgi-php.inc or similar include file for PHP invocation.
I have to reiterate that you don't have to use Nginx. There is a valid stack with Traefik -> Wordpress (non-npm: with bundled Apache) -> MariaDB / MySQL DB, but that is not covered here. There are plenty of good articles around on that.
I am building it in this way because I like to use IP whitelisting, controlled by Nginx, and there is a fastcgi caching possibility. And I simply prefer Nginx.
Preparing config files for multisite
The default.conf file I have in use in the "WP1" stack is for a single Wordpress site. That means that all the traffic that Traefik is delivering will be for one domain, and hence the server_name parameter in the server block is not necessary. But wait - my experiments show that Traefik can be configured easily with multiple domains, and the same default.conf can be used - also without server_name parameters.
In the docker-compose.yml we can simply update the Nginx service Traefik traefik.frontend.rule=Host label with comma separated values for each domain to be forwarded to the multisite. (The full compose file is at the end) For example:
nginx-wp2:
image: nginx:alpine
restart: unless-stopped
ports:
- '80'
volumes:
- /data/nginx-wp2:/etc/nginx/conf.d
- /data/nginx-wp2/nginx.conf.new:/etc/nginx/nginx.conf
- /data/logs/nginx-wp2:/var/log/nginx
- /data/wp2:/var/www/html
labels:
- traefik.backend=nginxwp2
- traefik.frontend.rule=Host:yourblog2a.com,www.yourblog2a.com,yourblog2b.com,www.yourblog2b.com,yourblog2c.com,www.yourblog2c.com,yourblog2d.com,www.yourblog2d.com
- traefik.docker.network=web
networks:
- web
- internal
depends_on:
- wp2Hmmm... surely it can't be this easy.
If you built this from the previous post there are instructions there to shutdown and scrub the Wordpress and MariaDB containers, and stop the Nginx-wp2 one, do that now.
Unpack the tarball, and import the Database as per the last post, substituting all the 1s with 2s.
This time, because I like to live dangerously, I am not going to do a blog/URL rename.
Set up the /data/nginx-wp2 files. I moved the old ones to a backup, then copied in the set of files from /data/nginx-wp1. The files needed editing and changing all instances of wp1 to wp2. There are some key contents we need in the Nginx config examples from the previous post.
The default Nginx config files are alright where this is a multisite configuration of separate domains. Where you have subdomain usage (such as a root site of yourblog.com, and the multisites as blog1.yourblog.com, blog2.yourblog.com etc), special Nginx config is needed. See the Nginx.com site for more.
Building the compose / Wordpress wp-config.php file for multisite
One key difference with Multisite is that it will need a bunch more parameters in the wp-config.php. These can probably be lifted from your current production config. You have a choice to either inject the parameters from the compose file, or load in a static wp-config.php and maintain it on the server.
An important note on docker compose and Wordpress wp-config.php:
If you have an existing wp-config.php in place when you run the docker-compose, it will not add any WORDPRESS_CONFIG_EXTRA content. If you don't have a wp-config.php, it will generate a new one based on your docker-compose parameters, and will add WORDPRESS_CONFIG_EXTRA content.
I am choosing to generate a new wp-config.php from my compose file, so after copying in the tarball, I move wp-config.php to a backup location. My WP2 compose entry looks like this (a full compose file is at the end).
wp2: # This is called from the Nginx config file
image: wordpress:php7.3-fpm-alpine
restart: unless-stopped
logging:
driver: syslog
user: "33"
environment:
WORDPRESS_DB_HOST: mariadb2 # This must match the DB service name
WORDPRESS_DB_NAME: wp2
WORDPRESS_DB_USER: wp2_user
WORDPRESS_DB_PASSWORD: secure_password_123
WORDPRESS_CONFIG_EXTRA: |
define('WP_CACHE', false);
define('WP_ALLOW_MULTISITE', true );
define('MULTISITE', true);
define('SUBDOMAIN_INSTALL', true);
define('DOMAIN_CURRENT_SITE', 'yourrootblog.com');
define('PATH_CURRENT_SITE', '/');
define('SITE_ID_CURRENT_SITE', 1);
define('BLOG_ID_CURRENT_SITE', 1);
define( 'FORCE_SSL_ADMIN', true );
define('COOKIE_DOMAIN', $$_SERVER['HTTP_HOST']);
labels:
- traefik.enable=false
networks:
- internal
volumes:
- /data/wp2:/var/www/html
depends_on:
- mariadb2First run of the compose update will generate a new wp-config.php (if there isn't one already there) and inject all of these parameters. You may choose not to do this and rely on manually updating the file.
I have been evolving these parameters over time, there are changes to the depends_on, and the logging. I might do a full throwdown of docker logging at a later time (I did!), but for now, the logging / driver / syslog as seen here will at least send logs to the default system log, e.g. /var/log/syslog
Go-live - press the domain name switch
All my containers are running, no relevant errors in the logs. I throw caution to the wind and update DNS for the root domain, and shortly after, the other domains.
There is a problem with the database - since fixed and reflected in the supplied compose examples.
Troubleshooting the multisite transition
Overall this went fairly well, with the key takeaway being that a big bang (i.e. without the domain change) preserved all of the theme, widget, menu etc settings, which is a win, especially with a lot of blogs.
Strange session behaviour
Disable caching and minifying plugins and options until you are well clear of migration. Try private/incognito browsers before panicking.
Containers in a restart death spiral
One of the first things to check is if your container is in a restart loop. In the docker ps output you will quickly see in the STATUS column if a container is in a restart cycle. This will often be because of an incorrectly mounted volume, an invalid network connection, invalid parameters, or incorrect permissions. Log files are your friend - see the logging section below.
"Cannot establish database connection" in a docker environment
Usually your wp-config.php DB connect parameters don't match the actual database ones. Try connecting into the DB container shell and logging in manually with the same creds, bearing in mind this is not root, it's the application user such as wp2_user. e.g.
ubuntu@ip-10-10-10-10:~$ docker exec -it traefik_mariadb2_1 bash
root@86bbb9531848:/# mysql -u wp2_user -p
Enter password:
[enter database user password]
Welcome to the MariaDB monitor. Commands end with ; or \g.
Your MariaDB connection id is 10
Server version: 10.4.7-MariaDB-1:10.4.7+maria~bionic mariadb.org binary distribution
Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
MariaDB [wp2]> show grants for wp2_user;
+---------------------------------------------------------------------------------------------------------+
| Grants for wp2_user@% |
+---------------------------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'wp2_user'@'%' IDENTIFIED BY PASSWORD 'long-password-appears-here' |
| GRANT ALL PRIVILEGES ON `wp2`.* TO 'wp2_user'@'%' |
+---------------------------------------------------------------------------------------------------------+
2 rows in set (0.003 sec)
MariaDB [(none)]> quit
Bye
root@86bbb9531848:/# exit
exit
ubuntu@ip-10-10-10-10:~$If you can get in to the MariaDB / mysql prompt as above, the creds work. If you created the wp2_user equivalent from docker it will have the admin permissions it needs. Otherwise you will need to grant them.
The wp2 container needs to be in the same docker network as the DB container (in my example the "internal" network). Also, in theory, the "depends-on" for wp2 should refer to mariadb2 although I haven't really tested if that's a show stopper.
Sometimes, there is a problem such as Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock', which I encountered today, in which case a restart of the MariaDB container will probably fix (it did for me). There may be more mileage in deep diving on log files if you want to know why.
Checking log files
This saved my bacon so many times. If you are trying to debug, have at least a syslog entry in the compose file as above, for any containers you want to be able to check.
Get the container ID from docker ps, and monitor the syslog thus. If my container ID is 22f785f850df :
$ tail -f /var/log/syslog | grep 22f785f850dfLeaving a screen running with this and working alongside in another screen will help with debugging.
You also have Nginx log files available, as defined in your default.conf. So another example:
$ tail -f /data/logs/nginx-wp2/wp2-error.log..or wp2-access.log. Logging to /data, and with no rotation like this is bad, I know. I must look into getting the logging happening properly, to the right place, with the right naming conventions, and with housekeeping. I did!
File and directory permissions
I tend to be reactive with this. I can't really say what the permissions should be, but what I got working in the end looks like this.
- nginx-wp2 and below - recursive chown www-data:www-data
- Perms: -rw-rw-r--
- Owner: www-data
- Group: www-data
- wp2 and below - recursive chown www-data:www-data
- Perms: Did a recursive chmod g+w
- Owner: www-data
- Group: www-data
- Run the container as the same user you choose, in my case this is 33 for www-data (check in /etc/passwd). Add compose parameter for user: "33"
- mariadb2 and below
- Perms: Mixed, -rw-rw---- for main files
- Owner: Defaults to 999
- Group: Defaults to docker
I added my ubuntu user to the www-data group in the previous article with sudo usermod -aG www-data ${USER}. This helps with editing and navigating Nginx and Wordpress directories without having to sudo all the time. If doing this, log out and back in to activate it.
I have never needed to access the database files directly. It should probably either have 999 added to the local host or run as something that is on the local host, but it works.
Performance issues
Server-side at least, I found that late on in my migration, the CPU went to 100% and the memory had filled. I couldn't do much except reboot. Browsing through the syslog I notice there is a "Starting Daily apt download activities" around the time it started so perhaps it was related.
docker stats is useful for a live picture of what the containers are doing, though if the server is being too smashed it won't run.
I have another post on reducing memory use in this build configuration.
Wrapping up
There is not too much to check in this case as the sites don't need reconfiguring. I do check those with SMTP and re-test emails, as these are notoriously fickle. I have a quick skim of my syslog. It's being smashed by all the docker noise at the moment so I might at some stage comment out the logging entries, or just get the logs better organised.
I'm also going to add some kind of external service monitoring to my to do list, to identify when a site is down. This is available from Jetpack in Wordpress, though I'd like to avoid installing it, and longer outages come up from Google webmaster, but something more light, and more immediate would be good.
Otherwise - I think I am good to snapshot and shutdown the original server!
Example of the full docker-compose.yml (with WP1 parts, adminer, removed for clarity)
version: "3"
networks:
web:
external: true
internal:
external: false
driver: bridge
services:
traefik:
image: traefik:maroilles-alpine
restart: unless-stopped
logging:
driver: syslog
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /data/traefik/traefik.toml:/traefik.toml
- /data/traefik/acme.json:/acme.json
labels:
- traefik.backend=traefik
- traefik.frontend.rule=Host:monitor.yourbloghost.com
- traefik.port=8080
networks:
- web
nginx-wp2:
image: nginx:alpine
restart: unless-stopped
logging:
driver: syslog
ports:
- '80'
volumes:
- /data/nginx-wp2:/etc/nginx/conf.d
- /data/nginx-wp2/nginx.conf.new:/etc/nginx/nginx.conf
- /data/logs/nginx-wp2:/var/log/nginx
- /data/wp2:/var/www/html
labels:
- traefik.backend=nginxwp2
- traefik.frontend.rule=Host:yourblog2a.com,www.yourblog2a.com,yourblog2b.com,www.yourblog2b.com,yourblog2c.com,www.yourblog2c.com,yourblog2d.com,www.yourblog2d.com
- traefik.docker.network=web
networks:
- web
- internal
depends_on:
- wp2
mariadb2: # This DB service name is also the DB Host name
image: mariadb:10.7
command: --max-allowed-packet=128MB
restart: unless-stopped
volumes:
- /data/mariadb2:/var/lib/mysql
labels:
- traefik.enable=false
environment:
MYSQL_ROOT_PASSWORD:
MYSQL_DATABASE: wp2 # This name is internal to the DB
MYSQL_USER: wp2_user
MYSQL_PASSWORD: secure_password_123
networks:
- internal
wp2: # This is called from the Nginx config file
image: wordpress:php7.3-fpm-alpine
restart: unless-stopped
logging:
driver: syslog
user: "33"
environment:
WORDPRESS_DB_HOST: mariadb2 # This must match the DB service name
WORDPRESS_DB_NAME: wp2
WORDPRESS_DB_USER: wp2_user
WORDPRESS_DB_PASSWORD: secure_password_123
WORDPRESS_CONFIG_EXTRA: |
define('WP_CACHE', false);
define('WP_ALLOW_MULTISITE', true );
define('MULTISITE', true);
define('SUBDOMAIN_INSTALL', true);
define('DOMAIN_CURRENT_SITE', 'yourblog2a.com'); # Your root site
define('PATH_CURRENT_SITE', '/');
define('SITE_ID_CURRENT_SITE', 1);
define('BLOG_ID_CURRENT_SITE', 1);
define( 'FORCE_SSL_ADMIN', true );
define('COOKIE_DOMAIN', $$_SERVER['HTTP_HOST']);
labels:
- traefik.enable=false
networks:
- internal
volumes:
- /data/wp2:/var/www/html
depends_on:
- mariadb2Optional extras - docker-compose.yml example for Adminer (phpMyAdmin)
Note I used to have Watchtower here but had problems. Now I run a cron script.
adminer:
image: adminer:4
labels:
- traefik.backend=adminer
- traefik.frontend.rule=Host:db-admin.yourhost.com
- traefik.port=8080
- traefik.docker.network=web
networks:
- web
- internal
depends_on:
- mariadb2Best of luck on the migration road!
Main photo courtesy of HelpStay on Unsplash
Retrospective blog post to use BlueSky for comments: techroads.org/this-time-it... #Wordpress #Docker
— TechRoads blog (@techroads.org) Feb 22, 2024 at 11:36 am
[image or embed]