This blog post will deep dive into how Terraform handles output and how we can leverage and use output values efficiently across our Terraform projects. Output values allow us to share data between modules and workspaces while also providing us the flexibility to pass values to external systems for automation purposes.
You have come to the right place if you are new to Terraform! Spacelift has curated a ton of valuable material, tutorials, and blog posts around Terraform and how industry experts use it on its Spacelift blog.
Terraform outputs are used for two things: either printing details about a resource/datasource/local/variable or exporting different details about these if we are using a module. They are very useful in both cases. For example, you can build different connection strings you can print in the console for easy use in an app, or you can export things that can be referenced in another configuration/in another module, facilitating the creation of other resources.
Input variables permit us to customize Terraform configurations without hardcoding any values. This way, we can reuse Terraform modules while assigning custom values based on our needs. Usually, we refer to them as just variables in the context of Terraform.
To define input variables, we must declare them using a variable block:
variable "aws_region" {
description = "AWS region"
type = string
}
variable "ec2_instance_type" {
description = "Instance type for EC2 instances"
type = string
default = "t2.small"
}
The variable’s name is the label we set following the variable keyword. For every variable, we have the option to set some arguments such as default, type, description, validation, sensitive, and nullable. Check the official documentation about these arguments and how to set them in detail here.
After declaring our input variables, we can utilize them in modules by referencing them like this var.<variable_name> where <variable_name> matches the label following the variable keyword. For example, to reference the variable ec2_instance_type that we defined above:
resource "aws_instance" "web_server" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.ec2_instance_type
}
On the other hand, output values empower us to export helpful information from our Terraform projects that we have defined and provisioned with Terraform. In the context of Terraform, we refer to output values as just outputs for simplicity.
Combining input and output variables, we get the flexibility to customize, automate, reuse and share our Terraform code easily. Input variables are similar to function arguments in traditional programming, while output variables work similarly to the return values of a function. Both are equally important to make our Terraform projects functional and facilitate data’s incoming and outgoing flow.
More specifically, output values are quite helpful in certain use cases:
- We can expose information from child modules to a parent module using outputs.
- Even more, from a root module, we can print outputs in the command line or pass these output values to external systems for automation purposes.
When we use a remote state, we can access the root module outputs by other configurations using the terraform_remote_state data source. Output values from child modules aren’t accessible.
In order to define an output value, we have to use the output block:
output "instance_public_ip" {
description = "Public IP of EC2 instance"
value = aws_instance.web_server.public_ip
}
In the above example, we define an output value with the name instance_public_ip. This way, we can pass the value to the parent module or display it to the end-user if it’s an output of the root module.
The value argument, which is the returned output value, takes an expression referencing other resources or module attributes. Terraform only renders and displays outputs when executing terraform apply and not when executing terraform plan.
To use outputs of nested modules from parent modules, we have to reference them as:
module.<module_name>.<output_value_name>
For example, to reference the output value instance_public_ip that we have declared above in a module named aws_web_server_instance from its parent module, we have to use:
module.aws_web_server_instance.instance_public_ip
Let’s examine how we can use all this in a real-world example. In this GitHub repository, we define the Terraform configuration for this example’s infrastructure. To follow along, you will need to install Terraform, have an AWS account ready, and authenticate with your AWS keys via the command line. Note that you might be charged a few dollars in your AWS account if you follow along.
In this example, we create the necessary infrastructure for a webserver. For the needs of this demo, we split our Terraform configuration into three modules, the root one and two child modules responsible for handling VPC-related resources and EC2 instance-related resources.
The project structure looks like this:
For each module, we define a main.tf file that handles the main functionality of the module.
Variable’s declarations and default values are populated in variables.tf files, while for the root module, we also use a terraform.tfvars file to set some variable values.
A good practice is to define our outputs in separate outputs.tf files, as you can see in the above example project structure. By declaring output values in an outputs.tf file per module, we improve the clarity of our modules as it’s easier for users to understand what outputs to expect from them quickly.
The root module utilizes and configures the aws provider and then just simply calls two child modules aws_web_server_vpc and aws_web_server_instance in main.tf of the top directory.
root module main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "3.16.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "aws_web_server_vpc" {
source = "./modules/aws-web-server-vpc"
}
module "aws_web_server_instance" {
source = "./modules/aws-web-server-instance"
ec2_instance_type = var.ec2_instance_type
vpc_id = module.aws_web_server_vpc.vpc_id
subnet_id = module.aws_web_server_vpc.subnet_id
}
We notice that when calling the module aws_web_server_instance, we are passing two expressions using output values from the aws_web_server_vpc module with the notation module.<module_name>.<output_value_name> we have seen earlier.
root module outputs.tf
output "vpc_id" {
description = "ID of the vpc"
value = module.aws_web_server_vpc.vpc_id
}
output "instance_id" {
description = "ID of EC2 instance"
value = module.aws_web_server_instance.instance_id
}
output "instance_public_ip" {
description = "Public IP of EC2 instance"
value = module.aws_web_server_instance.instance_public_ip
}
We define three output values for our root module, and we expect to see them at the command line after our infrastructure is provisioned. Checking the value parameter of each block, we notice that all of them are coming from output values of the two child modules, and by declaring them as output values of the root module, we are able to pass them through to the command line.
root module variables.tf
variable "aws_region" {
description = "AWS region"
type = string
}
variable "ec2_instance_type" {
description = "Instance type for EC2 instances"
type = string
default = "t2.small"
}
root module terraform.tfvars
aws_region = "us-east-1"
ec2_instance_type = "t2.nano"
Let’s examine next our two child modules and how we use output values to pass parameters between them.
aws-web-server-vpc module main.tf
resource "aws_vpc" "web_server" {
cidr_block = var.vpc_cidr_block
instance_tenancy = "default"
tags = {
Name = var.vpc_name
}
}
resource "aws_subnet" "web_server" {
vpc_id = aws_vpc.web_server.id
cidr_block = var.subnet_cidr_block
map_public_ip_on_launch = true
availability_zone = var.aws_az
tags = {
Name = var.subnet_name
}
}
resource "aws_internet_gateway" "web_server" {
vpc_id = aws_vpc.web_server.id
tags = {
Name = var.igw_name
}
}
resource "aws_route_table" "web_server" {
vpc_id = aws_vpc.web_server.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.web_server.id
}
tags = {
Name = var.rt_name
}
}
resource "aws_route_table_association" "web_server" {
subnet_id = aws_subnet.web_server.id
route_table_id = aws_route_table.web_server.id
}
In the above module, we define some resources necessary for the networking layer of our infrastructure.
aws-web-server-vpc module variables.tf
variable "vpc_cidr_block" {
description = "CIDR block for webserver VPC"
type = string
default = "10.0.0.0/16"
}
variable "vpc_name" {
description = "Name of the vpc"
type = string
default = "web_server"
}
variable "subnet_cidr_block" {
description = "CIDR block for the webserver subnet"
type = string
default = "10.0.0.0/24"
}
variable "subnet_name" {
description = "Name for the webserver subnet"
type = string
default = "web_server"
}
variable "aws_az" {
description = "Availability Zone for the webserver subnet"
type = string
default = "us-east-1a"
}
variable "igw_name" {
description = "Name for the Internet Gateway of the webserver vpc"
type = string
default = "web_server"
}
variable "rt_name" {
description = "Name for the route table of the webserver vpc"
type = string
default = "web_server"
}
aws-web-server-vpc module outputs.tf
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.web_server.id
}
output "subnet_id" {
description = "ID of the VPC subnet"
value = aws_subnet.web_server.id
}
The two outputs we export here from this module are passed to the aws-web-server-instance module as parameters in order to create the EC2 instance inside the vpc and subnet that we have just created. We saw how this was handled in the main.tf file of the root module. The output value vpc_id is passed along as an output of the root module and should be printed in the command line after we apply the plan.
Finally, the Terraform configuration for the aws-web-server-instance module uses the passed info from the aws-web-server-vpc module. It creates and configures the web server instance accordingly.
aws-web-server-instance module main.tf
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
resource "aws_security_group" "web_server" {
name = var.ec2_security_group_name
description = var.ec2_security_group_description
vpc_id = var.vpc_id
ingress {
description = "Allow traffic on port 80 from everywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
tags = {
Name = var.ec2_security_group_name
}
}
resource "aws_instance" "web_server" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.ec2_instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.web_server.id]
tags = {
Name = var.ec2_instance_name
}
user_data = <<-EOF
#!/bin/bash
sudo yum update -y
sudo yum install httpd -y
sudo systemctl enable httpd
sudo systemctl start httpd
echo "<html><body><div>This is a test webserver!</div></body></html>" > /var/www/html/index.html
EOF
}
aws-web-server-instance module variables.tf
variable "ec2_instance_name" {
description = "Name for web server EC2 instance"
type = string
default = "web_server"
}
variable "ec2_instance_type" {
description = "Instance type for web server EC2 instance"
type = string
default = "t2.micro"
}
variable "ec2_security_group_name" {
description = "Security group name for web server EC2 instance"
type = string
default = "web_server"
}
variable "ec2_security_group_description" {
description = "Security group description for web server EC2 instance"
type = string
default = "Allow traffic for webserver"
}
variable "vpc_id" {
description = "VPC id for web server EC2 instance"
type = string
}
variable "subnet_id" {
description = "Subnet id for web server EC2 instance"
type = string
}
The two output values that we pass through the root module are also defined in this module’s outputs.tf file.
output "instance_id" {
description = "ID of EC2 instance"
value = aws_instance.web_server.id
}
output "instance_public_ip" {
description = "Public IP of EC2 instance"
value = aws_instance.web_server.public_ip
}
Time to wrap up everything and execute the plan to provision our demo infrastructure.
Our terraform plan shows 7 new resources to be added and displays the changes to our three output values declared in the root module. Let’s go ahead and apply the plan.
As expected, the three outputs declared in the root module are displayed at the command line, sweet!
We could use these values to automate other parts of our systems and process, but for now, we can get the value from instance_public_ip and head to http://<instance_public_ip>, and we should see our demo web server up and running.
Great! Everything works as expected.
Output values are stored in the state Terraform file. Since we have successfully applied our plan, we can now access these output values at will. We can leverage the terraform output command for this purpose.
Additionally, we can query individual output values by name like this
terraform output <output_value_name>
To get the raw value without quotes, use the -raw flag.
terraform output -raw <output_value_name>
To get the JSON-formatted output, we can use the -json flag.
This is quite useful when we want to pass the outputs to other tools for automation since JSON is way easier to handle programmatically. Note that Terraform does not protect sensitive output values when using the -json flag.
You can use the -no-color option to ensure the stdout of the command does not have any colors in it. This is not very useful when you are running the terraform output command, but it can be useful when you are running an apply.
There is an option to specify the path to the state file and get the details from it when you are getting your outputs. This is done by specifying the “-state=path_to_statefile” option.
When we are done, let’s go ahead and delete all these resources to avoid paying for them.
From the top of our repository, execute:
When defining output values, we have a couple of options that might help us better define and organize them.
The description argument of an output is optional, yet very powerful. Even though it is optional, it should always be used to explain what the output does, as this will be very helpful when you are collaborating with other team members. Some outputs are very easy to understand (like the one below) and may be self-explanatory:
output "instance_id" {
value = aws_instance.web_server.id
description = "ID of the Web Server instance"
}
However, other outputs can be harder to understand:
output "even_numbers" {
value = [for i in [1, 2, 3, 4, 5, 6] : i if i % 2 == 0]
description = "Gets the even numbers from the list"
}
In cases where we want to handle sensitive values and suppress them in command line output, we can declare an output value as sensitive. Terraform will redact the values of sensitive outputs when planning, applying, destroying, or querying outputs to avoid printing them to the console. In practice, this is a good use case when we would like to pass values to other Terraform modules or automation tools without exposing them to the intermediate users.
output "example_password" {
description = "An example DB password"
value = aws_db_instance.database.password
sensitive = true
}
Note that Terraform won’t redact sensitive output values when you query a specific output by name. After we apply a plan with an output declared as sensitive, the console displays a message with the value redacted.
These values are still recorded in the state files, so anyone who can access them can also access any sensitive values of our Terraform configuration.
The depends_on argument creates explicit dependencies between different Terraform components. Usually, it is not required to create dependencies between outputs and other resources, but in some rare cases, you can do that. For example, if you want to reach an application before the security group allows you to do that, you will have connection issues. You can avoid these by creating an explicit dependency to that security group:
output "instance_id" {
value = aws_instance.web_server.id
description = "ID of the Web Server instance"
depends_on = [aws_security_group.web_sec.id]
}
You can use Terraform outputs in conjunction with pre/post conditions to specify guarantees on the specific output data. This is done by adding a precondition/postcondition block inside your resource:
output "instance_id" {
value = aws_instance.web_server.id
description = "ID of the Web Server instance"
precondition {
condition = aws_instance.web_server.instance_type == “t2.micro”
error_message = "The instance type is different than t2.micro"
}
}
Occasionally, we might need to share data between different Terraform configurations with separate states. This is where the terraform_remote_state data sources come into play. We can retrieve the root module outputs from another Terraform configuration using this data source. This built-in data source is available without any extra configuration needed.
Following up on our previous example, let’s say that we would like to create a new subnet in the vpc of our aws-web-server-vpc module. This time, the new subnet needs to be defined in a completely separate Terraform configuration that has its own state. We can leverage the terraform_remote_state to get the value of the vpc_id defined as an output of our previous example’s root module. In this case, we use the local backend to reach the state of another configuration in the local machine. The backend could be any remote backend that points to a Terraform state in a real-world scenario.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "3.16.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
data "terraform_remote_state" "terraform_output" {
backend = "local"
config = {
path = "../terraform-output/terraform.tfstate"
}
}
resource "aws_subnet" "test_terraform_remote_state_subnet" {
vpc_id = data.terraform_remote_state.terraform_output.outputs.vpc_id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1b"
tags = {
Name = "test_terraform_remote_state_subnet"
}
}
Note that only the output values of the root module are accessible from the remote state. If we want to pass values from nested modules, we have to configure a passthrough output value declaration as we defined earlier in the root module of our previous example.
Although this option is handy for some use cases, it also has some caveats. To use this data source, the user must have access to the entire state snapshot, which could potentially expose sensitive data. Check out the official docs to find alternative ways to share data between configurations.
We have seen how Terraform handles and exports output values between modules and the different options for outputs configuration. Even more, we compared input and output variables and examined multiple use cases where the use of outputs is helpful. Finally, we went through a complete example of using output values in our Terraform configuration between different modules and printing them to the console.
We encourage you also to explore how Spacelift makes it easy to work with Terraform. If you need any help managing your Terraform infrastructure, building more complex workflows based on Terraform, and managing AWS credentials per run, instead of using a static pair on your local machine, Spacelift is a fantastic tool for this. It supports Git workflows, policy as code, programmatic configuration, context sharing, drift detection, and many more great features right out of the box.
Note: New versions of Terraform are placed under the BUSL license, but everything created before version 1.5.x stays open-source. OpenTofu is an open-source version of Terraform that expands on Terraform’s existing concepts and offerings. It is a viable alternative to HashiCorp’s Terraform, being forked from Terraform version 1.5.6.
Thank you for reading, and I hope you enjoyed this “Terraform Outputs” blog post as much as I did.
Terraform Management Made Easy
Spacelift effectively manages Terraform state, more complex workflows, supports policy as code, programmatic configuration, context sharing, drift detection, resource visualization and includes many more features.