Using Terraform with Heroku
Last updated May 30, 2024
Table of Contents
As your collection of Heroku apps grows in number and complexity, the ability to automate the deployment of your entire infrastructure (apps, add-ons, domains, Private Spaces, etc.) becomes more and more valuable.
Hashicorp Terraform is a tool that enables you to configure and deploy a wide range of integrated cloud resources (including Heroku resources) with a simple declarative language called HCL.
This article demonstrates how to use Terraform with Heroku. It provides example configurations for setting up common Heroku resources, along with general Terraform best practices.
Set up Terraform
First, download Terraform and install it.
Before using Terraform, consider the backend that stores the configuration’s current state. This state includes identifiers for all existing resources and the relationships between them. Terraform uses this state to understand what needs to be done to complete a particular action (usually create or destroy).
If you’re just getting started with Terraform, the default local backend simply stores the state in a file. Once you start using Terraform for real projects, you’ll want to store your state information using a remote backend, such as Heroku Postgres.
Local backend
The local backend is the easiest way to get up and running with Terraform. If you don’t specify a backend in a Terraform configuration, then the local backend is used.
This backend stores your deployment’s state in a terraform.tfstate
file on your local machine. Do not check the terraform.tfstate
file into version control. It contains sensitive data such as secret keys.
The local backend is not recommended for long-term use or collaboration. The state is only saved on the individual machine, so it’s not accessible by other team members and not backed-up to avoid accidental loss. To solve this problem, you can instead use a remote backend, such as a Postgres database.
Remote Postgres backend
Set-up your project for long-term use and collaboration with the pg backend along with the Heroku Postgres add-on. The pg backend is included in Terraform version 0.12 and newer. With this remote backend, Terraform can be run on individual contributors’ machines (just like with the local backend) or as a Heroku app. Heroku’s automated database backups and failover ensures the Terraform state is available and can be restored in case of hardware failure.
To use the pg backend, specify it in your main.tf
(or other) configuration file:
terraform {
backend "pg" {
}
}
Then, create a Heroku app with a Heroku Postgres add-on, and use its DATABASE_URL
config variable as the pg backend’s conn_str
:
# Pick a unique app name
$ export APP_NAME=my-terraform-backend
# Create the database
$ heroku create $APP_NAME
$ heroku addons:create heroku-postgresql:essential-0 --app $APP_NAME
# On each machine where it's used, initialize Terraform
# with the database credentials
$ export DATABASE_URL=`heroku config:get DATABASE_URL --app $APP_NAME`
$ terraform init -backend-config="conn_str=$DATABASE_URL"
The previous example uses an Essential-0 Heroku Postgres database. Consider setting a Standard-tier or higher plan such as standard-0
or premium-0
to ensure best performance and availability for more critical use-cases.
See Terraform pg backend docs for more configuration options and examples.
Other remote backends
Although they aren’t covered in this article, a variety of other remote backends are available.
Hashicorp also offers a Remote State Management service and premium Terraform Enterprise product.
Set up the Heroku provider
Complete documentation is available in the Heroku provider docs.
Configuration
After you’ve installed Terraform, create your primary configuration file, main.tf
, specifying the Heroku provider. If you’re using the remote pg backend, then this file may already contain the backend configuration.
terraform {
required_providers {
heroku = {
source = "heroku/heroku"
version = "~> 5.0"
}
}
}
Upgrading
You may check for the latest Heroku provider release version and update your configuration if the version is out of date.
Minor and patch version upgrades are backward compatible, following the practice of semantic versioning. To migrate to a new major version, see the Upgrading guide.
Authorization
When Terraform uses the Heroku provider, it makes requests to the Platform API to create apps, add-ons, and other resources. Requests to the Platform API require an authorization token.
Authorization tokens used by Terraform require global scope to perform the various necessary actions with the Heroku API. If you want to isolate Terraform’s capabilities from your existing Heroku account, you can create a new Heroku account specifically for use with Terraform.
Obtaining an authorization token
First, use the Heroku CLI to ensure that you are logged in to the Heroku account that you want to use with Terraform:
$ heroku whoami
If you need to switch identities, log out and then log in like so:
$ heroku logout
$ heroku login
Second, use the Heroku CLI to generate an authorization token. The --description
parameter is a human-readable name to indicate the purpose or identity of each authorization:
$ heroku authorizations:create --description terraform-my-app
Set the returned Token value and the Heroku account’s email address as local environment variables for Terraform.
$ export HEROKU_API_KEY=<TOKEN> HEROKU_EMAIL=<EMAIL>
Environment variables must be exported again in each new terminal/shell you use.
You can use the Heroku CLI to look up the authorization token to use it again in the future. List all the authorizations, and then fetch the token for the desired authorization’s ID:
$ heroku authorizations
$ heroku authorizations:info <ID>
Initialization
Now that Terraform has a valid authorization token, you can initialize the provider:
$ terraform init
If using pg backend, also set the database credentials during init. (See Set-up remote Postgres backend):
$ terraform init -backend-config="conn_str=$DATABASE_URL"
After init
completes successfully, you don’t need to run it again unless the provider
changes (such as rotating credentials) or set up Terraform on a new computer.
Provisioning an empty app
Complete reference of Terraform resources is available in the Heroku provider docs.
Add an app resource
Append a Heroku app resource to your main.tf
file:
variable "example_app_name" {
description = "Name of the Heroku app provisioned as an example"
}
resource "heroku_app" "example" {
name = var.example_app_name
region = "us"
}
This tells Terraform to provision an empty Heroku app with a name that you will provide to the terraform apply
command.
Independent of the app’s Heroku name, Terraform’s identifier for this particular resource is heroku_app.example
, the resource type and its name. This identifier may be used as a variable in the configuration and for operations such as import or viewing state.
Plan and apply
Use the terraform apply
command to apply the configuration you saved to main.tf
:
Replace sushi
with a unique name for your app.
$ terraform apply -var example_app_name=sushi
You can split the above command into two commands to ensure that the plan can be reviewed and then exactly applied:
$ terraform plan -var example_app_name=sushi -out=current.tfplan
$ terraform apply current.tfplan
Read more about Terraform’s core workflow.
After apply
completes successfully, the resources created by Terraform will be present in the Heroku account associated with Terraform’s authorization token.
View Terraform’s current state to see what’s been created:
$ terraform show
When using the local backend, the output of terraform show
is based on the contents of the terraform.tfstate
file. When using a remote backend, the output is based on the contents of the backend’s state store.
Deploying code to an app
To use Terraform to deploy code to the empty application you provisioned in Provisioning an empty app, update main.tf
to match the following:
terraform {
required_providers {
heroku = {
source = "heroku/heroku"
version = "~> 5.0"
}
}
}
variable "example_app_name" {
description = "Name of the Heroku app provisioned as an example"
}
resource "heroku_app" "example" {
name = var.example_app_name
region = "us"
}
# Build code & release to the app
resource "heroku_build" "example" {
app_id = heroku_app.example.id
buildpacks = ["https://github.com/mars/create-react-app-buildpack.git"]
source {
url = "https://github.com/mars/cra-example-app/archive/v2.1.1.tar.gz"
version = "2.1.1"
}
}
# Launch the app's web process by scaling-up
resource "heroku_formation" "example" {
app_id = heroku_app.example.id
type = "web"
quantity = 1
size = "Standard-1x"
depends_on = [heroku_build.example]
}
output "example_app_url" {
value = heroku_app.example.web_url
}
Apply the config, again passing your app name as an input variable:
Replace sushi
with your app’s name.
$ terraform apply -var example_app_name=sushi
After terraform apply
completes successfully, visit your Heroku app’s URL which is available as an output of the configuration.
$ terraform output example_app_url
If you don’t want to keep the sample around, you can use Terraform to clean it all up:
$ terraform destroy -var example_app_name=sushi
Going further
The Examples section below provides full-fledged configurations you can use to provision complex architectures. This section provides simpler fragments to demonstrate more of what you can do with Terraform:
Creating an app and an add-on
This fragment creates an App resource and Add-on resource in a particular Heroku Team and Heroku region:
variable "heroku_team" {
description = "Name of the Team (must already exist)"
}
resource "heroku_app" "example" {
name = "${var.heroku_team}-example"
region = "us"
organization {
name = var.heroku_team
}
}
resource "heroku_addon" "papertrail_example" {
app_id = heroku_app.example.id
plan = "papertrail:choklad"
}
Note that the app’s name in this example uses the team’s name as a prefix. Consistent prefixing of resource names makes it much easier to track resources provisioned by Terraform.
Scaling an app
This fragment scales uses a Formation resource to scale an existing app. It also executes a local command (a provisioner health check) to wait until the app launches successfully.
resource "heroku_formation" "example" {
app_id = heroku_app.example.id
type = "web"
quantity = 2
size = "Standard-1x"
depends_on = [heroku_app_release.example]
provisioner "local-exec" {
command = "./bin/health-check ${heroku_app.example.web_url}"
}
}
Creating a Private Space
This fragment creates a Private Space resource, ensuring it is in the same region as AWS resources:
variable "heroku_enterprise_team" {
description = "Name of the Enterprise Team (must already exist)"
}
variable "heroku_private_space" {
description = "Name of the Private Space"
}
variable "aws_region" {
description = "Amazon Web Services region"
default = "us-east-1"
}
variable "aws_to_heroku_private_region" {
default = {
"eu-west-1" = "dublin"
"eu-central-1" = "frankfurt"
"eu-west-2" = "london"
"ca-central-1" = "montreal"
"ap-south-1" = "mumbai"
"us-west-2" = "oregon"
"ap-southeast-1" = "singapore"
"ap-southeast-2" = "sydney"
"ap-northeast-1" = "tokyo"
"us-east-1" = "virginia"
}
}
resource "heroku_space" "example" {
name = var.heroku_private_space
organization = var.heroku_enterprise_team
region = lookup(var.aws_to_heroku_private_region, var.aws_region)
}
Creating a VPN connection to Google Cloud Platform
This configuration fragment sets up a Heroku Private Space VPN connection with a Google Cloud VPC network (see complete Terraform example):
variable "heroku_vpn" {
description = "Name of the Heroku VPN connection"
}
module "heroku_vpn_gcp" {
source = "github.com/heroku-examples/terraform-heroku-vpn-gcp"
// (config details omitted)
}
resource "heroku_space_vpn_connection" "google" {
name = var.heroku_vpn
space = heroku_space.example.id
public_ip = module.heroku_vpn_gcp.google_vpn_ip
routable_cidrs = ["${module.heroku_vpn_gcp.google_cidr_block}"]
}
Examples
The following examples provide detailed Terraform configurations to set up Heroku resources, as well as resources on Amazon AWS and Google Cloud Platform:
- Peering a Private Space with an Amazon VPC
- Creating a VPN connection between a Private Space and Google Cloud Platform
Best practices
Be careful of config drift
Do not use the Heroku Dashboard or CLI to alter Terraform-managed resources. After a Terraform config is applied, modifying resources from outside of Terraform will cause your Terraform state to become out of sync with your config.
Actions such as scaling dynos, setting config vars, and modifying add-ons should all be performed by updating and re-applying your Terraform config. Otherwise, these differences make it impossible to apply or destroy further until the drifting values are either imported (for new resources) or manually updated in the state.
For more details, see Detecting and Managing Drift with Terraform.
Use consistent name prefixes
When viewing your Terraform-managed resources in the Heroku Dashboard or CLI, it can be difficult to understand how the resources relate to one another. By consistently prefixing your resource names across each configuration, this relationship becomes much easier to understand.
For example, use a prefix
input variable:
variable "prefix" {
description = "High-level name of this configuration, used as a resource name prefix"
type = "string"
}
resource "heroku_app" "example-1" {
name = "${var.prefix}-example-1"
region = "us"
}
resource "heroku_app" "example-2" {
name = "${var.prefix}-example-2"
region = "us"
}
Use one Heroku team per Terraform config
To improve the experience of managing Terraformed resources, give each Terraform config its own Heroku Team. This way, the Heroku Team contains everything provisioned by Terraform. Set the team name as an input variable, and then use that variable to set the team for each resource in the config.
variable "heroku_team_name" {
description = "Name of the Heroku Team owning this complete deployment."
type = "string"
}
resource "heroku_app" "example" {
name = "example"
region = "us"
organization = {
name = var.heroku_team_name
}
}
Use provisioner health checks
Sometimes a Terraform-created resource isn’t actually “ready” when the provider says it is. A good example of this is a web app that should not be considered ready until it responds to requests with HTTP status 200.
Terraform solves this problem with Provisioners. These are arbitrary commands that Terraform will wait on and by default fail creation of the resource if the command fails.
resource "heroku_formation" "sushi" {
app_id = heroku_app.sushi.id
type = "web"
quantity = 2
size = "Standard-1x"
depends_on = [heroku_app_release.sushi]
provisioner "local-exec" {
command = "./bin/health-check ${heroku_app.sushi.web_url}"
}
}
Example bin/health-check
script:
#!/bin/bash
# Check the health of the web service every thirty-seconds
# for up to ten minutes, until it responds with HTTP status 200.
fail_count=1
while true
do
http_status=$(curl --write-out %{http_code} --silent --output /dev/null $1)
if [ "$http_status" -eq "200" ]; then
echo "$(date -u) health check succeeded to $1"
exit 0
else
if [ "$fail_count" -eq "21" ]; then
echo "$(date -u) health check failed (status $http_status) to $1"
exit 2
else
echo "$(date -u) health check ${fail_count}/20 to $1"
sleep 30
fail_count=$[$fail_count +1]
fi
fi
done
Configuring infrastructure and operational resources
You can use Terraform to configure any combination of infrastructure and operational resources.
Infrastructure resources include:
- Apps
- Add-ons
- Domains
- Pipelines
- Private Spaces
When these resources are provisioned, none of your code is running on Heroku yet. Code must still be deployed and launched, either by a developer through the platform, or by Terraform provisioning operational resources.
Operational resources include:
- Builds
- Slugs
- App releases
- App formations (i.e., dyno scaling)
By provisioning these resources, Terraform can deploy app code directly and operate the complete system. This makes it possible to release multiple apps simultaneously and orchestrate a mutual release rollback if something goes wrong.
Note that when Terraform is used for operational config, the likelihood of config drift increases.