Many hot-air balloons in the sky

Deploy Everywhere

Treat local like prod!

Albequerque Ballon Fiesta by Rex Housour, used under CC BY / cropped

Deploy Everywhere

I've written before about my search for a way to create a satisfactory local development environment. Among the pre-configured solutions I've tried, I still really like Drupal VM, but there's an issue that no out-of-the-box solution I've encountered can solve. That issue is that we often treat production deployments as something to be developed and implemented separately from development. I'd like to see that change.

One reason I'd like to see this change is because while the problem of building a local environment for code to run in has largely been solved with virtual machines and containers (there are many local development options), the configuration of the application (Drupal, for example) on the local machine still often incorporates manual steps such as importing databases, or pasting database credentials into configuration files.

Another reason I'd like to see it change is because it's a solved problem. We already routinely use automation to perform all of the steps that are needed to get a Drupal site up and running on a server as part of CI/CD processes or to provision new servers. From beginning to running site, the basic requirements to get a Drupal site running in a new server are roughly:

  1. Provision a server
    • Install & configure packages and services,
    • Start services
  2. Deploy Drupal
    • Deploy codebase to server
    • Run build tasks (e.g. Composer/npm/Gulp/Grunt)
    • Create or customize settings file
    • Import database dump
    • Run Drupal tasks (e.g. Drush)

One way or another, we already use automation to do all of these things (and more) on our production servers.

So knowing that all the component parts for a solution to the problem already existed, I created a proof-of-concept to illustrate one way to solve the problem (but please note: this is not a complete solution, or a tool you can simply begin using). To do this, I used tools that I'm familiar with, Vagrant and Ansible (note that as I mentioned above, there are multiple solutions to each part of this problem—I'm sure the same thing could be achieved with e.g. Chef and Docker).

Requirements gathering

The basic approach I wanted to try is quite simple: treat the local environment exactly like production. In other words, I wanted to build a development environment that not only resembled production, but was similar enough to it that I could use exactly the same process to set up, modify, or rebuild my development server as I would for one of our in-production servers.

So given that we use Ansible for many of our automation needs at Chromatic, I decided to use Ansible and Vagrant so that I could build as close a copy of a production environment as possible, and use our usual tools to provision that environment and deploy Drupal.

Putting all the parts together, our requirements are:

  • An application to build
  • A Vagrantfile that provides:
    • A development server
    • A simulated production server (to simplify the demo)
  • An Ansible playbook that can:
    • Run server configuration and/or application deployment
    • Run tasks on development and/or production
    • Provide host-specific values (including secrets!) to tasks

Creating a Vagrantfile to provide two servers (one for development, and one to simulate a remote production server) is straightforward, and this post is long enough, so I won't spend any time on that here (see Vagrant’s extensive documentation). It's the three specific requirements of the Ansible playbook that make or break the solution. Fortunately—spoiler alert!—Ansible includes features that make all of these things not only possible, but reasonably easy to achieve. Let's consider each in turn.

Meeting the requirements

Run server configuration and/or application deployment

It's not necessarily desirable to revisit the entire server provisioning process every time we publish a small code change to the site. So this means we need a way to target only the server configuration tasks or only the deployment tasks. And of course we should also be able to target both at once. Ansible's tags enable this by making it possible to limit the tasks run to one or more specified tasks.

The deployment.yml playbook contains two "plays," one to provision the hosts, and another to deploy the site to them. Each play contains a block like this:

tags:
  - deploy

Specifically of note is that the "Provision hosts" play uses the "provision" tag and the "Deploy application" play uses the "deploy" tag. These two tags alone provide the following options for us:

  • Deploy and provision both hosts:
    ansible-playbook deployment.yml
  • Only provision both hosts:
    ansible-playbook deployment.yml --tags=provision
  • Only deploy to both hosts:
    ansible-playbook deployment.yml --tags=deploy

So it's possible for us to target either or both plays in the playbook at will.

Run tasks on development and/or production

Each play in the deployment.yml playbook, also contains the following identical block:

hosts:
  - deploy_dev
  - deploy_prod

deploy_dev and deploy_prod are groups of hosts defined in our hosts.yml file (in this instance, each group only contains one host). Similarly to tags, Ansible's groups allow us to limit what the ansible-playbook command does, except in this case, we can limit what hosts the playbook runs on:

  • Deploy and provision both hosts:
    ansible-playbook deployment.yml
  • Provision and deploy development only:
    ansible-playbook deployment.yml --limit=deploy_dev
  • Provision and deploy production only:
    ansible-playbook deployment.yml --limit=deploy_prod

Again, using the --limit flag, we can run the playbook against any combination of hosts.

Provide host specific values to tasks

Finally we come to what is arguably the most crucial of our three main requirements. There are many sorts of environment-specific variables that we may want to set on different hosts. For example in Drupal, we may want to provide different values for $settings['trusted_host_patterns'], or a different set of database credentials.

