<?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>AWS Elemental MediaConvert: The Video Service Every Developer Should Know</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Mon, 03 Aug 2026 05:30:00 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-elemental-mediaconvert-the-video-service-every-developer-should-know-3a7l</link>
      <guid>https://dev.to/aws-builders/aws-elemental-mediaconvert-the-video-service-every-developer-should-know-3a7l</guid>
      <description>&lt;h2&gt;
  
  
  About this series
&lt;/h2&gt;

&lt;p&gt;Most AWS learning stops after EC2, S3, IAM, and Lambda. But AWS has over two hundred services, and many of the most useful ones rarely appear in tutorials.&lt;/p&gt;

&lt;p&gt;AWS Hidden Gems covers those underrated services you shouldn't ignore. Each article picks one, then explains why it exists, what it does, where it fits, and how to set it up from the console. Know the four basics above and you can follow along. Everything else gets explained as it comes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Today's service: MediaConvert
&lt;/h2&gt;

&lt;p&gt;Almost every app has video in it, and every video hides one boring step: converting the raw file into formats that phones, laptops, TVs, and slow networks can all play. That is what MediaConvert does.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why does this service exist?
&lt;/h2&gt;

&lt;p&gt;Transcoding means rewriting a video into a different format, quality, or resolution. You need it because one file cannot serve every viewer. A weak mobile connection needs a small version. A fast home connection wants the crisp one.&lt;/p&gt;

&lt;p&gt;Before AWS, teams ran FFmpeg (a free tool that converts almost any video) on their own server fleets. Those servers were expensive, sat idle between uploads, and needed constant patching. Getting broadcast quality output took specialist knowledge most teams did not have.&lt;/p&gt;

&lt;p&gt;MediaConvert removes all of that. You hand it a file, it does the work on infrastructure you never manage, and you pay only for the minutes you process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is MediaConvert?
&lt;/h2&gt;

&lt;p&gt;MediaConvert is a fully managed, file based video transcoding service. Fully managed means AWS runs the servers for you. File based means it works on complete video files that already exist, usually in S3, not on live streams.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Changes the codec, the compression method, such as H.264 or the newer H.265&lt;/li&gt;
&lt;li&gt;Changes resolution, so one 4K master becomes 1080p, 720p, and 480p&lt;/li&gt;
&lt;li&gt;Adjusts bitrate, the data used per second and the main quality versus size lever&lt;/li&gt;
&lt;li&gt;Adds captions, extra audio tracks, logo overlays, and thumbnails&lt;/li&gt;
&lt;li&gt;Packages video for streaming with HLS and DASH, the formats behind adaptive bitrate, where the player switches quality as the network changes
What it does not do is store or deliver your videos. Storage is the job of S3. Delivery is the job of CloudFront, a network that keeps copies of your files close to users.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A real world problem
&lt;/h2&gt;

&lt;p&gt;An online course startup lets instructors upload lectures from their phones. Files arrive in every format and size. A student on wifi watches fine. A student on mobile data sees the giant 4K file stall every few seconds. Some older devices cannot play certain formats at all.&lt;/p&gt;

&lt;p&gt;The startup cannot control how instructors record or how students watch. It can only fix the middle: convert every upload into a consistent set of streaming friendly versions. That is what MediaConvert is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real world use cases
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Streaming platforms turn studio master files into every quality level for phones, browsers, and TVs&lt;/li&gt;
&lt;li&gt;Education platforms standardize instructor uploads so playback never depends on someone's recording setup&lt;/li&gt;
&lt;li&gt;Healthcare processes recorded procedures and telehealth sessions into required formats for review and archiving&lt;/li&gt;
&lt;li&gt;Finance and enterprise transcode earnings calls and training, captioned for accessibility and stored for the long term&lt;/li&gt;
&lt;li&gt;Ecommerce converts product and review clips into fast loading versions that do not slow the page&lt;/li&gt;
&lt;li&gt;Gaming and social apps normalize huge volumes of user uploaded clips into formats the app can play
The pattern is always the same. Video comes in messy, and it leaves clean and ready to play.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it fits in AWS
&lt;/h2&gt;

