Heroku Review Apps with Automated Custom Domains on Route 53
We were recently transitioning a client to use Heroku build pipelines for their Next.js project that handles most business logic through an external API, including authentication and cookie handling. This pipeline uses Heroku's review apps as well as separate instances for staging and production environments. The client handled their domains through AWS's Route 53 DNS service. Pointing their CNAME records for staging and production to the Heroku instances was easy enough, but enabling review apps is a different story.
Review apps
When working on a feature it's incredibly helpful to be able to poke around a standalone instance of the feature branch outside of the developer's local machine. This is useful for code reviewers and stakeholders to sign off on functionality and design as well as for developers to ensure their code works in a staging environment. Heroku allows for automatic creation of a review app when a pull request is opened on GitHub. Once that pull request is merged or closed, the review app is destroyed.
In order for our review apps to successfully share cookies and talk to the API server at https://api.clientdomain.com
, we needed to ensure that they shared the same domain. By default, review app domains are assigned a name based off of the branch name, like http://my-great-feature.herokuapp.com
. We needed to make sure we could access this branch at http://my-great-feature.clientdomain.com
. You can configure this in the UIs of Heroku and AWS but doing this manually for every review app is untenable.
Furthermore, Chrome recently pushed a change where cookies cannot be shared across protocols, even if the domain is the same. Now we have to make sure that we have a secure review app at https://my-great-feature.clientdomain.com
to match up with the secure API at https://api.clientdomain.com
.
Cool backstory, show me the code
If you're using "new" review apps, we take care of all this configuration in an app.json at the root of your project by using the postdeploy
and pr-predestroy
keys. Here we specify scripts to be run once after an app is created and when the review app is destroyed.
Note: The postdeploy
script will be run once after any app is created, including non-review apps. If the app already exists, this script will not be run on subsequent pushes.
app.json
{
"scripts": {
"postdeploy": "node bin/postdeploy.js",
"pr-predestroy": "node bin/pr-predestroy.js"
}
}
bin/postdeploy.js
If you're using Node, you'll need to install aws-sdk
and heroku-client
as dependencies because Heroku will prune devDependencies
before running these scripts. You'll also need to set up your API keys in the config vars for Review Apps through the Heroku UI.
const AWS = require('aws-sdk');
const Heroku = require('heroku-client');
const accessKeyId = process.env['AWS_ACCESS_KEY_ID'];
const secretAccessKey = process.env['AWS_SECRET_ACCESS_KEY'];
const heroku = new Heroku({ token: process.env['HEROKU_API_TOKEN'] });
AWS.config.update({
accessKeyId,
secretAccessKey,
region: 'us-east-1'
});
const route53 = new AWS.Route53();
run().catch(err => console.log(err));
Once we get into the main run
function, Heroku makes a few other configuration variables available to us automatically: HEROKU_APP_NAME
, HEROKU_BRANCH
, and HEROKU_PR_NUMBER
. We'll only make use of HEROKU_APP_NAME
.
async function run() {
const appName = process.env['HEROKU_APP_NAME'];
const hostName = `${appName}.yourdomain.com`;
// Asign new domain in Heroku for your review app
heroku.post(`/apps/${appName}/domains`, {
body: {
hostname: hostName,
sni_endpoint: null // Not needed since we'll have Heroku manage this for us
}
}).then(app => {
const res = await route53.listHostedZones().promise();
const zoneId = res.HostedZones.find(zone => zone.Name === 'iconnections.io.').Id;
// Create new CNAME in Route 53
const changeRes = await route53.changeResourceRecordSets({
HostedZoneId: zoneId,
ChangeBatch: {
Changes: [{
Action: 'CREATE',
ResourceRecordSet: {
Name: hostName,
Type: 'CNAME',
TTL: 60, // 1 minute
ResourceRecords: [{ Value: newCname }] // domain from Heroku
}
}]
}
}).promise();
console.log(changeRes);
// Turns on automatic certificate management
heroku.post(`/apps/${appName}/acm`).then(async (appAcm) => {
console.log(appAcm);
});
});
}
There might be a delay between when Heroku is able to assign your review app a certificate and when the Route 53 DNS updates with the new CNAME. If that's the case, give it a few minutes as Heroku will automatically retry to assign the certificate.
bin/pr-predestroy.js
The configuration setup is the same as postdeploy.js
. Since Heroku will handle destroying of our review app, we're mostly concerned with clearing the old CNAME record in Route53 so we don't have unused records piling up.
The same 3 Heroku-injected configuration variables that are available to us in postdeploy.js are also available in pr-predestroy. The Heroku documentation does not make that clear.
async function run() {
const appName = process.env['HEROKU_APP_NAME']; // This is available to us in pr-predestroy too!
const hostName = `${appName}.yourdomain.com`;
heroku.get(`/apps/${appName}/domains/${hostName}`).then(async (app) => {
const newCname = app.cname;
const res = await route53.listHostedZones().promise();
const zoneId = res.HostedZones.find(zone => zone.Name === 'yourdomain.com.').Id;
// Destroy CNAME record in Route 53
const changeRes = await route53.changeResourceRecordSets({
HostedZoneId: zoneId,
ChangeBatch: {
Changes: [{
Action: 'DELETE', // Now this is DELETE
ResourceRecordSet: {
Name: hostName,
Type: 'CNAME',
TTL: 60, // 1 minute
ResourceRecords: [{ Value: newCname }] // domain from Heroku
}
}]
}
}).promise();
console.log(changeRes);
});
}
Many thanks to the work of these people for helping document this process: