Want to get more articles like this one? Join my newsletter

In DevOps

How to use Bedrock with Pantheon

Disclaimer: I wasn’t paid by Pantheon or CircleCI to write this. I am, however, part of the Pantheon heroes program. I just like Bedrock, CircleCI and Pantheon a lot so I wanted to write about them.

Working on large projects with WordPress is often a challenging endeavour. You have a lot of plugins to manage as well as the custom code that you’ve written for your client. All this code often exists in a single git repository. This makes the whole thing complicated to maintain.

Lucky for us, there’s Bedrock. Bedrock is a custom WordPress project structure based on the Twelve-Factor App methodology. Using such a project structure lets you work using modern web development methodologies.

Without a doubt, the foundation of modern web development is the dependency manager. For example, it’s impossible to do modern JavaScript without using npm. This is the dependency management tool used by the JavaScript ecosystem.

In PHP, that dependency management tool is Composer. While WordPress has no current plans to support Composer, Bedrock has it as its central feature. This is great for WordPress developers who want to use modern web development methodologies.

Now, the problem with using a project structure like Bedrock is that a lot of hosts don’t have support for Composer. There’s a guide on how to use Bedrock with Trellis on Kinsta. But besides that, there isn’t much out there to help you setup Bedrock with a host. Since I have experience with the Pantheon platform, I figured I’d write about how to get Bedrock on it.

Using a continuous integration process

Now, like most hosts, Pantheon doesn’t have native support for Composer. It also doesn’t support direct SSH access. This prevents us from using Trellis or other automated deployment tools that depend on SSH access.

Instead, we’re going to have to rely on a continuous integration process. It’ll be in charge of putting everything together and sending it to the Pantheon servers. Here’s a graph describing that process:

Starting from the left side, we have the version control system where our Bedrock project resides. For most of us, that’ll be a git repository hosted on one of the git repository hosting services. For most of us, that service is GitHub. But you can use whichever service you prefer.

Whenever you push a commit or do a pull request to a repository hosted on either service, the continuous integration service will start the build process. The build process will combine the code in our Bedrock project with the dependencies what we’re managing by Composer. It’ll then deploy everything to the Pantheon platform on the right.

Most of the heavy lifting in the build process is done by Terminus. It’s a command-line tool that Pantheon built to interact with their platform. We’re going to be talking a lot about it throughout the rest of this article.

Now, you might not want to use a continuous integration service. We’ll also discuss how you can use Bedrock and Pantheon without it. However, as you’ll see in a moment, there are some serious drawbacks to using Bedrock and Pantheon without a continuous integration service.

Pre-made Bedrock project templates

Alright! So this covers the high-level look at how to get Bedrock working with Pantheon. Before we get started looking at the implementation, you should know that I already created two Bedrock project templates for you. So you don’t have to worry about doing this yourself, you can just refer to them instead.

We’re going to go over both project templates in some detail. That said, you should refer to the README file for both projects for instructions on how to use them. The article will only focus on explaining how both project templates work.

Standalone project

The first Bedrock project template is the standalone one. It forgoes the build process using a continuous integration service. This is a handy setup if you’re not interested in using a build process to deploy to the Pantheon platform. Instead, you can just SFTP or git push your charges to the Pantheon servers yourself.

That said, using this Bedrock project template comes with some pretty significant tradeoffs. That’s because foregoing the build process means that you’re responsible for it now. This translates into a few important changes to the project template compared to a regular Bedrock project.

The first and most significant one is that you have to commit the vendor and web directories. These are directories that Composer normally manages. But, since Composer isn’t available on Pantheon servers and we have no build process, we have to commit them into our git repository.

You also need to create a symlink for the uploads directory. That’s because Pantheon stores uploads in a different directory on their servers. This also means that you’ll have to create the same directory as the Pantheon servers on your development machine.

You’ll find more on all this in the README of the standalone project template that I linked at the beginning of the section. But, as mentioned, this isn’t the ideal way to use Bedrock with Pantheon. You’re trading the complexity of using a continuous integration process for complexity in your Bedrock project structure.

Project using CircleCI