&lt;p&gt;A common setup: a video lands in S3, the upload triggers a Lambda function, Lambda starts a MediaConvert job, MediaConvert reads the file and writes converted versions back to S3, and CloudFront delivers them worldwide. MediaConvert reports progress through EventBridge, the AWS service for reacting to events, and it accesses S3 using an IAM role, a set of permissions a service borrows to act for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[User uploads video] --&amp;gt; B[S3 input bucket]
    B --&amp;gt;|Upload event| C[Lambda function]
    C --&amp;gt;|Start job| D[MediaConvert]
    B --&amp;gt;|Reads source file| D
    D --&amp;gt;|Writes outputs| E[S3 output bucket]
    E --&amp;gt; F[CloudFront]
    F --&amp;gt; G[Viewers on any device]
    D --&amp;gt;|Job status| H[EventBridge]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MediaConvert is the processing engine in the middle. Everything around it is handled by services built for that job.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the workflow runs
&lt;/h2&gt;

&lt;p&gt;The unit of work is a job: one input file, the outputs you want, and the IAM role to use. A job is submitted, waits in a queue, gets processed on managed infrastructure, and writes its outputs to S3 when done. Status updates flow through EventBridge the whole time, and failures come back with an error message.&lt;/p&gt;

&lt;p&gt;Two features save repetition. Presets store output settings, so you set 1080p H.264 once and reuse it. Job templates store a whole job's settings, so your standard outputs become a one line request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Job submitted] --&amp;gt; B[Job waits in queue]
    B --&amp;gt; C[MediaConvert processes it]
    C --&amp;gt; D{Success?}
    D --&amp;gt;|Yes| E[Outputs written to S3]
    D --&amp;gt;|No| F[Job marked with error]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setting it up in the AWS Console
&lt;/h2&gt;

&lt;p&gt;Before you start, upload a short sample video to an S3 bucket. Note the bucket name and the folder where you want the output. Keep the file small so your first job finishes quickly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign in to the AWS Console, type MediaConvert into the search bar at the top, and open it. Look at the region shown in the top right corner and make sure it matches the region of your S3 bucket. MediaConvert reads from and writes to S3, so keeping both in the same region avoids extra data transfer charges and keeps the job faster.&lt;/li&gt;
&lt;li&gt;Click Create job. The page splits into two halves. The left panel is where you add the parts of your job, meaning the input and the output groups. The right panel is where you edit the settings for whatever part you have selected.&lt;/li&gt;
&lt;li&gt;In the Input section on the left, paste the full S3 path to your sample video, for example s3://your-bucket-name/sample.mp4. This is the file MediaConvert will read. Check the path carefully, because a wrong path or a missing file extension is the most common reason a job fails right away.&lt;/li&gt;
&lt;li&gt;In the left panel, find Output groups and click Add. MediaConvert asks what kind of group you want. Choose File group for this first test, which produces one converted file rather than a segmented streaming package, so it is the simplest way to confirm the flow works. Open the group and set its Destination to the S3 folder for your output, for example s3://your-bucket-name/output/.&lt;/li&gt;
&lt;li&gt;Inside the output group there is an output entry. Select it and set three things on the right: the video codec to H.264, which every device supports, the resolution to 1280 by 720, and the audio settings left at their defaults. These few values are enough to produce a clean, playable file.&lt;/li&gt;
&lt;li&gt;Scroll to the Job settings section and find the service access or IAM role option. MediaConvert needs a role that lets it read your input bucket and write to your output bucket. For a first job, pick the option that lets the console create this role for you. If you prefer to build it yourself, the role needs a trust policy allowing the MediaConvert service to assume it, plus s3:GetObject on the input bucket and s3:PutObject on the output bucket.&lt;/li&gt;
&lt;li&gt;Leave the default on demand queue selected. You do not need reserved capacity to start.&lt;/li&gt;
&lt;li&gt;Click Create at the bottom. The job appears with a status of Progressing. For a small file it changes to Complete within a minute or two. Open your output folder in S3, download the converted file, and play it to confirm the transcode worked.
Common mistakes: a permissions error almost always means the IAM role cannot reach one of your buckets, so check that it covers both the input and the output. A job that fails right away usually means the input path is wrong, so confirm it exactly, including the file extension.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Starting a job with code
&lt;/h2&gt;

