Skip to main content

Terraform Basics: Minimal HCL Syntax and Your First Apply

The previous chapter covered Tasuku's requirements and the overall design that maps them onto seven AWS services. Starting with this chapter, we begin writing that design as actual code.

The first thing we'll apply isn't a VPC or an RDS instance, but an AWS Budgets budget alert, which doesn't incur hourly charges. Starting with something you can work on hands-on without worrying about cost lets us focus on this chapter's main topics: HCL syntax and the concept of state.

This chapter is the starting point of the following dependency chain.

2.1. Why write infrastructure as code

You can also create AWS resources from the console screen. Creating a VPC, carving out subnets, and launching an EC2 instance are all operations you can complete entirely in the browser.

The reason to use Terraform isn't to reduce the effort of these operations. A change made in the console leaves a record of who changed what and when only on the screen itself, with no way for anyone else to review it afterward. If you define resources in HCL instead, changes remain as a code diff that you can review through a pull request. Being able to check what will change by running terraform plan before applying is also something console operations don't offer.

Tasuku's configuration will keep growing from here to include VPC, ECS, RDS, and more, but what we write first is the HCL in this chapter — fewer than a few hundred lines.

2.2. Minimal HCL syntax

HCL (HashiCorp Configuration Language) is written by combining the following four kinds of blocks.

  • terraform block: Configures Terraform's own behavior, such as the Terraform version and the providers to use1
  • provider block: Declares the connection settings for the target cloud (AWS, in this case). A single provider can also have multiple configurations, a typical use case being multi-region deployments2
  • resource block: Defines the infrastructure you actually create. When referencing it elsewhere in the configuration, use the <TYPE>.<LABEL> format3
  • variable block: Defines input values for customizing a module without modifying its source. If you specify the default argument, that default value is used when the value is omitted4

The basic form of a resource block is as follows.

resource "<RESOURCE_TYPE>" "<LOCAL_NAME>" {
<ARGUMENT_NAME> = <VALUE>
}

<RESOURCE_TYPE> is a type defined by the provider (AWS, in this case), fixed to values like aws_vpc or aws_budgets_budget. <LOCAL_NAME> is a reference name that's only meaningful within this configuration file, and it's a separate thing from the actual resource name shown in the AWS console.

2.3. Pinning the provider

Because Terraform's behavior can change between versions, the terraform block pins both the allowed version of the CLI itself and the required version of the provider. versions.tf looks like this.

terraform {
required_version = ">= 1.10"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.53"
}
}
}

required_version = ">= 1.10" requires the Terraform CLI to be version 1.10 or later.

The ~> in version = "~> 6.53" is called the pessimistic constraint operator, and it allows only the last segment of the specified version string to vary5. Here, 6.53 is a two-segment specification, so it allows the rightmost segment (the minor version) to increase — 6.53 and 6.54 are allowed, but 7.0 is not. With the same operator, writing three segments like ~> 6.53.0 restricts variation to the patch version only, so be careful not to confuse these two forms.

The provider's own settings go in providers.tf.

provider "aws" {
region = var.region

# Tags set here are automatically applied to all resources created via this provider
default_tags {
tags = {
Project = var.project
Env = var.env
ManagedBy = "terraform"
}
}
}

region, along with other var.xxx references in the code, refer to variables defined in variables.tf.

variable "project" {
description = "Project name. Used as the prefix for resource names"
type = string
default = "tasuku"
}

variable "env" {
description = "Environment identifier (dev / prod, etc.)"
type = string
default = "dev"
}

variable "region" {
description = "Deployment target region"
type = string
default = "ap-northeast-1"
}

variable "budget_limit_usd" {
description = "Threshold for the monthly budget alert (USD)"
type = string
default = "50"
}

variable "alert_email" {
description = "Email address to notify for the budget alert"
type = string
}

project, env, region, and budget_limit_usd have default values, which are used as-is if you omit a value. Only alert_email has no default, so if you don't specify a value, terraform plan will prompt you for input when it runs. We'll cover how to actually specify values in 2.6. The contents of default_tags are covered in 2.5.

2.4. What is state?

When you run terraform apply, Terraform records information about the resources it created in a file called state. The role of state is to map the declarations in your configuration files to the resources that actually exist on AWS6. For example, the declaration resource "aws_budgets_budget" "monthly" is, by itself, nothing more than a piece of HCL. It's state that remembers which real resource (the actual budget ID) corresponds to this declaration after an apply. When you delete a declaration from a configuration file, Terraform's ability to determine that "the corresponding real resource should be deleted" also comes from this correspondence being recorded in state.

