This is another article in the series of Guides I Wish I’d Had when I needed to accomplish something. In this case, that something is creating a custom Docker image. More specifically, creating an image on macOS for use with Bitbucket Pipelines.
about docker
What is Docker? It’s a means of creating a sharable computing environment via “containers.” If you have a team of developers who all need the same versions of software (such as Apache and Python) and software libraries (such as Python’s requests library), Docker lets you create a simple text file to define an image. The image, when run, creates the container. Docker has a lot more magic under the hood, but those are the basics.
Docker is a fork with three tines: a desktop application, a command-line interface, and a daemon:
- Docker Desktop allows you to view images and containers. Similar to GitHub Desktop, it provides a limited subset of Docker commands and functionality. To experience Docker in full, you must use the command-line interface.
- The command-line interface gives you the full range of the Docker experience. Build images, tag images, and upload them to an image registry such as DockerHub.
- The daemon does all of the heavy lifting. You interact with it indirectly through Docker Desktop and the command-line interface. The daemon launches when Docker Desktop is opened. You can close Docker Desktop (without quitting it), and Docker CLI commands will continue to work.
why make a custom image?
Why did I want to create a custom image? To answer that question, it’s useful to look at my current workflow:
- Commit code to a Git repository.
- Tag the commit with a semantic version.
- Push the commit to a repository hosted on Bitbucket.
- Bitbucket sees the tag and kicks off a pipeline. The pipeline creates a ZIP archive of the repository (stripping all files that don’t need to be on the production server, such as tests) and securely copies it to my WordPress asset web server.
While the ZIP archive is on the update server, it hasn’t been deployed. At this point, a shell script on my laptop takes over. The script executes the commands below.
- Delete a theme or plugin transient from the remote site’s WordPress database.
- List the themes or plugins on the remote site, forcing WordPress to check for any available updates (and repopulating the theme or plugin transient).
- Update the theme or plugin to the latest version via a WP CLI command.
I wanted to create a custom Docker image to offload the portion of the workflow that ran on my laptop. I wanted to commit code, tag it, push it, and let Bitbucket handle the rest.
challenges
I wanted to take advantage of the setup on my laptop that makes extensive use of two custom configurations: ~/.ssh/config
and ~/.wp-cli/config.yml
. My SSH configuration defines several host aliases. These aliases are referenced by several WP CLI aliases. An example for baizmandesign.com:
.ssh/config:
Host bd
HostName baizmandesign.com
.wp-cli/config.yml:
@bd-prod:
ssh: bd
path: baizmandesign.com
The “ssh” value, “bd,” in .wp-cli/config.yml
refers to the “Host” value in .ssh/config
.
One of my web hosts, DreamHost, defaults to an older version of PHP when running WP CLI commands, causing them to fail. I’ve compensated for this by installing a newer version of PHP in my home directory and requiring a file, dreamhost.php
, to fudge the path to this newer version when WP CLI commands are run. I’d need to download and install this file, too.
The second challenge is that I wanted to use a separate SSH key to connect to the remote web servers.
Could I dynamically download the two configuration files, dreamhost.php
, and the SSH key during the execution of the pipeline? It turns out, the answer is yes! An access token—for a repository or project or workspace (the latter two options only available on a Bitbucket Premium account)—can be issued. It grants access to a repository (or project or workspace) and can be passed to curl requests. Because my configuration files are stored in a repository named “dotfiles” and the SSH key is stored in a separate repository, I created a workspace access token with read-only access to all of my repositories.
step 1: create the image
To create an image, first create a file named Dockerfile
in a text editor. A Dockerfile contains a series of instructions to build an image. Most images don’t start from scratch. Instead, they build on other images. For example, you might want to start with a base image of Ubuntu Linux. From there, you can install any programs not already available via Ubuntu’s package manager, apt-get
. There’s a lot more to it, but once again, those are the basics.
By default, any shell commands in the Dockerfile are run as root.
Here’s my initial Dockerfile:
FROM alpine:3.14
RUN apk update
RUN apk upgrade
RUN apk add --no-cache php8
RUN apk add --no-cache php8-phar
RUN ln -s /usr/bin/php8 /usr/bin/php
RUN apk add --no-cache openssh
RUN apk add --no-cache git
RUN apk add --no-cache curl
We install Alpine Linux, a barebones Linux distribution, and several programs: PHP 8 CLI (which we symlink to /usr/bin/php
), OpenSSH, Git, and curl.
We also need WP CLI, a command-line interface to WordPress. Let’s install it:
RUN wget https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
&& chmod +x wp-cli.phar \
&& mv wp-cli.phar /usr/local/bin/wp
We download a PHAR, make it executable, and move/rename it to a place in my $PATH
.
Now, I want to accommodate the SSH and WP CLI configuration files I’ll be downloading later, so I make folders for them:
RUN mkdir /root/.wp-cli
RUN mkdir /root/.ssh && chmod 700 /root/.ssh
We create these folders in the root user’s home directory. The .ssh
folder needs proper permissions, so we lock it down.
Almost done! A couple more directives:
COPY update-asset.sh /usr/bin/
ENTRYPOINT ["/bin/sh"]
We copy a script, update-asset.sh
, into /usr/bin
. This script does all of the heavy lifting: it downloads all our files and runs our WP CLI commands. It takes three arguments:
update-asset.sh <wp_cli_alias> <asset_slug> <bitbucket_access_token>
These arguments are supplied via pipeline variables. See the pipeline definition below for details.
The final directive, “ENTRYPOINT,” just starts a shell when the image is run.
step 2: build the image
The next step is to build our image:
$ docker build -t wp-asset-deployment .
Here’s an enormous gotcha: this won’t work! When Bitbucket pipelines pulls this image, none of the commands will run and this error will appear in the logs:
exec /bin/sh: exec format error
I surmised this was an architecture problem and rebuilt the image as follows:
$ docker build --platform linux/amd64 -t wp-asset-deployment .
Voila! No more errors. If you want to test your image, run it:
$ docker run -i -t wp-asset-deployment
You should see a shell prompt. This command created a container that you can start and stop any time. The container will be visible in the “Containers” section of Docker Desktop. Super cool!
step 3: tag the image
Every Docker image needs to be tagged. It’s often, but not always, used to label versions.
Log into DockerHub:
$ docker login
Tag your image:
$ docker tag wp-asset-deployment:latest baizmandesign/wp-asset-deployment:latest
step 4: publish the image
We need to upload our image somewhere Bitbucket can download it. This is called an image registry. Thankfully, Docker’s default is its own registry, DockerHub. You’ll need to go to the DockerHub website, log in, and create a repository via the web interface. Mine is called “wp-asset-deployment,” and it’s private. (As of this writing, you may only store one private image on the free subscription plan.)
Back on the command line, issue this command:
$ docker push baizmandesign/wp-asset-deployment:latest
Wait a few moments for the image to upload, and we’re set.
step 5: create the pipeline
Now we’re ready to create the pipeline. Create a file named bitbucket-pipelines.yml
. Since my image is private, we need to provide some credentials. I’ve made them into variables:
image:
name: baizmandesign/wp-asset-deployment:latest
username: $DOCKERHUB_USERNAME
password: $DOCKERHUB_PASSWORD
email: $DOCKERHUB_EMAIL
I’ll skip some of the less relevant portions of my pipeline and just highlight the step responsible for deploying the asset:
- step:
name: Deploy asset on $WP_CLI_ALIAS.
script:
- /bin/sh /usr/bin/update-asset.sh $WP_CLI_ALIAS $BITBUCKET_REPO_SLUG $BB_ACCESS_TOKEN
- $WP_CLI_ALIAS is a repository variable I’ve defined.
- $BITBUCKET_REPO_SLUG is an environment variable automatically defined by the pipeline.
- $BB_ACCESS_TOKEN is a workspace variable I’ve defined.
step 6: define the pipeline variables
As referenced in the step above, you need to add a pipeline variable named “WP_CLI_ALIAS.” For this example, I need to set it to “bd-prod.” This value, of course, corresponds to an alias in ~/.wp-cli/config.yml
.
Note that the “BITBUCKET_REPO_SLUG” variable must match the WordPress plugin or theme slug. The reason is that the following command will be run in update-asset.sh
:
wp @${WP_CLI_ALIAS} ${asset_type} update ${BITBUCKET_REPO_SLUG}
This will translate into a command like the one below:
wp @bd-prod plugin update baizmandesign.com-plugin
step 7: configure the remote web server
For the WP CLI commands to work on the remote server, we have to add the SSH public key to ~/.ssh/authorized_keys
. Thanks to ssh-copy-id
, this is a breeze:
$ ssh-copy-id -i /path/to/bitbucket-pipeline-ssh-key user@remote_host
You’ll see a bit of output, and eventually it should say that it added 1 key.
conclusion
At this point, you should be all set. Manually run your pipeline and see how it goes.
It seems like it’s a lot of work, but once you understand the basics, it’s almost trivial. Much of this could be scripted, too, and some elements—such as update-asset.sh
—could be made more dynamic by downloading it when the pipeline is executed (rather than statically adding the file when the Docker image is created, requiring us to rebuild the image whenever we update the script).
goodies
If you’ve made it this far, you might be interested in seeing my actual files. Enjoy!
- View my Dockerfile.
- View update-asset.sh, the shell script that deploys the WordPress assets.
- View bitbucket-pipelines.yml, my full Bitbucket pipeline file.
epilogue
This article discusses creating and building Docker images on macOS. The workflow entails manually building and uploading the image from your computer to DockerHub. It is also possible to build and upload an image within a Bitbucket pipeline itself. If you store your Dockerfile
in a Bitbucket repository, it becomes trivial to modify the file, commit the changes, and then push your changes to Bitbucket. This may kick off the build process and upload the image right to DockerHub. Here’s the bitbucket-pipelines.yml
in my Dockerfile repository:
pipelines:
default:
- step:
name: Build Docker image and upload it to DockerHub.
script:
- IMAGE="${DOCKERHUB_REPO}:${TAG_NAME}"
- echo ${DOCKERHUB_PASSWORD} | docker login --username "${DOCKERHUB_USERNAME}" --password-stdin
- docker build -t ${DOCKERHUB_REPO} .
- docker tag ${IMAGE} ${DOCKERHUB_USERNAME}/${IMAGE}
- docker push ${DOCKERHUB_USERNAME}/${IMAGE}
services:
- docker
You’ll need to create all of the appropriate pipeline variables, but it works brilliantly. Note that you don’t have to target a specific platform in the docker build
command; the default platform works just fine.
View additional documentation on Atlassian’s website.