<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tanseer</title>
    <description>The latest articles on DEV Community by Tanseer (@tanseer).</description>
    <link>https://dev.to/tanseer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3901526%2Faae5933f-439d-4185-ad46-10b5e922d96c.jpg</url>
      <title>DEV Community: Tanseer</title>
      <link>https://dev.to/tanseer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tanseer"/>
    <language>en</language>
    <item>
      <title>Build and Deploy a 3 Tier Serverless App on AWS with Terraform Modules</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 14 Jul 2026 04:59:11 +0000</pubDate>
      <link>https://dev.to/aws-builders/build-and-deploy-a-3-tier-serverless-app-on-aws-with-terraform-modules-32pe</link>
      <guid>https://dev.to/aws-builders/build-and-deploy-a-3-tier-serverless-app-on-aws-with-terraform-modules-32pe</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Most tutorials show you how to create a Lambda function by clicking around the AWS console. That works once. Then you need a staging environment, and you click everything again. Then production. Then someone asks "wait, did staging have the 30 second timeout or was that only dev?" and nobody knows, because the infrastructure lives in memory and screenshots.&lt;/p&gt;

&lt;p&gt;This post walks through a project where the entire 3 tier stack comes from a single &lt;code&gt;terraform apply&lt;/code&gt;. Tier one is the frontend on Amplify, tier two is the API on Lambda and API Gateway, tier three is a MySQL database on RDS. Run the apply in the dev folder, you get a dev environment. Run it in prod, you get prod. Same code, zero clicking.&lt;/p&gt;

&lt;p&gt;The app itself is a small todo application. That is on purpose. The app is just a passenger. The real work is the Terraform: reusable modules, three isolated environments, remote state stored in S3, and module outputs wired into module inputs so everything builds in the correct order.&lt;/p&gt;

&lt;p&gt;The full source is here: &lt;a href="https://github.com/TanseerS/serverless-web-application" rel="noopener noreferrer"&gt;github.com/TanseerS/serverless-web-application&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quick note on the word serverless before we start. It does not mean there are no servers. It means AWS manages the servers and you only bring code. You pay when the code runs, not while it sits idle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1kcdw74i1t48tyot1ky.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1kcdw74i1t48tyot1ky.png" alt="Architecture diagram of the serverless web application" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flow is simple to trace. A user opens a React app hosted on AWS Amplify (a service that builds and hosts frontends straight from a GitHub repo). The app calls a REST API on API Gateway (the AWS service that gives your code an HTTPS URL). Every route on that API points to a single Lambda function (code that runs on demand without a server you manage). Inside the Lambda, an Express app handles the routing and talks to a MySQL database on RDS (managed relational databases on AWS).&lt;/p&gt;

&lt;p&gt;Deployments are split in two. Push to GitHub and Amplify rebuilds the frontend on its own, since CI/CD comes free with Amplify. Change the backend code or any infrastructure, and &lt;code&gt;terraform apply&lt;/code&gt; ships it. Terraform even re-zips the backend folder automatically whenever the source changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repository layout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;serverless-web-application/
├── frontend/            # React 18 + Vite app
├── backend/             # Express + Sequelize API, packaged for Lambda
├── assets/              # diagrams and screenshots
└── infrastructure/
    ├── bootstrap/       # run once, creates the S3 state bucket
    ├── modules/
    │   ├── database/    # RDS MySQL, security group, subnet group
    │   ├── backend/     # Lambda, API Gateway, IAM, code packaging
    │   └── frontend/    # Amplify app, branch, IAM
    └── environments/
        ├── dev/
        ├── staging/
        └── prod/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two ideas make this layout work.&lt;/p&gt;

&lt;p&gt;First, modules are reusable building blocks. The database module does not know or care whether it is building dev or prod. It takes a &lt;code&gt;project&lt;/code&gt; and an &lt;code&gt;environment&lt;/code&gt; variable and names everything &lt;code&gt;${var.project}-${var.environment}-...&lt;/code&gt;, so the same code stamps out &lt;code&gt;todo-app-dev-rds&lt;/code&gt;, &lt;code&gt;todo-app-staging-rds&lt;/code&gt; and &lt;code&gt;todo-app-prod-rds&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Second, each environment folder is tiny. It looks up the default VPC with data sources, calls the three modules, and passes in values from its own &lt;code&gt;terraform.tfvars&lt;/code&gt; file. Promoting a change from dev to prod means changing directory and applying again. You never edit module code to deploy an environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: solve the chicken and egg problem
&lt;/h2&gt;

&lt;p&gt;Terraform keeps a state file, which is its record of everything it has created. By default that file sits on your laptop, which is fine until your laptop is not around or a teammate runs an apply and now there are two versions of the truth. The fix is remote state: store the file in an S3 bucket instead.&lt;/p&gt;

&lt;p&gt;But here is the catch. The S3 bucket has to exist before Terraform can store state in it, and we want Terraform to create the bucket. Who creates the creator?&lt;/p&gt;

&lt;p&gt;The answer is a small run once setup called the bootstrap pattern. The &lt;code&gt;infrastructure/bootstrap/&lt;/code&gt; folder is a tiny Terraform project with plain local state. Its only job is to create the state bucket. You run it once per AWS account and forget about it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_caller_identity"&lt;/span&gt; &lt;span class="s2"&gt;"current"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-statefile-${data.aws_caller_identity.current.account_id}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the bucket name. S3 bucket names are globally unique across every AWS account on the planet, so &lt;code&gt;todo-app-statefile&lt;/code&gt; alone would probably be taken. The &lt;code&gt;aws_caller_identity&lt;/code&gt; data source reads your AWS account ID at plan time and appends it, so the name is unique without hardcoding anything.&lt;/p&gt;

&lt;p&gt;The bootstrap also turns on three protections for the bucket: versioning (every old copy of your state is kept, which is what saves you when an apply goes wrong), full public access blocking, and AES 256 encryption at rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: remote state with S3 native locking
&lt;/h2&gt;

&lt;p&gt;With the bucket in place, every environment points at it through a &lt;code&gt;backend.tf&lt;/code&gt; file. Same bucket, different key per environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"todo-app-statefile-&amp;lt;account-id&amp;gt;"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dev/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;use_lockfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because dev, staging and prod each write to their own key, an apply in dev can never touch prod. They are fully isolated.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;use_lockfile = true&lt;/code&gt; line deserves attention. State locking stops two people from running apply at the same time and corrupting the state. For years the standard answer was a DynamoDB table just for the lock. Terraform 1.10 added native S3 locking: Terraform drops a lock object into the bucket itself while an apply runs. One less resource to create, pay for and explain.&lt;/p&gt;

&lt;p&gt;Here is the bucket after all three environments have been initialized, one prefix per state file:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdq4ajvkybmnaancwssu5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdq4ajvkybmnaancwssu5.png" alt="S3 state bucket with dev, staging and prod prefixes, versioning and encryption enabled" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the database module
&lt;/h2&gt;

&lt;p&gt;The database module creates three things. A DB subnet group that tells RDS which subnets it can live in. A security group (a virtual firewall) allowing inbound MySQL on port 3306. And the RDS instance itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;identifier&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-${var.environment}-rds"&lt;/span&gt;
  &lt;span class="nx"&gt;db_name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TodoAppDb"&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mysql"&lt;/span&gt;
  &lt;span class="nx"&gt;engine_version&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"8.0"&lt;/span&gt;
  &lt;span class="nx"&gt;allocated_storage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
  &lt;span class="nx"&gt;storage_type&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gp3"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_class&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db.t3.micro"&lt;/span&gt;

  &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;

  &lt;span class="nx"&gt;db_subnet_group_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_subnet_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_security_group_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;publicly_accessible&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;skip_final_snapshot&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL 8.0 on a &lt;code&gt;db.t3.micro&lt;/code&gt; with 20 GB of gp3 storage, which keeps it inside the free tier.&lt;/p&gt;

&lt;p&gt;One honest admission. The security group currently allows 3306 from &lt;code&gt;0.0.0.0/0&lt;/code&gt;, meaning anywhere on the internet, and the instance is publicly accessible. That is a demo shortcut so the Lambda can reach the database without living inside the VPC. Production would put RDS in private subnets, place the Lambda inside the VPC, and scope the security group to the Lambda alone. The repo says this out loud in its trade offs section rather than pretending it is fine.&lt;/p&gt;

&lt;p&gt;The module ends by outputting &lt;code&gt;db_endpoint&lt;/code&gt;, &lt;code&gt;db_name&lt;/code&gt; and &lt;code&gt;db_port&lt;/code&gt;. Remember those. They are about to become someone else's inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the backend module, where most of the Terraform lives
&lt;/h2&gt;

&lt;p&gt;This module packages the code, creates the Lambda, and builds the entire REST API. A few pieces are worth slowing down for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Packaging code without a build script
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"archive_file"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"zip"&lt;/span&gt;
  &lt;span class="nx"&gt;source_dir&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/../../../backend"&lt;/span&gt;
  &lt;span class="nx"&gt;output_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/../../build/function.zip"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform zips the &lt;code&gt;backend/&lt;/code&gt; folder at plan time. The Lambda resource then sets &lt;code&gt;source_code_hash = data.archive_file.this.output_base64sha256&lt;/code&gt;, which means the function only gets updated when the code actually changed. Edit a Terraform file and apply, the Lambda is untouched. Edit &lt;code&gt;routes/tasks.js&lt;/code&gt; and apply, Terraform notices the hash changed and ships the new zip. No separate deploy pipeline for the backend at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Lambda function
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_function"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-${var.environment}-lambda-function"&lt;/span&gt;
  &lt;span class="nx"&gt;handler&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"server.handler"&lt;/span&gt;
  &lt;span class="nx"&gt;runtime&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nodejs22.x"&lt;/span&gt;
  &lt;span class="nx"&gt;timeout&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="nx"&gt;memory_size&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;

  &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;DB_HOST&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_endpoint&lt;/span&gt;
      &lt;span class="nx"&gt;DB_NAME&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_name&lt;/span&gt;
      &lt;span class="nx"&gt;DB_PORT&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_port&lt;/span&gt;
      &lt;span class="nx"&gt;DB_USER&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;
      &lt;span class="nx"&gt;DB_PASSWORD&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node.js 22, 512 MB of memory, 30 second timeout. The database connection details arrive as environment variables, and look where they come from: &lt;code&gt;var.db_endpoint&lt;/code&gt; is the output of the database module. The environment folder wires them together like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"backend"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../modules/backend"&lt;/span&gt;
  &lt;span class="nx"&gt;db_endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_endpoint&lt;/span&gt;
  &lt;span class="nx"&gt;db_name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_name&lt;/span&gt;
  &lt;span class="nx"&gt;db_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_port&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the trick that makes the whole project build in the right order. Terraform sees that the backend needs an output from the database, so it builds the database first. Nobody wrote &lt;code&gt;depends_on&lt;/code&gt; for that. The dependency graph falls out of the references themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  Eight API routes from one map
&lt;/h3&gt;

&lt;p&gt;The API has four paths (&lt;code&gt;/api&lt;/code&gt;, &lt;code&gt;/api/health&lt;/code&gt;, &lt;code&gt;/api/tasks&lt;/code&gt;, &lt;code&gt;/api/tasks/{id}&lt;/code&gt;) and eight method and path combinations once you count the OPTIONS methods needed for CORS preflight. Writing eight method blocks and eight integration blocks by hand would be sixteen nearly identical resources. Instead, the routes live in one &lt;code&gt;locals&lt;/code&gt; map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;routes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;health_get&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nx"&gt;http_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;tasks_get&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nx"&gt;http_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;tasks_post&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nx"&gt;http_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;task_put&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task_by_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;http_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PUT"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;task_delete&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task_by_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;http_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DELETE"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# ...plus three OPTIONS entries&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_api_gateway_method"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routes&lt;/span&gt;
  &lt;span class="nx"&gt;rest_api_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt;
  &lt;span class="nx"&gt;http_method&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;http_method&lt;/span&gt;
  &lt;span class="nx"&gt;authorization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NONE"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;for_each&lt;/code&gt; loops over the map and stamps out one resource per entry. The integrations do the same, every one an &lt;code&gt;AWS_PROXY&lt;/code&gt; integration pointing at the Lambda, which means API Gateway forwards the raw request and lets Express figure out the routing inside the function. Adding a new route later means adding one line to the map.&lt;/p&gt;

&lt;h3&gt;
  
  
  The silent API Gateway gotcha
&lt;/h3&gt;

&lt;p&gt;Here is a bug that costs beginners hours. API Gateway keeps a deployed snapshot of your API separate from its configuration. You can change methods and integrations in Terraform, apply successfully, and API Gateway will keep serving the old snapshot without a single complaint. Everything looks green. Your change is just not live.&lt;/p&gt;

&lt;p&gt;The fix is to force a redeploy whenever the routing changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_api_gateway_deployment"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;rest_api_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_api_gateway_rest_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;triggers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;redeploy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="nx"&gt;aws_api_gateway_method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;aws_api_gateway_integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;create_before_destroy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trigger is a hash of the routing config. Config changes, hash changes, Terraform creates a fresh deployment. The &lt;code&gt;create_before_destroy&lt;/code&gt; lifecycle rule makes sure the new deployment exists before the old one goes away, so the stage never has a gap where the API is down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the frontend module
&lt;/h2&gt;

&lt;p&gt;The Amplify app connects directly to the GitHub repo using a personal access token, with the build instructions written inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_amplify_app"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-${var.environment}-amplify-app"&lt;/span&gt;
  &lt;span class="nx"&gt;repository&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repository&lt;/span&gt;
  &lt;span class="nx"&gt;access_token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_access_token&lt;/span&gt;

  &lt;span class="nx"&gt;environment_variables&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;VITE_API_URL&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_uri&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;VITE_API_URL&lt;/code&gt; line is my favorite detail in the project. The value comes from &lt;code&gt;module.backend.api_invoke_url&lt;/code&gt;, the output of the backend module. So Terraform creates the API, learns its URL, and hands it to Amplify as a build time environment variable. The React app bakes it in during &lt;code&gt;npm run build&lt;/code&gt;. The frontend learns its backend address from Terraform and nothing is ever hardcoded.&lt;/p&gt;

&lt;p&gt;The module also adds a rewrite rule so deep links like &lt;code&gt;/tasks/5&lt;/code&gt; fall back to &lt;code&gt;index.html&lt;/code&gt; instead of returning 404, which every single page app on Amplify needs, and a branch resource with auto build turned on. The branch stage is set with a conditional: &lt;code&gt;var.environment == "prod" ? "PRODUCTION" : "DEVELOPMENT"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: the backend code that makes Express work in Lambda
&lt;/h2&gt;

&lt;p&gt;A normal Express app ends with &lt;code&gt;app.listen(3000)&lt;/code&gt;. There is no port to listen on inside Lambda, so the app is wrapped instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;serverlessExpress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vendia/serverless-express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// routes...&lt;/span&gt;
&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;serverlessExpress&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@vendia/serverless-express&lt;/code&gt; package translates the event API Gateway sends into the request object Express expects, and translates the response back. Your Express code does not change at all.&lt;/p&gt;

&lt;p&gt;One more pattern worth stealing. The database table has to exist before the first query, but you do not want to create it on every request. The code caches the promise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dbReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/tasks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dbReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dbReady&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sequelize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dbReady&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dbReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// don't cache a failed attempt&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;tasksRouter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first request in each Lambda container runs &lt;code&gt;sequelize.sync()&lt;/code&gt; which creates the &lt;code&gt;tasks&lt;/code&gt; table if needed. Every later request awaits the already resolved promise, which costs nothing. And if the sync fails, the cache resets so the next request tries again instead of being stuck with a failed promise forever. There is no manual schema step anywhere in the deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying it yourself
&lt;/h2&gt;

