Terraform already describes your infrastructure, precisely and in version control. So why does nobody understand the system from the code? Because a .tf file answers "what exists and how is it configured", while the people reading an architecture diagram ask something else: where does traffic come in, what talks to what, where does the data live, and what is exposed.
This guide shows how to get from Terraform to a diagram people can read: why terraform graph is not that diagram, how to map resources to frames and cards, when to use plan JSON, what to leave out, and how to use the result in pull request reviews.
Why the code and terraform graph are not the diagram
A small production stack easily has a hundred resources across several files and modules, and many of them are glue: IAM policy attachments, route table associations, security group rules, target group attachments. The answer to "can the internet reach the database" is in there, spread over a dozen references.
terraform graph does not fix that. It prints a graph in the DOT language, and since Terraform 1.7 the default output is a simplified graph that, in the words of the command reference, describes only the dependency ordering of resources. Options such as -type=plan show an approximation of the fuller graph Terraform uses to build a plan, and since Terraform 1.16, -format=mermaid prints Mermaid instead of DOT.
A dependency graph answers "what must exist before what". The ECS service depends on an IAM role, a target group and a task definition, so arrows point from the service toward its prerequisites, which is often the opposite of how traffic flows, and the glue resources take up as much room as the database. It is useful for debugging a plan. It is not an architecture diagram.
The example stack
A typical web app on AWS: a VPC with public and private subnets in two Availability Zones, an Application Load Balancer, an ECS service on Fargate, a PostgreSQL database on RDS and an S3 bucket for uploads. An excerpt of the configuration:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.11.0/24"
availability_zone = "eu-west-1a"
}
resource "aws_lb" "web" {
name = "web"
load_balancer_type = "application"
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
security_groups = [aws_security_group.alb.id]
}
resource "aws_ecs_service" "app" {
name = "app"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
launch_type = "FARGATE"
desired_count = 2
network_configuration {
subnets = [aws_subnet.private_a.id, aws_subnet.private_b.id]
security_groups = [aws_security_group.app.id]
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "app"
container_port = 8080
}
}
resource "aws_db_instance" "main" {
identifier = "app-db"
engine = "postgres"
instance_class = "db.t4g.medium"
allocated_storage = 50
username = "app"
manage_master_user_password = true
multi_az = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
}
Mapping Terraform to a diagram
Most of the work is deciding which resources become things on the diagram and which become details of other things:
| In Terraform | On the diagram |
|---|---|
aws_vpc, aws_subnet, and vpc_id / subnet_id references |
Frames: the VPC around public and private subnets |
aws_lb with its listener and target group |
One load balancer card; the listener port goes on the edge |
aws_ecs_service with cluster and task definition |
One compute card, with the desired count |
aws_db_instance with its subnet group |
One database card in the private subnets, with Multi-AZ noted |
aws_s3_bucket with versioning and access block |
One object storage card |
| Security group rules | Ports and protocols on the edges |
| References between resources | Connections, drawn in the direction traffic flows |
depends_on |
A dashed line, if it says something a reader needs |
IAM roles, policies, attachments; random_*, time_*, null_resource |
Left out, or a short note on the card |
.tf files or plan JSON
You can work from the configuration itself or from a plan:
terraform plan -out plan.out
terraform show -json plan.out > plan.json
The .tf files are quick and need nothing to run, but count and for_each are not expanded, variables are unknown and remote modules stay closed. Plan JSON has all of that resolved, and its resource_changes list says what the plan will do to each resource: ["create"], ["update"], ["delete"], or ["delete", "create"] for a replacement. That makes it the better input for reviewing a change. Treat the file with care: the terraform show documentation warns that sensitive values in state appear in plain text in its JSON output.
The diagram
Diagram contents
- Users
- app.example.com
- VPC
- Public subnets · 2 AZs
- Application LB
- Private subnets · 2 AZs
- ECS service ×2
- PostgreSQL, Multi-AZ
- Uploads bucket
- HTTPS 443
- alias record
- HTTP 8080
- PostgreSQL 5432
- HTTPS 443, uploads
How to read it:
- Entry. Users reach the Application Load Balancer over HTTPS on port 443. The DNS alias record points at it.
- Network boundaries. The VPC holds two frames: public subnets for the load balancer and private subnets for everything else. Nothing in the private subnets is reachable from the internet.
- The path. The load balancer forwards to the ECS service on port 8080. The service talks to PostgreSQL on 5432 and writes uploads to S3 over HTTPS.
- The note. It maps each card to the resource addresses behind it and lists what was left out on purpose, so a reader can find the code.
Cut, then add what Terraform does not know
Cut aggressively. IAM glue, route table associations, log groups and per-rule security group resources belong in the code. One card per deployable thing or data store is the right level for most readers.
Then add what Terraform cannot tell you:
- Who uses the system and where traffic enters.
- The direction of flow. References point from a resource to what it needs; traffic often runs the other way.
- Ports and protocols from the security group rules, written on the edges.
- External services the app calls that are not managed in this configuration.
- Facts that matter in an incident: Multi-AZ, backups, the desired count.
Use it in pull request reviews
A diagram generated from the plan of a pull request shows reviewers the shape of a change instead of a long diff. Look for a few things in particular:
- A new public entry point, such as a load balancer, a public subnet or a security group open to 0.0.0.0/0.
- A replacement of a stateful resource.
["delete", "create"]on a database or a bucket deserves a second reviewer. - Resources moving between subnets or out of the private network.
- New data stores, which need an owner, backups and an entry in your data map.
Keep the generated diagram and the curated architecture diagram separate. The generated one is a review aid for one change; the curated one is documentation that explains the system.
Keep it current
Treat the curated diagram like code: an owner, a link from the module's README, and an update whenever a change adds an entry point, a data store or a network boundary. Architecture diagrams that don't rot describes a workflow for exactly that. For the network side of the same stack, see the AWS VPC diagram guide, and for running it in two regions, the AWS multi-region architecture diagram.
Build it in Archboard
- Sign in with Google or GitHub and create a diagram.
- For a first draft, paste your
.tffiles or plan JSON into the free Terraform to diagram tool. It turns VPCs and subnets into frames, resources into cards and references into connections, marks planned changes from plan JSON, and folds glue such as target group attachments into connections. Select Open in Archboard to keep editing. - To draw it by hand, add a Network pack VPC frame with two Subnet frames, a Cloud pack Cloud LB in the public subnets, and a Compute and a Managed DB node in the private subnets.
- Add a Cloud pack Object Storage node for the bucket, a Network pack Internet node for users and a DNS node for the alias record.
- Label the edges with ports and protocols, and add a Core Markdown block that maps cards to resource addresses.
The example uses 10 diagram nodes, within the Free plan. If your stack is defined in CloudFormation instead, the CloudFormation to diagram tool does the same for templates.
FAQ
Can terraform graph produce an architecture diagram? It produces a dependency graph in DOT or, since Terraform 1.16, Mermaid. That is useful for debugging plans, but it shows build order rather than traffic and includes glue resources, so it needs curation before people can read it as architecture.
Should I diagram from .tf files or from plan JSON?
Use .tf files for a quick look at a module and plan JSON for reviewing a change, because the plan has counts, modules and planned actions resolved.
How do I show security groups? As ports and protocols on the edges between the resources they protect, not as separate boxes.
What should I leave out?
IAM attachments, route table associations, helper resources such as random_id, and anything that does not change how a reader understands traffic, data or exposure.
How do I keep the diagram in sync with the code? Regenerate a draft when a change adds an entry point, a data store or a network boundary, and update the curated diagram in the same pull request.