The other Bedrock project template is the one that we’ll go over for the rest of the article. It uses CircleCI as its continuous integration service. CircleCI will be in charge of building our Bedrock project and package its dependencies. It’ll then sends everything to the Pantheon platform.

The advantage of doing things this way is that you can just use Bedrock as is. You don’t have to make any significant changes to it like you had to do with the standalone version. This also makes your project platform agnostic so you can move away from Pantheon in the future if you have to.

Now, the project uses CircleCI as its continuous integration service, but that’s not a hard requirement. A lot of what we’ll go over isn’t specific to CircleCI itself. And the rest can be easily changed to fit another continuous integration service as well. So don’t feel like you have to use CircleCI if you don’t want to!

Analyzing the CircleCI continuous integration workflow

Now, that we’ve gone over the two sample Bedrock projects. We’re going to move on to the continuous integration workflow used by the CircleCI Bedrock project template. With CircleCI, you define this continuous integration workflow in the .circleci/config.yml file in your project repository.

version: 2.0

references:
  working_directory: &working_directory
    ~/bedrock

  # Default container configuration
  #
  container_config: &container_config
    docker:
      - image: circleci/php:7.3
    working_directory: *working_directory

  attach_workspace: &attach_workspace
    attach_workspace:
      at: *working_directory

jobs:
  build:
    <<: *container_config
    steps:
      - checkout
      - run:
          name: Install dependencies
          command: composer install -n -o
      - persist_to_workspace:
          root: .
          paths:
            - '*'

  test:
    <<: *container_config
    steps:
      - *attach_workspace
      - run:
          name: Run tests
          command: composer test

  deploy:
    <<: *container_config
    steps:
      - *attach_workspace
      - run:
          name: Remove development dependencies
          command: composer install --no-dev -n -o
      - run:
          name: Deploy to Pantheon
          command: .circleci/deploy_to_pantheon.sh

workflows:
  version: 2
  build_test_deploy:
    jobs:
      - build
      - test:
          requires:
            - build
      - deploy:
          requires:
            - test

Above you can see a copy of the .circleci/config.yml file from the Bedrock project template. The file is a simpler version of the one designed to deploy a plugin to the WordPress directory using CircleCI. You don’t have to worry about reading that article. (It’s the longest one on this site!) We’ll go over everything here.

References

The top of the config.yml file is the references section. This isn’t a normal section that you’ll see in the CircleCI documentation. Instead, it’s there to take advantage of a YAML feature that a lot of people don’t know about.

We call it “YAML anchors” (and references). (Bitbucket has a small knowledge base article on it for their pipeline continuous integration service.) Each subsection of the references section (shown below) is an anchor to a reusable part of our CircleCI configuration.

# .circleci/config.yml

references:
  working_directory: &working_directory
    ~/bedrock

  # Default container configuration
  #
  container_config: &container_config
    docker:
      - image: circleci/php:7.3
    working_directory: *working_directory

  attach_workspace: &attach_workspace
    attach_workspace:
      at: *working_directory

The first anchor is working_directory. We use it to define the working directory that CircleCI continuous integration jobs will use. We called the working directory bedrock, but you can call it whatever you’d like.

The next anchor is container_config. We use it to configure the Docker container that all our continuous integration jobs will use. We tell it to use the circleci/php:7.3 docker image. This is the pre-built CircleCI docker image for PHP 7.3. We also set the working_directory by using the working_directory reference.

The last reference relates to the use of workspaces. Workspaces are one of the two persistence mechanism available on CircleCI. The other is caching. (You can read about the difference between the two here.)

Why do we do workspaces instead of caching? This comes down to personal preference. Since Bedrock uses a composer.lock file, it’s possible to use caching with it. (Something that wasn’t possible when using CircleCI with a plugin.)

But caches in CircleCI are immutable. This means that you can’t change or delete them once they’re created. (You can read more about it here.) This makes them a lot less forgiving to use than workspaces. That’s why we’re not going to use them, but you’re free to do so if that’s what you prefer!

CircleCI jobs

Now that we’ve covered the references section, we can move on to the section in the config.yml file. That’s the jobs section. We use it to define all the jobs in our CircleCI continuous integration workflow.