&lt;p&gt;You need an AWS account with credentials configured, Terraform 1.15 or newer, and a GitHub personal access token that can read your fork and create webhooks.&lt;/p&gt;

&lt;p&gt;First, bootstrap the state bucket, once per account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infrastructure/bootstrap
terraform init
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, edit &lt;code&gt;backend.tf&lt;/code&gt; in each environment folder and set the bucket name to what the bootstrap printed. Backend blocks cannot take variables, so this one value is a literal.&lt;/p&gt;

&lt;p&gt;Third, deploy an environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;infrastructure/environments/dev
&lt;span class="nb"&gt;cp &lt;/span&gt;terraform.tfvars.example terraform.tfvars   &lt;span class="c"&gt;# fill in DB password, repo URL, token, branch&lt;/span&gt;
terraform init
terraform plan
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RDS takes a few minutes to come up. The result is 28 resources from one command:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftvjc3agl0tkxi7pyeyf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftvjc3agl0tkxi7pyeyf.png" alt="terraform apply output ending with Apply complete, 28 resources added" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Fourth, trigger the first frontend build. Amplify only builds on new commits, so push something to the configured branch after the apply. An empty commit does the job.&lt;/p&gt;

&lt;p&gt;Finally, check it is alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://&amp;lt;api-id&amp;gt;.execute-api.ap-south-1.amazonaws.com/dev/api/health
&lt;span class="c"&gt;# {"status":"ok","database":"connected"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What actually gets created
&lt;/h2&gt;

&lt;p&gt;Everything below came from the same modules, applied once per environment folder. Three Lambda functions on Node.js 22:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fll53oflnhttymltcxgnu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fll53oflnhttymltcxgnu.png" alt="Lambda console showing the dev, staging and prod functions" width="799" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three regional REST APIs, each with a stage named after its environment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fonsrap1xpjolec43lsuw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fonsrap1xpjolec43lsuw.png" alt="API Gateway console showing the three REST APIs" width="800" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A MySQL instance per environment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fns8s9ekye5lr0xq0lnc3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fns8s9ekye5lr0xq0lnc3.png" alt="RDS console showing the three MySQL instances" width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the Amplify apps, connected to GitHub and building on every push:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq3u350yd9m5yx0q375k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzq3u350yd9m5yx0q375k.png" alt="Amplify console showing the connected apps" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice what did not happen. Nobody opened the RDS console to click Create database three times. The staging environment is not "dev, but with some settings someone changed by hand last month." All three are provably identical because they came from identical code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest trade offs
&lt;/h2&gt;

&lt;p&gt;A few shortcuts were taken deliberately, and it is worth knowing what production would change. The open security group and public RDS would become private subnets with the Lambda inside the VPC. Database credentials would move from Lambda environment variables into Secrets Manager. The single AZ &lt;code&gt;db.t3.micro&lt;/code&gt; with &lt;code&gt;skip_final_snapshot&lt;/code&gt; would become Multi AZ with deletion protection. And the hardcoded bucket name in &lt;code&gt;backend.tf&lt;/code&gt; would be handled with partial backend config through &lt;code&gt;terraform init -backend-config&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The roadmap includes a GitHub Actions pipeline that runs &lt;code&gt;terraform fmt&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; on pull requests and applies on merge, plus tflint and checkov for static checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The todo app is forgettable. The patterns are not. The bootstrap trick for remote state, S3 native locking without a DynamoDB table, modules whose outputs feed the next module's inputs so the build order takes care of itself, &lt;code&gt;for_each&lt;/code&gt; over a map instead of sixteen copy pasted resources, and a hash trigger that stops API Gateway from silently serving stale config. Every one of these shows up in real production Terraform, and this repo is a small enough place to learn them without drowning.&lt;/p&gt;

&lt;p&gt;Clone it, deploy dev, then run &lt;code&gt;terraform destroy&lt;/code&gt; when you are done exploring. RDS is the only always on cost here, and tearing it down is one command, the same way bringing it up was.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/TanseerS/serverless-web-application" rel="noopener noreferrer"&gt;github.com/TanseerS/serverless-web-application&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Contact
&lt;/h2&gt;

&lt;p&gt;Questions, feedback, or stuck on a step? Reach me at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;. Happy to help.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>s3</category>
    </item>
    <item>
      <title>Go Live in Minutes: Building Your First Live Stream with Amazon IVS</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 07 Jul 2026 05:08:24 +0000</pubDate>
      <link>https://dev.to/aws-builders/go-live-in-minutes-building-your-first-live-stream-with-amazon-ivs-5159</link>
      <guid>https://dev.to/aws-builders/go-live-in-minutes-building-your-first-live-stream-with-amazon-ivs-5159</guid>
      <description>&lt;h2&gt;
  
  
  The same tech that powers Twitch, minus the servers, minus the headache
&lt;/h2&gt;

&lt;p&gt;A few weeks ago I wanted to test a live streaming idea. My first thought was the usual one. I will need a media server, something like Wowza or a self hosted RTMP setup on EC2, then a CDN in front of it, then hours of tuning latency. Before I even opened the console I was already tired.&lt;/p&gt;

&lt;p&gt;Then I found Amazon IVS, and honestly, the whole thing took less time than making tea.&lt;/p&gt;

&lt;p&gt;If you are a developer who has never touched live streaming on AWS before, this post is for you. By the end of it you will know exactly what IVS is, why it is different from rolling your own setup, and how to go live in four steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exactly is Amazon IVS
&lt;/h2&gt;

&lt;p&gt;Amazon Interactive Video Service, or IVS for short, is a fully managed live streaming service from AWS. Fully managed means AWS runs everything behind the scenes. You do not create servers, you do not install streaming software on the cloud, and you do not worry about scaling.&lt;/p&gt;

&lt;p&gt;Here is the part that made me sit up. IVS runs on the exact same infrastructure that powers Twitch. Twitch is one of the biggest live streaming platforms in the world, handling millions of concurrent viewers every single day. When you create an IVS channel, your tiny test stream rides on that same battle tested backbone.&lt;/p&gt;

&lt;p&gt;So instead of you building a smaller, buggier version of Twitch infrastructure, AWS just hands you Twitch infrastructure as an API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The killer feature: ultra low latency
&lt;/h2&gt;

&lt;p&gt;If you have ever streamed with a typical setup, you know the pain. You wave at the camera, and viewers see the wave 15 to 30 seconds later. That delay is called latency, the time it takes for video to travel from your camera to the viewer's screen.&lt;/p&gt;

&lt;p&gt;IVS brings that down to under 3 seconds.&lt;/p&gt;

&lt;p&gt;Why does that matter? Because under 3 seconds, a stream stops feeling like a broadcast and starts feeling like a conversation. Viewers can react in chat and you can respond almost instantly.&lt;/p&gt;

&lt;p&gt;IVS also lets you sync interactive elements with the video. Think live chat, polls, quizzes, or even shopping buttons that appear exactly when the host says "buy this now." This is done through something called timed metadata, small pieces of data you inject into the stream that arrive on the viewer's screen in sync with the video frame. For a marketplace or live commerce product, this is gold.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is it serverless
&lt;/h2&gt;

&lt;p&gt;The word serverless gets thrown around a lot, so let me be specific about what it means here.&lt;/p&gt;

&lt;p&gt;There are no servers for you to manage. No EC2 instances, no patching, no capacity planning. You create a channel and AWS handles the rest.&lt;/p&gt;

&lt;p&gt;It scales automatically. Whether 5 people show up to your stream or 5 million, you do nothing. The same channel handles both. There is no "upgrade your instance" moment at 2 AM when your stream suddenly goes viral.&lt;/p&gt;

&lt;p&gt;You pay only for what you use. IVS pricing is based on two things: hours of video you send into the service (input) and hours of video delivered to viewers (output). No stream running means no bill running. This is the same pay as you go model that makes Lambda and other serverless tools so friendly for side projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going live in 4 steps
&lt;/h2&gt;

&lt;p&gt;This is the part that genuinely surprised me. Here is the entire setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create a channel
&lt;/h3&gt;

&lt;p&gt;Log in to the AWS Console and search for Amazon IVS. Click Create channel, give it a name, and keep the default configuration for now. That is it. AWS provisions the channel in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Copy your Ingest Server and Stream Key
&lt;/h3&gt;

&lt;p&gt;Open your new channel and you will see two important values. The Ingest Server is the address your video gets sent to, something like rtmps://xxxxxx.global-contribute.live-video.net. The Stream Key is a secret token that proves the stream belongs to you. Treat the stream key like a password. Anyone who has it can broadcast on your channel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Paste them into OBS Studio
&lt;/h3&gt;

&lt;p&gt;OBS Studio is free, open source broadcasting software and the de facto standard for streaming. Download it, open Settings, go to Stream, and choose Custom as the service. Paste the Ingest Server into the Server field and the Stream Key into the Stream Key field. Save.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Click Start Streaming
&lt;/h3&gt;

&lt;p&gt;Back on the main OBS screen, add a source (your webcam or screen), then hit Start Streaming. Switch to the AWS Console, open your channel, and you will see yourself live in the built in preview player within seconds.&lt;/p&gt;

&lt;p&gt;Four steps. No infrastructure. You are now running on Twitch grade streaming systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;The console preview is just the beginning. IVS gives you a playback URL for every channel, and AWS provides a player SDK for web, iOS, and Android. That means you can drop your live stream into your own React app or mobile app with a few lines of code. Add the IVS chat feature and timed metadata, and you have a real interactive streaming product without touching a single server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Live streaming used to be a project measured in weeks. With Amazon IVS it is a project measured in minutes. You get the infrastructure behind Twitch, latency under 3 seconds, automatic scaling from a handful of viewers to millions, and a bill that only grows when your stream does. If you have been putting off a live video feature because the plumbing felt scary, this is your sign to create a channel and press Start Streaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's connect
&lt;/h2&gt;

&lt;p&gt;If you tried this and got stuck somewhere, or if you built something cool with IVS, I would love to hear about it. Reach out at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>ivs</category>
    </item>
    <item>
      <title>Terraform guide to IAM Policies: The Two Cases You Need to Know</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Wed, 01 Jul 2026 11:21:21 +0000</pubDate>
      <link>https://dev.to/tanseer/terraform-guide-to-iam-policies-the-two-cases-you-need-to-know-j0b</link>
      <guid>https://dev.to/tanseer/terraform-guide-to-iam-policies-the-two-cases-you-need-to-know-j0b</guid>
      <description>&lt;h2&gt;
  
  
  When to attach a ready made policy and when to write your own
&lt;/h2&gt;

&lt;p&gt;When you first start writing IAM setup in Terraform, the examples online can feel like they contradict each other. One tutorial writes a long block called &lt;code&gt;aws_iam_policy_document&lt;/code&gt;. Another just pastes a single line with a long string inside it. Both of them work, so which one is correct?&lt;/p&gt;

&lt;p&gt;The answer is that both are correct. They simply solve two different problems. Once you can tell which situation you are in, IAM in Terraform stops feeling random and starts feeling obvious.&lt;/p&gt;

&lt;p&gt;Before we get into the two cases, here are the words you will keep seeing:&lt;/p&gt;

&lt;p&gt;IAM stands for Identity and Access Management. It is the AWS service that decides who is allowed to do what. An IAM role is an identity that an AWS service (like a Lambda function) takes on so it can act with certain permissions. An IAM policy is the document that lists which actions are allowed or denied. An ARN, short for Amazon Resource Name, is the unique address of a resource in AWS.&lt;/p&gt;

&lt;p&gt;With that out of the way, let us look at the two cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 1: AWS Managed Policy (attach directly)
&lt;/h2&gt;

&lt;p&gt;A managed policy is a policy that AWS already wrote and maintains for you. If AWS already provides the exact permissions you need, you do not create your own &lt;code&gt;aws_iam_policy_document&lt;/code&gt;. You just point your role at the policy AWS already made.&lt;/p&gt;

&lt;p&gt;For example, giving a Lambda function permission to write logs is a very common need, and AWS has a ready made policy for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"logs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another common one is letting a Lambda function run inside a VPC (a Virtual Private Cloud, which is your own private network in AWS):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"vpc"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what is happening here. You are not writing any permissions yourself. You are taking an existing AWS managed policy, referring to it by its ARN, and attaching it to your role. That is the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2: Your own custom permissions
&lt;/h2&gt;

&lt;p&gt;Sometimes AWS does not have a managed policy that matches exactly what you need. Maybe you want a Lambda function to read and write files in one specific S3 bucket and nothing else. In that case you build the policy yourself, and it takes three small steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the policy document
&lt;/h3&gt;

&lt;p&gt;First you describe the permissions. This &lt;code&gt;aws_iam_policy_document&lt;/code&gt; is a data source, which means it does not create anything in AWS on its own. It is a way to write your permission rules in clean Terraform instead of raw JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"s3_access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;

    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uploads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"${aws_s3_bucket.uploads.arn}/*"&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading this in plain English: allow the actions of getting an object and putting an object, and only on the uploads bucket and everything inside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the IAM policy
&lt;/h3&gt;

&lt;p&gt;Now you turn that document into a real IAM policy that exists in AWS. The &lt;code&gt;policy&lt;/code&gt; field pulls in the JSON produced by the document from Step 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy"&lt;/span&gt; &lt;span class="s2"&gt;"s3_access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-${var.environment}-s3-access"&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;s3_access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Attach it
&lt;/h3&gt;

&lt;p&gt;Finally you attach the policy you just created to your role. This looks almost the same as Case 1, but the ARN now comes from your own policy instead of an AWS managed one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"s3_access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;s3_access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;The easiest way to remember the difference is this. In Case 1 the policy already exists, so you skip straight to attaching. In Case 2 you have to build the policy first, then attach it.&lt;/p&gt;

&lt;p&gt;The custom flow always moves in the same direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Policy Document
      │
      ▼
IAM Policy
      │
      ▼
Attach to Role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You write the document, the document becomes a policy, and the policy gets attached to the role. If you ever feel lost, just ask yourself one question: does AWS already have a policy for this? If yes, attach it directly. If no, build your own with the three steps above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;IAM in Terraform is not as confusing as it first looks. There are really only two situations. When AWS already provides the exact permissions, you attach a managed policy by its ARN in a single resource. When you need something specific that AWS does not offer, you create a policy document, turn it into an IAM policy, and attach that.&lt;/p&gt;

&lt;p&gt;Keep that fork in mind and you will always know which approach the moment fits, instead of copying an example and hoping it is the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let us connect
&lt;/h2&gt;

&lt;p&gt;If you have questions or want to share how you handle IAM in your own projects, reach out at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;. I am always happy to talk Terraform and AWS.&lt;/p&gt;




</description>
      <category>aws</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Lambda MicroVMs Are Kind of Insane: Tiny Virtual Machines That Boot in Milliseconds and Sleep When You Are Not Looking</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 30 Jun 2026 04:53:28 +0000</pubDate>
      <link>https://dev.to/aws-builders/lambda-microvms-are-kind-of-insane-tiny-virtual-machines-that-boot-in-milliseconds-and-sleep-when-4b93</link>
      <guid>https://dev.to/aws-builders/lambda-microvms-are-kind-of-insane-tiny-virtual-machines-that-boot-in-milliseconds-and-sleep-when-4b93</guid>
      <description>&lt;p&gt;&lt;em&gt;A beginner friendly guide to the Firecracker powered microVMs behind AWS Lambda, the new suspend and resume superpower, and the big question everyone keeps asking: is this still serverless?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Imagine a virtual computer so small and so fast that it starts up in less time than it takes you to blink. Now imagine thousands of them running on a single physical server, each one completely walled off from its neighbours, and each one quietly going to sleep the moment it has nothing to do, so that you stop paying for it.&lt;/p&gt;

