{"id":42,"date":"2026-07-30T22:30:45","date_gmt":"2026-07-30T19:30:45","guid":{"rendered":"https:\/\/john-nessime.com\/blog\/?p=42"},"modified":"2026-07-30T22:30:47","modified_gmt":"2026-07-30T19:30:47","slug":"aws-account-baseline-terraform","status":"publish","type":"post","link":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/","title":{"rendered":"A Secure-by-Default AWS Account Baseline in Terraform"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 <a href=\"https:\/\/github.com\/JohnNessime\/terraform-aws-baseline\" target=\"_blank\" rel=\"noreferrer noopener\">terraform-aws-baseline<\/a> \u2014 so you can read the real Terraform rather than take my word for any of it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What a baseline is, and what it is not<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 dev gets one NAT gateway and seven-day log retention, prod gets one per availability zone and a year.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Decision one: no long-lived AWS credentials, anywhere<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The important part is the trust policy, and one word in it decides whether this is a security improvement or a vulnerability:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>data \"aws_iam_policy_document\" \"ci_assume\" {\n  statement {\n    effect  = \"Allow\"\n    actions = [\"sts:AssumeRoleWithWebIdentity\"]\n\n    principals {\n      type        = \"Federated\"\n      identifiers = [aws_iam_openid_connect_provider.github.arn]\n    }\n\n    condition {\n      test     = \"StringEquals\"\n      variable = \"token.actions.githubusercontent.com:aud\"\n      values   = [\"sts.amazonaws.com\"]\n    }\n\n    # StringEquals, not StringLike. This is the whole ballgame.\n    condition {\n      test     = \"StringEquals\"\n      variable = \"token.actions.githubusercontent.com:sub\"\n      values = [\n        \"repo:${var.github_org}\/${var.github_repo}:ref:refs\/heads\/${var.github_branch}\"\n      ]\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Swap <code>StringEquals<\/code> for <code>StringLike<\/code> and add an asterisk, and you have widened that trust from one branch of one repository to whatever the wildcard covers. Get sloppy enough \u2014 a bare <code>repo:*<\/code> \u2014 and <em>any repository on GitHub<\/em> can assume your role. This is not theoretical; it is a recurring finding in real audits.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Scope it to one repository and one branch. If you need more, add more statements, explicitly.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Decision two: treat Terraform state as a secret, because it is<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Terraform state contains every attribute of every resource it manages. Database passwords, generated keys, connection strings. Marking a variable <code>sensitive<\/code> redacts it from console output and changes nothing about what is written to the state file.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"aws_s3_bucket_public_access_block\" \"state\" {\n  bucket                  = aws_s3_bucket.state.id\n  block_public_acls       = true\n  block_public_policy     = true\n  ignore_public_acls      = true\n  restrict_public_buckets = true\n}\n\ndata \"aws_iam_policy_document\" \"deny_insecure_transport\" {\n  statement {\n    sid       = \"DenyInsecureTransport\"\n    effect    = \"Deny\"\n    actions   = [\"s3:*\"]\n    resources = [\n      aws_s3_bucket.state.arn,\n      \"${aws_s3_bucket.state.arn}\/*\",\n    ]\n\n    principals {\n      type        = \"*\"\n      identifiers = [\"*\"]\n    }\n\n    condition {\n      test     = \"Bool\"\n      variable = \"aws:SecureTransport\"\n      values   = [\"false\"]\n    }\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">All four public access block flags, not two. They cover different paths \u2014 ACLs and bucket policies, existing objects and future ones \u2014 and setting a subset leaves a gap that reads as protected on a dashboard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cd bootstrap\nterraform init\nterraform apply\nterraform output          # bucket, lock table, KMS key, region\n\n# Now move bootstrap's own state into the bucket it just created\nterraform init -migrate-state<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Decision three: shut the default security group<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 nothing errors, the instance works, and it has more network reach than anyone intended.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Terraform can adopt and empty it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"aws_default_security_group\" \"default\" {\n  vpc_id = aws_vpc.this.id\n\n  # No ingress and no egress blocks. Terraform strips AWS's rules,\n  # so anything that accidentally lands here can reach nothing.\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four lines, and misconfiguration becomes loud instead of silent.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Decision four: three subnet tiers, and the bottom one has no way out<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The standard two-tier split \u2014 public and private \u2014 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.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Public<\/strong> \u2014 NAT gateways and load balancers. Route to the internet gateway.<\/li>\n\n<li><strong>Private<\/strong> \u2014 workloads. Outbound through NAT, no inbound from the internet.<\/li>\n\n<li><strong>Database<\/strong> \u2014 data stores. No default route at all.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The third tier is enforced by absence rather than by a rule:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"aws_route_table\" \"database\" {\n  vpc_id = aws_vpc.this.id\n\n  # Deliberately no 0.0.0.0\/0 route. Not to the IGW, not to NAT.\n  # Nothing here can reach the internet, and nothing can reach it.\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Decision five: gateway endpoints, the one genuinely free win<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">S3 and DynamoDB gateway endpoints cost nothing. Not &#8220;cheap&#8221; \u2014 zero. They add a route so traffic to those services stays on the AWS backbone rather than going out through NAT.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"aws_vpc_endpoint\" \"s3\" {\n  vpc_id            = aws_vpc.this.id\n  service_name      = \"com.amazonaws.${var.region}.s3\"\n  vpc_endpoint_type = \"Gateway\"\n\n  route_table_ids = concat(\n    aws_route_table.private[*].id,\n    aws_route_table.database[*].id,\n  )\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Decision six: SSM Session Manager, not SSH<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Port 22 open anywhere means key pairs to distribute, a bastion host to patch, and an audit trail that is whatever <code>auth.log<\/code> happens to contain.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resource \"aws_iam_role_policy_attachment\" \"ssm_core\" {\n  role       = aws_iam_role.instance.name\n  policy_arn = \"arn:aws:iam::aws:policy\/AmazonSSMManagedInstanceCore\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>ssm<\/code>, <code>ssmmessages<\/code> and <code>ec2messages<\/code>, and those cost roughly seven dollars a month per availability zone each.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Decision seven: pin actions to commit SHAs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A Git tag is a movable pointer. If an action&#8217;s repository is compromised, an attacker can repoint <code>v4<\/code> at whatever they like, and every workflow referencing that tag runs it on the next build \u2014 with your OIDC role attached.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>steps:\n  # A tag can be moved. A commit SHA cannot.\n  # Dependabot updates these pins and shows you the diff.\n  - uses: actions\/checkout@&lt;full-40-character-sha&gt;   # v4.2.2<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">What it actually costs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Almost all of it is NAT gateways. Everything else is rounding.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>NAT gateways<\/strong> \u2014 roughly $32 a month each, plus about $0.045 per gigabyte processed. One in dev, one per availability zone in prod.<\/li>\n\n<li><strong>VPC, subnets, route tables, internet gateway<\/strong> \u2014 free.<\/li>\n\n<li><strong>S3 and DynamoDB gateway endpoints<\/strong> \u2014 free.<\/li>\n\n<li><strong>KMS keys<\/strong> \u2014 about a dollar a month each.<\/li>\n\n<li><strong>Flow logs<\/strong> \u2014 around $0.50 per gigabyte ingested. Retention is the lever: seven days in dev, a year in prod.<\/li>\n\n<li><strong>State bucket and lock table<\/strong> \u2014 cents.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">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.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\">Gates that run before any credentials exist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># On every commit, locally\npre-commit install        # gitleaks, fmt, validate, tflint, checkov, terraform-docs\n\n# The same gates CI runs\nmake all                  # fmt + validate + tflint + checkov<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Running 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note also what is missing: there is no <code>terraform plan<\/code> 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 \u2014 but that is a deliberate step up in blast radius, not a default.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What a baseline like this deliberately leaves out<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Being clear about scope is more useful than pretending completeness. The reference implementation covers preventative controls and stops there. Missing, on purpose:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Detective controls.<\/strong> No CloudTrail, Config, GuardDuty or Security Hub. A complete account baseline includes them; this is the preventative half.<\/li>\n\n<li><strong>Compute.<\/strong> There is an instance role and profile, but nothing uses them. No ASG, no ECS or EKS, no load balancer.<\/li>\n\n<li><strong>Interface endpoints.<\/strong> Only the free gateway ones. Fully private SSM needs paid interface endpoints.<\/li>\n\n<li><strong>Multi-region and DR.<\/strong> Versioning covers state recovery. There is no cross-region replication and no failover story.<\/li>\n\n<li><strong>Policy-as-code tests.<\/strong> Checkov catches known misconfigurations; there are no Terratest or Conftest assertions on module contracts.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button is-style-outline is-style-outline--1\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/github.com\/JohnNessime\/terraform-aws-baseline\" target=\"_blank\" rel=\"noreferrer noopener\">Read the Terraform on GitHub<\/a><\/div>\n<\/div>\n\n\n\n<h2 class=\"wp-block-heading\">Common mistakes<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Using <code>StringLike<\/code> with a wildcard in an OIDC trust policy. Scope to one repository and one branch.<\/li>\n\n<li>Assuming <code>sensitive = true<\/code> keeps values out of state. It does not.<\/li>\n\n<li>Setting two of the four S3 public access block flags and considering the bucket locked down.<\/li>\n\n<li>Leaving the default security group as AWS shipped it.<\/li>\n\n<li>Putting databases in the same tier as application servers because two tiers felt like enough.<\/li>\n\n<li>Skipping gateway endpoints and paying NAT processing charges on your own state file.<\/li>\n\n<li>Referencing actions by tag and assuming a tag is immutable.<\/li>\n\n<li>Committing <code>backend.hcl<\/code> or <code>terraform.tfvars<\/code>. State bucket names embed the account ID.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Is an AWS account baseline the same as a landing zone?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. A landing zone in the Control Tower sense governs many accounts \u2014 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why not just use Control Tower?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I really need a separate database subnet tier?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I cut the NAT gateway cost?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 often less than you assume \u2014 and consider dropping NAT for environments that do not.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use this with OpenTofu?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. It is standard HCL against the AWS provider, so it runs on either. Swap <code>terraform<\/code> for <code>tofu<\/code> in the commands and everything else is the same.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Where does the OIDC provider itself live?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The full implementation is on GitHub at <a href=\"https:\/\/github.com\/JohnNessime\/terraform-aws-baseline\" target=\"_blank\" rel=\"noreferrer noopener\">JohnNessime\/terraform-aws-baseline<\/a>, MIT licensed. It is a reference implementation rather than something that has served production traffic \u2014 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Need a baseline laid down properly?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I work with teams on AWS foundations \u2014 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you would rather have this done once and done right, you can hire me on Upwork.<\/p>\n\n\n\n<div class=\"wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.upwork.com\/freelancers\/~01f15a912ad84a6620\" target=\"_blank\" rel=\"noreferrer noopener\">Work with me on Upwork<\/a><\/div>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A fresh AWS account ships wide open by default. Here are the seven decisions that make one defensible from the start \u2014 OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints \u2014 with a working Terraform reference implementation on GitHub.<\/p>\n","protected":false},"author":1,"featured_media":43,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[25,24,30],"tags":[93,104,95,19,103,3,101,99,21,91,102,88,100],"class_list":["post-42","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-cloud-computing","category-devops","category-web-security","tag-aws","tag-checkov","tag-ci-cd","tag-cloud","tag-cloud-security","tag-devops","tag-github-actions","tag-iam","tag-infrastructure","tag-infrastructure-as-code","tag-oidc","tag-terraform","tag-vpc","entry","has-media"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.1 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Secure AWS Account Baseline in Terraform<\/title>\n<meta name=\"description\" content=\"Build an AWS account baseline in Terraform: GitHub OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints and real costs.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Secure AWS Account Baseline in Terraform\" \/>\n<meta property=\"og:description\" content=\"Build an AWS account baseline in Terraform: GitHub OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints and real costs.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/\" \/>\n<meta property=\"og:site_name\" content=\"John Nessime\" \/>\n<meta property=\"article:published_time\" content=\"2026-07-30T19:30:45+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-07-30T19:30:47+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/aws-account-baseline-terraform-featured.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"800\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"John Nessime\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"John Nessime\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"10 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/\"},\"author\":{\"name\":\"John Nessime\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"headline\":\"A Secure-by-Default AWS Account Baseline in Terraform\",\"datePublished\":\"2026-07-30T19:30:45+00:00\",\"dateModified\":\"2026-07-30T19:30:47+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/\"},\"wordCount\":2222,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/aws-account-baseline-terraform-featured.png\",\"keywords\":[\"AWS\",\"Checkov\",\"CI\\\/CD\",\"Cloud\",\"Cloud Security\",\"DevOps\",\"GitHub Actions\",\"IAM\",\"Infrastructure\",\"Infrastructure as Code\",\"OIDC\",\"Terraform\",\"VPC\"],\"articleSection\":[\"Cloud Computing\",\"DevOps\",\"Web Security\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/\",\"name\":\"Secure AWS Account Baseline in Terraform\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/aws-account-baseline-terraform-featured.png\",\"datePublished\":\"2026-07-30T19:30:45+00:00\",\"dateModified\":\"2026-07-30T19:30:47+00:00\",\"description\":\"Build an AWS account baseline in Terraform: GitHub OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints and real costs.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#primaryimage\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/aws-account-baseline-terraform-featured.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/aws-account-baseline-terraform-featured.png\",\"width\":1200,\"height\":800,\"caption\":\"AWS VPC architecture diagram showing public, private and database subnet tiers with the database tier having no internet route, free S3 and DynamoDB gateway endpoints, and GitHub Actions authenticating to encrypted Terraform state via OIDC\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/devops\\\/aws-account-baseline-terraform\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"A Secure-by-Default AWS Account Baseline in Terraform\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/\",\"name\":\"John Nessime\",\"description\":\"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained\",\"publisher\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/#\\\/schema\\\/person\\\/ede0b56d0c808f123f57d5d796902105\",\"name\":\"John Nessime\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"contentUrl\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\",\"width\":512,\"height\":512,\"caption\":\"John Nessime\"},\"logo\":{\"@id\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/cropped-jn.png\"},\"description\":\"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.\",\"sameAs\":[\"https:\\\/\\\/john-nessime.com\\\/blog\",\"https:\\\/\\\/www.linkedin.com\\\/in\\\/john-m-nessime\"],\"url\":\"https:\\\/\\\/john-nessime.com\\\/blog\\\/author\\\/johnnessime\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Secure AWS Account Baseline in Terraform","description":"Build an AWS account baseline in Terraform: GitHub OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints and real costs.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/","og_locale":"en_US","og_type":"article","og_title":"Secure AWS Account Baseline in Terraform","og_description":"Build an AWS account baseline in Terraform: GitHub OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints and real costs.","og_url":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/","og_site_name":"John Nessime","article_published_time":"2026-07-30T19:30:45+00:00","article_modified_time":"2026-07-30T19:30:47+00:00","og_image":[{"width":1200,"height":800,"url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/aws-account-baseline-terraform-featured.png","type":"image\/png"}],"author":"John Nessime","twitter_card":"summary_large_image","twitter_misc":{"Written by":"John Nessime","Est. reading time":"10 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#article","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/"},"author":{"name":"John Nessime","@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"headline":"A Secure-by-Default AWS Account Baseline in Terraform","datePublished":"2026-07-30T19:30:45+00:00","dateModified":"2026-07-30T19:30:47+00:00","mainEntityOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/"},"wordCount":2222,"commentCount":0,"publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/aws-account-baseline-terraform-featured.png","keywords":["AWS","Checkov","CI\/CD","Cloud","Cloud Security","DevOps","GitHub Actions","IAM","Infrastructure","Infrastructure as Code","OIDC","Terraform","VPC"],"articleSection":["Cloud Computing","DevOps","Web Security"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/","url":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/","name":"Secure AWS Account Baseline in Terraform","isPartOf":{"@id":"https:\/\/john-nessime.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#primaryimage"},"image":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#primaryimage"},"thumbnailUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/aws-account-baseline-terraform-featured.png","datePublished":"2026-07-30T19:30:45+00:00","dateModified":"2026-07-30T19:30:47+00:00","description":"Build an AWS account baseline in Terraform: GitHub OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints and real costs.","breadcrumb":{"@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#primaryimage","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/aws-account-baseline-terraform-featured.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/aws-account-baseline-terraform-featured.png","width":1200,"height":800,"caption":"AWS VPC architecture diagram showing public, private and database subnet tiers with the database tier having no internet route, free S3 and DynamoDB gateway endpoints, and GitHub Actions authenticating to encrypted Terraform state via OIDC"},{"@type":"BreadcrumbList","@id":"https:\/\/john-nessime.com\/blog\/devops\/aws-account-baseline-terraform\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/john-nessime.com\/blog\/"},{"@type":"ListItem","position":2,"name":"A Secure-by-Default AWS Account Baseline in Terraform"}]},{"@type":"WebSite","@id":"https:\/\/john-nessime.com\/blog\/#website","url":"https:\/\/john-nessime.com\/blog\/","name":"John Nessime","description":"Cloud, DevOps, Data &amp; AI \u2014 Built, Tested, Explained","publisher":{"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/john-nessime.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/john-nessime.com\/blog\/#\/schema\/person\/ede0b56d0c808f123f57d5d796902105","name":"John Nessime","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","url":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","contentUrl":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png","width":512,"height":512,"caption":"John Nessime"},"logo":{"@id":"https:\/\/john-nessime.com\/blog\/wp-content\/uploads\/2026\/07\/cropped-jn.png"},"description":"AWS Certified Solutions Architect helping businesses build reliable cloud, data, reporting, and automation solutions. I help startups, agencies, and growing businesses replace manual processes and disconnected data with practical AWS architectures, clean data pipelines, useful dashboards, and maintainable automation.","sameAs":["https:\/\/john-nessime.com\/blog","https:\/\/www.linkedin.com\/in\/john-m-nessime"],"url":"https:\/\/john-nessime.com\/blog\/author\/johnnessime\/"}]}},"_links":{"self":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/42","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/comments?post=42"}],"version-history":[{"count":1,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/42\/revisions"}],"predecessor-version":[{"id":45,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/posts\/42\/revisions\/45"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media\/43"}],"wp:attachment":[{"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/media?parent=42"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/categories?post=42"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/john-nessime.com\/blog\/wp-json\/wp\/v2\/tags?post=42"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}