&lt;p&gt;Clicking through the console is fine for learning, but in a real system you want jobs to start on their own. The usual pattern is a Lambda function that runs whenever a new video lands in S3 and tells MediaConvert to start a job.&lt;/p&gt;

&lt;p&gt;One detail trips up newcomers. MediaConvert gives each account its own endpoint, which is its own address, so you fetch that endpoint once and build the client with it. To keep the code short, first save your settings from steps 4 and 5 as a job template in the console. The template holds the codec, resolution, and output destination, so the function only has to pass the input file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="c1"&gt;# MediaConvert has a per account endpoint, so fetch it once when the function loads.
&lt;/span&gt;&lt;span class="n"&gt;default_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mediaconvert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;default_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describe_endpoints&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Endpoints&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;mediaconvert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mediaconvert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# The S3 event tells us which file was just uploaded.
&lt;/span&gt;    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bucket&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mediaconvert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:iam::123456789012:role/MediaConvertRole&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;JobTemplate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-video-template&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Settings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Inputs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FileInput&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;source&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Started MediaConvert job:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Job&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Job&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make this run on its own, add an S3 trigger on your input bucket that invokes the function on new uploads, and give the function's role permission to call MediaConvert and to pass the MediaConvert role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Billing unit&lt;/td&gt;
&lt;td&gt;Per minute of output video, prorated per second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Billed on&lt;/td&gt;
&lt;td&gt;Output, not input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output math&lt;/td&gt;
&lt;td&gt;One 10 minute video into 3 versions = 30 output minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basic tier HD&lt;/td&gt;
&lt;td&gt;About $0.015 per minute (US, on demand)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basic tier SD&lt;/td&gt;
&lt;td&gt;Lower than HD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basic tier 4K&lt;/td&gt;
&lt;td&gt;Higher than HD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Professional tier&lt;/td&gt;
&lt;td&gt;Higher rate, for advanced broadcast features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost example&lt;/td&gt;
&lt;td&gt;100 videos x 10 min x 2 HD versions = 2,000 min, about $30 per month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;Monthly allowance of basic tier output minutes for 12 months&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reserved pricing&lt;/td&gt;
&lt;td&gt;Lower rate for large, predictable monthly volume&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The AWS media services family
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AWS Media Services
├── MediaConvert   file based transcoding for existing videos
├── MediaLive      real time encoding for live broadcasts
├── MediaPackage   packages and protects streams for delivery
├── MediaConnect   reliable transport of live video
├── MediaTailor    inserts personalized ads into streams
└── Amazon IVS     managed low latency interactive live streaming
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key split is file versus live. MediaConvert handles finished files. MediaLive and IVS handle video happening right now. If your video already exists, MediaConvert is the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;MediaConvert takes video in any shape and produces clean, playable versions for every device and network, with no servers to run. Next time you build anything with video, you know the managed service that does the heavy lifting and how to wire it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Series progress
&lt;/h2&gt;

&lt;p&gt;You are on stop one of AWS Hidden Gems.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AWS Elemental MediaConvert (you are here)&lt;/li&gt;
&lt;li&gt;Amazon IVS&lt;/li&gt;
&lt;li&gt;Amazon Rekognition&lt;/li&gt;
&lt;li&gt;Amazon Personalize&lt;/li&gt;
&lt;li&gt;AWS AppSync
Next up is Amazon IVS, which picks up where MediaConvert stops, handling live and interactive video.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Questions, corrections, or want to talk through where this fits in your own project? Reach me at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>mediaconvert</category>
      <category>elementalmediaconvert</category>
    </item>
    <item>
      <title>Terraform State Locking Without DynamoDB: S3 Native Locking Explained</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 21 Jul 2026 04:53:34 +0000</pubDate>
      <link>https://dev.to/aws-builders/terraform-state-locking-without-dynamodb-s3-native-locking-explained-448l</link>
      <guid>https://dev.to/aws-builders/terraform-state-locking-without-dynamodb-s3-native-locking-explained-448l</guid>
      <description>&lt;p&gt;A beginner friendly guide to S3 native state locking and why it makes your Terraform setup so much cleaner&lt;/p&gt;

