<?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>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>
    <item>
      <title>AWS Amplify Cache Is Useless — And Here Is the Data to Prove It</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Wed, 29 Apr 2026 06:47:04 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-amplify-cache-is-useless-and-here-is-the-data-to-prove-it-2jn3</link>
      <guid>https://dev.to/aws-builders/aws-amplify-cache-is-useless-and-here-is-the-data-to-prove-it-2jn3</guid>
      <description>&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;If you are deploying a frontend or full-stack app on AWS Amplify and your builds feel slower than they should be, this blog is worth reading. We are going to talk about Amplify's caching system — what it is supposed to do, what it actually does, and why in my experience it makes things worse, not better.&lt;/p&gt;

&lt;p&gt;No deep AWS knowledge is required. If you know what a build pipeline is and have used Amplify at least once, you will follow this completely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem I Ran Into
&lt;/h2&gt;

&lt;p&gt;I was deploying an app on AWS Amplify. The build had two phases: install packages and build the app. Pretty standard setup.&lt;/p&gt;

&lt;p&gt;The total build time was sitting at around 9 minutes. That felt too long. So I opened the build logs and started looking at where the time was actually going.&lt;/p&gt;

&lt;p&gt;Here is what I found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 minutes to restore cache (fetch previously stored files)&lt;/li&gt;
&lt;li&gt;3 minutes to install packages and build the app&lt;/li&gt;
&lt;li&gt;3 minutes to save cache (store files for the next build)
So out of 9 minutes, the actual work — installing and building — was only 3 minutes. The other 6 minutes were spent entirely on cache operations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That immediately felt wrong. Cache is supposed to speed things up. If it is consuming twice the time of the actual build, something is broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  The First Experiment: Disable Cache Entirely
&lt;/h2&gt;

&lt;p&gt;My first instinct was simple. What if I just removed the cache configuration completely and let Amplify install everything fresh every time?&lt;/p&gt;

&lt;p&gt;I removed the cache settings from my &lt;code&gt;amplify.yml&lt;/code&gt; build config and triggered a new build.&lt;/p&gt;

&lt;p&gt;The result: 3 minutes and 30 seconds.&lt;/p&gt;

&lt;p&gt;The build went from 9 minutes to 3 minutes 30 seconds just by removing cache. Yes, it took an extra 30 seconds to download packages compared to the ideal cached scenario. But it saved 6 full minutes of cache overhead.&lt;/p&gt;

&lt;p&gt;This alone should raise a flag. The cache was not saving time. It was adding time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Amplify Cache, Exactly?
&lt;/h2&gt;

&lt;p&gt;Before going further, let me explain how Amplify's caching works, because understanding the mechanism is key to understanding why it fails.&lt;/p&gt;