# .circleci/config.yml

jobs:
  build:
    <<: *container_config
    steps:
      - checkout
      - run:
          name: Install dependencies
          command: composer install -n -o
      - persist_to_workspace:
          root: .
          paths:
            - '*'

  test:
    <<: *container_config
    steps:
      - *attach_workspace
      - run:
          name: Run tests
          command: composer test

  deploy:
    <<: *container_config
    steps:
      - *attach_workspace
      - run:
          name: Remove development dependencies
          command: composer install --no-dev -n -o
      - run:
          name: Deploy to Pantheon
          command: .circleci/deploy_to_pantheon.sh

You can see all the jobs in the jobs section above. But don’t worry if you’re not too sure what this all means. We’re going to go over each of them in the following sections.

Build job

The first job in the jobs section is the build job. This is the job that’ll install all the dependencies in our composer.json file. Once installed, it’ll save everything to the workspace that we’ll use for the rest of the workflow.

# .circleci/config.yml

jobs:
  build:
    <<: *container_config
    steps:
      - checkout
      - run:
          name: Install dependencies
          command: composer install -n -o
      - persist_to_workspace:
          root: .
          paths:
            - '*'

The build job which you can see above starts by referencing our container_config anchor. This is the default Docker container configuration that the build job will use. It comes from the references section that we saw before.

Build job steps

Next, we have the steps section. This is where we define all the steps that the build job has to perform. There are three here.

First, there’s checkout. This is a special step that CircleCI uses to check out our code from our git repository service. We’re only going to do this once. After that, we’ll leverage the use of workspaces.

Next, we use composer to install all the dependencies in the project’s composer.json file. This includes all development dependencies under require-dev. We need those for testing purposes, but we’ll clean them up before deploying to Pantheon.

The last step of the build job is to save everything to the workspace that we’ll use in our workflow. To do that, we’re going to use the persist_to_workspace special step. We have to specify both a root as well as a list of paths relative to the root to save to the workspace.

Since we want to save everything in our working directory, our root will be .. The dot symbolizes your current directory in Linux and Windows operating systems. For the list of paths, we only need to use * which means everything.

Test job

So that covered the build job. After it, we have the test job. This job will run all the test for our Bedrock project.

# .circleci/config.yml

jobs:
  test:
    <<: *container_config
    steps:
      - *attach_workspace
      - run:
          name: Run tests
          command: composer test

The test job starts by referencing the container_config anchor much like the build job did. After that, we have the steps section. It’s a lot simpler than the one for the build step.

The first thing that we do is restore everything that we saved to our workspace to our working directory. To do that, we use the attach_workspace special step. This is the last anchor in our references section that we hadn’t seen yet.

The attach_workspace step will restore the workspace that we saved earlier in our working directory. We specify where to restore the workspace with the at parameter. We pass the working_directory reference to it. This tells CircleCI to restore the workspace there.

Once the workspace restored, we just have to run the tests for our Bedrock project. By default, bedrock projects have a custom Composer command in their composer.json file. This composer test command is the command that CircleCI will use to run all the test for our Bedrock project. If you want to edit it, it’s under the scripts section in the composer.json file.

Deploy job

The last job in our CircleCI continuous integration workflow is the deploy job. It’s the job that will take our tested Bedrock project and deploy it to the Pantheon platform. From a configuration perspective, it’s very similar to the test job.

# .circleci/config.yml

jobs:
  deploy:
    <<: *container_config
    steps:
      - *attach_workspace
      - run:
          name: Remove development dependencies
          command: composer install --no-dev -n -o
      - run:
          name: Deploy to Pantheon
          command: .circleci/deploy_to_pantheon.sh

We again start by referencing our container_config anchor and reattaching our workspace. The next step is to run composer install with the --no-dev flag. This will remove all the development dependencies that we had for testing. This will remove all the development dependencies that we had for testing. It’s better to not have those as part of our deployment.

The final step is to deploy all our code to the Pantheon platform. This is all done using the deploy_to_pantheon.sh Bash script. You can find it in the .circleci directory.

Pantheon deployment script