&lt;p&gt;Setting up remote state in Terraform used to come with a small annoyance. You could not just use an S3 bucket on its own. You also had to create a DynamoDB table sitting right next to it, purely to handle locking. Two AWS services for something that felt like one job.&lt;/p&gt;

&lt;p&gt;That has changed. Starting with Terraform 1.10, S3 can handle state locking by itself. No DynamoDB table, no extra resource to provision, no extra permissions to hand out. In this post I will walk through what state locking is, why it needed DynamoDB in the first place, what changed under the hood, and the exact steps to switch to the simpler setup.&lt;/p&gt;

&lt;p&gt;This is written for people who are still new to AWS and Terraform, so I will explain each term the first time it shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick refresher on the Terraform state file
&lt;/h2&gt;

&lt;p&gt;When you run Terraform, it needs to remember what it built. It keeps this record in a file called the state file, usually named &lt;code&gt;terraform.tfstate&lt;/code&gt;. Think of it as a map that connects the resources written in your code to the real resources living in your AWS account.&lt;/p&gt;

&lt;p&gt;If you work alone on your laptop, that file can just sit on your machine. But the moment a second person joins, everyone needs to share the same map. If two people have two different copies, they will fight over what exists and what does not.&lt;/p&gt;

&lt;p&gt;The common fix is to store the state file in a shared location that the whole team points to. This shared location is called a backend. An S3 bucket is one of the most popular backends because it is cheap, durable, and simple. A bucket is just a storage container in S3 where you keep files, which AWS calls objects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why state locking matters
&lt;/h2&gt;

&lt;p&gt;Here is the problem that locking solves.&lt;/p&gt;

&lt;p&gt;Say two engineers on your team both run &lt;code&gt;terraform apply&lt;/code&gt; at almost the same moment. Both runs read the current state file. Both make their changes. Both try to write the updated state back. One write lands on top of the other, and now your state file is scrambled. In the worst case Terraform gets confused about what already exists and starts creating duplicates or destroying things it should not touch.&lt;/p&gt;

&lt;p&gt;State locking stops this. Before Terraform makes any change, it grabs a lock. While that lock is held, any other run that tries to start is told to wait. Once the first run finishes, it releases the lock and the next person can go. Only one operation writes at a time, so the state file stays clean.&lt;/p&gt;

&lt;p&gt;The question is where that lock actually lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old way: a DynamoDB table just for locks
&lt;/h2&gt;

&lt;p&gt;For a long time, the S3 backend could store your state, but it could not lock it. S3 on its own had no simple way to say "only write this if nobody else is holding it." So Terraform borrowed a second service to keep track of locks: DynamoDB, which is a fast key value database from AWS.&lt;/p&gt;

&lt;p&gt;The setup looked like this. You created a DynamoDB table with a single partition key called &lt;code&gt;LockID&lt;/code&gt;. A partition key, also called a hash key, is just the main field DynamoDB uses to find a row. When Terraform started an operation, it wrote a lock item into that table. When it finished, it deleted the item. If a second run showed up and saw a lock item already there, it knew the state was busy.&lt;/p&gt;

&lt;p&gt;The Terraform code for that table usually looked 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;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_dynamodb_table"&lt;/span&gt; &lt;span class="s2"&gt;"terraform_locks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-locks"&lt;/span&gt;
  &lt;span class="nx"&gt;billing_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PAY_PER_REQUEST"&lt;/span&gt;
  &lt;span class="nx"&gt;hash_key&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LockID"&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LockID"&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S"&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;And the backend block that tied it all together looked 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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-terraform-state-bucket"&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;"global/s3/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-locks"&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;Here &lt;code&gt;key&lt;/code&gt; is the path to the state file inside the bucket, and &lt;code&gt;encrypt = true&lt;/code&gt; tells S3 to encrypt the file at rest.&lt;/p&gt;