&lt;p&gt;That is not a science fiction pitch. That is roughly what is happening inside AWS Lambda today, and the newer microVM capabilities push the idea even further.&lt;/p&gt;

&lt;p&gt;In this post we will start from zero. We will explain what a microVM even is, meet the tiny engine that makes the whole thing possible, walk through what happens when a single request comes in, and then look at the features that make people say this is a little bit crazy. By the end you will be able to answer the question that is dividing the cloud community: after all of this, is Lambda even serverless anymore?&lt;/p&gt;

&lt;p&gt;Let us get into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So What Exactly Is a Lambda MicroVM?
&lt;/h2&gt;

&lt;p&gt;A microVM is a tiny, lightweight virtual machine. It behaves like its own small computer with a strong security wall around it, but it throws away almost all the heavy baggage of a normal machine, so it starts in milliseconds and uses very little memory. Picture a tiny, fast, single purpose box that appears, does one job, and disappears.&lt;/p&gt;

&lt;p&gt;Here is the part that surprises most people. Lambda has always run your code inside these microVMs. Every time your function runs, AWS quietly spins up a tiny isolated machine just for you. The recent excitement is about giving those microVMs new powers, such as the ability to pause when idle and to be reached directly over the internet. So when people say "Lambda MicroVMs," they are really talking about this next chapter of an idea that has been humming away under the hood for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet Firecracker, the Tiny Engine Doing All the Work
&lt;/h2&gt;

&lt;p&gt;Powering all of this is Firecracker, a technology that AWS built and then released to the public as open source. AWS created it specifically to run microVMs safely at massive scale, and it is the reason a tiny machine can boot in around 125 milliseconds while still keeping the strong security wall of a real virtual machine. This same AWS made engine quietly runs underneath both Lambda and AWS Fargate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw1q30edvsu8vtej1o6ii.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw1q30edvsu8vtej1o6ii.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works: The Journey of a Single Request
&lt;/h2&gt;

&lt;p&gt;Let us follow one request from start to finish so the architecture clicks.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You upload your code.&lt;/strong&gt; This can be a simple zip file or a container image. You also choose how much memory the function gets, which in turn decides how much CPU power it receives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Something triggers it.&lt;/strong&gt; A user hits an endpoint, a file lands in storage, a message arrives on a queue, and Lambda decides your function needs to run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A microVM appears.&lt;/strong&gt; AWS spins up a Firecracker microVM with your chosen runtime and your code inside. This little box is called the &lt;strong&gt;execution environment&lt;/strong&gt;. If a fresh one has to be created from scratch, that small delay is known as a &lt;strong&gt;cold start&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your code runs.&lt;/strong&gt; The environment initialises, your handler runs, and a response goes back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The environment is kept around.&lt;/strong&gt; Instead of throwing the microVM away immediately, Lambda often keeps it ready for a little while. If another request arrives soon, it reuses the same warm environment and skips the startup cost entirely. This is called a &lt;strong&gt;warm start&lt;/strong&gt;, and it is why the second request to a function usually feels much faster than the first.
So the architecture is really a fleet of these tiny machines being created, reused, and recycled constantly, all managed for you so you never touch a server.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Each One Gets Its Own Front Door: Dedicated HTTP Endpoints
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;endpoint&lt;/strong&gt; is just a web address that something can send a request to. &lt;strong&gt;HTTP&lt;/strong&gt; is the everyday language of the web that browsers and apps use to talk to servers.&lt;/p&gt;

&lt;p&gt;For a long time, if you wanted your Lambda function to be reachable over the web, you usually had to put another service in front of it to handle the incoming traffic. The newer approach gives a function its own dedicated HTTPS endpoint, a clean and direct web address that points straight at it. No extra plumbing in between.&lt;/p&gt;

&lt;p&gt;The address looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://your-unique-id.lambda-url.your-region.on.aws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can keep it open or lock it down so only authorised callers get through. For simple use cases, such as a webhook that some other service calls, or a small backend for a single page app, this is wonderfully direct. Your request lands on a private little microVM that exists just to answer you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Magic: Suspend and Resume
&lt;/h2&gt;

&lt;p&gt;This is the feature that earns the word "crazy."&lt;/p&gt;

&lt;p&gt;To understand it, you need one more idea: a &lt;strong&gt;snapshot&lt;/strong&gt;. A snapshot is a complete saved picture of a running machine at a single moment in time, including everything sitting in its memory. If you take a snapshot and then restore it later, the machine wakes up exactly where it left off, as if no time had passed at all.&lt;/p&gt;

&lt;p&gt;Firecracker can do this with microVMs. It can freeze a running microVM, save its full state, and bring it back to life almost instantly.&lt;/p&gt;

&lt;p&gt;Now think about what that unlocks. Traditionally you had two bad choices. Either you kept a server running all the time so it was ready to respond instantly, and you paid for every idle second. Or you shut it down to save money and then suffered a slow start whenever traffic came back.&lt;/p&gt;

&lt;p&gt;Suspend and resume gives you a third option. The microVM can stay paused while nothing is happening, and the headline benefit is simple: &lt;strong&gt;you are not paying for a fully running machine while it sits idle.&lt;/strong&gt; When a request finally arrives, it wakes up quickly and carries on. You get the responsiveness of an always on server with a cost profile much closer to pay only for what you use.&lt;/p&gt;

&lt;p&gt;That is the holy grail people have chased for years. Be instantly ready, but only pay when you are actually doing work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lambda MicroVM vs EC2 vs Classic Lambda
&lt;/h2&gt;

&lt;p&gt;It helps to see where this sits between the two options most people already know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EC2&lt;/strong&gt; is a rented virtual server that you control and manage yourself. &lt;strong&gt;Classic Lambda&lt;/strong&gt; is short lived, event driven functions with a hard time limit. The microVM approach lands in the middle, borrowing the best of each.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;EC2&lt;/th&gt;
&lt;th&gt;Classic Lambda&lt;/th&gt;
&lt;th&gt;Lambda MicroVM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What you manage&lt;/td&gt;
&lt;td&gt;The whole server&lt;/td&gt;
&lt;td&gt;Just your code&lt;/td&gt;
&lt;td&gt;Just your code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How long it can run&lt;/td&gt;
&lt;td&gt;As long as you like&lt;/td&gt;
&lt;td&gt;Up to 15 minutes per run&lt;/td&gt;
&lt;td&gt;Designed for longer and more stateful work than the 15 minute limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Startup speed&lt;/td&gt;
&lt;td&gt;Slow, minutes&lt;/td&gt;
&lt;td&gt;Fast, with occasional cold starts&lt;/td&gt;
&lt;td&gt;Fast, with quick resume from a paused state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost when idle&lt;/td&gt;
&lt;td&gt;You keep paying while it runs&lt;/td&gt;
&lt;td&gt;Nothing, it is not running&lt;/td&gt;
&lt;td&gt;The big win is not paying for a fully running machine while idle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;You set it up&lt;/td&gt;
&lt;td&gt;Automatic, scales to zero&lt;/td&gt;
&lt;td&gt;Automatic, scales to zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best feeling&lt;/td&gt;
&lt;td&gt;Total control&lt;/td&gt;
&lt;td&gt;Pure simplicity&lt;/td&gt;
&lt;td&gt;Control and simplicity together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The short version is this. EC2 gives you full control but you babysit it and pay for idle time. Classic Lambda is beautifully simple but tightly constrained. The microVM model tries to give you longer running, more flexible compute that still scales down to nothing when no one is using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is This Actually Good For?
&lt;/h2&gt;

&lt;p&gt;A new tool is only exciting if it solves real problems. Here is where this approach shines.&lt;/p&gt;

&lt;p&gt;It is great for &lt;strong&gt;long running jobs&lt;/strong&gt; that do not fit neatly into a short window, such as heavier data processing or video work that would bump into the classic 15 minute ceiling.&lt;/p&gt;

&lt;p&gt;It suits &lt;strong&gt;stateful sessions&lt;/strong&gt;, meaning work where the machine needs to remember something between requests rather than starting fresh every single time.&lt;/p&gt;

&lt;p&gt;It is a natural fit for &lt;strong&gt;AI and machine learning inference&lt;/strong&gt;, where a large model can be expensive to load. You load it once, let the machine pause when traffic is quiet, and resume instantly when the next request comes, instead of paying to keep a big GPU style box running all day.&lt;/p&gt;

&lt;p&gt;It works beautifully for &lt;strong&gt;interactive sandboxes and code execution&lt;/strong&gt;, the kind of thing where each user needs their own safe, isolated little environment that spins up fast and tears down cleanly.&lt;/p&gt;

&lt;p&gt;And it is useful for &lt;strong&gt;bursty traffic&lt;/strong&gt;, where requests come in unpredictable spikes. You get instant readiness during the spike and near zero cost during the long quiet stretches in between.&lt;/p&gt;

&lt;p&gt;When would you not bother? If your workload is genuinely tiny and event driven, classic Lambda is still the simplest possible answer. And if you need deep control over the operating system, custom networking, or hardware that must run nonstop, a traditional server like EC2 is still the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About the Price?
&lt;/h2&gt;

&lt;p&gt;Cost is usually the deciding factor, so let us compare the three billing styles in plain terms. To follow along, you need one bit of vocabulary: a &lt;strong&gt;GB second&lt;/strong&gt; is just one gigabyte of memory used for one second. It is the unit Lambda uses to measure how much work you actually consumed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EC2&lt;/strong&gt; charges you for the time the server is switched on, whether or not anyone is using it. A box running quietly overnight with zero visitors still appears on your bill. You pay for capacity, not for usage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classic Lambda&lt;/strong&gt; charges you in two parts: a tiny amount for each request, plus an amount for compute measured in those GB seconds. If nobody calls your function, it costs you nothing. You pay for usage, not for capacity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The microVM model&lt;/strong&gt; aims for the best of both. You get a more capable, longer lived machine, but the key promise is that you are not charged for a fully running VM while it is paused and idle. You pay mainly for the active time when it is actually doing something.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Important: cloud pricing changes often, and the exact rates for these options vary by region and configuration. Rather than trust any single number you read in a blog, including this one, always check the current AWS pricing pages before you make a decision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So Is It Still Serverless?
&lt;/h2&gt;

&lt;p&gt;Here is the fun part, and it is genuinely a matter of debate.&lt;/p&gt;

&lt;p&gt;The word &lt;strong&gt;serverless&lt;/strong&gt; never meant there were no servers. It meant you, the developer, never had to think about them. The classic definition came with a few expectations: your code is event driven and short lived, it scales automatically all the way down to zero, you never manage infrastructure, and you only pay for what you use.&lt;/p&gt;

&lt;p&gt;Now look at what these microVMs offer. Long running. Able to hold state. Reachable through their own dedicated web address. Sitting paused and ready rather than vanishing completely. To some people that starts to look an awful lot like a small, managed server wearing a serverless costume.&lt;/p&gt;

&lt;p&gt;The case for "yes, it is still serverless" is strong. You still never patch an operating system. It still scales to zero. You still pay for value rather than for idle capacity. By the spirit of the original promise, nothing has been broken.&lt;/p&gt;

&lt;p&gt;The case for "no, the purity is gone" is also fair. The original idea of a tiny, stateless, event triggered function that lives for a moment and dies has clearly been stretched into something bigger and more persistent.&lt;/p&gt;

&lt;p&gt;The honest answer is probably this. Serverless is becoming a spectrum rather than a strict box. On one end you have pure functions that flash in and out of existence. On the other you have long lived managed machines. These microVMs live somewhere in the comfortable middle, and they are quietly redefining what the word can mean. Maybe the better question is not "is this serverless," but "does this let me ship without babysitting infrastructure." If the answer is yes, the label matters a lot less than the freedom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Lambda has always been powered by tiny, fast, isolated microVMs running on Firecracker. What is changing is how clever those microVMs are getting. They can now boot in a blink, answer the web directly, and most remarkably of all, doze off when idle so you are not paying for a machine that is doing nothing.&lt;/p&gt;

&lt;p&gt;Whether you call it serverless, managed compute, or something brand new, one thing is clear. The line between a server and a function is getting blurrier, and that is a great problem for developers to have. The more the cloud handles for us, the more time we get to spend building the things we actually care about.&lt;/p&gt;

&lt;p&gt;If you are new to AWS, the best next step is to try spinning up a simple function, give it a dedicated endpoint, and call it from your browser. Seeing your own tiny microVM answer back is the moment the whole idea finally clicks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let Us Connect
&lt;/h2&gt;

&lt;p&gt;If you found this helpful, have a question, or spot something you want to discuss, I would love to hear from you. Reach out at &lt;strong&gt;&lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




</description>
      <category>aws</category>
      <category>lambda</category>
      <category>serverless</category>
      <category>stateful</category>
    </item>
    <item>
      <title>Who Wins the Variable Fight in Terraform?</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 30 Jun 2026 04:39:15 +0000</pubDate>
      <link>https://dev.to/aws-builders/who-wins-the-variable-fight-in-terraform-kk3</link>
      <guid>https://dev.to/aws-builders/who-wins-the-variable-fight-in-terraform-kk3</guid>
      <description>&lt;h2&gt;
  
  
  A beginner friendly guide to understanding which value Terraform actually picks when the same variable is set in many places
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;When you start writing Terraform, you quickly learn that you can set the same variable in many different ways. You can give it a default value, you can put it in a file, you can pass it on the command line, and you can even set it through your computer's environment.&lt;/p&gt;

&lt;p&gt;This is great for flexibility, but it also raises a confusing question. If the same variable gets a value from five different places, which value does Terraform actually use?&lt;/p&gt;

&lt;p&gt;This is called variable precedence. Precedence simply means the order of priority. It is the set of rules Terraform follows to decide who wins when there is a conflict.&lt;/p&gt;

&lt;p&gt;In this post we will walk through every way you can set a variable, then look at the exact order Terraform uses to pick a winner. We will use small, repeatable examples so you can try each one yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Quick note on the words we will use. A variable is just a named value you can reuse, like a name tag on a box. A value is what is inside that box. Precedence is the priority order Terraform uses when two boxes have the same name tag but different contents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  First, the ways you can set a variable
&lt;/h2&gt;

&lt;p&gt;Before we talk about who wins, let us list all the players in the game. There are six common ways to give a variable a value in Terraform.&lt;/p&gt;

&lt;p&gt;The first is a default value. You write this directly inside the variable block in your code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is an environment variable. An environment variable is a value stored in your computer's shell, outside of Terraform. Terraform reads any environment variable that starts with &lt;code&gt;TF_VAR_&lt;/code&gt; followed by the variable name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"us-west-2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third is a file called &lt;code&gt;terraform.tfvars&lt;/code&gt;. A tfvars file is a plain file where you list variable names and the values you want to give them. Terraform loads this file automatically without you doing anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth is a file called &lt;code&gt;terraform.tfvars.json&lt;/code&gt;. This is the same idea as above, but written in JSON format instead. JSON is just another way of writing structured data using curly braces and quotes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fifth is any file ending in &lt;code&gt;.auto.tfvars&lt;/code&gt; or &lt;code&gt;.auto.tfvars.json&lt;/code&gt;. The word auto here means automatic. Terraform loads these files for you automatically too, just like the standard tfvars file. A common example name is &lt;code&gt;prod.auto.tfvars&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ca-central-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sixth is a command line flag. A flag is an extra instruction you type when running a command. You can pass a value directly with &lt;code&gt;-var&lt;/code&gt;, or point to a specific file with &lt;code&gt;-var-file&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=sa-east-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The order that decides the winner
&lt;/h2&gt;

