Redirects at the Edge

Serverless redirects for legacy domains using Fastly, Terraform, and CircleCI.

The final CircleCI 2.0 workflow, working a treat 🙌

Previously at the Financial Times we’ve made use of the Dyn HTTP redirect service to send traffic from domains and subdomains to www.ft.com, which has been working just fine for our legacy domains that never used TLS.

However, there has been a number of recent tasks to redirect subdomains with support for TLS, and so far several of these domains have ended up under our www.ft.com Fastly service, a scary thought given the complexity the configuration (over 5,000 lines of VCL).

What I’ve been looking to work on recently was a continuous delivery pipeline composed of Terraform and CircleCI 2.0, and thanks to the Fastly provider in Terraform and the recent redirect tasks, it seemed like the ideal time to try out the combination.

While I knew Terraform and CircleCI were very compatible, I wanted to solve a few issues that would prevent their use in our production pipelines.

Limit deploys in CircleCI to one build at a time

Otherwise we could potentially update a Fastly service by more than one build at the same time, which I’d file under undefined behaviour.

I’d found a post in the CircleCI forums on how to serialize the builds of a project and wanted to give it a whirl.

Simplify the Terraform state file management

While remote state files and locking in DynamoDB looks to be the best for safety, that’s a lot of resources to setup for your basic Fastly service. Could I just use terraform import and terraform apply on every deploy?

Don’t use any custom VCL

We have a habit of jumping straight to using the custom VCL feature of Fastly, rather than making the most of their API.


This is a run through of a Friday morning project.

Setting up the Fastly Service

I began by creating a new Fastly service via the UI, this gave me means to generate a Fastly API token that was limited to this one service.

A new service has a number of required fields, the main two being a domain and a backend.

For the domain I setup a generic subdomain under ft.com that could be used to test the service before any production domains were added.

The backend is a little more tricky, this is a redirect service, there is no backend. Fastly however lets you define 127.0.0.1 so I used just that, opting for port 80 so that there was no TLS configuration to go with it.

Defining the Terraform Configuration

The end result for our Fastly Terraform resource.

After creating the Fastly service, initially I needed to use the brilliant terraform import command to generate a state file locally for the existing resources.

But first I needed a basic main.tf.

resource "fastly_service_v1" "secure_domain_redirect" {

name = "Secure Domain Redirect"
  domain {
name = "example.com"
comment = "Test domain redirecting to www.ft.com."
}
  // A dummy backend, all responses from this service are synthetic.
backend {
name = "dummy 127.0.0.1"
address = "127.0.0.1"
port = 80
}
}

I could then run terraform import fastly_service_v1.secure_domain_redirect <fastly-service-id>, and received a terraform.tfstate file in return.

Then it was time to define the first redirect, a default, for any domains added with no other redirect rules.

// Default response to redirect to www.ft.com.
condition {
name = "default request"
statement = "req.url != \"\"" // Always true.
type = "REQUEST"
priority = 1000 // Super low priority.
}
response_object {
name = "default response"
status = 303
response = "See Other"
request_condition = "default request"
}
header {
name = "default location"
action = "set"
type = "response"
destination = "http.Location"
source = "\"https://www.ft.com\""
ignore_if_set = true
}

Then a combination of request_object, condition, domain, and header blocks are used to define the more complex redirects.

For example to send traffic from http://try.ft.comto https://www.ft.com/try you need the following, which overrides the default redirect.

// Permanent redirect for try.ft.com.
domain {
name = "try.ft.com"
comment = "Corporate trials redirecting to https://www.ft.com/try."
}
condition {
name = "try.ft.com request"
statement = "req.http.Host == \"try.ft.com\""
type = "REQUEST"
priority = 100
}
condition {
name = "try.ft.com response"
statement = "req.http.Host == \"try.ft.com\""
type = "RESPONSE"
priority = 100
}
response_object {
name = "try.ft.com response"
status = 301
response = "Moved Permanently"
request_condition = "try.ft.com request"
}
header {
name = "try.ft.com location"
action = "set"
type = "response"
destination = "http.Location"
source = "\"https://www.ft.com/try\" req.url"
response_condition = "try.ft.com response"
}

Now we’re good to deploy, I simply ran terraform apply and the Fastly service was deployed. Doing a little manual testing confirmed the conditions were all working correctly.

Defining the CircleCI 2.0 Pipeline

The end goal 🛀

For simplicity this pipeline will run tests after deploying, smoke tests, to verify a deploy is correct.

Before the deploy we want to validate configuration and apply some linting rules. Terraform comes with a neat terraform fmt command, and for the Node.js tests I use prettier to ensure the style is consistent.

There are two parts to this CircleCI pipeline, the jobs and the workflow.

The Jobs

First we want to validate everything, so we split that into two jobs, validate_node and validate_terraform.

validate_terraform:
docker:
- image: hashicorp/terraform
steps:
- checkout
- run:
name: Validate Terraform Formatting
command: "[ -z \"$(terraform fmt -write=false)\" ] || { terraform fmt -write=false -diff; exit 1; }"

The above simply runs the terraform fmt command, and fails the job if there are any changes in the diff. It also prints the diff for reference.

validate_node:
docker:
- image: circleci/node:8
steps:
- checkout
- restore_cache:
key: npm-dependency-cache-{{ checksum "package-lock.json" }}
- run:
name: Install Dependencies
command: npm install
- save_cache:
key: npm-dependency-cache-{{ checksum "package-lock.json" }}
paths:
- ./node_modules
- run:
name: Validate Node.js Formatting
command: "[ -z \"$(node_modules/.bin/prettier --list-different 'test/**/*.js')\" ] || { node_modules/.bin/prettier --list-different 'test/**/*.js'; exit 1; }"

Here we’re just running prettier, somewhat ironically this could probably be a prettier bit of configuration, but it works so ¯\_(ツ)_/¯.

Next up is deployment.

deploy:
docker:
- image: hashicorp/terraform
steps:
- checkout
- run:
name: Install Alpine dependencies
command: apk add bash jq
- run:
name: Initialize Terraform
command: terraform init
- run:
name: Import State
command: terraform import fastly_service_v1.secure_domain_redirect <fastly-service-id>
- run:
name: Deploy
command: .circleci/do-exclusively.sh --branch master terraform apply

Note, I’ve copied over the do-exclusively.sh script from the CircleCI forum into the repository.

This runs terraform init to install the Fastly Terraform module, terraform import to generate a state file for this build, and finally a terraform apply to deploy the changes.

The do-exclusively.sh script ensures that no other deploy job is currently running under this project.

Finally we have a test job, that just runs npm test. I’ll include the job as part of the full configuration below.

The Workflows

This is the nitty gritty configuration for describing the order of our jobs, and which ones should wait for others.

workflows:
version: 2
deploy:
jobs:
- validate_terraform
- validate_node
- deploy:
requires:
- validate_terraform
- validate_node
filters:
branches:
only: master
- test:
requires:
- deploy
filters:
branches:
only: master

Nothing special, it runs the validate jobs for all builds (so we get something running for pull requests), and only deploys on commits to the master branch.

You can find the full configuration file at the following gist, https://gist.github.com/sjparkinson/2871841a1ce5d4c9157d22da5255fddd.

Bring it all together and we get a passing build 🚀.