Using a feature rather like PHP's class autoloading, Ansible is able to automatically load variables specific to a single host. If a file or folder with the name of one of the groups defined in hosts.yml exists in the group_vars directory, its contents are automatically made available to the playbook when it runs on hosts in that group. Besides custom groups defined in inventory, Ansible also includes two default groups, all and ungrouped.

This means we can target variables very precisely to individual groups (and also to nested groups, though that's beyond the scope of this post). In the repo, the group_vars folder has the following structure:

group_vars
├── all
│   ├── common.yml
│   └── vault.yml
├── deploy_dev.yml
└── deploy_prod.yml

So this means, for example, that when running tasks on the deploy_dev group, variables from all/common.yml, all/common/vault.yml, and deploy_dev.yml are available for use in tasks, but the variables in deploy_prod.yml are not. To see what differs between dev and prod in the repo, just compare the deploy_dev vars file to the deploy_prod vars file.

What about the secrets?

In the original list, I specified that our solution needed to "provide host-specific values (including secrets!) to tasks." If you inspect the contents of vault.yml, you'll find something a bit odd—apparently an encrypted blob!

$ANSIBLE_VAULT;1.1;AES256
30346634326166343330643433383738313666636566373230353837356533323562306232393963
3430316633373161313165623239386632623036666532370a313731613837303966336531623566
34643832313465626364643435633935313131306437386235373836303936393037373266396535
6463303132363939380a373232383935343862346339613534653633316661323136353233346239
66616239316538373435393039653630386236356135643866663932313165306432363164643030
32396262353662623339346132333333356335653633316633613161626337303465363761633836
33633165373434386532303739343330633434376639313133633138633035386261613630316536
33613537333832663534

We're using this file to store site-related secrets in the repository using Ansible Vault. This isn't necessary, but to achieve the one-step build described here and demonstrated in the repo, Ansible must have a way to read and write secrets such as database credentials or (as in this case) basic authentication passwords.

Deployment tasks and what would Drupal do?

Having theoretically satisfied the requirements, let’s run a deployment. The fastest way to do this is to use the quickstart.sh script in the repo (note that you must have all of Ansible, Vagrant, VirtualBox, vagrant-hostsupdater, and vagrant-vbguest installed). Follow these steps:

  1. git clone git@github.com:ctorgalson/deploy-everywhere.git
  2. cd deploy-everywhere && ./quickstart.sh
  3. ansible-playbook deployment.yml --limit=deploy_prod
  4. Visit http://dev-01.deploy.local in the browser (use deploy-dev and depl0y to enter)
  5. Visit http://prod-01.deploy.local in the browser

Once those tasks are complete (on an older Macbook Air, this takes around six minutes for me), you should see two similar but different pages:

image alt text

Screenshot of loaded dev and prod index.html pages

To finish up our look at deployments, we'll take a short look at the contents of the deploy-app.yml tasks file. This file runs eight tasks:

  1. Check if node_modules exists (on local machine)
  2. Run npm install if it doesn't (on local machine)
  3. Run gulp build (on local machine)
  4. Use rsync to copy files to prod only (Vagrant handles this on dev)
  5. Create customized index.html file on each host
  6. Download the new index.html file from each host
  7. Verify that the http status code from step 6 was 200 for each host
  8. Verify that the timestamp in index.html matches Ansible's for each host

Even though this is a simple, static site, the build-and-deploy process is very similar to database-driven systems like Drupal. At the beginning of this post, I summarized a Drupal build in five steps:

  1. Deploy codebase to server
  2. Run build tasks (e.g. Composer/npm/Gulp/Grunt)
  3. Create or customize settings file
  4. Import database dump.
  5. Run Drupal tasks (e.g. Drush)

Steps 1-4 in app-deploy.yml map neatly onto steps 1-2 of the list above, though the order is different (in app-deploy.yml, we're simulating the process of running a complete build on a build server and then deploying the resulting code; sometimes it's just as easy to build on the destination server). Ansible also provides the composer module and the command module which are both useful in Drupal builds.

Step 5 in app-deploy.yml is identical to step 3 above in every respect but the specific file. In combination with group_vars and Ansible Vault, it's easy to see how we could use the same strategy to vary the content (or output separate versions of) settings.local.php in a Drupal context.

In app-deploy.yml, we don't do anything comparable to importing a database, but Ansible has tools that make this fairly easy. Use the get_url module or the copy module to move a sql dump onto the server, then use either the command module or the mysql_db module:

# Use mysql_db module:
- name: Import db dump
  mysql_db:
    name: drupal_db
    state: import
    target: /tmp/drupal_db-20180622.sql

# Use command module (requires drush!):
- name: Import db dump.
  command: "drush sql-cli 

Related Posts & Presentations