&lt;p&gt;Here is the part everyone wants to know. Terraform checks these sources in a fixed order, from lowest priority to highest priority. A source that comes later in the list overrides anything set earlier.&lt;/p&gt;

&lt;p&gt;Here is the order from weakest to strongest.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The default value inside the variable block. This is the fallback that is used only when nothing else provides a value.&lt;/li&gt;
&lt;li&gt;Environment variables that start with &lt;code&gt;TF_VAR_&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;terraform.tfvars&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;terraform.tfvars.json&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;Any files ending in &lt;code&gt;.auto.tfvars&lt;/code&gt; or &lt;code&gt;.auto.tfvars.json&lt;/code&gt;, loaded in alphabetical order of their file names.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;-var&lt;/code&gt; and &lt;code&gt;-var-file&lt;/code&gt; flags on the command line, applied in the exact order you type them.
The simplest way to remember this is that the command line always wins, and the default value always loses. Everything else sits in the middle.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A full example you can run
&lt;/h2&gt;

&lt;p&gt;Let us prove the rules with a tiny project. Create a folder and add the following files.&lt;/p&gt;

&lt;p&gt;First the variable declaration in a file called &lt;code&gt;variables.tf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next a file called &lt;code&gt;main.tf&lt;/code&gt; that just prints the value back to us using an output. An output is simply a way for Terraform to show you a value after it runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"selected_region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run this command to see the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point only the default exists, so Terraform will show &lt;code&gt;us-east-1&lt;/code&gt;. The default wins because nobody else is competing.&lt;/p&gt;

&lt;p&gt;Now add a file called &lt;code&gt;terraform.tfvars&lt;/code&gt; with this line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run apply again. This time Terraform shows &lt;code&gt;ap-south-1&lt;/code&gt;. The tfvars file beat the default because it has higher priority.&lt;/p&gt;

&lt;p&gt;Now add a file called &lt;code&gt;prod.auto.tfvars&lt;/code&gt; with this line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ca-central-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run apply once more. Now the answer becomes &lt;code&gt;ca-central-1&lt;/code&gt;. The auto tfvars file sits higher than the standard tfvars file, so it takes over.&lt;/p&gt;

&lt;p&gt;Finally, run apply with a command line flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=sa-east-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is now &lt;code&gt;sa-east-1&lt;/code&gt;. The command line beats every file, so it has the final say.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens with two auto tfvars files
&lt;/h2&gt;

&lt;p&gt;A common question is what happens when you have more than one auto tfvars file and both set the same variable. The answer is alphabetical order.&lt;/p&gt;

&lt;p&gt;Imagine you have two files named &lt;code&gt;a.auto.tfvars&lt;/code&gt; and &lt;code&gt;b.auto.tfvars&lt;/code&gt;, and both set the region. Terraform loads &lt;code&gt;a.auto.tfvars&lt;/code&gt; first and &lt;code&gt;b.auto.tfvars&lt;/code&gt; second. Because the later one overrides the earlier one, the value inside &lt;code&gt;b.auto.tfvars&lt;/code&gt; wins.&lt;/p&gt;

&lt;p&gt;So the file whose name comes later in the alphabet has the higher priority. This is easy to forget, so name your files carefully if order matters to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Command line order also matters
&lt;/h2&gt;

&lt;p&gt;There is one more detail worth knowing. When you pass several flags on the command line, Terraform applies them left to right, and the rightmost one wins.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=us-east-1"&lt;/span&gt; &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=us-west-2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this case the final value is &lt;code&gt;us-west-2&lt;/code&gt;, because it appears last. The same rule applies when you mix &lt;code&gt;-var&lt;/code&gt; and &lt;code&gt;-var-file&lt;/code&gt;. Whatever comes last on the line takes priority.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick warning about missing values
&lt;/h2&gt;

&lt;p&gt;If a variable has no default and you never give it a value through any source, Terraform will stop and ask you to type the value in. This is a safety feature so you never accidentally run with a blank value.&lt;/p&gt;

&lt;p&gt;If you want a variable to be optional, give it a sensible default. If you want it to be required on purpose, leave the default out so Terraform forces someone to provide it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Variable precedence in Terraform looks confusing at first, but it follows one calm and predictable rule. Lower priority sources set the value, and higher priority sources override it.&lt;/p&gt;

&lt;p&gt;The order, from weakest to strongest, is the default value, then environment variables, then the standard tfvars file, then the JSON tfvars file, then the auto tfvars files in alphabetical order, and finally the command line flags which always win.&lt;/p&gt;

&lt;p&gt;Once this clicks, you gain real control over your setup. You can keep safe defaults in your code, store normal settings in tfvars files, and override anything on the fly from the command line when you need to. That is the whole point of precedence, giving you layers you can trust.&lt;/p&gt;

&lt;p&gt;Try the small example above and watch the value change with each step. Seeing it happen is the fastest way to make this stick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get in touch
&lt;/h2&gt;

&lt;p&gt;If you have questions or want me to cover another Terraform topic, reach out at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;. I am always happy to help fellow builders.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
    </item>
    <item>
      <title>Who Wins the Variable Fight in Terraform?</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Wed, 24 Jun 2026 11:02:27 +0000</pubDate>
      <link>https://dev.to/aws-builders/who-wins-the-variable-fight-in-terraform-2e6f</link>
      <guid>https://dev.to/aws-builders/who-wins-the-variable-fight-in-terraform-2e6f</guid>
      <description>&lt;h2&gt;
  
  
  A beginner friendly guide to understanding which value Terraform actually picks when the same variable is set in many places
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;When you start writing Terraform, you quickly learn that you can set the same variable in many different ways. You can give it a default value, you can put it in a file, you can pass it on the command line, and you can even set it through your computer's environment.&lt;/p&gt;

&lt;p&gt;This is great for flexibility, but it also raises a confusing question. If the same variable gets a value from five different places, which value does Terraform actually use?&lt;/p&gt;

&lt;p&gt;This is called variable precedence. Precedence simply means the order of priority. It is the set of rules Terraform follows to decide who wins when there is a conflict.&lt;/p&gt;

&lt;p&gt;In this post we will walk through every way you can set a variable, then look at the exact order Terraform uses to pick a winner. We will use small, repeatable examples so you can try each one yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Quick note on the words we will use. A variable is just a named value you can reuse, like a name tag on a box. A value is what is inside that box. Precedence is the priority order Terraform uses when two boxes have the same name tag but different contents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  First, the ways you can set a variable
&lt;/h2&gt;

&lt;p&gt;Before we talk about who wins, let us list all the players in the game. There are six common ways to give a variable a value in Terraform.&lt;/p&gt;

&lt;p&gt;The first is a default value. You write this directly inside the variable block in your code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is an environment variable. An environment variable is a value stored in your computer's shell, outside of Terraform. Terraform reads any environment variable that starts with &lt;code&gt;TF_VAR_&lt;/code&gt; followed by the variable name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"us-west-2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third is a file called &lt;code&gt;terraform.tfvars&lt;/code&gt;. A tfvars file is a plain file where you list variable names and the values you want to give them. Terraform loads this file automatically without you doing anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth is a file called &lt;code&gt;terraform.tfvars.json&lt;/code&gt;. This is the same idea as above, but written in JSON format instead. JSON is just another way of writing structured data using curly braces and quotes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fifth is any file ending in &lt;code&gt;.auto.tfvars&lt;/code&gt; or &lt;code&gt;.auto.tfvars.json&lt;/code&gt;. The word auto here means automatic. Terraform loads these files for you automatically too, just like the standard tfvars file. A common example name is &lt;code&gt;prod.auto.tfvars&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ca-central-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sixth is a command line flag. A flag is an extra instruction you type when running a command. You can pass a value directly with &lt;code&gt;-var&lt;/code&gt;, or point to a specific file with &lt;code&gt;-var-file&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=sa-east-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The order that decides the winner
&lt;/h2&gt;

&lt;p&gt;Here is the part everyone wants to know. Terraform checks these sources in a fixed order, from lowest priority to highest priority. A source that comes later in the list overrides anything set earlier.&lt;/p&gt;

&lt;p&gt;Here is the order from weakest to strongest.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The default value inside the variable block. This is the fallback that is used only when nothing else provides a value.&lt;/li&gt;
&lt;li&gt;Environment variables that start with &lt;code&gt;TF_VAR_&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;terraform.tfvars&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;terraform.tfvars.json&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;Any files ending in &lt;code&gt;.auto.tfvars&lt;/code&gt; or &lt;code&gt;.auto.tfvars.json&lt;/code&gt;, loaded in alphabetical order of their file names.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;-var&lt;/code&gt; and &lt;code&gt;-var-file&lt;/code&gt; flags on the command line, applied in the exact order you type them.
The simplest way to remember this is that the command line always wins, and the default value always loses. Everything else sits in the middle.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A full example you can run
&lt;/h2&gt;

&lt;p&gt;Let us prove the rules with a tiny project. Create a folder and add the following files.&lt;/p&gt;

&lt;p&gt;First the variable declaration in a file called &lt;code&gt;variables.tf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next a file called &lt;code&gt;main.tf&lt;/code&gt; that just prints the value back to us using an output. An output is simply a way for Terraform to show you a value after it runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"selected_region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now run this command to see the result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point only the default exists, so Terraform will show &lt;code&gt;us-east-1&lt;/code&gt;. The default wins because nobody else is competing.&lt;/p&gt;

&lt;p&gt;Now add a file called &lt;code&gt;terraform.tfvars&lt;/code&gt; with this line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run apply again. This time Terraform shows &lt;code&gt;ap-south-1&lt;/code&gt;. The tfvars file beat the default because it has higher priority.&lt;/p&gt;

&lt;p&gt;Now add a file called &lt;code&gt;prod.auto.tfvars&lt;/code&gt; with this line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ca-central-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run apply once more. Now the answer becomes &lt;code&gt;ca-central-1&lt;/code&gt;. The auto tfvars file sits higher than the standard tfvars file, so it takes over.&lt;/p&gt;

&lt;p&gt;Finally, run apply with a command line flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=sa-east-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is now &lt;code&gt;sa-east-1&lt;/code&gt;. The command line beats every file, so it has the final say.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens with two auto tfvars files
&lt;/h2&gt;

&lt;p&gt;A common question is what happens when you have more than one auto tfvars file and both set the same variable. The answer is alphabetical order.&lt;/p&gt;

&lt;p&gt;Imagine you have two files named &lt;code&gt;a.auto.tfvars&lt;/code&gt; and &lt;code&gt;b.auto.tfvars&lt;/code&gt;, and both set the region. Terraform loads &lt;code&gt;a.auto.tfvars&lt;/code&gt; first and &lt;code&gt;b.auto.tfvars&lt;/code&gt; second. Because the later one overrides the earlier one, the value inside &lt;code&gt;b.auto.tfvars&lt;/code&gt; wins.&lt;/p&gt;

&lt;p&gt;So the file whose name comes later in the alphabet has the higher priority. This is easy to forget, so name your files carefully if order matters to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Command line order also matters
&lt;/h2&gt;

&lt;p&gt;There is one more detail worth knowing. When you pass several flags on the command line, Terraform applies them left to right, and the rightmost one wins.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform apply &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=us-east-1"&lt;/span&gt; &lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=us-west-2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this case the final value is &lt;code&gt;us-west-2&lt;/code&gt;, because it appears last. The same rule applies when you mix &lt;code&gt;-var&lt;/code&gt; and &lt;code&gt;-var-file&lt;/code&gt;. Whatever comes last on the line takes priority.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick warning about missing values
&lt;/h2&gt;

&lt;p&gt;If a variable has no default and you never give it a value through any source, Terraform will stop and ask you to type the value in. This is a safety feature so you never accidentally run with a blank value.&lt;/p&gt;

&lt;p&gt;If you want a variable to be optional, give it a sensible default. If you want it to be required on purpose, leave the default out so Terraform forces someone to provide it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Variable precedence in Terraform looks confusing at first, but it follows one calm and predictable rule. Lower priority sources set the value, and higher priority sources override it.&lt;/p&gt;

&lt;p&gt;The order, from weakest to strongest, is the default value, then environment variables, then the standard tfvars file, then the JSON tfvars file, then the auto tfvars files in alphabetical order, and finally the command line flags which always win.&lt;/p&gt;

&lt;p&gt;Once this clicks, you gain real control over your setup. You can keep safe defaults in your code, store normal settings in tfvars files, and override anything on the fly from the command line when you need to. That is the whole point of precedence, giving you layers you can trust.&lt;/p&gt;

&lt;p&gt;Try the small example above and watch the value change with each step. Seeing it happen is the fastest way to make this stick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get in touch
&lt;/h2&gt;

&lt;p&gt;If you have questions or want me to cover another Terraform topic, reach out at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;. I am always happy to help fellow builders.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
    </item>
    <item>
      <title>The Bucket That Must Exist Before Everything Else</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 23 Jun 2026 10:31:31 +0000</pubDate>
      <link>https://dev.to/tanseer/the-bucket-that-must-exist-before-everything-else-4n04</link>
      <guid>https://dev.to/tanseer/the-bucket-that-must-exist-before-everything-else-4n04</guid>
      <description>&lt;h2&gt;
  
  
  How I created a dedicated S3 bucket to hold my Terraform remote backend, and every small lesson I learned along the way
&lt;/h2&gt;




&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;When you start working with Terraform, one of the first real puzzles you hit is a classic chicken and egg situation.&lt;/p&gt;

&lt;p&gt;Terraform needs a safe place to store its state file. The state file is a record of everything Terraform has created for you, like a memory of your infrastructure. The most common safe place to keep this file is an S3 bucket on AWS. S3 stands for Simple Storage Service, and a bucket is just a container where you store files in the cloud.&lt;/p&gt;

&lt;p&gt;Here is the puzzle. Terraform wants to store its state in an S3 bucket, but that bucket does not exist yet. So how do you create the very bucket that Terraform itself needs in order to remember what it created?&lt;/p&gt;

&lt;p&gt;The answer is a small, separate Terraform setup called a bootstrap. You run it once, it creates the bucket, and then you never touch it again. In this post I will walk you through exactly how I built mine for my project, and I will explain every piece of jargon the first time it shows up. This is written for developers who are new to AWS, so nothing here assumes prior cloud knowledge.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a Bootstrap Actually Is
&lt;/h2&gt;

&lt;p&gt;A bootstrap is a tiny Terraform project whose only job is to create the foundation that the main project depends on. In our case the foundation is a single S3 bucket that will store the remote backend.&lt;/p&gt;

&lt;p&gt;A backend is simply where Terraform keeps its state. A local backend keeps the state on your own computer. A remote backend keeps the state in the cloud, which is much safer and lets a whole team share it. We want a remote backend, and to have a remote backend we first need the bucket to hold it.&lt;/p&gt;

&lt;p&gt;The golden rule of a bootstrap is this. Run it once, before anything else, then leave it alone forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Folder Structure
&lt;/h2&gt;

&lt;p&gt;I kept the bootstrap deliberately small. Here is the entire layout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bootstrap/
├── main.tf        # everything in one file
├── variables.tf
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. Three files. A lot of beginners expect more files because larger Terraform projects have many. But a bootstrap is special, and the missing files are missing on purpose. Let me explain why, because the reasoning here taught me a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why there is no backend.tf
&lt;/h3&gt;

&lt;p&gt;A file called &lt;code&gt;backend.tf&lt;/code&gt; is where you would normally tell Terraform to use a remote backend. We do not have one here, and that is intentional. The bucket does not exist yet, so we cannot point Terraform at it. Instead the bootstrap uses local state, meaning the state file sits on our machine for now. This is the one time local state is the correct choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why there is no outputs.tf
&lt;/h3&gt;

