July 30, 2026
Secure AWS Account Baseline in Terraform
A fresh AWS account is not a neutral starting point. It ships with a default VPC whose default security group allows every instance in it…
By John Nessime
10 min read
A fresh AWS account is not a neutral starting point. It ships with a default VPC whose default security group allows every instance in it to talk to every other instance, a default route to the internet in every subnet, no logging of network traffic, and an expectation that you will create an access key and paste it into your CI system.
None of that is a bug. AWS optimises the empty account for getting something running in ten minutes. The problem is that most accounts never get corrected afterwards, and eighteen months later there is production traffic sitting on top of decisions nobody made deliberately.
An AWS account baseline is the set of things you lay down before the first workload, so the account starts from a defensible posture instead of a blank console. This walks through the decisions that actually matter, why each one is the way it is, and what it costs. I have put a working reference implementation on GitHub — terraform-aws-baseline — so you can read the real Terraform rather than take my word for any of it.
What a baseline is, and what it is not
A baseline is not a landing zone in the Control Tower sense, and it is not a compliance framework. It does not make you SOC 2 compliant and nothing that fits in one repository will.
What it is: the boring foundational layer that everything else sits on. State storage, network segmentation, identity, and the guardrails that stop a careless afternoon becoming an incident. If you get these right once, every workload that lands afterwards inherits them for free.
The structure that works is three layers. A bootstrap stack that creates the state backend and runs exactly once. Reusable modules for the things every environment needs. Thin per-environment roots that compose those modules with different parameters — dev gets one NAT gateway and seven-day log retention, prod gets one per availability zone and a year.
Decision one: no long-lived AWS credentials, anywhere
Static access keys in CI are the single most common way AWS accounts get compromised. They live in a secrets store, they get copied into a second one, somebody echoes one into a build log, and nobody rotates them because rotation breaks things.
GitHub Actions can assume an AWS role using a short-lived OIDC token that GitHub signs and AWS verifies. There is no key to store, nothing to leak, and nothing to rotate.
The important part is the trust policy, and one word in it decides whether this is a security improvement or a vulnerability:
data "aws_iam_policy_document" "ci_assume" { statement { effect = "Allow" actions = ["sts:AssumeRoleWithWebIdentity"] principals { type = "Federated" identifiers = [aws_iam_openid_connect_provider.github.arn] } condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:aud" values = ["sts.amazonaws.com"] } # StringEquals, not StringLike. This is the whole ballgame. condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:sub" values = [ "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${var.github_branch}" ] } } }data "aws_iam_policy_document" "ci_assume" { statement { effect = "Allow" actions = ["sts:AssumeRoleWithWebIdentity"] principals { type = "Federated" identifiers = [aws_iam_openid_connect_provider.github.arn] } condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:aud" values = ["sts.amazonaws.com"] } # StringEquals, not StringLike. This is the whole ballgame. condition { test = "StringEquals" variable = "token.actions.githubusercontent.com:sub" values = [ "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${var.github_branch}" ] } } }Swap StringEquals for StringLike and add an asterisk, and you have widened that trust from one branch of one repository to whatever the wildcard covers. Get sloppy enough - a bare repo:* - and any repository on GitHub can assume your role. This is not theoretical; it is a recurring finding in real audits.
Scope it to one repository and one branch. If you need more, add more statements, explicitly.
Decision two: treat Terraform state as a secret, because it is
Terraform state contains every attribute of every resource it manages. Database passwords, generated keys, connection strings. Marking a variable sensitive redacts it from console output and changes nothing about what is written to the state file.
So the state bucket gets four things: server-side encryption with a customer-managed KMS key that rotates, versioning so a corrupted state can be recovered, all four public access block flags, and a bucket policy that refuses plaintext connections outright.
resource "aws_s3_bucket_public_access_block" "state" { bucket = aws_s3_bucket.state.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } data "aws_iam_policy_document" "deny_insecure_transport" { statement { sid = "DenyInsecureTransport" effect = "Deny" actions = ["s3:*"] resources = [ aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*", ] principals { type = "*" identifiers = ["*"] } condition { test = "Bool" variable = "aws:SecureTransport" values = ["false"] } } }resource "aws_s3_bucket_public_access_block" "state" { bucket = aws_s3_bucket.state.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } data "aws_iam_policy_document" "deny_insecure_transport" { statement { sid = "DenyInsecureTransport" effect = "Deny" actions = ["s3:*"] resources = [ aws_s3_bucket.state.arn, "${aws_s3_bucket.state.arn}/*", ] principals { type = "*" identifiers = ["*"] } condition { test = "Bool" variable = "aws:SecureTransport" values = ["false"] } } }All four public access block flags, not two. They cover different paths — ACLs and bucket policies, existing objects and future ones — and setting a subset leaves a gap that reads as protected on a dashboard.
There is a chicken-and-egg problem here that catches everyone: the stack that creates the state bucket cannot store its own state in a bucket that does not exist yet. You run it once with local state, then migrate:
cd bootstrap terraform init terraform apply terraform output # bucket, lock table, KMS key, region # Now move bootstrap's own state into the bucket it just created terraform init -migrate-statecd bootstrap terraform init terraform apply terraform output # bucket, lock table, KMS key, region # Now move bootstrap's own state into the bucket it just created terraform init -migrate-stateDecision three: shut the default security group
Every VPC gets a default security group that permits all traffic between members and all outbound traffic. Anything launched without an explicit security group lands on it. That is a quiet failure mode — nothing errors, the instance works, and it has more network reach than anyone intended.
Terraform can adopt and empty it:
resource "aws_default_security_group" "default" { vpc_id = aws_vpc.this.id # No ingress and no egress blocks. Terraform strips AWS's rules, # so anything that accidentally lands here can reach nothing. }resource "aws_default_security_group" "default" { vpc_id = aws_vpc.this.id # No ingress and no egress blocks. Terraform strips AWS's rules, # so anything that accidentally lands here can reach nothing. }Four lines, and misconfiguration becomes loud instead of silent.
Decision four: three subnet tiers, and the bottom one has no way out
The standard two-tier split — public and private — leaves databases in the same tier as application servers. Both can reach the internet through NAT. That is fine right up until something in the data tier is compromised and quietly exfiltrates over 443, which looks like ordinary egress traffic.
- Public — NAT gateways and load balancers. Route to the internet gateway.
- Private — workloads. Outbound through NAT, no inbound from the internet.
- Database — data stores. No default route at all.
The third tier is enforced by absence rather than by a rule:
resource "aws_route_table" "database" { vpc_id = aws_vpc.this.id # Deliberately no 0.0.0.0/0 route. Not to the IGW, not to NAT. # Nothing here can reach the internet, and nothing can reach it. }resource "aws_route_table" "database" { vpc_id = aws_vpc.this.id # Deliberately no 0.0.0.0/0 route. Not to the IGW, not to NAT. # Nothing here can reach the internet, and nothing can reach it. }No security group to misconfigure, no NACL to get wrong. The route simply does not exist. That is a much stronger guarantee than a rule someone can widen during an incident and forget to narrow afterwards.
Decision five: gateway endpoints, the one genuinely free win
S3 and DynamoDB gateway endpoints cost nothing. Not "cheap" — zero. They add a route so traffic to those services stays on the AWS backbone rather than going out through NAT.
resource "aws_vpc_endpoint" "s3" { vpc_id = aws_vpc.this.id service_name = "com.amazonaws.${var.region}.s3" vpc_endpoint_type = "Gateway" route_table_ids = concat( aws_route_table.private[*].id, aws_route_table.database[*].id, ) }resource "aws_vpc_endpoint" "s3" { vpc_id = aws_vpc.this.id service_name = "com.amazonaws.${var.region}.s3" vpc_endpoint_type = "Gateway" route_table_ids = concat( aws_route_table.private[*].id, aws_route_table.database[*].id, ) }Two benefits, and the second is the one people miss. Traffic to S3 never traverses NAT, so you stop paying per-gigabyte processing on it. And the isolated database tier can still reach S3 and DynamoDB despite having no internet route, because the endpoint is a route within the VPC rather than a path out of it.
Your Terraform state lives in S3. Without this endpoint, every plan and apply pushes state through the NAT gateway and you are billed for the privilege.
Decision six: SSM Session Manager, not SSH
Port 22 open anywhere means key pairs to distribute, a bastion host to patch, and an audit trail that is whatever auth.log happens to contain.
resource "aws_iam_role_policy_attachment" "ssm_core" { role = aws_iam_role.instance.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" }resource "aws_iam_role_policy_attachment" "ssm_core" { role = aws_iam_role.instance.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" }That one attachment gets you shell access with no inbound ports, no key pairs, no bastion. Access is IAM-authorised, so revoking someone is a permissions change rather than a key rotation, and every session can be logged centrally.
Worth knowing the caveat: SSM agents reach the SSM endpoints over the internet by default, so instances in the private tier still need NAT for this. Removing NAT entirely means adding interface endpoints for ssm, ssmmessages and ec2messages, and those cost roughly seven dollars a month per availability zone each.
Decision seven: pin actions to commit SHAs
A Git tag is a movable pointer. If an action's repository is compromised, an attacker can repoint v4 at whatever they like, and every workflow referencing that tag runs it on the next build - with your OIDC role attached.
steps: # A tag can be moved. A commit SHA cannot. # Dependabot updates these pins and shows you the diff. - uses: actions/checkout@<full-40-character-sha> # v4.2.2steps: # A tag can be moved. A commit SHA cannot. # Dependabot updates these pins and shows you the diff. - uses: actions/checkout@<full-40-character-sha> # v4.2.2The comment matters as much as the pin. Without it nobody can tell at a glance which version they are looking at, and the pin becomes something people update blindly.
What it actually costs
Almost all of it is NAT gateways. Everything else is rounding.
- NAT gateways — roughly $32 a month each, plus about $0.045 per gigabyte processed. One in dev, one per availability zone in prod.
- VPC, subnets, route tables, internet gateway — free.
- S3 and DynamoDB gateway endpoints — free.
- KMS keys — about a dollar a month each.
- Flow logs — around $0.50 per gigabyte ingested. Retention is the lever: seven days in dev, a year in prod.
- State bucket and lock table — cents.
Ballpark, that lands around $35 a month for dev and $105 for prod, dominated entirely by NAT. If you only need the isolated tiers and nothing requires outbound internet, dropping NAT takes dev close to free.
Per-AZ NAT in production is an availability decision, not a security one. A single NAT gateway is a single point of failure for outbound traffic across every AZ that routes through it.
Gates that run before any credentials exist
Most of what goes wrong in Terraform is catchable statically. Formatting, syntax, provider misuse, and known-insecure patterns can all be found without touching an AWS account, which means they can run on every commit and in CI without the pipeline holding any cloud credentials at all.
# On every commit, locally pre-commit install # gitleaks, fmt, validate, tflint, checkov, terraform-docs # The same gates CI runs make all # fmt + validate + tflint + checkov# On every commit, locally pre-commit install # gitleaks, fmt, validate, tflint, checkov, terraform-docs # The same gates CI runs make all # fmt + validate + tflint + checkovRunning the identical command locally and in CI is the part that matters. When those two drift, people stop trusting the local check and start pushing to see what the pipeline says, and the fast feedback loop you built quietly stops existing.
Note also what is missing: there is no terraform plan in that pipeline. Plan needs real credentials, and a static-only pipeline is one that cannot leak anything. A production setup would add a plan job running under the OIDC role and posting to the pull request - but that is a deliberate step up in blast radius, not a default.
What a baseline like this deliberately leaves out
Being clear about scope is more useful than pretending completeness. The reference implementation covers preventative controls and stops there. Missing, on purpose:
- Detective controls. No CloudTrail, Config, GuardDuty or Security Hub. A complete account baseline includes them; this is the preventative half.
- Compute. There is an instance role and profile, but nothing uses them. No ASG, no ECS or EKS, no load balancer.
- Interface endpoints. Only the free gateway ones. Fully private SSM needs paid interface endpoints.
- Multi-region and DR. Versioning covers state recovery. There is no cross-region replication and no failover story.
- Policy-as-code tests. Checkov catches known misconfigurations; there are no Terratest or Conftest assertions on module contracts.
A baseline you can describe the edges of is more useful than one advertised as complete. You know exactly what you still have to build.
Common mistakes
- Using
StringLikewith a wildcard in an OIDC trust policy. Scope to one repository and one branch. - Assuming
sensitive = truekeeps values out of state. It does not. - Setting two of the four S3 public access block flags and considering the bucket locked down.
- Leaving the default security group as AWS shipped it.
- Putting databases in the same tier as application servers because two tiers felt like enough.
- Skipping gateway endpoints and paying NAT processing charges on your own state file.
- Referencing actions by tag and assuming a tag is immutable.
- Committing
backend.hclorterraform.tfvars. State bucket names embed the account ID.
Frequently asked questions
Is an AWS account baseline the same as a landing zone?
No. A landing zone in the Control Tower sense governs many accounts — organisational units, service control policies, account vending. A baseline is what you lay down inside a single account. Baselines are what you need when you have one or two accounts and Control Tower would be enormous overkill.
Why not just use Control Tower?
Control Tower is a good answer for a multi-account organisation with governance requirements. It is a lot of machinery for a team running a handful of accounts, and it hides the decisions rather than making them legible. Writing the baseline yourself means you can read every choice and change any of them.
Do I really need a separate database subnet tier?
It costs nothing and closes an entire exfiltration path. If your data tier has no route to the internet, a compromised database cannot phone home regardless of what the attacker does with it. Two tiers is not wrong, but the third is close to free.
How do I cut the NAT gateway cost?
Use one NAT for non-production instead of one per AZ. Add gateway endpoints so S3 and DynamoDB traffic bypasses NAT entirely. Beyond that, work out what genuinely needs outbound internet — often less than you assume — and consider dropping NAT for environments that do not.
Can I use this with OpenTofu?
Yes. It is standard HCL against the AWS provider, so it runs on either. Swap terraform for tofu in the commands and everything else is the same.
Where does the OIDC provider itself live?
It is account-global, so only one environment can create it and the others reference it. That is workable but awkward. In a real organisation it belongs in a separate account-level stack that runs before any environment.
Wrapping up
None of the decisions in an AWS account baseline are individually clever. Scope the trust policy. Encrypt the state. Empty the default security group. Give the data tier no way out. Take the free endpoints. Use SSM instead of SSH. Pin your actions. Each one is a few lines of Terraform.
What makes them valuable is doing them before the first workload arrives, because retrofitting network segmentation into a running account is a project, and doing it at the start is an afternoon.
The full implementation is on GitHub at JohnNessime/terraform-aws-baseline, MIT licensed. It is a reference implementation rather than something that has served production traffic — a worked example of how I would lay an account down, with the reasoning written out in the docs. Read it, disagree with parts of it, take what is useful.
Need a baseline laid down properly?
I work with teams on AWS foundations — account structure, network segmentation, IAM that does not rely on long-lived keys, Terraform layouts that survive more than one engineer, and CI pipelines that catch problems before they reach an account.
If you would rather have this done once and done right, you can hire me on Upwork.
Originally published at https://john-nessime.com on July 30, 2026.