&lt;p&gt;When Amplify runs a build, it can be configured to save certain folders — most commonly &lt;code&gt;node_modules&lt;/code&gt; — by zipping them up and storing them in S3 (AWS's file storage service). On the next build, it fetches that zip, unzips it into the build environment, and in theory your packages are already there so the install step is faster.&lt;/p&gt;

&lt;p&gt;The key operation here is: zip and upload after a build, download and unzip before the next build.&lt;/p&gt;

&lt;p&gt;This is how Amplify's cache model works. It is essentially just copying folders in and out of storage between builds.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Second Experiment: Maybe It Is My Project
&lt;/h2&gt;

&lt;p&gt;After the first result, I thought maybe the problem was specific to my project. I had a reasonably large dependency tree. Maybe the &lt;code&gt;node_modules&lt;/code&gt; folder was so big that zipping and unzipping it was always going to take longer than just reinstalling.&lt;/p&gt;

&lt;p&gt;So I created a minimal test project — a simple website with almost no packages. Just enough to have a &lt;code&gt;package.json&lt;/code&gt; and a basic build step. The kind of project where &lt;code&gt;node_modules&lt;/code&gt; is tiny and cache should be trivially fast.&lt;/p&gt;

&lt;p&gt;I deployed it on Amplify with cache enabled.&lt;/p&gt;

&lt;p&gt;Same result. Amplify spent time fetching the cache, and then installed all dependencies from scratch anyway. The cache folder it had stored from the previous build was essentially ignored from a practical standpoint.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Root Cause: Cache and npm Are Fundamentally Incompatible
&lt;/h2&gt;

&lt;p&gt;After these experiments, I did some digging and found the real reason this does not work. It comes down to how npm (the package manager) behaves versus how Amplify's cache model works.&lt;/p&gt;

&lt;p&gt;Amplify caches folders. That is it. It saves a folder, restores a folder.&lt;/p&gt;

&lt;p&gt;But here is the problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you use &lt;code&gt;npm ci&lt;/code&gt;&lt;/strong&gt; (which is the recommended command for CI/CD pipelines because it gives you clean, reproducible installs), it deletes &lt;code&gt;node_modules&lt;/code&gt; entirely before installing. 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 and start over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you use &lt;code&gt;npm install&lt;/code&gt;&lt;/strong&gt; (the more common development command), it does not always delete &lt;code&gt;node_modules&lt;/code&gt;, but it re-evaluates the dependency tree and may reinstall or update packages depending on what it finds. So even here, the cache is not reliably used.&lt;/p&gt;

&lt;p&gt;In both cases, the cached &lt;code&gt;node_modules&lt;/code&gt; folder is either deleted outright or partially ignored.&lt;/p&gt;

&lt;p&gt;Amplify's own documentation recommends using &lt;code&gt;npm ci&lt;/code&gt; for builds. But &lt;code&gt;npm ci&lt;/code&gt; by design destroys exactly what Amplify's cache tries to preserve. These two things directly contradict each other.&lt;/p&gt;

&lt;p&gt;The cache model and the install command are working against each other.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Simple Way to Think About It
&lt;/h2&gt;

&lt;p&gt;Imagine you spend 10 minutes carefully organizing your desk every night before bed so it is ready for tomorrow. But every morning, the first thing you do is clear everything off the desk and start fresh. The organizing you did the night before is completely wasted.&lt;/p&gt;

&lt;p&gt;That is exactly what is happening here. Amplify organizes the &lt;code&gt;node_modules&lt;/code&gt; folder into cache. npm wipes the desk clean every build.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Numbers Look Like Side by Side
&lt;/h2&gt;

&lt;p&gt;To make this concrete, here is a comparison of what I observed:&lt;/p&gt;

&lt;p&gt;With cache enabled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Restore cache: ~3 minutes&lt;/li&gt;
&lt;li&gt;Install and build: ~3 minutes&lt;/li&gt;
&lt;li&gt;Save cache: ~3 minutes&lt;/li&gt;
&lt;li&gt;Total: ~9 minutes
With cache disabled:&lt;/li&gt;
&lt;li&gt;Install and build: ~3 minutes 30 seconds&lt;/li&gt;
&lt;li&gt;Total: ~3 minutes 30 seconds
The "optimized" build with cache took more than twice as long as the build with no cache at all.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What You Should Do Instead
&lt;/h2&gt;

&lt;p&gt;Based on everything above, my recommendation is straightforward: disable Amplify cache unless you have a very specific reason to use it and have verified it is actually helping.&lt;/p&gt;

&lt;p&gt;To disable it, remove or empty the &lt;code&gt;cache&lt;/code&gt; section from your &lt;code&gt;amplify.yml&lt;/code&gt;. Here is what a build config without cache looks like:&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. Clean and simple.&lt;/p&gt;

&lt;p&gt;If your builds are still slow after removing cache, the bottleneck is likely somewhere else — large dependencies, slow build tools, or the build machine itself. Those are worth investigating separately, but at least you will not be wasting time on a cache that is not working.&lt;/p&gt;




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

&lt;p&gt;AWS Amplify's cache feature is built on a model that zips and unzips folders between builds. That model does not account for how npm actually works. &lt;code&gt;npm ci&lt;/code&gt; deletes &lt;code&gt;node_modules&lt;/code&gt; before every install. &lt;code&gt;npm install&lt;/code&gt; may partially reinstall anyway. The result is that the cache restore step costs real time — in my case, 3 minutes per build — and delivers no actual benefit.&lt;/p&gt;

&lt;p&gt;I tested this on a large app and a minimal app. I tried &lt;code&gt;npm ci&lt;/code&gt; and &lt;code&gt;npm install&lt;/code&gt;. I made sure cache folders were correctly configured and permissions were in place. In every scenario, disabling cache made builds faster.&lt;/p&gt;

&lt;p&gt;This feels like a fundamental design mismatch between Amplify's caching mechanism and how modern package managers work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Has This Happened to You?
&lt;/h2&gt;

&lt;p&gt;I am genuinely curious whether other developers have experienced this. Have you found a way to make Amplify cache actually work? Did you measure a real improvement? Or did you hit the same wall?&lt;/p&gt;

&lt;p&gt;Drop a comment or reach out — I would love to hear if someone has cracked this or if this is a widely shared frustration in the community.&lt;/p&gt;




&lt;h2&gt;
  
  
  Need Help With Your Amplify Setup?
&lt;/h2&gt;

&lt;p&gt;If you are running into build time issues or anything else with your Amplify deployment, 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>amplify</category>
      <category>serverless</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Your AWS Cognito Emails Are Going to Spam — Here Is How to Fix It Step by Step</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Wed, 29 Apr 2026 06:28:10 +0000</pubDate>
      <link>https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-41b9</link>
      <guid>https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-41b9</guid>
      <description>&lt;p&gt;A beginner-friendly guide to setting up SPF, DKIM, DMARC, Amazon SES, and custom email templates so your emails actually reach the inbox&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who This Guide Is For&lt;/strong&gt;&lt;br&gt;
If you are building an app on AWS and using Cognito to handle user sign-up and login, you have probably noticed that Cognito sends emails automatically — a verification email when someone signs up, and a password reset email when they forget their password.&lt;br&gt;
But if those emails are landing in spam, or not arriving at all, this guide is for you.&lt;br&gt;
We are going to fix that problem from scratch. No prior knowledge of email infrastructure is assumed. Every term will be explained.&lt;/p&gt;

&lt;p&gt;What Is the Problem, Exactly?&lt;br&gt;
When your app sends an email from something like &lt;a href="mailto:no-reply@yourdomain.com"&gt;no-reply@yourdomain.com&lt;/a&gt;, the email does not go directly from your laptop to the user's inbox. It travels through mail servers, and along the way, the receiving server (Gmail, Outlook, etc.) checks a simple question:&lt;br&gt;
"Can I trust that this email actually came from this domain?"&lt;br&gt;
If the answer is unclear or no, the email gets flagged as spam — or silently dropped.&lt;br&gt;
To answer that question, three things need to be in place on your domain: SPF, DKIM, and DMARC. These are DNS records (small pieces of configuration stored on your domain) that prove your emails are legitimate.&lt;br&gt;
We will set all of them up in this guide.&lt;/p&gt;

&lt;p&gt;The Stack We Are Working With&lt;br&gt;
Here is what this guide assumes:&lt;/p&gt;

&lt;p&gt;You have an AWS account&lt;br&gt;
You have a Cognito User Pool set up (or are planning to set one up)&lt;br&gt;
Your domain is managed in Route 53 (AWS's DNS service)&lt;br&gt;
You want Cognito to send emails from your custom domain, like &lt;a href="mailto:no-reply@yourdomain.com"&gt;no-reply@yourdomain.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your domain is with GoDaddy, Namecheap, or another provider, the DNS steps will look slightly different in their interface, but the records you need to add are identical.&lt;/p&gt;

&lt;p&gt;Step 1: Understand What Amazon SES Is&lt;br&gt;
Before we do anything, let us understand a key service: Amazon SES.&lt;br&gt;
SES stands for Simple Email Service. It is AWS's service for sending emails. By default, Cognito uses its own basic email system, which has very low sending limits and no support for custom domains. That is why emails look generic and often end up in spam.&lt;br&gt;
The fix is to connect Cognito to SES, and configure SES properly with your domain. SES is free for low volumes, and the setup is a one-time thing.&lt;/p&gt;

&lt;p&gt;Step 2: Verify Your Domain in Amazon SES&lt;br&gt;
Before SES can send emails on behalf of your domain, it needs to confirm that you actually own it. This is called domain verification.&lt;br&gt;
Here is how to do it:&lt;/p&gt;

&lt;p&gt;Log in to the AWS Console and search for "Amazon SES" in the top search bar.&lt;br&gt;
In the left sidebar, click "Verified identities".&lt;br&gt;
Click "Create identity".&lt;br&gt;
Choose "Domain" and type your domain name (for example, mydomain.com).&lt;br&gt;
If your domain is in Route 53, check the option that says "Use Route 53 to publish DNS records automatically." AWS will handle the DNS setup for you.&lt;br&gt;
Click "Create identity".&lt;/p&gt;

&lt;p&gt;If you are not using Route 53, SES will show you a CNAME record that you need to manually add in your DNS provider's dashboard. Copy it exactly as shown and add it there.&lt;br&gt;
Once AWS detects the record, the status will change to "Verified". This can take anywhere from a few minutes to a couple of hours.&lt;/p&gt;

&lt;p&gt;Step 3: Set Up DKIM&lt;br&gt;
DKIM stands for DomainKeys Identified Mail. Think of it like a wax seal on a letter — it proves the email came from you and was not tampered with in transit.&lt;br&gt;
Every email sent through SES gets a digital signature. The receiving server checks that signature against a public key stored in your DNS. If they match, the email is trusted.&lt;br&gt;
When you verified your domain in the previous step, SES automatically generated DKIM keys for you. You just need to add the DNS records.&lt;br&gt;
SES will show you three CNAME records under the "DKIM signatures" section of your verified identity. If you used Route 53 automatic publishing, these are already added. If not, copy all three and add them as CNAME records in your DNS provider.&lt;br&gt;
Once AWS confirms the records are live, DKIM status will show "Verified."&lt;/p&gt;

&lt;p&gt;Step 4: Set Up SPF&lt;br&gt;
SPF stands for Sender Policy Framework. It is a DNS record that tells the world which servers are allowed to send email from your domain.&lt;br&gt;
Without SPF, anyone could send an email claiming to be from your domain. Receiving servers know this, which is why they check for it.&lt;br&gt;
Here is how to add it:&lt;/p&gt;

&lt;p&gt;Go to Route 53 in the AWS Console.&lt;br&gt;
Click "Hosted zones" and open your domain.&lt;br&gt;
Click "Create record".&lt;br&gt;
Set the record type to "TXT".&lt;br&gt;
Leave the record name empty (or use @ if required).&lt;br&gt;
In the value field, paste this exactly:&lt;/p&gt;

&lt;p&gt;"v=spf1 include:amazonses.com ~all"&lt;/p&gt;

&lt;p&gt;Click "Create records".&lt;/p&gt;

&lt;p&gt;What this record says: emails from this domain are allowed to come from Amazon SES servers. The ~all at the end means "treat anything else with suspicion but do not block it outright." This is the safe starting point.&lt;/p&gt;

&lt;p&gt;Step 5: Set Up DMARC&lt;br&gt;
DMARC stands for Domain-based Message Authentication, Reporting and Conformance. It is the policy that ties SPF and DKIM together.&lt;br&gt;
DMARC tells receiving servers: "Here is what to do if my emails fail the SPF or DKIM check." Without DMARC, servers make their own decisions, and those decisions often mean spam folder.&lt;br&gt;
Here is how to add it:&lt;/p&gt;

&lt;p&gt;In Route 53, go to your hosted zone again.&lt;br&gt;
Create another TXT record.&lt;br&gt;
Set the record name to _dmarc (Route 53 will automatically append your domain, making it _dmarc.yourdomain.com).&lt;br&gt;
In the value field, paste this:&lt;/p&gt;

&lt;p&gt;"v=DMARC1; p=quarantine; rua=mailto:&lt;a href="mailto:youremail@yourdomain.com"&gt;youremail@yourdomain.com&lt;/a&gt;"&lt;/p&gt;

&lt;p&gt;Replace the email address with one you actually check.&lt;br&gt;
Click "Create records".&lt;/p&gt;

&lt;p&gt;What each part means:&lt;/p&gt;

&lt;p&gt;v=DMARC1 — this is a DMARC record&lt;br&gt;
p=quarantine — if authentication fails, send the email to spam (safer than p=reject when you are just starting, which would block emails entirely)&lt;br&gt;
rua=mailto:... — where AWS should send weekly reports about your email activity&lt;/p&gt;

&lt;p&gt;Once you are confident your setup is working correctly, you can upgrade to p=reject to fully block unauthenticated emails.&lt;/p&gt;

&lt;p&gt;Step 6: Exit the SES Sandbox&lt;br&gt;
Every new AWS account starts in something called the SES sandbox. In sandbox mode, you can only send emails to addresses you have manually verified. This is a security measure AWS uses to prevent spam from new accounts.&lt;br&gt;
The problem is, your real users have not verified their emails with AWS. So if you are still in sandbox mode, your emails will fail silently.&lt;br&gt;
To exit the sandbox:&lt;/p&gt;

&lt;p&gt;Go to Amazon SES in the AWS Console.&lt;br&gt;
Click "Account dashboard" in the left sidebar.&lt;br&gt;
Under "Sending limits", you will see a message about sandbox mode. Click "Request production access".&lt;br&gt;
Fill in the short form. Select "Transactional" as the mail type (since you are sending verification and password reset emails, not marketing).&lt;br&gt;
Describe your use case clearly: something like "Sending user verification and password reset emails for a web application."&lt;br&gt;
Submit the request.&lt;/p&gt;

&lt;p&gt;AWS typically approves this within a few hours to one business day. You will get an email confirmation once approved.&lt;/p&gt;

&lt;p&gt;Step 7: Connect Cognito to SES&lt;br&gt;
Now that SES is properly configured, you need to tell Cognito to use it instead of its default email system.&lt;/p&gt;

&lt;p&gt;Go to the AWS Console and open "Amazon Cognito".&lt;br&gt;
Click on your User Pool.&lt;br&gt;
Go to the "Messaging" tab.&lt;br&gt;
Under "Email", click "Edit".&lt;br&gt;
Change the "Email provider" from "Send email with Cognito" to "Send email with Amazon SES".&lt;br&gt;
Under "SES Region", choose the same region where you set up your SES identity.&lt;br&gt;
Under "FROM email address", enter &lt;a href="mailto:no-reply@yourdomain.com"&gt;no-reply@yourdomain.com&lt;/a&gt; (or whatever address you want to send from, as long as the domain is verified in SES).&lt;br&gt;
Optionally, set a "FROM sender name" like "MyApp Support". This is what appears as the sender name in the user's inbox.&lt;br&gt;
Save the changes.&lt;/p&gt;

&lt;p&gt;From this point on, all Cognito emails — verifications and password resets — will go through your verified SES identity with proper authentication.&lt;/p&gt;

&lt;p&gt;Step 8: Customize Your Email Templates&lt;br&gt;
This step is optional but strongly recommended, especially for beginner projects. Cognito's default email messages are very generic, and a branded email builds trust with users (and also looks less like spam to mail filters).&lt;br&gt;
In the same "Messaging" section of your Cognito User Pool:&lt;/p&gt;

&lt;p&gt;Click on "Message templates".&lt;br&gt;
You will see options for "Verification message" and "Password reset message".&lt;br&gt;
Click edit on each one.&lt;br&gt;
You can write a plain text or HTML message. Here is a simple example for the verification email:&lt;/p&gt;

&lt;p&gt;Subject: Verify your email for MyApp&lt;/p&gt;

&lt;p&gt;Hi,&lt;/p&gt;

&lt;p&gt;Thank you for signing up. Please verify your email address by entering the code below in the app:&lt;/p&gt;

&lt;p&gt;Your verification code: {####}&lt;/p&gt;

&lt;p&gt;If you did not sign up for MyApp, you can ignore this email.&lt;/p&gt;

&lt;p&gt;Regards,&lt;br&gt;
The MyApp Team&lt;br&gt;
The {####} placeholder is automatically replaced by Cognito with the actual verification code or link.&lt;br&gt;
Keep the message clear, short, and professional. Avoid using words like "free", "click here", or excessive capitalization, as these can trigger spam filters even when authentication is correct.&lt;/p&gt;

&lt;p&gt;How to Confirm Everything Is Working&lt;br&gt;
After setting everything up, use a free tool called MXToolbox to verify your DNS records:&lt;/p&gt;

&lt;p&gt;Go to MXToolbox and run an "SPF Lookup" for your domain. You should see the SES include statement in the result.&lt;br&gt;
Run a "DMARC Lookup" for your domain. You should see your DMARC policy.&lt;br&gt;
For DKIM, you can run a "DKIM Lookup" using the selector that SES provided.&lt;/p&gt;

&lt;p&gt;To test the full email delivery, use Mail Tester. It gives you a temporary email address. Trigger a verification email from your Cognito signup flow to that address, then check your score. A score of 8 or higher means your setup is solid.&lt;/p&gt;

&lt;p&gt;Quick Checklist Before You Go Live&lt;br&gt;
Before launching your app to real users, confirm the following:&lt;/p&gt;

&lt;p&gt;Domain is verified in Amazon SES&lt;br&gt;
DKIM records (3 CNAMEs) are added and verified&lt;br&gt;
SPF TXT record is added to your domain&lt;br&gt;
DMARC TXT record is added to _dmarc.yourdomain.com&lt;br&gt;
SES sandbox mode has been exited (production access approved)&lt;br&gt;
Cognito is configured to use SES as the email provider&lt;br&gt;
Custom email templates are set up with a clear sender name and message&lt;/p&gt;

&lt;p&gt;Conclusion&lt;br&gt;
Email deliverability is one of those things that most developers only think about after something breaks. Verification emails going to spam, users not completing sign-up, support tickets about missing password reset emails — these are all symptoms of the same root cause: an unauthenticated domain.&lt;br&gt;
The good news is that fixing it is straightforward. SPF, DKIM, and DMARC are one-time DNS configurations. Connecting Cognito to SES takes less than ten minutes. And once it is done, your emails will reliably reach inboxes.&lt;br&gt;
If you are building something on AWS and handling user authentication, getting this right early will save you a lot of headaches later.&lt;/p&gt;

&lt;p&gt;Need Help?&lt;br&gt;
If you run into any issues with the setup — whether it is a DNS record that does not verify, SES sandbox approval, or connecting things in Cognito — feel free to reach out. I am happy to walk you through it.&lt;br&gt;
Email me 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>
    </item>
    <item>
      <title>Hey everyone! Just published a blog on a problem I ran into recently.
If you are using AWS Cognito for auth and your verification or password reset emails are going to spam, this one is for you.</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 28 Apr 2026 04:50:04 +0000</pubDate>
      <link>https://dev.to/tanseer/hey-everyone-just-published-a-blog-on-a-problem-i-ran-into-recently-if-you-are-using-aws-cognito-1512</link>
      <guid>https://dev.to/tanseer/hey-everyone-just-published-a-blog-on-a-problem-i-ran-into-recently-if-you-are-using-aws-cognito-1512</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-4989" class="crayons-story__hidden-navigation-link"&gt;Your AWS Cognito Emails Are Going to Spam — Here Is How to Fix It Step by Step&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/tanseer" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3901526%2Faae5933f-439d-4185-ad46-10b5e922d96c.jpg" alt="tanseer profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/tanseer" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Tanseer
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Tanseer
                
              
              &lt;div id="story-author-preview-content-3560107" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/tanseer" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3901526%2Faae5933f-439d-4185-ad46-10b5e922d96c.jpg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Tanseer&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-4989" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 28&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-4989" id="article-link-3560107"&gt;
          Your AWS Cognito Emails Are Going to Spam — Here Is How to Fix It Step by Step
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/aws"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;aws&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/serverless"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;serverless&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-4989" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-4989#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            8 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Your AWS Cognito Emails Are Going to Spam — Here Is How to Fix It Step by Step</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 28 Apr 2026 04:31:04 +0000</pubDate>
      <link>https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-4989</link>
      <guid>https://dev.to/tanseer/your-aws-cognito-emails-are-going-to-spam-here-is-how-to-fix-it-step-by-step-4989</guid>
      <description>&lt;h3&gt;
  
  
  A beginner-friendly guide to setting up SPF, DKIM, DMARC, Amazon SES, and custom email templates so your emails actually reach the inbox
&lt;/h3&gt;

&lt;h2&gt;
  
  
  Who This Guide Is For
&lt;/h2&gt;

&lt;p&gt;If you are building an app on AWS and using Cognito to handle user sign-up and login, you have probably noticed that Cognito sends emails automatically — a verification email when someone signs up, and a password reset email when they forget their password.&lt;/p&gt;

&lt;p&gt;But if those emails are landing in spam, or not arriving at all, this guide is for you.&lt;/p&gt;

&lt;p&gt;We are going to fix that problem from scratch. No prior knowledge of email infrastructure is assumed. Every term will be explained.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is the Problem, Exactly?
&lt;/h2&gt;

&lt;p&gt;When your app sends an email from something like &lt;code&gt;no-reply@yourdomain.com&lt;/code&gt;, the email does not go directly from your laptop to the user's inbox. It travels through mail servers, and along the way, the receiving server (Gmail, Outlook, etc.) checks a simple question:&lt;/p&gt;

&lt;p&gt;"Can I trust that this email actually came from this domain?"&lt;/p&gt;

&lt;p&gt;If the answer is unclear or no, the email gets flagged as spam — or silently dropped.&lt;/p&gt;

&lt;p&gt;To answer that question, three things need to be in place on your domain: SPF, DKIM, and DMARC. These are DNS records (small pieces of configuration stored on your domain) that prove your emails are legitimate.&lt;/p&gt;

&lt;p&gt;We will set all of them up in this guide.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack We Are Working With
&lt;/h2&gt;

&lt;p&gt;Here is what this guide assumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have an AWS account&lt;/li&gt;
&lt;li&gt;You have a Cognito User Pool set up (or are planning to set one up)&lt;/li&gt;
&lt;li&gt;Your domain is managed in Route 53 (AWS's DNS service)&lt;/li&gt;
&lt;li&gt;You want Cognito to send emails from your custom domain, like &lt;code&gt;no-reply@yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your domain is with GoDaddy, Namecheap, or another provider, the DNS steps will look slightly different in their interface, but the records you need to add are identical.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Understand What Amazon SES Is
&lt;/h2&gt;

&lt;p&gt;Before we do anything, let us understand a key service: Amazon SES.&lt;/p&gt;

&lt;p&gt;SES stands for Simple Email Service. It is AWS's service for sending emails. By default, Cognito uses its own basic email system, which has very low sending limits and no support for custom domains. That is why emails look generic and often end up in spam.&lt;/p&gt;

&lt;p&gt;The fix is to connect Cognito to SES, and configure SES properly with your domain. SES is free for low volumes, and the setup is a one-time thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Verify Your Domain in Amazon SES
&lt;/h2&gt;

&lt;p&gt;Before SES can send emails on behalf of your domain, it needs to confirm that you actually own it. This is called domain verification.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Log in to the AWS Console and search for "Amazon SES" in the top search bar.&lt;/li&gt;
&lt;li&gt;In the left sidebar, click "Verified identities".&lt;/li&gt;
&lt;li&gt;Click "Create identity".&lt;/li&gt;
&lt;li&gt;Choose "Domain" and type your domain name (for example, &lt;code&gt;mydomain.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;If your domain is in Route 53, check the option that says "Use Route 53 to publish DNS records automatically." AWS will handle the DNS setup for you.&lt;/li&gt;
&lt;li&gt;Click "Create identity".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are not using Route 53, SES will show you a CNAME record that you need to manually add in your DNS provider's dashboard. Copy it exactly as shown and add it there.&lt;/p&gt;

&lt;p&gt;Once AWS detects the record, the status will change to "Verified". This can take anywhere from a few minutes to a couple of hours.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Set Up DKIM
&lt;/h2&gt;

&lt;p&gt;DKIM stands for DomainKeys Identified Mail. Think of it like a wax seal on a letter — it proves the email came from you and was not tampered with in transit.&lt;/p&gt;

&lt;p&gt;Every email sent through SES gets a digital signature. The receiving server checks that signature against a public key stored in your DNS. If they match, the email is trusted.&lt;/p&gt;

&lt;p&gt;When you verified your domain in the previous step, SES automatically generated DKIM keys for you. You just need to add the DNS records.&lt;/p&gt;

&lt;p&gt;SES will show you three CNAME records under the "DKIM signatures" section of your verified identity. If you used Route 53 automatic publishing, these are already added. If not, copy all three and add them as CNAME records in your DNS provider.&lt;/p&gt;

&lt;p&gt;Once AWS confirms the records are live, DKIM status will show "Verified."&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Set Up SPF
&lt;/h2&gt;

&lt;p&gt;SPF stands for Sender Policy Framework. It is a DNS record that tells the world which servers are allowed to send email from your domain.&lt;/p&gt;

&lt;p&gt;Without SPF, anyone could send an email claiming to be from your domain. Receiving servers know this, which is why they check for it.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Go to Route 53 in the AWS Console.&lt;/li&gt;
&lt;li&gt;Click "Hosted zones" and open your domain.&lt;/li&gt;
&lt;li&gt;Click "Create record".&lt;/li&gt;
&lt;li&gt;Set the record type to "TXT".&lt;/li&gt;
&lt;li&gt;Leave the record name empty (or use &lt;code&gt;@&lt;/code&gt; if required).&lt;/li&gt;
&lt;li&gt;In the value field, paste this exactly:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"v=spf1 include:amazonses.com ~all"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Click "Create records".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What this record says: emails from this domain are allowed to come from Amazon SES servers. The &lt;code&gt;~all&lt;/code&gt; at the end means "treat anything else with suspicion but do not block it outright." This is the safe starting point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Set Up DMARC
&lt;/h2&gt;

&lt;p&gt;DMARC stands for Domain-based Message Authentication, Reporting and Conformance. It is the policy that ties SPF and DKIM together.&lt;/p&gt;

&lt;p&gt;DMARC tells receiving servers: "Here is what to do if my emails fail the SPF or DKIM check." Without DMARC, servers make their own decisions, and those decisions often mean spam folder.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;In Route 53, go to your hosted zone again.&lt;/li&gt;
&lt;li&gt;Create another TXT record.&lt;/li&gt;
&lt;li&gt;Set the record name to &lt;code&gt;_dmarc&lt;/code&gt; (Route 53 will automatically append your domain, making it &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;In the value field, paste this:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"v=DMARC1; p=quarantine; rua=mailto:youremail@yourdomain.com"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Replace the email address with one you actually check.&lt;/li&gt;
&lt;li&gt;Click "Create records".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What each part means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;v=DMARC1&lt;/code&gt; — this is a DMARC record&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;p=quarantine&lt;/code&gt; — if authentication fails, send the email to spam (safer than &lt;code&gt;p=reject&lt;/code&gt; when you are just starting, which would block emails entirely)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rua=mailto:...&lt;/code&gt; — where AWS should send weekly reports about your email activity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you are confident your setup is working correctly, you can upgrade to &lt;code&gt;p=reject&lt;/code&gt; to fully block unauthenticated emails.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Exit the SES Sandbox
&lt;/h2&gt;

&lt;p&gt;Every new AWS account starts in something called the SES sandbox. In sandbox mode, you can only send emails to addresses you have manually verified. This is a security measure AWS uses to prevent spam from new accounts.&lt;/p&gt;

&lt;p&gt;The problem is, your real users have not verified their emails with AWS. So if you are still in sandbox mode, your emails will fail silently.&lt;/p&gt;

&lt;p&gt;To exit the sandbox:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to Amazon SES in the AWS Console.&lt;/li&gt;
&lt;li&gt;Click "Account dashboard" in the left sidebar.&lt;/li&gt;
&lt;li&gt;Under "Sending limits", you will see a message about sandbox mode. Click "Request production access".&lt;/li&gt;
&lt;li&gt;Fill in the short form. Select "Transactional" as the mail type (since you are sending verification and password reset emails, not marketing).&lt;/li&gt;
&lt;li&gt;Describe your use case clearly: something like "Sending user verification and password reset emails for a web application."&lt;/li&gt;
&lt;li&gt;Submit the request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;AWS typically approves this within a few hours to one business day. You will get an email confirmation once approved.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Connect Cognito to SES
&lt;/h2&gt;

&lt;p&gt;Now that SES is properly configured, you need to tell Cognito to use it instead of its default email system.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to the AWS Console and open "Amazon Cognito".&lt;/li&gt;
&lt;li&gt;Click on your User Pool.&lt;/li&gt;
&lt;li&gt;Go to the "Messaging" tab.&lt;/li&gt;
&lt;li&gt;Under "Email", click "Edit".&lt;/li&gt;
&lt;li&gt;Change the "Email provider" from "Send email with Cognito" to "Send email with Amazon SES".&lt;/li&gt;
&lt;li&gt;Under "SES Region", choose the same region where you set up your SES identity.&lt;/li&gt;
&lt;li&gt;Under "FROM email address", enter &lt;code&gt;no-reply@yourdomain.com&lt;/code&gt; (or whatever address you want to send from, as long as the domain is verified in SES).&lt;/li&gt;
&lt;li&gt;Optionally, set a "FROM sender name" like "MyApp Support". This is what appears as the sender name in the user's inbox.&lt;/li&gt;
&lt;li&gt;Save the changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From this point on, all Cognito emails — verifications and password resets — will go through your verified SES identity with proper authentication.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Customize Your Email Templates
&lt;/h2&gt;

&lt;p&gt;This step is optional but strongly recommended, especially for beginner projects. Cognito's default email messages are very generic, and a branded email builds trust with users (and also looks less like spam to mail filters).&lt;/p&gt;

&lt;p&gt;In the same "Messaging" section of your Cognito User Pool:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click on "Message templates".&lt;/li&gt;
&lt;li&gt;You will see options for "Verification message" and "Password reset message".&lt;/li&gt;
&lt;li&gt;Click edit on each one.&lt;/li&gt;
&lt;li&gt;You can write a plain text or HTML message. Here is a simple example for the verification email:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;Subject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; Verify your email for MyApp&lt;/span&gt;

Hi,

Thank you for signing up. Please verify your email address by entering the code below in the app:

Your verification code: {####}

If you did not sign up for MyApp, you can ignore this email.

Regards,
The MyApp Team
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{####}&lt;/code&gt; placeholder is automatically replaced by Cognito with the actual verification code or link.&lt;/p&gt;

&lt;p&gt;Keep the message clear, short, and professional. Avoid using words like "free", "click here", or excessive capitalization, as these can trigger spam filters even when authentication is correct.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Confirm Everything Is Working
&lt;/h2&gt;

&lt;p&gt;After setting everything up, use a free tool called &lt;a href="https://mxtoolbox.com" rel="noopener noreferrer"&gt;MXToolbox&lt;/a&gt; to verify your DNS records:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go to MXToolbox and run an "SPF Lookup" for your domain. You should see the SES include statement in the result.&lt;/li&gt;
&lt;li&gt;Run a "DMARC Lookup" for your domain. You should see your DMARC policy.&lt;/li&gt;
&lt;li&gt;For DKIM, you can run a "DKIM Lookup" using the selector that SES provided.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To test the full email delivery, use &lt;a href="https://www.mail-tester.com" rel="noopener noreferrer"&gt;Mail Tester&lt;/a&gt;. It gives you a temporary email address. Trigger a verification email from your Cognito signup flow to that address, then check your score. A score of 8 or higher means your setup is solid.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Checklist Before You Go Live
&lt;/h2&gt;

&lt;p&gt;Before launching your app to real users, confirm the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain is verified in Amazon SES&lt;/li&gt;
&lt;li&gt;DKIM records (3 CNAMEs) are added and verified&lt;/li&gt;
&lt;li&gt;SPF TXT record is added to your domain&lt;/li&gt;
&lt;li&gt;DMARC TXT record is added to &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SES sandbox mode has been exited (production access approved)&lt;/li&gt;
&lt;li&gt;Cognito is configured to use SES as the email provider&lt;/li&gt;
&lt;li&gt;Custom email templates are set up with a clear sender name and message&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;Email deliverability is one of those things that most developers only think about after something breaks. Verification emails going to spam, users not completing sign-up, support tickets about missing password reset emails — these are all symptoms of the same root cause: an unauthenticated domain.&lt;/p&gt;

&lt;p&gt;The good news is that fixing it is straightforward. SPF, DKIM, and DMARC are one-time DNS configurations. Connecting Cognito to SES takes less than ten minutes. And once it is done, your emails will reliably reach inboxes.&lt;/p&gt;

&lt;p&gt;If you are building something on AWS and handling user authentication, getting this right early will save you a lot of headaches later.&lt;/p&gt;




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

&lt;p&gt;If you run into any issues with the setup — whether it is a DNS record that does not verify, SES sandbox approval, or connecting things in Cognito — feel free to reach out. I am happy to walk you through it.&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;




&lt;h1&gt;
  
  
  AWS #AmazonSES #Cognito #EmailDeliverability #SPF #DKIM #DMARC #Route53 #Serverless #AWSCommunity #CloudComputing #BackendDevelopment #AWSBuilder #EmailAuthentication #LearnAWS #WebDevelopment
&lt;/h1&gt;

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