WordPress Containerized with SQLite
Building a WordPress site in a container, backed by SQLite
What’s this about?
I have a handful of WordPress sites I’ve built for friends and family over the years. They’ve each been built and deployed in different ways as my development skills changed over that timeframe, which means each time I need to revisit one of the sites, I have to relearn how it’s deployed and how to make updates.
I want to rethink how I deploy these WordPress sites in a way that is
- modern
- repeatable
- reliable
- cheap
Currently, each site has their own dedicated VM, and all pieces of the stack run on that VM. None of them have backups, so if I lose any of the VM’s, I’m screwed. Some of them, I’ve been doing development in production, and I can’t guarantee that the parts I maintain are even stored to a repository.
So the solution I hope to create here
- Makes the runtime ephemeral
- Keeps state in a more reliable location
- Treats the environment as “immutable”, where changes must be commited to the repository and redeployed.
The New Stack
Runtime
Rather than a VM-based deployment, I’ve chosen to containerize each site. Here’s a few reasons I prefer to containerize these sites going forward:
- The runnable artifact is easily portable.
- The production environment is easily repeatable locally.
- There’s a wide variety of tooling around containers that I enjoy.
- Builds are fast.
By using containers, I’m personally pushed to treating the runtime as more ephemeral than if I were using a VM. Treating the containers as though they won’t live for too long encourages better practices regarding the stability of the stateful portions of the site. This is realistically how I should’ve been treating my VM’s too, but I naively never really bothered with that. So a brand new architecture provides a wide open opportunity to push myself toward better practices.
Database
WordPress requires a MySQL Database. I don’t have anything against MySQL, but operating it requires more thought than handling an SQLite database. SQLite is not supported by WordPress, but there are shims that the community have created to add support for SQLite. I’m using this one without issue as of yet. Since it’s not officially supported, there’s certainly some degree of risk for errors to occur, but I’m okay with that.
SQLite isn’t necessarily the best solution, particularly for a container-based architecture. For one thing, it limits your scalability options to vertical-scalability. It also means I can’t deploy a highly-available system; if the container crashes, the site remains down until a new container replaces it.
For my purposes, however, these are not concerns I worry about. I’m primarily running portfolio and brochure websites with low traffic. If there’s downtime, that’s okay with me. No one is dying or losing sleep over this kind of downtime.
The SQLite database that the application makes use of does reside directly on the container, which means if the container goes down, the database disappears with it. This is obviously not a good scenario.
To alleviate that, I’m using Litestream. Litestream continuously streams SQLite changes to a variety of external storages. Litestream becomes the primary process in the container, and starts a subprocess to run Apache to handle requests to the WordPress site. On startup, Litestream restores the database from external storage, and continues to replicate the database back to external storage. So when a container goes down, I have a copy of the database for the next container to start from.
Uploads
With the container runtime being treated as ephemeral, the uploads directory can’t live directly on the container. Just as with the SQLite database, if not handleded differently, each time the container is removed, all of the uploads would disappear with it.
Uploads are a little easier to handle, since handling assets in an ephemeral environment common problem than the database issues we get with SQLite, since any highly-available application needs to think about this problem. The common solution, which I employ here, is to put uploads in an object store separate from the container. HumanMade’s S3 Uploads plugin for WordPress does exactly this for me. Uploaded files get pushed to an S3 compatible object store, and when links are provided to the upload, it rewrites the URL to the object store’s url.
A few caveats
Composer
Since none of the sites I’m migrating have used Composer, I’d like to not add another component to the new stack that I need to be concerned about. One reason I’d consider Composer is to install WordPress for me, but the official WordPress container image does that for me. The other reason I’d consider Composer is to install plugins, but I am comfortable with installing plugins directly into the container image at build time. In my opinion, it’s even simpler than using Composer, because I don’t need to learn how to do things “the Composer way”. I can just copy files around and call it a day.
Auto-Updating
Automatic Updates to WordPress Core, plugins, or third party themes are pointless, since those updates will be lost when the container is replaced. The correct way to update those dependencies is to change the versions in the Containerfile and rebuild/redeploy.
I’ve specifically built these sites for non-technical users to be able to update content themselves, without having to call me up. I need a way to communicate to them “Just don’t bother updating these - I’ll handle it.” With the base assumption that they’ll forget anything I tell them, it’s better to find a way in the code to prevent them from either performing updates or from seeing that anything is outdated. I don’t do so in this post, but it’s worth exploring different options to manage this.
- Disable automatic updates
- Hide relevant admin pages from users that aren’t you
- Intercept upgrade downloads and return a message
The idea is for the code in the container to never change from what’s built into the image, so that you can know that redeploying the image won’t have any undesired side-effects as a result of using different versions of packages.
Step by Step
This step by step assumes an empty repository as a starting point. See the final repository here.
podman
and podman-compose
cli tools for interacting with container
images. They are mostly interchangable with the docker
and docker-compose
cli tools, so where you see podman
, you should be able to switch that with
docker
without issue.Database
Create two files: Containerfile
and wp-config.php
. These install and
configure the SQLite shim.
Containerfile
:
|
|
wp-config.php
:
|
|
Current Directory Structure:
.
├── Containerfile
├── wp-config.php
The Containerfile
installs the SQLite shim and copies our wp-config.php
file
into the image. A few Containerfile practices I employ that are worth noting, in
case you’re unfamiliar:
Containerfile
is just aDockerfile
, but employs a vendor-agnostic naming convention.- Uses a Multistage Build. See tip below for more info.
- Uses the fully qualified image name. Podman on my machine expects the fully
qualified name of the image, whereas Docker will assume the
docker.io
when not present. Docker should still read this fine.docker.io
is the registry for images you’ll find on Docker Hub.
The shim works out of the box. It will create a database file in
wp-content/database
. I’ve set the DB_DIR
and DB_FILE
constants in
wp-config.php
explicitly for two reasons:
- I don’t want to revisit the shim’s source code in the distant future to find the default location of my database.
- Later in the Step by Step, we’ll use Litestream to copy the database into the container at startup. In order to keep a consistant location of the database between the shim and Litestream, it’s safer to define this explicitly.
The wp-config.php
also has some boilerplate I borrowed from the default config
file that the WordPress image
uses when one isn’t provided.
A Multistage Build, in concept, creates several images from which you can inherit artifacts from. When creating a Containerfile with several image definitions, refer to the images as stages. Each stage can grab artifacts from stages defined previously in the file. The reason to do this here is to keep the final image as minimally additive to the base WordPress image as possible.
In the current situation, my final image does not need curl
installed, but I
need curl
or a similar tool to download a remote file. There are other ways to
get remote files into an image without a tool like this, but later we’ll add
some plugins which need to be unzipped. We can add any other tools we need, such
as unzip
, and do the work of unzipping archives in this former stage, then
only copy the things we need into our final image. So we reduce the installed
packages in the final image, and don’t need to concern ourselves much with
cleanup of interim artifacts.
Here’s why I think it’s important to be minimally additive to the final image:
- I trust the maintainers of the official WordPress image to invest more resources into the security of the image than I am able to. Installing new packages has the potential to introduce unpatched security issues into my image.
- The smaller that I can keep the final image in size, the faster my production environment can download the image and start running it. A significant chunk of startup time of an image on a fresh production node is getting a copy of the image to read. Fewer bytes = less time to download.
Make sure that a SQLite database file is created and populated. The database file is created immediately, but isn’t populated until you’ve completed the site setup.
Startup
|
|
Setup
Visit http://localhost:8080/wp-admin and setup the site.
Inspect
|
|
You should be able to poke around the database and see that it was populated!
Cleanup:
|
|
Theme
For the sake of simplicity, this Step by Step will copy a free theme into the repository. Using SKT Software theme.
“Create” a theme:
|
|
Add the theme to the Containerfile
|
|
Current Directory Structure:
.
├── Containerfile
├── skt-software
│ ├── ...
└── wp-config.php
For the sake of simplicity in the Step by Step, I’m electing to use a free theme, rather than create one. There’s nothing special about the one I chose.
The main idea is to create a directory for your theme, and keep all theme files there. Don’t polute the root directory with theme files. Leave that for build and config-like files.
Notice that the SQLite shim and wp-config.php
went into /usr/src/wordpress/
,
but the theme is going into /var/www/html/
. When the container starts, the
entrypoint copies files from /usr/src/wordpress/
-> /var/www/html/
. The
entrypoint allows for us to copy themes and plugins directly into where they’ll
be served from. But any other WordPress files we want to modify need to be
placed in the source directory to be copied in at run time.
Make sure that a SQLite database file is created and populated. The database file is created immediately, but isn’t populated until you’ve completed the site setup.
Startup
|
|
Setup
- Visit http://localhost:8080/wp-admin and setup the site.
- Under
Appearance > Themes
, select the SKT Software theme.
Inspect
Visit the frontend at http://localhost:8080/, and see the theme in use.
Cleanup:
|
|
Plugins
Adding a single third-party plugin for demonstration.
Install plugin from wordpress.org:
|
|
Current Directory Structure:
.
├── Containerfile
├── skt-software
│ ├── ...
└── wp-config.php
Installing a plugin is simply taking the plugin directory and copying it into
/var/www/html/wp-content/themes/
. The plugin can come from anywhere - your
repository, a remote repository,
wordpress.org, etc…
In this case, I’m installing Advanced Custom
Fields from
wordpress.org. Notice in the top-right of the page, there’s a Download
button.
This is a direct link to the zip archive of the plugin.
Startup
|
|
Setup
- Visit http://localhost:8080/wp-admin and setup the site.
- Under
Appearance > Themes
, select theSKT Software
theme. - Under
Plugins
, activate theAdvanced Custom Fields
plugin.
Cleanup:
|
|
Local Development
Mount the theme directory:
|
|
Setup
- Visit http://localhost:8080/wp-admin and setup the site.
- Under
Appearance > Themes
, select theSKT Software
theme. - Visit the homepage: http://localhost:8080/
Inspect
Make some change to the files. I changed the output of the footer.php
.
|
|
Refresh the homepage, and see the footer change.
Cleanup:
|
|
Data Persistence
Minio
Create a directory for the database, and don’t commit it:
|
|
.gitignore
|
|
Create an entrypoint to create the bucket
.local/makebucket-entrypoint.sh
|
|
|
|
Define containers:
compose.yml
|
|
Current Directory Structure:
.
├── .gitignore
├── .local
│ ├── database
│ ├── makebucket-entrypoint.sh
├── compose.yml
├── Containerfile
├── skt-software
│ ├── ...
└── wp-config.php
To persist data when the WordPress container shuts down, we’ll use Litestream. Litestream is a tool built specifically for SQLite data replication, and supports numerous backends for which to replicate to. For local development, we’ll use Minio in a separate container to emulate an S3 bucket.
Here, we create a compose file, since we’re going to coordinate multiple containers together. In this section, I only want to get Minio working locally and make sure I can persist data, and in the next section we can add the WordPress container as well.
There are two containers being created here. The first we’re calling minio
,
which is the minio
server process that will host the local S3 bucket. The
second is makebucket
, which uses an image that has the mc
command from
minio. mc
is a cli tool similar to the aws s3
cli tools. The container will
remain alive only long enough to do some startup work, then it will exit.
In this case, I want to make sure that a bucket is created for us to put objects into, and that those objects persist on container restarts.
Note that I’ve added the entrypoint script as a file, whereas I could just put
it directly in the compose.yml
file since it’s only going to be used for local
development purposes. docker-compose
is able to parse this type of entrypoint
just fine, but podman-compose
is not able to at the time of this writing.
Rather than fight the issue in podman-compose
, it’s easier to make the
entrypoint a separate file.
So, the chain of operation right now is first for the minio
container to
start, then the makebucket
container will start and run the mc
commands to
create a bucket for us to use.
Setup
|
|
Inspect
- Add a file to the bucket
- Visit http://localhost:9000
- Navigate to the
Buckets
page - Browse the
litestream
bucket - Upload a file
- Restart the containers
1
podman-compose -f compose.yml down && podman-compose -f compose.yml up
- Check that the file is still in the bucket
- Visit http://localhost:9000
- Navigate to the
Buckets
page - Browse the
litestream
bucket
- Validate the volume mount
1
ls .local/database/litestream
Cleanup:
|
|
Litestream
litestream.yml
|
|
entrypoint.sh
|
|
Containerfile
|
|
compose.yml
|
|
.local/wp-entrypoint.sh
|
|
Current Directory Structure:
.
├── .gitignore
├── .local
│ ├── database
│ ├── makebucket-entrypoint.sh
│ ├── wp-entrypoint.sh
├── compose.yml
├── Containerfile
├── entrypoint.sh
├── litestream.yml
├── skt-software
│ ├── ...
└── wp-config.php
Using Litestream isn’t an overly complicated ordeal, but there are several files being touched for this portion.
The first is litestream.yml
. There’s nothing in the config file that couldn’t
be defined on the CLI, but since one of my goals is to be able to revisit the
project long into the future with few knowledge gaps, it’s better to have a
structured definition. The config file is pretty self-explanatory.
WordPress gets a new entrypoint, which is responsible for
restoring the existing database, and replicating any new changes made to the
database. It also starts the default entrypoint as a subprocess. A few notes
about entrypoint.sh
:
- The conditional allows for you to place your own database file into the container, but note that Litestream will replicate it to the external storage.
- The
chown
ensures that the Apache user is able to utilize the database. - The last line of
entrypoint.sh
begins replication of the database, and with the-exec
flag, we start a subprocess, which is the WordPress image’s default entrypoint.
Containerfile
has been updated to install Litestream, and add the new
Litestream config and entrypoint that were just created.
Rather than starting the WordPress container directly as we have been so far,
adding it to the compose.yml
to start along with Minio. This has an added
benefit of declaring some startup order, to make sure that the makebucket
container has started before WordPress starts.
However, there’s a bit of a race condition between the wp
and makebucket
containers. depends_on
only waits for the depended-upon container to start,
not necessarily to be ready.
wp
requires the bucket to have been created, and generally starts for me
faster than makebucket
is able to complete. This is really only an issue
during local development, so I don’t want to add extra logic directly to
entrypoint.sh
. Rather, I continue the pattern of adding local-only workarounds
to .local/
.
So for local development, there’s also a wrapper around the WordPress entrypoint
named .local/wp-entrypoint.sh
. This pings the health endpoint on the minio
container until it returns a success code, then it begins the normal entrypoint.
This allows the root directory code to look like it should for production, and
local development works. Notice in compose.yml
that we overwrite the
entrypoint that was defined in Containerfile
to use .local/wp-entrypoint
,
which has been mounted into the container at run time.
For offline persistence, the easy answer would be for the container to mount a volume, and persist the database file there. There’s two reasons I wouldn’t do that in this case.
The first is because the WordPress image runs the Apache webserver as user
www-data
. Any volume mount I’ve tried to add adds as use root
. So when
www-data
tries to create or update an SQLite file in the volume mount, it
can’t due to permission errors. This is an old issue in the Moby project
(Docker), and is the same issue in
Podman. It’s been around for so long because it’s not an easy problem to solve.
The Github issue contains a couple of workarounds, my favorite being starting
another container to change ownership of the mount on startup. However, in our
case, there’s a second reason for not using volume mounts, and for that reason,
I’d prefer to just avoid the issue altogether.
The second reason is because we’re using Litestream for data persistence in production. It makes sense, from the concern of consistency between environments, to run Litestream for local development as well. You could certainly persist your local database to a cloud-vendored bucket, but for local development purposes, it’s easier to start a Minio container and create a bucket there, so that you can develop offline.
Setup:
Start with a clean slate, to verify that everything works when there is no existing database.
|
|
Visit http://localhost:8080/wp-admin and setup the site. (Database is not saved/created until you’ve completed setup).
Inspect
See that the database was persisted to Minio
Visit
http://localhost:9001/buckets/litestream/browse
and see that there’s a directory wp/
.
|
|
Restore a copy of the database locally, and inspect it
- Install Litestream locally, if you haven’t already
- Set access variables. Note that I had an issue where my environment
already had
AWS_ACCESS_KEY_ID
andAWS_SECRET_ACCESS_KEY
set with actual AWS credentials, and Litestream appeared to favor those environment variables over theLITESTREAM_
variables, so I had to overwrite them in the shell session.
|
|
Reboot the containers to verify that the database is restored
|
|
Visit http://localhost:8080/wp-admin. If you can login, then the database was successfully restored.
Cleanup:
|
|
Deployability
.local/litestream.yml
|
|
litestream.yml
|
|
compose.yml
|
|
wp-config.php
|
|
What’s been created up to this point is a reproducable container image for running a WordPress site. The container does contain state in the form of an SQLite database, but since that state is streamed to some external storage, the container can be handled as though it were stateless.
The only real difference between running this container locally vs. in a production environment is where the SQLite database is replicated to. For local development, I’ve described out an offline environment. As long as the image has already been pre-built, development could be done entirely offline locally. For production, we point Litestream at a more reliable and online storage.
To make things simpler, we can keep two litestream.yml
files - one for local
development, and one for production. Following the pattern set so far, I placed
the local copy at .local/litestream.yml
and mounted it into the wp
container
at run time to overwrite the production file that was copied into the image at
build time. Everything else can remain as it was.
There’s also one last change to make your life easier while changing the
database for production, and that is to set the sitename and home variables. If
those get set in the database, it can cause some annoying-to-debug redirects.
Set the SITENAME
variable in your deployment to the URL you’ll use in
production, and this will fallback to localhost when not set.
Setup:
|
|
Inspect
Just check that the config file is still what it used to be, which is the
contents of what is now .local/litestream.yml
|
|
Cleanup:
|
|
And that’s it!
Migration
The original purpose of this exercise was to create a more maintainable deployment process for several WordPress sites I had existing already. I found myself conflating the migration process with the setup process while writing the post originally, so I decided to break it into two separate pieces.
Now that I’ve created a base image, I’m going to outline a few of the common migration tasks I’ve found myself repeating with the sites I’ve handled thus far.
My typical deployment prior to containerization was a LAMP-stack VM which ran all of the site’s components. These were small sites, so I didn’t worry much about offloading assets to object storage, regular backups of the database, etc… So migration means getting everything off the VM and into the new architecture.
The things that I’m most concerned about are the stateful portions of the VM, namely the database and user uploads.
Database
Install conversion tool:
|
|
Optionally, move the executable into your $PATH
.
Dump the database:
|
|
Convert to SQLite:
|
|
Create replica:
|
|
The mysql2sqlite tool is the evolution of a few predecessors, which the project’s README credits. It mostly works as defined, but I did have an issue of my MySQL user not having all of the privileges it needed to handle the dump.
|
|
Upon a not very extensive search through the interwebz, I landed on applying an
additional --no-tablespaces
flag
with no further issues.
I’m assuming you have an existing and valid litestream.yml
at this point. If
you don’t, you need to create one first or define the arguments in the
replicate
command. Follow the Litestream documentation for more help.
If all went well, you should have a new directory containing the “first
generation” of your Litestream replica in the external storage you configured in
litestream.yml
.
Uploads
Containerfile
|
|
wp-config.php
|
|
Sync to Object Storage
|
|
Since WordPress is now containerized and “stateless” (the database is replicated externally, so we can treat the container as though it were stateless), user uploads can’t go into the container. They need to be stored and served externally.
I’m putting my user uploads into an S3 compatible object storage, and using this S3 Uploads plugin for WordPress, which handles uploading to the object storage and rewriting URLs to come from the storage.
For the actual migration from my VM to the object storage, I created a set of credentials specifically for the VM, then deleted them after the sync.
Make sure the bucket is publicly readable.
If you are keeping the Litestream replica in a different bucket than the user uploads (I recommend keeping them separate. It makes bucket permissioning more simple), then you’ll need to inject separate credentials into the container for Litestream and the S3 Uploads plugin.
Litestream can read LITESTREAM_ACCESS_KEY_ID
and
LITESTREAM_SECRET_ACCESS_KEY
, so it’s easy enough to inject credentials by
those names for Litestream. However, in my experience, if I also have
credentials named AWS_ACCESS_KEY_ID
and AWS_SECRET_ACCESS_KEY
, Litestream
will prefer those variables. So I inject the S3 Uploads credentials with a
different name (UPLOADS_
), and read the matching environment variable in the
config file. This way, there are no AWS_
environment variables, so as to keep
the distinction clear for Litestream and S3 Uploads.