&lt;p&gt;An output in Terraform is a value you ask Terraform to print or pass along after it runs, like the name of something it just created. You might think we need to output the bucket name so the main project can read it. We do not. The bucket name is already known from our variables, and the &lt;code&gt;backend.tf&lt;/code&gt; file in the main project is static. Static means it is fixed text that cannot read or react to outputs. So an output here would serve no purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why there is no providers.tf
&lt;/h3&gt;

&lt;p&gt;A provider is the plugin that lets Terraform talk to a specific cloud, in this case AWS. In big projects people split the provider setup into its own &lt;code&gt;providers.tf&lt;/code&gt; file for tidiness. Our bootstrap is so small that everything fits comfortably in &lt;code&gt;main.tf&lt;/code&gt;, so there is no need to split it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Order of Blocks Inside main.tf
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;main.tf&lt;/code&gt; the order of things matters for readability. I followed this order from top to bottom.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform block → provider block → data source → resources
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terraform block holds settings about Terraform itself, such as which version to use. The provider block configures AWS. A data source reads information that already exists rather than creating anything new. Resources are the things Terraform actually creates for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Four Resources, and Nothing More
&lt;/h2&gt;

&lt;p&gt;The bootstrap creates exactly four resources. Each one has a clear reason to exist.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;aws_s3_bucket&lt;/code&gt;. This is the bucket itself. I named it &lt;code&gt;this&lt;/code&gt; in the code, which is a common Terraform convention when there is only one of something.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;aws_s3_bucket_versioning&lt;/code&gt;. Versioning means S3 keeps old copies of a file whenever it changes. This matters a lot for a state file. If the state ever gets corrupted, versioning lets you roll back to a healthy earlier copy. It is your safety net.&lt;/p&gt;

&lt;p&gt;The third is &lt;code&gt;aws_s3_bucket_public_access_block&lt;/code&gt;. This shuts the bucket off from the public internet. It has four separate arguments, and I set all four to true. The reason I did this explicitly, rather than trusting AWS to do it for me, leads to one of the biggest lessons in this whole project, which I will come back to shortly.&lt;/p&gt;

&lt;p&gt;The fourth is &lt;code&gt;aws_s3_bucket_server_side_encryption_configuration&lt;/code&gt;. Encryption scrambles the stored data so that only authorized access can read it. I chose AES256, which is a strong and standard encryption method. Again I set this explicitly rather than relying on any default.&lt;/p&gt;

&lt;p&gt;Here is the complete &lt;code&gt;main.tf&lt;/code&gt; so you can see how all four resources fit together with the terraform block, the provider, and the data source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Add AWS Provider&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 1.15.0"&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 6.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Configure the AWS Provider&lt;/span&gt;
&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# AWS account id&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_caller_identity"&lt;/span&gt; &lt;span class="s2"&gt;"current"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;# Create s3 bucket&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-statefile-${data.aws_caller_identity.current.account_id}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Versioning bucket&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_versioning"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;versioning_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Enabled"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Block public access&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_public_access_block"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;block_public_acls&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;block_public_policy&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_public_acls&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;restrict_public_buckets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Encryption&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_server_side_encryption_configuration"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;apply_server_side_encryption_by_default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;sse_algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AES256"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the small detail inside each resource. The line &lt;code&gt;bucket = aws_s3_bucket.this.id&lt;/code&gt; is how Terraform links resources together. It tells the versioning, public access, and encryption resources to attach themselves to the exact bucket we just created, rather than to some other bucket. This linking is how Terraform understands the order to build things in.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reference image placeholder: a screenshot of the four resources listed in main.tf.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Variables File
&lt;/h2&gt;

&lt;p&gt;A variable is a named value you can reuse and change in one place. Here is the full &lt;code&gt;variables.tf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS region where the statefile must exists"&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"project"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Name of the project"&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"todo-app"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first variable is &lt;code&gt;region&lt;/code&gt;. A region is the geographic location of your AWS data center. Mine is a string with a default of &lt;code&gt;ap-south-1&lt;/code&gt;, which is the Mumbai region. A string simply means text.&lt;/p&gt;

&lt;p&gt;The second variable is &lt;code&gt;project&lt;/code&gt;. This is the name of the project, with a default of &lt;code&gt;todo-app&lt;/code&gt;. A nice improvement you can add later is validation, which is a rule Terraform checks before running. A validation rule could force the name to use lowercase letters and hyphens only, which keeps bucket names clean and valid. I left it simple here, but it is worth knowing the option exists.&lt;/p&gt;

&lt;p&gt;Notice what is missing. There is no &lt;code&gt;account_id&lt;/code&gt; variable. Your account ID is the unique number that identifies your AWS account. Instead of typing it in by hand as a variable, where you could easily make a mistake, I read it automatically using a data source called &lt;code&gt;data "aws_caller_identity"&lt;/code&gt;. This always returns the correct account, so it can never be wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Bucket Gets Its Name
&lt;/h2&gt;

&lt;p&gt;S3 bucket names must be unique across all of AWS, not just within your account. So I built the name out of pieces that are guaranteed to be unique together.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="nx"&gt;-statefile-$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_caller_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This combines the project name, the word statefile, and the account ID. Because the account ID is unique to me, the resulting name will not clash with anyone else.&lt;/p&gt;

&lt;p&gt;One choice worth pointing out. I did not put the environment, such as dev or prod, into the name. One bucket serves all environments. This keeps things simple, and the different environments are separated inside the bucket by their file paths instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Version Constraints, and Why the Tiny Symbols Matter
&lt;/h2&gt;

&lt;p&gt;In the terraform block I pinned the Terraform version like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 1.15.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The little &lt;code&gt;~&amp;gt;&lt;/code&gt; symbol is called the pessimistic constraint operator. It controls how much Terraform is allowed to upgrade itself automatically. The exact form you write changes the meaning in a way that is easy to miss.&lt;/p&gt;

&lt;p&gt;Writing &lt;code&gt;~&amp;gt; 1.15.0&lt;/code&gt; allows only patch releases, meaning small bug fix updates like 1.15.1 or 1.15.2, but not 1.16.&lt;/p&gt;

&lt;p&gt;Writing &lt;code&gt;~&amp;gt; 1.15&lt;/code&gt; would also allow minor versions, meaning larger feature updates like 1.16 or 1.17.&lt;/p&gt;

&lt;p&gt;These are very different. Patch only is cautious and safe. Minor allowed is more relaxed. The point is to pick one consciously rather than copying a symbol without understanding it.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Small Rule About String Interpolation
&lt;/h2&gt;

&lt;p&gt;Interpolation is the way Terraform inserts a variable value into a piece of text, using the &lt;code&gt;${...}&lt;/code&gt; syntax. There is a simple rule I learned to keep code clean.&lt;/p&gt;

&lt;p&gt;Use the wrapped form &lt;code&gt;"${var.region}"&lt;/code&gt; only when you are combining the variable with other text, like inside the bucket name above.&lt;/p&gt;

&lt;p&gt;Use the plain form &lt;code&gt;var.region&lt;/code&gt; when you are referring to a single variable on its own, with no surrounding text.&lt;/p&gt;

&lt;p&gt;Wrapping a lone variable in &lt;code&gt;${...}&lt;/code&gt; for no reason just adds clutter, so I stopped doing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problems I Hit, and What They Taught Me
&lt;/h2&gt;

&lt;p&gt;This is the part I find most valuable, because every one of these came from getting something wrong first.&lt;/p&gt;

&lt;p&gt;I tried to use a data source as the default value for a variable. This does not work. A default must be a static literal, meaning a fixed value typed directly in, not something that has to be looked up while Terraform runs.&lt;/p&gt;

&lt;p&gt;I learned the &lt;code&gt;account_id&lt;/code&gt; must be treated as a string, not a number. Account IDs can begin with a zero, and numbers drop leading zeros, which would silently break the value. Storing it as text keeps it intact.&lt;/p&gt;

&lt;p&gt;I confirmed that &lt;code&gt;backend.tf&lt;/code&gt; is static and is read during the init phase, which is the very first setup step when you run Terraform. Because it runs so early, it cannot use variables or outputs at all. That is exactly why the bootstrap uses local state instead.&lt;/p&gt;

&lt;p&gt;I learned that &lt;code&gt;providers.tf&lt;/code&gt; is also static and read at the same early init phase, for the same reason.&lt;/p&gt;

&lt;p&gt;I learned never to rely on AWS defaults for security. AWS settings can change over time, and assuming a default protects you is risky. I now set encryption and public access blocking explicitly every single time, so the protection is written down and guaranteed.&lt;/p&gt;

&lt;p&gt;And one more lesson that applies to everything, including advice from me. Always verify information. At one point I had a wrong belief about which Terraform version existed, and checking it directly set me straight. Trust, but confirm.&lt;/p&gt;




&lt;h2&gt;
  
  
  Git Rules
&lt;/h2&gt;

&lt;p&gt;Git is the tool that tracks changes to your code. When using it, some files should be saved and shared, while others must never leave your machine because they contain secrets or local state. Here is how I split them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# commit
&lt;/span&gt;*.&lt;span class="n"&gt;tf&lt;/span&gt;
.&lt;span class="n"&gt;terraform&lt;/span&gt;.&lt;span class="n"&gt;lock&lt;/span&gt;.&lt;span class="n"&gt;hcl&lt;/span&gt;
*.&lt;span class="n"&gt;tfvars&lt;/span&gt;.&lt;span class="n"&gt;example&lt;/span&gt;

&lt;span class="c"&gt;# ignore
&lt;/span&gt;.&lt;span class="n"&gt;terraform&lt;/span&gt;/
*.&lt;span class="n"&gt;tfstate&lt;/span&gt;
*.&lt;span class="n"&gt;tfstate&lt;/span&gt;.&lt;span class="n"&gt;backup&lt;/span&gt;
*.&lt;span class="n"&gt;tfvars&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The files under commit are safe to share. The lock file pins the exact provider versions so everyone uses the same ones. The example file shows the shape of variable values without revealing real ones.&lt;/p&gt;

&lt;p&gt;The files under ignore must stay private. The state files describe your live infrastructure and can hold sensitive details. The &lt;code&gt;.tfvars&lt;/code&gt; files often contain real secret values. The &lt;code&gt;.terraform&lt;/code&gt; folder is just local cache that does not belong in version control.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Run Order
&lt;/h2&gt;

&lt;p&gt;Finally, here is the exact order to run everything. The bootstrap goes first, the main project follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;cd bootstrap → terraform init → terraform apply
cd environments/dev → terraform init → terraform apply
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command &lt;code&gt;terraform init&lt;/code&gt; prepares the working folder and downloads what Terraform needs. The command &lt;code&gt;terraform apply&lt;/code&gt; actually builds the resources. You run them in the bootstrap folder once to create the bucket, then move into your real environment and run them again to build the rest of your infrastructure, now safely backed by the remote state bucket you just made.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reference image placeholder: a terminal screenshot showing terraform apply finishing successfully in the bootstrap folder.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The bootstrap is small, but it solves a real problem that confuses almost everyone at the start. You cannot store Terraform state in a bucket that does not exist, so you build that bucket once with a tiny standalone setup that uses local state, and then you never look back.&lt;/p&gt;

&lt;p&gt;Along the way I learned that the missing files in a bootstrap are missing on purpose, that security should always be explicit rather than assumed, that tiny version symbols carry real meaning, and that even confident advice should be checked. None of these are hard once you see the reasoning, and together they make your foundation solid.&lt;/p&gt;

&lt;p&gt;If you are new to AWS and Terraform, I hope walking through my journey makes your own first bootstrap far less mysterious than mine was.&lt;/p&gt;




&lt;h2&gt;
  
  
  Contact
&lt;/h2&gt;

&lt;p&gt;If you have questions or want to share your own setup, feel free to reach out at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;.&lt;/p&gt;




</description>
      <category>aws</category>
      <category>terraform</category>
      <category>bootstrap</category>
    </item>
    <item>
      <title>Stop Hardcoding Passwords: A Beginner's Guide to AWS Secrets Manager</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 23 Jun 2026 05:26:44 +0000</pubDate>
      <link>https://dev.to/aws-builders/stop-hardcoding-passwords-a-beginners-guide-to-aws-secrets-manager-kg</link>
      <guid>https://dev.to/aws-builders/stop-hardcoding-passwords-a-beginners-guide-to-aws-secrets-manager-kg</guid>
      <description>&lt;h3&gt;
  
  
  How to keep your database passwords and API keys safe, and why rotating them doesn't break your app the way you think it will
&lt;/h3&gt;

&lt;p&gt;If you are new to AWS, there is a good chance you have done something like this at least once. You needed your app to connect to a database, so you took the database password and pasted it straight into your code, or into a Lambda environment variable, and moved on. It worked, so why worry?&lt;/p&gt;

&lt;p&gt;The problem is that a password sitting in plain view is a password waiting to leak. In this tutorial we will walk through AWS Secrets Manager, a service built to store exactly these kinds of sensitive values safely. We will cover what it is, why environment variables are a weak place to keep secrets, what a KMS key actually does, and the one topic that confuses almost every beginner: automatic rotation. By the end you will understand why rotation does not break your application, even though it changes your password while the app is running.&lt;/p&gt;

&lt;p&gt;Let us start from the beginning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Jargon check before we go further: a &lt;strong&gt;secret&lt;/strong&gt; just means any sensitive string your app needs but should never be public. Think database passwords, API keys, and access tokens. An &lt;strong&gt;API key&lt;/strong&gt; is a long secret string that proves your app is allowed to use some service. A &lt;strong&gt;token&lt;/strong&gt; is similar, usually a temporary one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What Is AWS Secrets Manager?
&lt;/h2&gt;

&lt;p&gt;AWS Secrets Manager is an encrypted vault for your secrets. Instead of writing your database password directly into your code, you store it inside Secrets Manager, and your app asks for it at runtime (meaning while the app is actually running, not while you are writing it).&lt;/p&gt;

&lt;p&gt;A simple way to picture it is a hotel room safe. You do not tape your passport to the wall. You lock it in the safe, and only someone with the right key can open it. The hotel also keeps a log of every time the safe is opened. Secrets Manager works the same way.&lt;/p&gt;

&lt;p&gt;It gives you three main benefits.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;encryption at rest&lt;/strong&gt;. "At rest" means while the data is sitting in storage, as opposed to while it is travelling over the network. Your secret is scrambled so that even someone who somehow gets to the raw storage cannot read it.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;IAM controlled access&lt;/strong&gt;. IAM stands for Identity and Access Management, which is the AWS system that decides who is allowed to do what. With Secrets Manager you can say "only my application is allowed to read this database password" and nothing else can touch it.&lt;/p&gt;

&lt;p&gt;The third is &lt;strong&gt;automatic rotation&lt;/strong&gt;, which means AWS can change your password on a schedule for you. This is the feature we will spend the most time on, because it is the most useful and the most misunderstood.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not Just Use a Lambda Environment Variable?
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;environment variable&lt;/strong&gt; is a setting you attach to your app from the outside, so the value lives next to the app rather than inside your code. In AWS Lambda (the service that runs your backend code without you managing a server) you can set these in the function configuration. It works, and plenty of people do it. So what is wrong with it for secrets?&lt;/p&gt;

&lt;p&gt;There are four real problems.&lt;/p&gt;

&lt;p&gt;First, the value sits in plain text inside your function configuration. Anyone who can open the AWS console and view your Lambda can simply read the password. There is no lock on it.&lt;/p&gt;

&lt;p&gt;Second, the value tends to leak into places you did not expect. If you manage your infrastructure with a tool like Terraform, the password ends up written into your Terraform state file, which is the record Terraform keeps of everything it created. It can also show up in deployment logs. So one password quietly becomes several copies scattered around.&lt;/p&gt;

