I’ve been self-hosting my blog on a Linode VPS for a while now. Bare metal WordPress, Apache, and MySQL. It worked. It was fine. But I’d been hearing about containers for years and decided it was time to stop nodding along and figure it out. Honestly what turned me off the most about it was hearing people say “Just stick it in a container” like that was some kind of easy solution I didn’t think of. Duh!
Anyways, this is the story of how I moved my blog into containers. Mistakes and all.
Why Bother?
I’ve been told that instead of installing software on the server and hoping nothing conflicts, the container has everything it needs to run and the server doesn’t care.
Also, whenever the topic of containers comes up, someone inevitably says “infrastructure as code”.
For me, there was also a simpler reason: I wanted to learn it, and my blog seemed like the right project.
The Setup Going In
- Linode VPS, freshly upgraded from Ubuntu 22.04 to 24.04
- WordPress running directly on Apache
- MySQL on the host
- Let’s Encrypt handling HTTPS via Certbot
- One snapshot slot. So I used it before touching anything
Stage 1: Getting Everything Out
Before Docker can run my blog, it needs a copy of everything that makes it mine: the database and the wp-content folder (themes, plugins, uploads).
First, create the project directory:
mkdir -p ~/wp-docker/db-init ~/wp-docker/wp-content
cd ~/wp-docker
Then export the database. First attempt:
mysqldump -u johnny -p wordpress > ~/wp-docker/db-init/site-backup.sql
Gotcha #1: MySQL 8 throws an access error if your user doesn’t have the PROCESS privilege. The fix is straightforward:
mysqldump --no-tablespaces -u johnny -p wordpress > ~/wp-docker/db-init/site-backup.sql
Then copy wp-content from the live site. Note for future me: my actual path had an extra public_html level that the guides I was following didn’t account for:
cp -a /var/www/html/johnnycarlos.com/public_html/wp-content/. ~/wp-docker/wp-content/
Stage 2: First Launch
The heart of the setup is a docker-compose.yml. It’s a config file that describes two containers: one for WordPress (Apache + PHP baked in) and one for MySQL. Drop a .env file next to it with your database credentials, and Docker Compose does the rest.
docker compose up -d
First run pulls the images from Docker Hub.
Where does the database actually live? This confused me. The answer is a Docker “named volume”. Docker manages a directory on the host (under /var/lib/docker/volumes/) and mounts it into the container. It’s not inside the container itself, so it survives container restarts and rebuilds. The wp-content folder, on the other hand, is a plain bind-mount to ~/wp-docker/wp-content. A normal directory you can see and touch directly.
Stage 3: Getting the URLs Right
Visiting http://your-server-ip:8080 in an incognito window (important, regular browser cache will lie to you) should show your site. Mine redirected immediately back to the live domain.
Gotcha #2: WordPress stores siteurl and home in the database, and those values override everything else — including any config you set in wp-config.php. The database still had https://johnnycarlos.com hardcoded.
Fix it directly:
docker compose exec db mysql -u johnny -p wordpress -e \
"UPDATE wp_options SET option_value='http://your-ip:8080' \
WHERE option_name IN ('siteurl','home');"
After that, the containerized site loaded correctly on port 8080. My blog, running in a container, completely separate from the live site.
Stage 4: Infrastructure as Code
One of the quiet wins of this whole project: your entire server setup is now a handful of text files.
git init
printf '.env\nwp-content/\ndb-init/*.sql\n' > .gitignore
git add docker-compose.yml .env.example php-uploads.ini .gitignore
git commit -m "WordPress infrastructure as code"
The .gitignore keeps secrets (.env), bulky content (wp-content), and the database dump out of the repo. What’s left is a clean, version-controlled description of how to rebuild the whole thing from scratch.
Stage 5: Cutover
This is the part I was scared of. Everything up to here had been parallel. The live site was untouched. Cutover meant actually switching.
Before doing anything: take a snapshot.
Then re-run the database dump and wp-content copy to capture everything posted since Stage 1. Gotcha #3 (subtle one): Docker’s auto-import from db-init/ only runs once, on the very first startup when the database volume is empty. If the volume already exists, MySQL ignores the folder entirely. To force a fresh import you need docker compose down -v (which destroys the volume) before bringing things back up. I learned this the hard way when my fresh dump didn’t load and I had to redo some minor content changes manually.
For the cutover itself, I kept my existing Apache rather than adding a new reverse proxy. Apache is already handling HTTPS with Certbot — why replace something that’s working? Instead, I reconfigured it to proxy traffic to the container on port 8080:
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
Enable the required modules first:
sudo a2enmod proxy proxy_http headers
sudo apache2ctl configtest
sudo systemctl reload apache2
Gotcha #4: After cutover, the site loaded but CSS and fonts were broken. The browser console showed mixed content errors. WordPress was generating http:// URLs for assets while the page itself was served over https://. The fix is the RequestHeader set X-Forwarded-Proto "https" line above, which tells WordPress it’s sitting behind an SSL connection. Without it, WordPress doesn’t know it’s behind a proxy and generates insecure URLs.
After adding that header and reloading Apache: everything worked.
What It Looks Like Now
Internet → Apache (port 443, SSL, Let's Encrypt) → WordPress container (port 8080)
→ MySQL container (internal only)
Certbot still handles certificate renewal exactly as before. Apache still handles all the public-facing traffic. The containers just handle WordPress and MySQL, tucked away on an internal port.
My live site ran on bare metal for years. Now it runs in a container, and from the outside nothing has changed.
Lessons Learned
- Containers don’t trap your data. The database lives in a Docker-managed volume on the host.
wp-contentis a plain directory. Rolling back to bare metal is amysqldumpand a folder copy. - The browser cache will gaslight you. Always test in incognito when debugging redirect issues.
- The
db-init/auto-import only runs once. If you need to reimport,docker compose down -vfirst. - Keep the old install around for a few weeks. Rolling back is just stopping the containers and restarting Apache. That safety net is worth more than the disk space.
- Your existing Apache is a perfectly good reverse proxy. You don’t need Nginx or Caddy if Apache is already doing the job.
This blog runs on a Linode VPS, Ubuntu 24.04, Docker Compose, and stubbornness.