&lt;p&gt;None of this is hard on its own. But notice how much you are carrying. You provision a whole extra service. You manage its name and region and make sure they match the backend config. You grant IAM permissions for it, where IAM is the AWS system that decides who is allowed to do what. And you have one more thing that can silently break, like a table created in the wrong region or a name typo that only shows up when a lock fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;Two things had to line up for S3 to do locking on its own.&lt;/p&gt;

&lt;p&gt;First, in August 2024, AWS added conditional writes to S3. This is the important piece. A conditional write lets you upload an object only if it does not already exist. Technically this works through an HTTP header called &lt;code&gt;If-None-Match&lt;/code&gt;. In plain terms, you can now tell S3 "create this file, but only if it is not already there, otherwise refuse." That refuse behavior is exactly what a lock needs. If the lock file is already there, your write bounces, which means someone else has the lock.&lt;/p&gt;

&lt;p&gt;Second, Terraform 1.10 (released in November 2024) added a new backend option called &lt;code&gt;use_lockfile&lt;/code&gt;. When you turn it on, Terraform uses that S3 conditional write trick to manage locks directly in the same bucket as your state. DynamoDB is no longer part of the picture.&lt;/p&gt;

&lt;p&gt;Terraform 1.11 went a step further and marked the old &lt;code&gt;dynamodb_table&lt;/code&gt; argument as deprecated, which is the maintainers' way of saying the DynamoDB path is on its way out and you should move over.&lt;/p&gt;

&lt;p&gt;One quick clarification so you do not get tripped up. S3 native state locking has nothing to do with a feature called S3 Object Lock. Object Lock is a compliance feature that stops files from being deleted or changed for a set period. That is not what we are using here. Terraform state locking uses conditional writes, and it works on a plain, normal S3 bucket with no special mode turned on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works under the hood
&lt;/h2&gt;

&lt;p&gt;When you enable &lt;code&gt;use_lockfile&lt;/code&gt; and run an operation, Terraform creates a small lock file in the same bucket, right next to your state file. It reuses your state path and adds a &lt;code&gt;.tflock&lt;/code&gt; extension.&lt;/p&gt;

&lt;p&gt;So if your state file is at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;global/s3/terraform.tfstate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the lock file will appear at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;global/s3/terraform.tfstate.tflock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While an operation runs, that &lt;code&gt;.tflock&lt;/code&gt; object exists. When the operation ends, Terraform deletes it. If someone else runs Terraform during that window, S3 rejects their conditional write because the lock file is already there, and Terraform shows them an error instead of letting two runs collide.&lt;/p&gt;

&lt;p&gt;The error looks something like this.&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;Error: Error acquiring the state lock

Error message: operation error S3: PutObject, https response error
StatusCode: 412, RequestID: ..., HostID: ..., api error PreconditionFailed:
At least one of the pre-conditions you specified did not hold

Lock Info:
  ID:        3f6a1c9e-...-b2d4
  Path:      my-terraform-state-bucket/global/s3/terraform.tfstate.tflock
  Operation: OperationTypeApply
  Who:       sahil@laptop
  Created:   2026-07-21 09:14:22.5 +0000 UTC
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;StatusCode: 412&lt;/code&gt; and &lt;code&gt;PreconditionFailed&lt;/code&gt;. That 412 is the HTTP status S3 returns when a conditional write is refused because the object already exists. In other words, the lock is doing its job. Everything you need to identify who holds the lock is right there in the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steps to set it up
&lt;/h2&gt;

&lt;p&gt;Here is the full walkthrough for a fresh setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Check your Terraform version
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;use_lockfile&lt;/code&gt; needs Terraform 1.10.0 or newer. Check what you have.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;If you see anything older than 1.10, upgrade before continuing.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Create the S3 bucket for your state
&lt;/h3&gt;

&lt;p&gt;If you already have a state bucket you can skip ahead. If not, create one with the AWS CLI, which is the command line tool for talking to AWS. Bucket names have to be globally unique across all of AWS, so pick something specific to you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api create-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-terraform-state-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; ap-south-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-bucket-configuration&lt;/span&gt; &lt;span class="nv"&gt;LocationConstraint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ap-south-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small gotcha. Every region except &lt;code&gt;us-east-1&lt;/code&gt; needs that &lt;code&gt;--create-bucket-configuration LocationConstraint&lt;/code&gt; line. If you are using &lt;code&gt;us-east-1&lt;/code&gt;, drop that last line, because that region rejects it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Turn on versioning
&lt;/h3&gt;