&lt;p&gt;Third, changing the value means redeploying or updating the function. There is no clean way to swap it on the fly.&lt;/p&gt;

&lt;p&gt;Fourth, there is no audit trail. You cannot answer the question "who read this secret and when," because nothing was recorded.&lt;/p&gt;

&lt;p&gt;Environment variables are perfectly fine for values that are not sensitive, like which region your app runs in or a feature toggle. They are just a weak place to keep real secrets.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is a KMS Key?
&lt;/h2&gt;

&lt;p&gt;When we said Secrets Manager encrypts your data, something has to do the actual encrypting. That something is a KMS key.&lt;/p&gt;

&lt;p&gt;KMS stands for Key Management Service. A &lt;strong&gt;KMS key&lt;/strong&gt; is the cryptographic key used to scramble (encrypt) and unscramble (decrypt) your secret. Encryption is just the process of turning readable text into unreadable text so that only someone with the key can read it again.&lt;/p&gt;

&lt;p&gt;Here is the clever part. You never see or hold the raw key yourself. It lives inside tamper resistant hardware, meaning special equipment built so the key cannot be copied out or stolen. You simply ask KMS "please decrypt this for me," and KMS checks your IAM permission before doing it.&lt;/p&gt;

&lt;p&gt;Think of KMS as the master key to a building, locked inside a vault that you personally cannot open. You do not get to carry the master key around. Instead you hand your locked box to the security desk and say "open this please," and they check your badge first. Because the key never leaves the vault, nobody can walk off with it.&lt;/p&gt;

&lt;p&gt;When Secrets Manager stores your password, it quietly uses a KMS key to encrypt it. AWS gives you a default key for free, so as a beginner you usually do not need to create your own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Automatic Rotation: The Part Everyone Misunderstands
&lt;/h2&gt;

&lt;p&gt;Rotation means automatically changing a password to a new one on a schedule. Here is the worry almost every beginner has, and it is a reasonable one:&lt;/p&gt;

&lt;p&gt;"If Secrets Manager changes my password, but my database still has the old one, won't my app stop being able to connect?"&lt;/p&gt;

&lt;p&gt;The answer is no, and understanding why is the heart of this whole topic.&lt;/p&gt;

&lt;p&gt;Rotation does not change the password in only one place. It changes it in &lt;strong&gt;both&lt;/strong&gt; the database and the secret, as a single coordinated operation. Here is the exact sequence the rotation process runs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It generates a brand new random password.&lt;/li&gt;
&lt;li&gt;It connects to the database using the current password (which still works at this point) and runs the command &lt;code&gt;ALTER USER&lt;/code&gt;, which is the SQL instruction that sets a new password on the database account. Now the database expects the new password.&lt;/li&gt;
&lt;li&gt;It stores that new password back into Secrets Manager as the current value.&lt;/li&gt;
&lt;li&gt;It connects once more using the new value to confirm everything works.
Because both sides are updated together, they are never out of sync in a way that locks you out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But there is a second condition you must meet for this to stay smooth. Your app has to &lt;strong&gt;fetch the secret fresh from Secrets Manager at runtime&lt;/strong&gt;, rather than holding its own permanent copy. If your code reads the current value when it needs it, then after rotation it simply reads the new value next time and carries on.&lt;/p&gt;

&lt;p&gt;There is one trap here worth naming. To avoid asking Secrets Manager for the password on every single request (which costs a little money and adds delay), developers often cache it, meaning they keep a copy in memory for reuse. That is good practice, but if you cache the password forever and never refresh it, then after rotation your app is holding a stale password and will fail to connect. The fix is a cache with a TTL, which stands for Time To Live, a setting that tells the cache to throw the value away and fetch a fresh one after a few minutes.&lt;/p&gt;

&lt;p&gt;So the rule is simple. Rotation does not break your app, as long as your app reads the secret fresh and does not cling to an old cached copy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Versioning: Why the Old Password Sticks Around for a Moment
&lt;/h2&gt;

&lt;p&gt;A secret in Secrets Manager does not hold just one value. It holds versions, and each version carries a label that tells you what it is for. Three labels matter.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AWSCURRENT&lt;/code&gt; is the value you get by default when you ask for the secret. This is the live, in use password.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AWSPREVIOUS&lt;/code&gt; is the value from just before the last change. Secrets Manager keeps it valid for a short window on purpose. Picture rotation happening at the exact moment a request is already in flight, meaning a request that started a split second before the change. That request may have grabbed the old value already. Keeping &lt;code&gt;AWSPREVIOUS&lt;/code&gt; alive briefly lets that in flight request finish successfully instead of erroring out. It smooths over the handover.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AWSPENDING&lt;/code&gt; is the new value during rotation, after it has been created but before it has been promoted to current. It is the password in waiting.&lt;/p&gt;

&lt;p&gt;You rarely need to think about these directly, because asking for the secret gives you &lt;code&gt;AWSCURRENT&lt;/code&gt; automatically. But knowing the labels exist helps you understand why rotation is so smooth and why the brief overlap does not cause failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cross Region Replication: A Feature for Later
&lt;/h2&gt;

&lt;p&gt;By default, a secret lives in a single AWS region. A &lt;strong&gt;region&lt;/strong&gt; is a geographic location where AWS runs its data centers, such as Mumbai (named &lt;code&gt;ap-south-1&lt;/code&gt;) or Singapore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross region replication&lt;/strong&gt; keeps an automatically synced copy of your secret in other regions you choose. If you store a secret in Mumbai and replicate it to Singapore, then any change in Mumbai is copied across to the Singapore version on its own.&lt;/p&gt;

&lt;p&gt;You would want this in two situations. One is disaster recovery, so that if an entire region has an outage, an app running in your backup region can still read the secret locally. The other is when your app runs in several regions at once and you want each one to read the secret nearby instead of reaching across the world every time.&lt;/p&gt;

&lt;p&gt;As a beginner running a single region project, you almost certainly do not need this yet. File it away as a "when I grow into multiple regions" feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If you take away one idea from this tutorial, let it be this. Secrets stay in sync because rotation changes both sides at once, the database and the stored secret, in a single coordinated operation. And your application stays connected because it reads the current value fresh from Secrets Manager whenever it needs it, rather than holding a hardcoded copy that goes stale.&lt;/p&gt;

&lt;p&gt;Everything else builds on that. Environment variables are weak for secrets because they sit in plain text and leak into your state files and logs. A KMS key does the encrypting behind the scenes without ever letting you touch the raw key. Versioning with &lt;code&gt;AWSCURRENT&lt;/code&gt; and &lt;code&gt;AWSPREVIOUS&lt;/code&gt; keeps a brief safety net so in flight requests survive a rotation. And cross region replication is there for the day you outgrow a single region.&lt;/p&gt;

&lt;p&gt;So the next time you are tempted to paste a password into an environment variable, reach for Secrets Manager instead. Your future self, and anyone who inherits your code, will thank you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get in Touch
&lt;/h2&gt;

&lt;p&gt;Found this helpful, or stuck on something? I would love to hear from you. Reach out at &lt;strong&gt;&lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;&lt;/strong&gt; and I will do my best to help.&lt;/p&gt;




</description>
      <category>aws</category>
    </item>
    <item>
      <title>Terraform outputs.tf Explained: What It Is, When to Use It, and When to Skip It</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 23 Jun 2026 04:53:47 +0000</pubDate>
      <link>https://dev.to/aws-builders/terraform-outputstf-explained-what-it-is-when-to-use-it-and-when-to-skip-it-gf</link>
      <guid>https://dev.to/aws-builders/terraform-outputstf-explained-what-it-is-when-to-use-it-and-when-to-skip-it-gf</guid>
      <description>&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;If you are learning Terraform and have come across &lt;code&gt;outputs.tf&lt;/code&gt; in project structures or tutorials but are not fully clear on what it actually does or why it exists, this blog is for you.&lt;/p&gt;

&lt;p&gt;We will start from the basics and build up to how outputs work across modules, what they look like in practice, and the rules around where they can and cannot be used. Every term will be explained along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is an Output in Terraform?
&lt;/h2&gt;

&lt;p&gt;When Terraform creates infrastructure, it stores everything it knows about that infrastructure in a file called the state file. The state file is Terraform's database. It tracks every resource, every attribute, every ID — everything.&lt;/p&gt;

&lt;p&gt;But the state file is not something you read directly. It is large, it is internal, and most of the values in it are not things you care about day to day.&lt;/p&gt;

&lt;p&gt;Outputs are a way to surface specific values from that state file — the ones you actually need. You decide what gets exposed. Everything else stays internal.&lt;/p&gt;

&lt;p&gt;A good way to think about it:&lt;/p&gt;

&lt;p&gt;State file = the entire database.&lt;/p&gt;

&lt;p&gt;Outputs = a view on top of that database that exposes only what you chose to make visible.&lt;/p&gt;

&lt;p&gt;Outputs do not add new information. They do not change what Terraform tracks. They simply choose what to surface from what already exists.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Contexts Where Outputs Are Used
&lt;/h2&gt;

&lt;p&gt;Outputs are not just for one purpose. They serve three distinct purposes depending on where you use them. Understanding the difference between these three will make the entire concept click.&lt;/p&gt;




&lt;h3&gt;
  
  
  Context 1: Passing Values Between Modules in the Same Project
&lt;/h3&gt;

&lt;p&gt;This is the most common use case, and the one you will use in almost every real Terraform project.&lt;/p&gt;

&lt;p&gt;Imagine your project has two modules: a database module and a backend module. The database module creates an RDS instance and stores credentials in AWS Secrets Manager. The backend module is a Lambda function that needs to connect to that database. To do that, it needs the ARN (unique identifier) of the secret.&lt;/p&gt;

&lt;p&gt;But here is the problem. Module internals are private. A resource defined inside &lt;code&gt;modules/database/main.tf&lt;/code&gt; is not visible anywhere outside that module. It is intentionally hidden. You cannot just reference it from another module directly.&lt;/p&gt;

&lt;p&gt;This is where &lt;code&gt;outputs.tf&lt;/code&gt; comes in. The database module uses &lt;code&gt;outputs.tf&lt;/code&gt; to explicitly expose the values it wants to share. The backend module then receives those values as input variables.&lt;/p&gt;

&lt;p&gt;The wiring between them happens in your environment file, for example &lt;code&gt;environments/dev/main.tf&lt;/code&gt;. That file is the connection layer. It takes what one module exposes and passes it as input to another.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;modules/database/outputs.tf
        |
        v
environments/dev/main.tf   (the wiring layer)
        |
        v
modules/backend/variables.tf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what that looks like in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# modules/database/outputs.tf&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"secret_arn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"secret_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/main.tf&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"database"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../modules/database"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"backend"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../modules/backend"&lt;/span&gt;
  &lt;span class="nx"&gt;secret_arn&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret_arn&lt;/span&gt;
  &lt;span class="nx"&gt;secret_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# modules/backend/variables.tf&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"secret_arn"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"secret_name"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database module is a black box. &lt;code&gt;variables.tf&lt;/code&gt; is what it accepts as input. &lt;code&gt;main.tf&lt;/code&gt; is its internal implementation. &lt;code&gt;outputs.tf&lt;/code&gt; is what it hands back out. Nothing inside the module leaks out unless you explicitly put it in &lt;code&gt;outputs.tf&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Context 2: Displaying Values in the Terminal After Apply
&lt;/h3&gt;

&lt;p&gt;Outputs defined in the root module, meaning the environment folder like &lt;code&gt;environments/dev&lt;/code&gt;, are printed to the terminal after &lt;code&gt;terraform apply&lt;/code&gt; completes.&lt;/p&gt;

&lt;p&gt;This is purely for you as the person running the deployment. It is a convenience feature so you do not have to go hunting through the AWS console for values you commonly need.&lt;/p&gt;

&lt;p&gt;Good candidates for terminal outputs are things like:&lt;/p&gt;

&lt;p&gt;The API Gateway URL your backend is reachable at.&lt;/p&gt;

&lt;p&gt;The Amplify URL where your frontend is deployed.&lt;/p&gt;

&lt;p&gt;The RDS endpoint for your database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/outputs.tf&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"api_gateway_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"amplify_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frontend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After apply, these print cleanly to your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Outputs:&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;api_gateway_url&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://abc123.execute-api.ap-south-1.amazonaws.com/dev"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;amplify_url&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://main.abc123.amplifyapp.com"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No digging through the console. The values are right there.&lt;/p&gt;




&lt;h3&gt;
  
  
  Context 3: Sharing Values Across Completely Separate Terraform Projects
&lt;/h3&gt;

&lt;p&gt;Sometimes infrastructure is split across multiple completely separate Terraform projects, each with their own state file. A networking team might manage VPCs in one project. An application team might manage Lambda functions in another. The application team needs the VPC ID from the networking project.&lt;/p&gt;

&lt;p&gt;This is handled using something called &lt;code&gt;terraform_remote_state&lt;/code&gt;. It is a data source (a way to read information from outside the current project) that reads another project's state file and exposes its outputs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"terraform_remote_state"&lt;/span&gt; &lt;span class="s2"&gt;"networking"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"company-tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"networking/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Now you can use:&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;terraform_remote_state&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;networking&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important detail here: &lt;code&gt;terraform_remote_state&lt;/code&gt; can only access values that were explicitly defined as outputs in the other project. It cannot reach into the raw state and pull arbitrary values. If the networking team did not put the VPC ID in their &lt;code&gt;outputs.tf&lt;/code&gt;, you cannot get it from here.&lt;/p&gt;

&lt;p&gt;This reinforces the same rule: outputs are the only gateway for values to leave a Terraform boundary, whether that boundary is a module or an entire separate project.&lt;/p&gt;

&lt;p&gt;This pattern does not apply if your entire infrastructure lives in one root, which is the case for smaller projects. But it is a real and widely used pattern in team environments.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Outputs Cannot Be Used
&lt;/h2&gt;

&lt;p&gt;This is important. Outputs work during the plan and apply phase, when Terraform is evaluating your configuration and building infrastructure. But not every file in your project is evaluated at that phase.&lt;/p&gt;

&lt;p&gt;Two files are evaluated earlier, during the init phase, before any HCL evaluation happens:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;backend.tf&lt;/code&gt; — This is where you configure where Terraform stores its state file, for example an S3 bucket. Terraform reads this file during &lt;code&gt;terraform init&lt;/code&gt;, before it knows anything about your resources or variables. Everything here must be a hardcoded string. You cannot reference an output, a variable, or a local.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;providers.tf&lt;/code&gt; — This is where you configure your cloud provider, for example the AWS region and profile. This is also read during init. Same restriction. Hardcode everything here.&lt;/p&gt;

&lt;p&gt;Trying to use a variable or output in either of these files will cause Terraform to throw an error, and the reason is always the same: those values are not available yet at init time.&lt;/p&gt;

&lt;p&gt;Here is a simple way to remember the two phases:&lt;/p&gt;

&lt;p&gt;Init phase reads &lt;code&gt;backend.tf&lt;/code&gt; and &lt;code&gt;providers.tf&lt;/code&gt; statically. Downloads providers. Sets up the backend. No HCL logic allowed.&lt;/p&gt;

&lt;p&gt;Plan and Apply phase evaluates everything else. Outputs, variables, locals, data sources, resource references — all of this works here.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why You Cannot Just Skip outputs.tf and Read the State Directly
&lt;/h2&gt;

&lt;p&gt;A common question when learning this is: the state file already has all the values, why do I need outputs at all?&lt;/p&gt;

&lt;p&gt;There are two reasons.&lt;/p&gt;