Since the deploy_to_pantheon.sh Bash script does all the work deploying our Bedrock project to Pantheon, we’re going to over it. The good news is that most of the script is generic. So you can use with other continuous integration services and not just with CircleCI. You can see the full complete deploy_to_pantheon.sh Bash script below.

#!/usr/bin/env bash

if [[ -z "$CIRCLECI" ]]; then
    echo "This script can only be run by CircleCI. Aborting." 1>&2
    exit 1
fi

if [[ -z "$TERMINUS_SITE" ]]; then
    echo "Terminus site not set. Aborting." 1>&2
    exit 1
fi

if [[ -z "$TERMINUS_TOKEN" ]]; then
    echo "Terminus token not set. Aborting." 1>&2
    exit 1
fi

# Add global composer bin directory to $PATH variable
export PATH=$HOME/.composer/vendor/bin:$PATH

# Configure git
git config --global user.email "${GIT_EMAIL:-pantheon@circleci.com}"
git config --global user.name "${GIT_NAME:-Circle CI}"

# Configure SSH
mkdir -p "$HOME/.ssh"
touch "$HOME/.ssh/config"
echo "StrictHostKeyChecking no" >> "$HOME/.ssh/config"

# Convert uploads directory to a symlink
sed -i '/web\/app\/uploads.*/d' .gitignore
rm -r web/uploads
ln -s ../../../files web/uploads

# Install Terminus globally
composer global require pantheon-systems/terminus:^2.0

# Install Terminus plugins
mkdir -p $HOME/.terminus/plugins
composer create-project -n -d $HOME/.terminus/plugins pantheon-systems/terminus-build-tools-plugin:^2.0.0-beta13

# Authenticate with Terminus
terminus auth:login -n --machine-token="$TERMINUS_TOKEN"

# Wake up the main development environment
terminus env:wake -n "$TERMINUS_SITE.dev"

# Push code to Pantheon
if [[ ${CIRCLE_BRANCH} == "master" ]]; then
    terminus build:env:push -n "$TERMINUS_SITE.dev"
elif [[ ! -z "$CIRCLE_PULL_REQUEST" ]]; then
    terminus build:env:create -n "$TERMINUS_SITE.dev" "pr-${CIRCLE_PULL_REQUEST##*/}"
fi

# Clean up unused PR environments (if GITHUB_TOKEN is set)
if [[ ! -z "$GITHUB_TOKEN" ]]; then
    terminus build:env:delete:pr -n "$TERMINUS_SITE" --yes
fi

Environment variables

The script starts with a few guard clauses. These guard clauses check for environment variables that the script needs. You can find the section of the Bash script with the guard clauses below.

if [[ -z "$CIRCLECI" ]]; then
    echo "This script can only be run by CircleCI. Aborting." 1>&2
    exit 1
fi

if [[ -z "$TERMINUS_SITE" ]]; then
    echo "Terminus site not set. Aborting." 1>&2
    exit 1
fi

if [[ -z "$TERMINUS_TOKEN" ]]; then
    echo "Terminus token not set. Aborting." 1>&2
    exit 1
fi

The reason we have these guard clauses is because these environment variables are sensitive. We would create a security vulnerability by storing them in our project. Instead, we have to add them in your continuous integration service. (You can read on how to do that with CircleCI here.)

The first environment variable that we check is CIRCLECI. This is the environment variable that tells us that the script is running on the CircleCI platform. This check is optional. If you’re using another continuous integration service, you can either remove it or use another environment variable for that service.

The other two environment variables are TERMINUS_SITE and TERMINUS_TOKEN. We use them with Pantheon’s Terminus command-line tool. TERMINUS_SITE is the name of the site on the Pantheon platform. Meanwhile, TERMINUS_TOKEN is the token used by Terminus to authenticate with the Pantheon platform.

The README of the Bedrock project template explains where you can find these values on the Pantheon platform. It also shows you how to add them to CircleCI. If you’re using another continuous integration service, you’ll have to find the documentation for adding environment variables for that service.

Configuring Composer, git and SSH

Once through the guard clauses, the script begins to do its work. The first thing it does is configure the command-line tools that we’ll use for deploying our Bedrock project. The three command-line tools that we’ll use to deploy our Bedrock project are Composer, git and SSH.