&lt;p&gt;Versioning tells S3 to keep old copies of a file each time it changes. For a state file this is a safety net. If something ever corrupts your state, you can roll back to an earlier version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws s3api put-bucket-versioning &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; my-terraform-state-bucket &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--versioning-configuration&lt;/span&gt; &lt;span class="nv"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Write the backend block
&lt;/h3&gt;

&lt;p&gt;Now the good part. In your Terraform project, add the backend block with &lt;code&gt;use_lockfile = true&lt;/code&gt;. Notice there is no &lt;code&gt;dynamodb_table&lt;/code&gt; line anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-terraform-state-bucket"&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;"global/s3/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;use_lockfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;use_lockfile = true&lt;/code&gt; line is the whole feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Set the IAM permissions
&lt;/h3&gt;

&lt;p&gt;Because DynamoDB is gone, your permissions get shorter too. The role or user running Terraform now only needs S3 actions, and no DynamoDB actions at all. A minimal 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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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="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="s2"&gt;"s3:ListBucket"&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:s3:::my-terraform-state-bucket"&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="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="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&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:s3:::my-terraform-state-bucket/global/s3/terraform.tfstate*"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trailing &lt;code&gt;*&lt;/code&gt; on the resource is doing quiet but important work. It matches both the state file and the &lt;code&gt;.tflock&lt;/code&gt; lock file, since the lock file shares the same path with &lt;code&gt;.tflock&lt;/code&gt; tacked on. One rule covers both.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Initialize Terraform
&lt;/h3&gt;

&lt;p&gt;Run init to wire everything up. Terraform reads your backend block and gets the bucket ready.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  7. Test that the lock works
&lt;/h3&gt;

&lt;p&gt;Run a normal plan and apply.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;While the apply is running, open the S3 console or run &lt;code&gt;aws s3 ls&lt;/code&gt; on your bucket path. You will see a &lt;code&gt;terraform.tfstate.tflock&lt;/code&gt; object appear during the run and vanish once it finishes. If you want to see the lock in action, open a second terminal and start another &lt;code&gt;terraform apply&lt;/code&gt; while the first one is still going. The second one will hit that 412 lock error from earlier and refuse to run until the first is done. That is the whole point working exactly as designed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving over if you already use DynamoDB
&lt;/h2&gt;

&lt;p&gt;If you have an existing setup with a DynamoDB lock table, switching is easy and you do not have to do it all in one nervous jump.&lt;/p&gt;

&lt;p&gt;The cautious approach is to keep both options set for a short transition period. You can list &lt;code&gt;use_lockfile = true&lt;/code&gt; and &lt;code&gt;dynamodb_table&lt;/code&gt; at the same time, and Terraform will acquire locks in both places. That way nothing breaks while you and your team catch up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-terraform-state-bucket"&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;"global/s3/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-south-1"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-locks"&lt;/span&gt;
    &lt;span class="nx"&gt;use_lockfile&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After changing the backend, run init again with the reconfigure flag so Terraform picks up the new settings.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init &lt;span class="nt"&gt;-reconfigure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you are confident everyone is on Terraform 1.10 or newer and the S3 locking is working, remove the &lt;code&gt;dynamodb_table&lt;/code&gt; line, run &lt;code&gt;terraform init -reconfigure&lt;/code&gt; one more time, and then delete the DynamoDB table you no longer need.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a lock gets stuck
&lt;/h2&gt;

&lt;p&gt;Every so often a Terraform run crashes halfway, or someone closes their laptop mid apply. When that happens the &lt;code&gt;.tflock&lt;/code&gt; file can be left behind even though no operation is actually running. The next person then gets the lock error for a lock nobody holds.&lt;/p&gt;