&lt;p&gt;Within a module, resources are private by design. Terraform intentionally encapsulates module internals so that modules are independent and reusable. You cannot reach into &lt;code&gt;modules/database/main.tf&lt;/code&gt; from &lt;code&gt;environments/dev/main.tf&lt;/code&gt; and reference a resource directly. The module must explicitly hand that value out through &lt;code&gt;outputs.tf&lt;/code&gt;. This is the same reason functions in a programming language return values instead of letting callers read internal variables directly.&lt;/p&gt;

&lt;p&gt;Across separate roots, &lt;code&gt;terraform_remote_state&lt;/code&gt; only exposes outputs. There is no mechanism to read arbitrary resource attributes from another project's state file. Outputs are the only thing that crosses that boundary.&lt;/p&gt;




&lt;h2&gt;
  
  
  When outputs.tf Is Optional
&lt;/h2&gt;

&lt;p&gt;Not every module needs an &lt;code&gt;outputs.tf&lt;/code&gt;. The rule is straightforward.&lt;/p&gt;

&lt;p&gt;If nothing downstream needs a value from your module, there is nothing to expose. The outputs file is optional.&lt;/p&gt;

&lt;p&gt;A common example is a frontend module in a project. If the frontend module deploys an Amplify app and no other module needs the Amplify URL as an input, then &lt;code&gt;outputs.tf&lt;/code&gt; in the frontend module is not needed. The URL might still be shown in the terminal via the root module's outputs, but the frontend module itself does not need to expose anything.&lt;/p&gt;

&lt;p&gt;The question to ask before adding any output is:&lt;/p&gt;

&lt;p&gt;Who is the consumer of this value, and how will they read it?&lt;/p&gt;

&lt;p&gt;If the answer is nobody, the output does not belong. Adding outputs that nothing consumes is just noise in your codebase.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Quick Reference
&lt;/h2&gt;

&lt;p&gt;Here is everything in one place:&lt;/p&gt;

&lt;p&gt;Outputs between modules: use &lt;code&gt;outputs.tf&lt;/code&gt; in the source module, wire it in the environment's &lt;code&gt;main.tf&lt;/code&gt;, receive it in the destination module's &lt;code&gt;variables.tf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Outputs in the terminal: define them in the root module's &lt;code&gt;outputs.tf&lt;/code&gt;. They print after &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Outputs across separate roots: use &lt;code&gt;terraform_remote_state&lt;/code&gt; to read another project's outputs from its remote state file.&lt;/p&gt;

&lt;p&gt;Outputs do not work in &lt;code&gt;backend.tf&lt;/code&gt; or &lt;code&gt;providers.tf&lt;/code&gt; because those are read during init before HCL evaluation.&lt;/p&gt;

&lt;p&gt;You cannot skip &lt;code&gt;outputs.tf&lt;/code&gt; because module internals are private and remote state only exposes outputs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;outputs.tf&lt;/code&gt; is optional when nothing downstream needs the module's values.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Outputs are one of those concepts in Terraform that seem small but are actually the backbone of how information flows through your infrastructure code. Once you understand that modules are black boxes and outputs are the only thing that leaves them, the entire system starts to make sense.&lt;/p&gt;

&lt;p&gt;Before adding any output, always ask who the consumer is. That one question will keep your outputs intentional, your modules clean, and your project easy to follow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need Help?
&lt;/h2&gt;

&lt;p&gt;If you are learning Terraform and have questions about project structure, modules, state management, or anything else, feel free to reach out.&lt;/p&gt;

&lt;p&gt;Email me at &lt;strong&gt;&lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




</description>
      <category>terraform</category>
    </item>
    <item>
      <title>How to Add a Custom Domain to AWS Cognito Google Login (And the Errors Nobody Warns You About)</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 02 Jun 2026 04:39:24 +0000</pubDate>
      <link>https://dev.to/aws-builders/how-to-add-a-custom-domain-to-aws-cognito-google-login-and-the-errors-nobody-warns-you-about-4742</link>
      <guid>https://dev.to/aws-builders/how-to-add-a-custom-domain-to-aws-cognito-google-login-and-the-errors-nobody-warns-you-about-4742</guid>
      <description>&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;If you are using AWS Cognito to handle user authentication in your app and you have added Google as a social login option, you may have noticed something uncomfortable. When a user clicks "Sign in with Google", the URL in the browser does not say your domain. It says something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://yourapp.auth.us-east-1.amazoncognito.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the default Cognito hosted UI URL. It works, but it looks like your users are leaving your app to sign in somewhere else. It does not look professional, and for security conscious users, it can feel suspicious.&lt;/p&gt;

&lt;p&gt;The fix is to set up a custom domain so the login page shows your own URL, something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://auth.yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guide walks through the full setup step by step, and more importantly, it covers the three errors you will likely hit along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Before You Start
&lt;/h2&gt;

&lt;p&gt;Here is what you need to have in place before following this guide:&lt;/p&gt;

&lt;p&gt;A Cognito User Pool already created with Google set up as a social identity provider. If you have not done that yet, set that up first and come back here.&lt;/p&gt;