# Add global composer bin directory to $PATH variable
export PATH=$HOME/.composer/vendor/bin:$PATH

# Configure git
git config --global user.email "${GIT_EMAIL:-pantheon@circleci.com}"
git config --global user.name "${GIT_NAME:-Circle CI}"

# Configure SSH
mkdir -p "$HOME/.ssh"
touch "$HOME/.ssh/config"
echo "StrictHostKeyChecking no" >> "$HOME/.ssh/config"

First, we want to add the global Composer bin directory to PATH environment variable. The PATH environment variable contains a list of directories where we can find executable programs. Composer is going to install Terminus in a different directory than the ones stored in the PATH environment variable by default. So we add this new directory so we can use Terminus more easily once we install it later.

Next, we want to configure git. We want to set the user.email and user.name configuration options. These are the default email address and name that git will use when making a commit.

We need those because Terminus is going to commit all our Bedrock assets before sending everything to Pantheon. The Bash script has default variables for both values, but you can also change them using environment variables. These environment variables are GIT_EMAIL and GIT_NAME.

The last step is to configure SSH. We start by creating the .ssh directory and config file if they don’t exist. We do that by using the -p option for mkdir and touching the config file.

Once we’re sure that we have a config file, we need to add an option to it. That option is StrictHostKeyChecking which we want to set to no. This disables the host identity verification with SSH. This is often required when automated scripts need to use SSH.

Symlinking the uploads directory

The next set commands relate to creating a symlink for the uploads directory. This is necessary because Pantheon doesn’t store your media files in the uploads directory in your WordPress installation. Instead, they store them in the files directory at the root of the server.

# Convert uploads directory to a symlink
sed -i '/web\/app\/uploads.*/d' .gitignore
rm -r web/uploads
ln -s ../../../files web/uploads

By default, Bedrock doesn’t let you commit anything in the uploads directory. So, to change that, we have to edit the .gitignore file inside the project. We use the sed command to do it.

After that, we delete the existing uploads directory. We then create the symlink replacing the uploads directory and pointing to the files directory. It’s worth noting that it doesn’t matter that the files directory doesn’t exist on CircleCI. We can create it anyways.

We also don’t need to commit this change. Terminus will commit the symlink as part of its larger commit before sending everything to Pantheon. We’ll discuss that larger commit a bit later.

Installing Terminus

Speaking of Terminus, we’re now at the point in the Bash script where we want to install it. The script does this in two steps. You can see them below.

# Install Terminus globally
composer global require pantheon-systems/terminus:^2.0

# Install Terminus plugins
mkdir -p $HOME/.terminus/plugins
composer create-project -n -d $HOME/.terminus/plugins pantheon-systems/terminus-build-tools-plugin:^2.0.0-beta13

The first one is to install Terminus using the composer global require command. This is the composer way of installing something for all projects as opposed to just a specific one. It’s also why we added the global Composer bin directory to the PATH environment variable earlier.

By default, Terminus can’t package our Bedrock project and send it to the Pantheon platform. That capability comes from the Terminus build tools plugin. So that’s the next step once Composer finishes installing Terminus.

Before we can install the build tools plugin, we need to create the Terminus plugins directory. This isn’t done automatically when we install Terminus with Composer. So we need to have our Bash script take care of it.

Installing the build tools plugin is also a bit different from how we installed Terminus. We won’t use the composer global require command. Instead, we have to use the composer create-project command. This command clones the package repository then runs composer install.

Authenticating with Pantheon

Once Composer finishes installing the build tools plugin, we can start using Terminus. First, we need to authenticate with the Pantheon platform. We do this by using the terminus auth:login command as shown below.

# Authenticate with Terminus
terminus auth:login -n --machine-token="$TERMINUS_TOKEN"

In this scenario, we’re going to authenticate using a machine token. (You can read more about it here.) We store this machine token in the TERMINUS_TOKEN environment variable that we checked for earlier. We pass the value to the machine-token option of the terminus auth:login command.

Next, we run the terminus env:wake command with TERMINUS_SITE.dev as the argument. This is a precautionary step because development environments on Pantheon will automatically go to sleep after a while. So by running this command, we ensure that the development environment is ready to receive our code.