&lt;p&gt;To clear it, grab the lock ID from the error output (the &lt;code&gt;ID&lt;/code&gt; field) and run force unlock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform force-unlock 3f6a1c9e-...-b2d4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this carefully. Only force unlock when you are sure no real Terraform operation is running anywhere, otherwise you are back to the exact race condition that locking was meant to prevent. If in doubt, ask your team before you unlock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this genuinely reduces complexity
&lt;/h2&gt;

&lt;p&gt;Pull back and look at what you removed. You went from two services to one. There is no DynamoDB table to create, name, tag, watch, or clean up. Your lock now lives in the same bucket as your state, so there is one place to look when you want to understand what is going on.&lt;/p&gt;

&lt;p&gt;The bootstrap problem shrinks too. Every Terraform team hits the same chicken and egg puzzle at the start. To use remote state you first need the backend infrastructure to exist, but that infrastructure is itself something you want to manage. With the old approach that meant standing up a bucket and a table before anything else. Now it is just a bucket.&lt;/p&gt;

&lt;p&gt;Your permissions shrink. No DynamoDB actions to grant means a shorter IAM policy and fewer things a reviewer has to reason about. Your Terraform code shrinks, because the whole &lt;code&gt;aws_dynamodb_table&lt;/code&gt; resource can be deleted. And you cut out a category of quiet failures, like a lock table sitting in the wrong region or a name that does not match the backend config.&lt;/p&gt;

&lt;p&gt;Same safety, fewer moving parts. That is a rare and welcome trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;State locking has always been one of those things you set up once, forget about, and only remember when it saves you from corrupting shared state. The mechanics used to lean on DynamoDB because S3 could not do the job alone. Now that S3 supports conditional writes, that reason is gone, and Terraform 1.10 gives you a one line switch to take advantage of it.&lt;/p&gt;

&lt;p&gt;If you are starting a new project, reach for &lt;code&gt;use_lockfile&lt;/code&gt; from day one. If you already have a DynamoDB table doing this work, plan a calm migration, keep both settings on for a bit, then retire the table. Your future self, staring at a much simpler backend block, will thank you.&lt;/p&gt;

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

&lt;p&gt;If you have questions, ran into a snag while trying this, or just want to talk Terraform and AWS, reach out at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;. I am always happy to help someone get unstuck.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>s3</category>
      <category>terraform</category>
      <category>dynamodb</category>
    </item>
    <item>
      <title>Build and Deploy a 3 Tier Serverless App on AWS with Terraform Modules</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Tue, 14 Jul 2026 04:59:11 +0000</pubDate>
      <link>https://dev.to/aws-builders/build-and-deploy-a-3-tier-serverless-app-on-aws-with-terraform-modules-32pe</link>
      <guid>https://dev.to/aws-builders/build-and-deploy-a-3-tier-serverless-app-on-aws-with-terraform-modules-32pe</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

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

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

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

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

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

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

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

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

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

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



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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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



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

&lt;/div&gt;



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

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



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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




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

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

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

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




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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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




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

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

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

&lt;/div&gt;



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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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




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

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

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

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

&lt;/div&gt;



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

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

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




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

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

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

&lt;/div&gt;



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

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




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

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

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

&lt;/div&gt;



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

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

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

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




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

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

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

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

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




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

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

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

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

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

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

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

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




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

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

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

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

&lt;/div&gt;



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

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




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

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

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

&lt;/div&gt;



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

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




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

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

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

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




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

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




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

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

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

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

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




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

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

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

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

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

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

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




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

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

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

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

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

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

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

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




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

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

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

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

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

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




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

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

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

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

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

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

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

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

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




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

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

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

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

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

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




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

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

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

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

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




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

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

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

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




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

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




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

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

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




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

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

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

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

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

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

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

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




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

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




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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;





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

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

&lt;/div&gt;





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

&lt;/div&gt;



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




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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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




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

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

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

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

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

&lt;/div&gt;



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

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

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




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

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

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

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

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

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

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

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

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




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

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

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

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

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




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

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

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

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

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

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

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




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

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

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

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

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

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

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

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




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

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

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




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

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

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




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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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




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

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

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

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

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




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

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

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

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

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

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

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

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

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

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




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

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

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




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

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

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

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

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

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




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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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




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

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

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

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




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

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

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

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




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

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

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




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

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

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




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