The backend determines where state is stored. If you don't specify one, the local backend is used by default, saving resource information to a file called terraform.tfstate, relative to the root module7. This chapter continues with the local backend for now. Collaborating with multiple people, and securely storing the state file itself, become necessary only after we migrate to S3 in Chapter 5.

2.5. name_prefix and default_tags

locals.tf consolidates the naming convention used across Tasuku in one place.

locals {
# Prefix for all resource names. Includes env in anticipation of environment separation (Chapter 11)
name_prefix = "${var.project}-${var.env}"
}

local.name_prefix becomes a string like tasuku-dev, and we'll use it repeatedly as the prefix for resource names created in later chapters. We include env so that, when Chapter 11 covers dev / prod environment separation, resources with the same name won't collide across environments.

For tags, instead of writing tags = { ... } on each individual resource, we use the default_tags in the provider block we saw in 2.3. default_tags is a setting that applies tags across all resources handled by that provider instance8. It takes effect on nearly every resource that implements tags, with one exception: aws_autoscaling_group is excluded9 (since Auto Scaling Groups don't appear in this series until Chapter 11 onward, you don't need to worry about this yet).

By placing the three tags Project, Env, and ManagedBy in default_tags, you avoid rewriting the same tagging on every resource each time you add a new one in later chapters.

2.6. Your first apply: creating a budget alert

We'll now add one actual resource to the terraform, provider, and variable settings we've built so far: the aws_budgets_budget defined in budget.tf.

resource "aws_budgets_budget" "monthly" {
name = "${local.name_prefix}-monthly-budget"
budget_type = "COST"
limit_amount = var.budget_limit_usd
limit_unit = "USD"
time_unit = "MONTHLY"

# Notify when actual spend exceeds 80% of the threshold
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_email_addresses = [var.alert_email]
}

# Notify when the month-end forecast looks likely to exceed the threshold
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_email_addresses = [var.alert_email]
}
}

This budget sends two kinds of notifications by email to var.alert_email: one when actual monthly cost reaches 80% of the limit (var.budget_limit_usd, default 50 USD), and another when the forecast for month-end looks likely to exceed 100%.

This resource itself costs nothing. A budget with budget actions enabled — a feature that automatically applies IAM policies or controls EC2 / RDS instances when a threshold is exceeded10 — starts costing 0.10 USD per day from the third such budget onward, but monitoring and notifying with a notification-only budget is free11.

Constraints on notification timing

Budget information is updated at most 3 times per day, typically 8 to 12 hours after the previous update12. In addition, there's a delay between AWS resource usage and when the charge is actually finalized, so a time lag occurs between exceeding a threshold and the notification arriving12. This mechanism helps you notice unexpected charges sooner, but it isn't real-time spending monitoring.

As mentioned in 2.3, alert_email has no default value. The actual value goes in terraform.tfvars. Copy terraform.tfvars.example and replace at least alert_email with your own email address.

# Copy this to terraform.tfvars and set your own values
# project = "tasuku"
# env = "dev"
# region = "ap-northeast-1"
alert_email = "you@example.com"

2.7. Verification

Once you've put versions.tf, providers.tf, locals.tf, variables.tf, budget.tf, and terraform.tfvars together in the same directory, verify them in the following order. For instructions on installing Terraform itself, see Chapter 1, Section 1.7.

terraform fmt -check -recursive -diff
terraform init
terraform validate
terraform plan

terraform fmt formats HCL, and terraform validate checks that the syntax is consistent with the provider schema. terraform plan uses your AWS credentials to read the current state and shows what would be created, changed, or deleted if you applied it. Confirm that the plan shows one budget being added, then proceed to terraform apply.

Summary

  • Unlike console operations, Terraform leaves infrastructure changes as a code diff, which you can review with terraform plan before applying
  • HCL is written by combining four kinds of blocks — terraform, provider, resource, and variable — and you pin the provider version with the ~> operator
  • State is a record that maps declarations in the configuration file to actual AWS resources; in this chapter, it's stored as-is in the default local backend
  • Setting default_tags on the provider means you don't need to write tagging individually for each resource that follows

Next steps

Footnotes

  1. Source: Terraform block reference.

  2. Source: Provider block reference.

  3. Source: resource block reference.

  4. Source: variable block reference.

  5. Source: Version Constraints. A two-segment specification (equivalent to ~> 1.1) allows only the rightmost segment to increase.

  6. Source: Purpose of Terraform State.

  7. Source: Backend Type: local.

  8. Source: AWS Provider's default_tags Configuration Block.

  9. Source: same as above. default_tags applies to all resources that implement tags, except for aws_autoscaling_group.

  10. Source: Configuring budget actions. Three types are available: IAM policies, Service Control Policies (SCPs), and EC2 / RDS instance controls.

  11. Source: AWS Budgets Pricing.

  12. Source: Budget details. 2