Deploying with Terminus

At this point, we’re ready to have Terminus send our code to Pantheon. There are two possible commands that we can use to do this. (You can see them below.) Which one we use will depend on the context of the commit that triggered our CircleCI workflow.

# Push code to Pantheon
if [[ ${CIRCLE_BRANCH} == "master" ]]; then
    terminus build:env:push -n "$TERMINUS_SITE.dev"
elif [[ ! -z "$CIRCLE_PULL_REQUEST" ]]; then
    terminus build:env:create -n "$TERMINUS_SITE.dev" "pr-${CIRCLE_PULL_REQUEST##*/}"
fi

The first command is terminus build:env:push. We use it whenever we make a commit to the master branch of our repository. It’ll then push our code to the main Pantheon development environment.

How do we know if the commit was for the master branch of our repository? We check the CIRCLE_BRANCH environment variable. It’ll say master if the commit came from the master branch of our repository.

The other command is terminus build:env:create. We use this one whenever the commit that triggered the CircleCI workflow was for a pull request. This command will push our code to a multidev environment. If the multidev environment doesn’t exist for the pull request, Terminus will create it for us.

For this command, we want to check the CIRCLE_PULL_REQUEST environment variable. We do this by using the test command-line utility. The -z test checks if the length of a string is 0.

If the test fails, it means that our commit was for a pull request. We then use the CIRCLE_PULL_REQUEST environment variable to create the environment on Pantheon. We use Bash string manipulation to extract the pull request number from the CIRCLE_PULL_REQUEST environment variable. We want the environment name on Pantheon to be the pull request number prefixed with pr-.

Cleaning up unused environments

So the reason why we want to prefix our environments with pr- is because of this final part of our Bash script. This part of our script handles the clean up of our environment on the Pantheon platform. You can see it below.

# Clean up unused PR environments (if GITHUB_TOKEN is set)
if [[ ! -z "$GITHUB_TOKEN" ]]; then
    terminus build:env:delete:pr -n "$TERMINUS_SITE" --yes
fi

The first thing that we do is test to see if we have a GITHUB_TOKEN environment variable. The GITHUB_TOKEN environment variable is an environment variable that contains a GitHub personal access token. Terminus needs this access token to interact with GitHub.

Like TERMINUS_SITE and TERMINUS_TOKEN environment variables, the value stored in the GITHUB_TOKEN environment variable is sensitive. The GitHub personal access token gives anyone access to your GitHub account. So we can’t store it inside our Bedrock project repository.

You’ll need it to add it as an environment variable inside CircleCI like you did for TERMINUS_SITE and TERMINUS_TOKEN. That said, unlike the two Terminus environment variables, the GITHUB_TOKEN environment variable is optional. That’s why the Bash script tests for it.

If the test for the GITHUB_TOKEN environment variable shows that it isn’t empty, we make a call to the terminus build:env:delete:pr command. This Terminus command cleans up the multidev environments that we created for pull requests. Without it, all the environments created by the terminus build:env:create command would never get deleted.

The command works by going through all our multidev environments prefixed with pr-. It then checks the pull request associated with the environment with its status on GitHub. If the pull request was closed, Terminus will delete the environment on the Pantheon platform.

And that’s how you do it

Once our Bash script finishes running, your changes should now be on the Pantheon development environment! At this point, you should use the Pantheon admin to deploy your development environment into testing and then production. This makes our workflow more of a continuous delivery workflow than a continuous deployment one.

That said, you could still create a continuous deployment workflow if you wanted. Terminus doesn’t limit deployments to just the development environment. In practice, you can use it to deploy code to any environment including the production one.

However, this is out of scope for this article. The main goal of this article was to look at how to get Bedrock working with the Pantheon. To do that, we didn’t need to continuous integration service or continuous delivery.

But, as we saw, doing it without a continuous integration service isn’t ideal. That’s why a lot of the article explained how the CircleCI workflow and Bash script worked. The benefit of having done that is that, because the workflow and Bash script are generic, you can easily use them with another continuous integration service if you wanted.

Creative Commons License