Managing Application Secrets for Terraform across Teams

TL;DR

Ok, that's a bit of a wordy title, but that's exactly the challenge I was tasked with solving recently - and it proved to be more of an issue that I expected. The objective was to be able to use application secrets for ECS task definitions inside Terraform and have those be easily shared across the team.

🔐 Encrypting the Configuration

My first approach was simply to use environment variables and then put the environments file in secure storage (i.e., KeyBase) rather than committing it to source control. Simple right? So that works, but in our case, we have TravisCI do the deployments for our stack and so that approach would have been lengthy whenever we wanted to add a new secret parameter.

To solve this, I created a script that encrypts and decrypts a config with OpenSSL encryption. These encryption keys could then be shared across the CI and the rest of the team. It would be ideal to use a GPG key for this but I do not believe this is possible in CI. Plus it's a pain to onboard a new team member as you need to re-encrypt the secrets with their public key - less than ideal.

We have a file called .env in the root of the Terraform repo with the environment variables as you'd usually find them. This is ignored from source control. We then have one script to decrypt and unzip from tar using OpenSSL and another to encrypt the file in a tar. The tar is then committed to source control, it can then be decrypted by Travis and another other team member with access.

# encrypt-config.sh
#!/bin/bash

echo "Compressing config..."
tar czf config.tar.gz .env

echo "Encrypting config tarball..."
openssl enc -aes-256-cbc \
  -in ./config.tar.gz \
  -out ./config.tar.gz.enc \
  -K ${CI_ENC_KEY} \
  -iv ${CI_ENC_IV}

rm config.tar.gz

# decrypt-config.sh
#!/bin/bash

echo "Decrypting config..."
openssl enc -aes-256-cbc -d \
  -in config.tar.gz.enc \
  -out config.tar.gz \
  -K ${CI_ENC_KEY} \
  -iv ${CI_ENV_IV}

echo "Extracting config..."
tar xzf config.tar.gz
rm config.tar.gz

☝ Loading the variables

Terraform environment variables must be prefixed with TF_VAR_, since I didn't want the laborious process of adding this to each variable, I wrote a script to prefix them and then load the variables into the shell environment. The script can be found below:

# loadenv.sh
export $(egrep -v '^#' .env | while read line; do echo "TF_VAR_$line"; done | xargs)

This then meant that my variables could be referenced in terraform by adding them to the root variables.tf like this

variable "REACT_APP_ENVIRONMENT_VAR" {
  type = string
}

...and then passed into the module like this

// main.tf
module "Sample" {
  source                                = "./modules/general-cluster"
  REACT_APP_ENVIRONMENT_VAR                = var.REACT_APP_ENVIRONMENT_VAR
}

In our shell the variable would be loaded as TF_VAR_REACT_APP_ENVIRONMENT_VAR.

🤖 Automating

But I wanted to go a step further, because adding to the root variables.tf each time is a pain. Me thinks, time for a script... so I did some digging and added this to the end of the loadenv.sh from earlier

# Clear the old variables.tf file out
> variables.tf

# Loop through each line of our .env file
egrep -v '^#' .env | while read line;
do
  # Get the first part (before the =) of the line for the variable name
  var_name=$( cut -d '=' -f 1 <<< "$line" )

  # Write it to the variables.tf file
  cat >> variables.tf <<EOL
variable "${var_name}" {
  type = string
}
EOL
done

This now automagically generates a variables.tf file at the root of the Terraform folder. Travis can run this loadenv.sh script based on the encryption keys it has stored in it's own environment. I created yet another script called deploy.sh that Travis runs only on the master branch. As the name implies, it handles the deployments and notifications of such.

# deploy.sh
#!/bin/bash

set -e

if [[ $TRAVIS_BRANCH == 'master' ]]
then
  errorstatus() {
    echo "Error when deploying Terraform config"
    # Slack Webhook Message
    curl -X POST --data-urlencode "payload={\"channel\": \"#deployments\", \"username\": \"Deploy Bot\", \"text\": \":poop: Build #$TRAVIS_BUILD_NUMBER Failed when deployed Terraform Stack. Error log $TRAVIS_BUILD_WEB_URL\", \"icon_emoji\": \":rocket:\"}" $SLACK_WEBHOOK_URL
  }

  # When exiting due to an error, run the error status
  trap errorstatus ERR

  . ./decrypt-config.sh

  # shellcheck disable=SC1091
  source ./loadenv.sh
  terraform init
  terraform validate
  terraform apply -auto-approve
  curl -X POST --data-urlencode "payload={\"channel\": \"#deployments\", \"username\": \"Deploy Bot\", \"text\": \":tada: Build $TRAVIS_BUILD_NUMBER successfully deployed Terraform Stack\", \"icon_emoji\": \":rocket:\"}" $SLACK_WEBHOOK_URL
  echo "Deployment Completed Successfully"
else
  echo "Branch is not master so skipping deployment"
fi

After all this has run, I end up with a nice deployment message in Slack!

And that's all! We are still iterating our approach with Terraform and working to get it running against our entire stack rather than just parts of it. I am enjoying learning it so far, even if it's a bit rough around the edges I'm excited to see where Terraform goes!