&lt;p&gt;A domain you own, managed in Route 53 (AWS's domain and DNS service). The subdomain you will be using for the login page, something like &lt;code&gt;auth.yourdomain.com&lt;/code&gt;, should not be pointing anywhere yet.&lt;/p&gt;

&lt;p&gt;Access to the Google Cloud Console where your OAuth app is configured.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Create an ACM Certificate — But Only in us-east-1
&lt;/h2&gt;

&lt;p&gt;This is the first place most developers go wrong, and it is the most confusing one because nothing in the Cognito console tells you about it until you are already stuck.&lt;/p&gt;

&lt;p&gt;ACM stands for AWS Certificate Manager. It is the service AWS uses to create and manage SSL certificates. An SSL certificate is what makes your domain load over HTTPS (the secure version of HTTP) and shows the padlock icon in the browser.&lt;/p&gt;

&lt;p&gt;You need to create a certificate for your custom domain, like &lt;code&gt;auth.yourdomain.com&lt;/code&gt;. That part is straightforward. The gotcha is the region.&lt;/p&gt;

&lt;p&gt;Even if your Cognito User Pool is in a different region, say &lt;code&gt;ap-south-1&lt;/code&gt; or &lt;code&gt;eu-west-1&lt;/code&gt;, the ACM certificate must be created in &lt;code&gt;us-east-1&lt;/code&gt;. No exceptions.&lt;/p&gt;

&lt;p&gt;The reason is that Cognito uses CloudFront behind the scenes to serve the hosted UI. CloudFront is a global content delivery service, and it only accepts SSL certificates from the &lt;code&gt;us-east-1&lt;/code&gt; region. Your Cognito User Pool can be anywhere, but the certificate must always be in &lt;code&gt;us-east-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is how to create it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to the AWS Console and switch your region to US East (N. Virginia) which is &lt;code&gt;us-east-1&lt;/code&gt;. You can change the region from the top right dropdown in the console.&lt;/li&gt;
&lt;li&gt;Search for "Certificate Manager" and open ACM.&lt;/li&gt;
&lt;li&gt;Click "Request a certificate".&lt;/li&gt;
&lt;li&gt;Choose "Request a public certificate" and click Next.&lt;/li&gt;
&lt;li&gt;Enter your domain name. Use the subdomain you want for login, for example &lt;code&gt;auth.yourdomain.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Choose "DNS validation" as the validation method. This is the recommended option.&lt;/li&gt;
&lt;li&gt;Click "Request".
After requesting, ACM will give you a CNAME record that you need to add to your DNS to prove you own the domain. Go to Route 53, open your hosted zone, and add that CNAME record exactly as ACM shows it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once the record is in place, ACM will automatically verify it and mark the certificate as "Issued". This usually takes a few minutes but can take up to 30 minutes.&lt;/p&gt;

&lt;p&gt;Do not move to the next step until the certificate status shows "Issued".&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Add the Custom Domain in Cognito
&lt;/h2&gt;

&lt;p&gt;Now that the certificate is ready, go back to your Cognito User Pool. Make sure you are in the correct region where your User Pool lives, not us-east-1 unless that is where your pool is.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open your User Pool in the Cognito console.&lt;/li&gt;
&lt;li&gt;Go to the "App integration" tab.&lt;/li&gt;
&lt;li&gt;Scroll down to "Domain" and click "Actions", then "Create custom domain".&lt;/li&gt;
&lt;li&gt;Enter your custom domain, for example &lt;code&gt;auth.yourdomain.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In the SSL certificate dropdown, select the ACM certificate you just created. It will only appear if the certificate is in us-east-1 and has a status of Issued.&lt;/li&gt;
&lt;li&gt;Click "Create".
After saving, Cognito will provision a CloudFront distribution for your domain. This can take anywhere from 15 to 40 minutes. You will see a CloudFront domain name appear once it is ready, something like &lt;code&gt;d1234abcde.cloudfront.net&lt;/code&gt;. Copy this value. You will need it in the next step.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 3: Add the DNS Record in Route 53
&lt;/h2&gt;

&lt;p&gt;This is the second place where things can go wrong.&lt;/p&gt;

&lt;p&gt;Once Cognito gives you the CloudFront domain, you need to create a DNS record so that when someone visits &lt;code&gt;auth.yourdomain.com&lt;/code&gt;, they are pointed to that CloudFront distribution.&lt;/p&gt;

&lt;p&gt;The record type to use here is an Alias record, not a CNAME. Route 53 has a special Alias record type that is designed to point to AWS services like CloudFront. Using a regular CNAME at the root level of a subdomain can cause issues, so always use Alias when pointing to a CloudFront domain in Route 53.&lt;/p&gt;

&lt;p&gt;Here is how to add it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Route 53 in the AWS Console.&lt;/li&gt;
&lt;li&gt;Go to "Hosted zones" and open your domain.&lt;/li&gt;
&lt;li&gt;Click "Create record".&lt;/li&gt;
&lt;li&gt;Set the record name to &lt;code&gt;auth&lt;/code&gt; (Route 53 will append your domain automatically).&lt;/li&gt;
&lt;li&gt;Set the record type to "A".&lt;/li&gt;
&lt;li&gt;Toggle on "Alias".&lt;/li&gt;
&lt;li&gt;In the "Route traffic to" dropdown, choose "Alias to CloudFront distribution".&lt;/li&gt;
&lt;li&gt;Paste the CloudFront domain that Cognito gave you.&lt;/li&gt;
&lt;li&gt;Click "Create records".
DNS changes can take a few minutes to a few hours to fully propagate. You can check the status using a tool like MXToolbox or WhatsMyDNS by looking up your subdomain.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 4: Update the Redirect URI in Google Cloud Console
&lt;/h2&gt;

&lt;p&gt;This is the third gotcha, and it is easy to miss because everything might look like it is working until a user actually tries to sign in with Google and gets an error.&lt;/p&gt;

&lt;p&gt;When Google handles the OAuth login flow (OAuth is the protocol that lets users sign in with their Google account), it redirects the user back to a specific URL after they authenticate. That URL is called the redirect URI.&lt;/p&gt;

&lt;p&gt;Before you set up the custom domain, Cognito's redirect URI was something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://yourapp.auth.us-east-1.amazoncognito.com/oauth2/idpresponse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now that you have a custom domain, the redirect URI has changed to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://auth.yourdomain.com/oauth2/idpresponse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google will reject any redirect to a URI that is not explicitly listed as an authorized redirect URI in your OAuth app settings. If you do not update this, users will see a Google error page saying the redirect URI is not authorized.&lt;/p&gt;

&lt;p&gt;Here is how to update it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to the Google Cloud Console at console.cloud.google.com.&lt;/li&gt;
&lt;li&gt;Open your project and navigate to "APIs and Services", then "Credentials".&lt;/li&gt;
&lt;li&gt;Click on the OAuth 2.0 Client ID you created for Cognito.&lt;/li&gt;
&lt;li&gt;Under "Authorized redirect URIs", click "Add URI".&lt;/li&gt;
&lt;li&gt;Add your new redirect URI: &lt;code&gt;https://auth.yourdomain.com/oauth2/idpresponse&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Also update "Authorized JavaScript origins" if it was set to the old Cognito domain. Add &lt;code&gt;https://auth.yourdomain.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Click "Save".
You do not need to remove the old Cognito URI right away. Keep both for now while you test the new setup, and remove the old one once everything is confirmed working.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 5: Test the Full Flow
&lt;/h2&gt;

&lt;p&gt;Once all the above steps are done and DNS has propagated, test the complete sign in flow from your app.&lt;/p&gt;

&lt;p&gt;Open your app and trigger the Google login. The URL in the browser should now show &lt;code&gt;auth.yourdomain.com&lt;/code&gt; instead of the Cognito domain. The user should be able to complete the Google sign in and land back in your app without any errors.&lt;/p&gt;

&lt;p&gt;If you see a Google error about redirect URI mismatch, double check Step 4. If you see an SSL error, double check that the ACM certificate is in us-east-1 and is fully issued. If the DNS is not resolving, give it more time or check your Route 53 record.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Summary of the Three Errors to Avoid
&lt;/h2&gt;

&lt;p&gt;The ACM certificate must be in us-east-1 no matter what region your Cognito User Pool is in. Creating it in the wrong region means it will not appear in the Cognito domain setup and you will waste time wondering why.&lt;/p&gt;

&lt;p&gt;The DNS record in Route 53 should be an Alias A record pointing to the CloudFront domain, not a plain CNAME. Using the wrong record type can cause resolution issues.&lt;/p&gt;

&lt;p&gt;The redirect URI in Google Cloud Console must be updated to the new custom domain. Forgetting this means Google will block the login flow and your users will see an error.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Adding a custom domain to your Cognito login page is a small change that makes a big difference in how professional and trustworthy your app feels. The steps themselves are not complicated, but the three gotchas around the ACM region, DNS record type, and Google redirect URI are not obvious and not well documented in one place.&lt;/p&gt;

&lt;p&gt;If you follow this guide in order and do not skip steps, you should have your custom domain up and running without the frustration of debugging silent failures.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need Help?
&lt;/h2&gt;

&lt;p&gt;If you are stuck at any of these steps or running into an error not covered here, feel free to reach out. Happy to help.&lt;/p&gt;

&lt;p&gt;Email me at &lt;strong&gt;&lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




</description>
      <category>aws</category>
      <category>community</category>
      <category>cloud</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Lambda Execution Roles Are Quietly Breaking Your Least Privilege Policy</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Thu, 21 May 2026 05:26:38 +0000</pubDate>
      <link>https://dev.to/aws-builders/lambda-execution-roles-are-quietly-breaking-your-least-privilege-policy-2ldi</link>
      <guid>https://dev.to/aws-builders/lambda-execution-roles-are-quietly-breaking-your-least-privilege-policy-2ldi</guid>
      <description>&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;If you are using AWS Lambda to build serverless applications and you have never stopped to look closely at the IAM roles attached to your functions, this blog is for you.&lt;/p&gt;

&lt;p&gt;We are going to talk about what a Lambda execution role is, why the way most people set them up creates a security problem, and exactly what you should do instead. Every term will be explained along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Quick Refresher: What Is an Execution Role?
&lt;/h2&gt;

&lt;p&gt;When a Lambda function runs, it needs permission to interact with other AWS services. For example, if your function reads data from a database or writes a file to S3 (AWS's file storage service), AWS needs to know whether your function is allowed to do that.&lt;/p&gt;

&lt;p&gt;This permission is controlled by something called an IAM execution role. IAM stands for Identity and Access Management. It is the system AWS uses to control who or what can access which resources.&lt;/p&gt;

&lt;p&gt;Every Lambda function has an execution role attached to it. When the function runs, it automatically assumes that role and gets whatever permissions the role has. Think of it like a staff ID card. The card determines which rooms in the building you are allowed to enter.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is the Least Privilege Principle?
&lt;/h2&gt;

&lt;p&gt;The principle of least privilege is a security concept that says: every user, system, or service should have only the minimum permissions it needs to do its job, and nothing more.&lt;/p&gt;

&lt;p&gt;If a Lambda function only needs to read from one specific DynamoDB table (a type of AWS database), it should have permission to read from that one table. It should not have permission to read from all tables, write to tables, delete tables, or access any other AWS service.&lt;/p&gt;

&lt;p&gt;This sounds obvious. But in practice, it is almost never what happens.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Execution Roles Are Usually Set Up
&lt;/h2&gt;

&lt;p&gt;When you create a Lambda function for the first time, AWS creates a basic execution role for you automatically. This default role usually includes permission to write logs to CloudWatch (AWS's logging service), which is fine.&lt;/p&gt;

&lt;p&gt;But here is where the problem starts.&lt;/p&gt;

&lt;p&gt;As you build your function and it needs to access more services, the easiest thing to do is go to the role and add more permissions. Need to read from S3? Add &lt;code&gt;s3:GetObject&lt;/code&gt;. Actually, to save time, just add &lt;code&gt;s3:*&lt;/code&gt; which gives the function permission to do everything in S3. Need to write to DynamoDB? Add &lt;code&gt;dynamodb:*&lt;/code&gt;. Need to send messages? Add &lt;code&gt;sns:*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Within a few iterations, your Lambda function has a role that can read, write, and delete across half your AWS account.&lt;/p&gt;

&lt;p&gt;And then there is the even worse pattern: one shared role for all Lambda functions. Instead of creating a separate role for each function, many developers just reuse the same role across every function in the project. It is faster to set up. But now every single function has every permission that any function ever needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Is a Real Problem
&lt;/h2&gt;

&lt;p&gt;You might be thinking: my app is not a high value target, nobody is going to attack my Lambda function.&lt;/p&gt;

&lt;p&gt;That thinking is what attackers rely on.&lt;/p&gt;

&lt;p&gt;Lambda functions are often triggered by external events. An API Gateway request, an S3 file upload, an SNS message. Any of these entry points can potentially be exploited if there is a vulnerability in your code, a dependency with a known security issue, or even a misconfigured trigger.&lt;/p&gt;

&lt;p&gt;If an attacker gains control of one Lambda function that has an overly permissive role, they do not just have access to what that function was supposed to do. They have access to everything that role allows. That could mean reading sensitive data from your database, writing files to your storage, sending messages to your users, or even modifying other parts of your infrastructure.&lt;/p&gt;

&lt;p&gt;One compromised function becomes a key to your entire account.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Most Common Mistakes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mistake 1: Using wildcard permissions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A wildcard in IAM policy looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dynamodb:*&lt;/code&gt; means allow all DynamoDB actions. The &lt;code&gt;"Resource": "*"&lt;/code&gt; means on all resources. So this one policy line gives your Lambda function full DynamoDB access across your entire account. If your function only needed to read from one table, this is massively over-permissioned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 2: Sharing one role across multiple Lambda functions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every function has different responsibilities. A function that sends a password reset email should not have the same permissions as a function that processes payments. When you share a role, you are taking the maximum permissions needed by any one function and giving them to all functions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake 3: Never revisiting the role after initial setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;During development, it is common to grant broad permissions just to get things working. The problem is that most developers never go back to tighten those permissions before going live. The overly permissive development role becomes the production role.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Fix It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fix 1: One role per Lambda function&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each Lambda function should have its own dedicated execution role. Yes, this means more roles to manage. But it means a compromised function can only access what it specifically needs, not what any other function needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 2: Scope permissions to specific actions and resources&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of &lt;code&gt;dynamodb:*&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt;, write out exactly what the function needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:GetItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:PutItem"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:dynamodb:us-east-1:123456789012:table/MySpecificTable"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives permission only to read and write items, and only on one specific table. Nothing more.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;arn&lt;/code&gt; in the resource field is a unique identifier for a specific AWS resource. Every resource in AWS has one. Using it in your policy means you are granting access to that one resource, not everything in the service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 3: Use IAM Access Analyzer to generate policies from actual usage&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;IAM Access Analyzer is a free AWS tool that can look at your CloudTrail logs (the record of every action taken in your AWS account) and tell you exactly which permissions your Lambda function actually used over a given time period.&lt;/p&gt;

&lt;p&gt;Instead of guessing what permissions a function needs, you can run it for a while, then use Access Analyzer to generate a policy based on real usage. This is one of the most accurate ways to arrive at a least privilege policy.&lt;/p&gt;

&lt;p&gt;To use it, go to IAM in the AWS console, find the role attached to your Lambda function, and look for the "Generate policy" option under Access Analyzer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 4: Use permission boundaries as a safety net&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A permission boundary is a policy that sets a ceiling on what permissions a role can ever have. Even if someone attaches a broad policy to the role later, the boundary limits the effective permissions.&lt;/p&gt;

&lt;p&gt;This is especially useful in team environments where multiple developers are creating and modifying Lambda functions. It acts as a guardrail so that even if someone makes a mistake, the damage is contained.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Simple Checklist Before You Deploy
&lt;/h2&gt;

&lt;p&gt;Before your Lambda function goes live, go through these:&lt;/p&gt;

&lt;p&gt;Does this function have its own dedicated execution role, not shared with any other function?&lt;/p&gt;

&lt;p&gt;Does the role use specific actions like &lt;code&gt;dynamodb:GetItem&lt;/code&gt; instead of &lt;code&gt;dynamodb:*&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Does the role point to specific resource ARNs instead of &lt;code&gt;*&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Have you removed any permissions that were added during development and are no longer needed?&lt;/p&gt;

&lt;p&gt;If you are working in a team, is there a permission boundary in place?&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Least privilege is one of the most talked about security principles in cloud development. But it is also one of the most commonly ignored in practice, not because developers do not care, but because the default path leads away from it.&lt;/p&gt;

&lt;p&gt;AWS makes it easy to create broad roles. It is faster to write &lt;code&gt;dynamodb:*&lt;/code&gt; than to list out every specific action. It is more convenient to reuse one role across all your functions. But each of those shortcuts quietly widens the blast radius if something goes wrong.&lt;/p&gt;

&lt;p&gt;Fixing this does not require rebuilding anything. It just requires going back to your Lambda functions one by one, looking at their execution roles, and asking: does this function actually need all of this?&lt;/p&gt;

&lt;p&gt;Most of the time, the answer will be no.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need Help?
&lt;/h2&gt;

&lt;p&gt;If you want help auditing your Lambda execution roles or figuring out what permissions your functions actually need, feel free to reach out.&lt;/p&gt;

&lt;p&gt;Email me at &lt;strong&gt;&lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




</description>
      <category>aws</category>
      <category>serverless</category>
      <category>lambda</category>
      <category>security</category>
    </item>
    <item>
      <title>AWS Amplify Looks Simple, Until It Is Not</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 12 May 2026 05:22:47 +0000</pubDate>
      <link>https://dev.to/tanseer/aws-amplify-looks-simple-until-it-is-not-3053</link>
      <guid>https://dev.to/tanseer/aws-amplify-looks-simple-until-it-is-not-3053</guid>
      <description>&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;If you are new to AWS and thinking of using Amplify to deploy your app, this blog is for you.&lt;/p&gt;

&lt;p&gt;Amplify is marketed as an easy, all-in-one deployment platform. And for simple use cases, it is. But once you start pushing it a little : different package managers, larger projects, custom configurations : you start running into walls that are not mentioned anywhere in the getting started guides.&lt;/p&gt;

&lt;p&gt;I have spent a lot of time deploying different kinds of applications on Amplify. I have hit these walls firsthand. This blog is a collection of the things I discovered the hard way, explained simply, so you do not have to go through the same debugging sessions.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Quick Intro to AWS Amplify for Beginners
&lt;/h2&gt;

&lt;p&gt;Before we get into the issues, here is a quick explanation of what Amplify actually is.&lt;/p&gt;

&lt;p&gt;AWS Amplify is a hosting and deployment platform from Amazon Web Services. You connect your GitHub repository to Amplify, and every time you push code, Amplify automatically builds and deploys your app. It handles the servers, the CDN (Content Delivery Network : the system that delivers your app quickly to users around the world), and the deployment pipeline for you.&lt;/p&gt;

&lt;p&gt;It sounds perfect. And for many projects, it works well. But here are the things you need to know before you go all in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Issue 1: The Cache Feature Does Not Actually Help
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Amplify has a built-in caching feature. In theory, it saves folders like &lt;code&gt;node_modules&lt;/code&gt; (the folder where all your app's dependencies or packages live) between builds. The idea is that on the next build, those packages are already there and do not need to be downloaded again, making the build faster.&lt;/p&gt;

&lt;p&gt;In practice, the opposite happens.&lt;/p&gt;

&lt;p&gt;Amplify's cache works by zipping up the folder and uploading it to storage after a build, then downloading and unzipping it before the next build. This zip and unzip process itself takes significant time — in my experience, around 3 minutes to restore and 3 minutes to save.&lt;/p&gt;

&lt;p&gt;Now here is the deeper problem. The two most common commands used to install packages are:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm ci&lt;/code&gt; : This is the recommended command for automated deployments. It deletes &lt;code&gt;node_modules&lt;/code&gt; completely and reinstalls everything from scratch every single time. It does not matter that Amplify just spent 3 minutes restoring that folder. &lt;code&gt;npm ci&lt;/code&gt; will delete it immediately.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;npm install&lt;/code&gt;: This one does not always delete &lt;code&gt;node_modules&lt;/code&gt;, but it re-evaluates your packages and may reinstall parts of it anyway.&lt;/p&gt;

&lt;p&gt;In both cases, the cache Amplify restored is either deleted immediately or partially ignored.&lt;/p&gt;

&lt;p&gt;I ran experiments on both large projects and minimal ones. The result was consistent — removing the cache made builds faster, not slower.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;Remove the &lt;code&gt;cache&lt;/code&gt; section from your &lt;code&gt;amplify.yml&lt;/code&gt; file entirely. Here is what a clean build config looks like without cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;preBuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
  &lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;baseDirectory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**/*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No cache block. Cleaner, faster, simpler.&lt;/p&gt;




&lt;h2&gt;
  
  
  Issue 2: The 220MB Artifact Size Limit
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Amplify has a hard limit on the size of your build artifacts. Artifacts are the files produced after your build, things like your compiled JavaScript, CSS, images, and in some cases the &lt;code&gt;node_modules&lt;/code&gt; folder if your app needs it at runtime.&lt;/p&gt;

&lt;p&gt;The limit is 220MB.&lt;/p&gt;

&lt;p&gt;This sounds like a lot until you are building a Next.js app (a popular React framework) with server-side features. Next.js SSR (Server-Side Rendering — where pages are generated on the server instead of the browser) requires parts of &lt;code&gt;node_modules&lt;/code&gt; to be bundled with the output. A medium-sized project can easily exceed 220MB once all those dependencies are included.&lt;/p&gt;

&lt;p&gt;When you hit this limit, the build fails with a vague error. If you are not aware of this constraint, you will spend a long time looking in the wrong places.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;There are a few ways to handle this:&lt;/p&gt;

&lt;p&gt;The first option is to use &lt;code&gt;output: 'standalone'&lt;/code&gt; in your &lt;code&gt;next.config.js&lt;/code&gt; file if you are using Next.js. Standalone mode bundles only the exact files needed to run the app, which significantly reduces the output size.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second option is to audit your dependencies. Tools like &lt;code&gt;npm-analyze&lt;/code&gt; or the &lt;code&gt;--why&lt;/code&gt; flag in pnpm can help you find packages that are large and may not be needed at runtime.&lt;/p&gt;

&lt;p&gt;The third option, if you are consistently hitting this limit, is to consider moving to a different deployment setup such as running your app in a container on ECS (Elastic Container Service) or using a different hosting provider that does not have this constraint.&lt;/p&gt;




&lt;h2&gt;
  
  
  Issue 3: pnpm's Symlink Structure Breaks at Runtime
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;pnpm is a modern, faster alternative to npm for managing packages. It is popular because it saves disk space and installs faster. However, pnpm uses a unique approach to storing packages : it uses symlinks (think of them as shortcuts that point to where a file actually lives) instead of copying files directly into your &lt;code&gt;node_modules&lt;/code&gt; folder.&lt;/p&gt;

&lt;p&gt;Amplify's build and runtime environment does not handle these symlinks well.&lt;/p&gt;

&lt;p&gt;What typically happens is this: your build completes successfully. Everything looks fine. But when your app actually runs, it throws a &lt;code&gt;Cannot find module&lt;/code&gt; error : meaning it cannot locate a package that is clearly installed. The app is broken at runtime even though the build passed.&lt;/p&gt;

&lt;p&gt;This is a known issue that has been reported by multiple developers and is documented in Amplify's GitHub issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;The fix is to force pnpm to use a "hoisted" install mode, which makes it behave more like npm by copying packages directly instead of using symlinks. Add this to your &lt;code&gt;amplify.yml&lt;/code&gt; in the preBuild section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;preBuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "node-linker=hoisted" &amp;gt;&amp;gt; .npmrc&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;corepack enable&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pnpm install --shamefully-hoist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--shamefully-hoist&lt;/code&gt; flag tells pnpm to put all packages directly in &lt;code&gt;node_modules&lt;/code&gt; the traditional way. The name sounds alarming but it is a well-known workaround specifically for environments that do not support symlinks.&lt;/p&gt;

&lt;p&gt;Alternatively, if you are not tied to pnpm, switching to npm for your Amplify builds is the simplest fix. You can keep pnpm locally for development and use npm in the Amplify build config.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Note on These Constraints
&lt;/h2&gt;

&lt;p&gt;None of these limitations are front and center in Amplify's documentation. Most of them are buried in GitHub issue threads, Stack Overflow answers, or AWS re:Post forums. As a beginner, you would have no reason to look for them until something breaks.&lt;/p&gt;

&lt;p&gt;That is the pattern with Amplify, it works smoothly for straightforward use cases, but the moment you step slightly outside the default path, you hit constraints that are hard to debug without prior knowledge.&lt;/p&gt;

&lt;p&gt;This is not to say Amplify is bad. For small to medium projects, it is genuinely convenient. But going in with eyes open about these limitations will save you real time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;p&gt;Here is a summary of everything covered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache, remove it entirely.&lt;/strong&gt; It adds time instead of saving it because npm ci and npm install both ignore or delete the cached node_modules anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;220MB artifact limit plan for it.&lt;/strong&gt; Use Next.js standalone output mode and audit your dependencies if you are close to the limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pnpm symlinks, use hoisted mode.&lt;/strong&gt; Add &lt;code&gt;node-linker=hoisted&lt;/code&gt; to your &lt;code&gt;.npmrc&lt;/code&gt; and use &lt;code&gt;--shamefully-hoist&lt;/code&gt; when installing, or switch to npm for Amplify builds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Have You Hit Other Amplify Limitations?
&lt;/h2&gt;

&lt;p&gt;This list is based on my own experience. Amplify has more quirks than these three, and I am sure other developers have hit walls I have not encountered yet.&lt;/p&gt;

&lt;p&gt;If you have run into something that is not covered here, I would love to hear about it. Drop a comment or reach out directly, let us build a more complete picture of what Amplify can and cannot do, so the next developer does not have to figure it out the hard way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need Help?
&lt;/h2&gt;

&lt;p&gt;If you are stuck on any of these issues or running into something else with your Amplify setup, feel free to reach out. I am happy to help.&lt;/p&gt;

&lt;p&gt;Email me at &lt;strong&gt;&lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>amplify</category>
      <category>serverless</category>
    </item>
  </channel>
</rss>
