<?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: Hari Krishna Pokala</title>
    <description>The latest articles on DEV Community by Hari Krishna Pokala (@hari_k_d8e7c7a9a3c63bda9b).</description>
    <link>https://dev.to/hari_k_d8e7c7a9a3c63bda9b</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3879582%2F8ff17b3a-855c-4336-a783-999d79c0785a.jpg</url>
      <title>DEV Community: Hari Krishna Pokala</title>
      <link>https://dev.to/hari_k_d8e7c7a9a3c63bda9b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hari_k_d8e7c7a9a3c63bda9b"/>
    <language>en</language>
    <item>
      <title>Eliminating Static AWS Credentials From GitHub Actions With OIDC and Terragrunt</title>
      <dc:creator>Hari Krishna Pokala</dc:creator>
      <pubDate>Thu, 23 Apr 2026 22:29:44 +0000</pubDate>
      <link>https://dev.to/hari_k_d8e7c7a9a3c63bda9b/eliminating-static-aws-credentials-from-github-actions-with-oidc-and-terragrunt-4cbe</link>
      <guid>https://dev.to/hari_k_d8e7c7a9a3c63bda9b/eliminating-static-aws-credentials-from-github-actions-with-oidc-and-terragrunt-4cbe</guid>
      <description>&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;

&lt;p&gt;If you want to clone and run before reading:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update &lt;code&gt;bootstrap/terraform.tfvars&lt;/code&gt; and &lt;code&gt;terragrunt/account.hcl&lt;/code&gt; with your account details&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;cd bootstrap &amp;amp;&amp;amp; terraform init &amp;amp;&amp;amp; terraform apply&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;AWS_ROLE_ARN&lt;/code&gt; (bootstrap output) and &lt;code&gt;INFRACOST_API_KEY&lt;/code&gt; to GitHub Secrets&lt;/li&gt;
&lt;li&gt;Open a PR — Checkov, plan diff, and cost estimate appear as PR comments&lt;/li&gt;
&lt;li&gt;Merge to &lt;code&gt;develop&lt;/code&gt; → deploys to dev. Merge to &lt;code&gt;main&lt;/code&gt; → deploys to prod.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full repo: &lt;a href="https://github.com/krishph/terragrunt-aws-secure-starter" rel="noopener noreferrer"&gt;github.com/krishph/terragrunt-aws-secure-starter&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bootstrap Problem
&lt;/h2&gt;

&lt;p&gt;Most Terraform + GitHub Actions setups start with: &lt;em&gt;"Add your &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt; to GitHub Secrets."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That works. But it means a long-lived credential with broad AWS access is sitting in GitHub. Rotate it and you break the pipeline until every reference is updated. Leak it and someone can deploy — or destroy — your infrastructure.&lt;/p&gt;

&lt;p&gt;The better approach is OIDC. GitHub mints a short-lived token per workflow run, AWS verifies it matches a trust policy scoped to your specific repo, and the pipeline gets temporary credentials that expire in minutes. No secrets stored, no rotation burden.&lt;/p&gt;

&lt;p&gt;But there is a well-known catch: GitHub Actions needs an IAM role to access AWS, and creating that IAM role requires AWS access. You cannot use the pipeline to create the role the pipeline depends on.&lt;/p&gt;

&lt;p&gt;The solution is a one-time bootstrap step run locally with your personal credentials. After that, your credentials are never used again. This article walks through that pattern end to end — OIDC bootstrap, Terraform modules, Terragrunt multi-environment config, and a GitHub Actions pipeline with security scanning, cost estimation, and daily drift detection.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;The demo app is a URL shortener: &lt;code&gt;POST /shorten&lt;/code&gt; stores a URL in S3 and returns a 6-character code, &lt;code&gt;GET /{code}&lt;/code&gt; resolves it and 301-redirects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────────────────────────────────────────────────────────────────────────┐
│                                   AWS Account                                     │
│                                                                                   │
│  ┌────────────────┐                                                               │
│  │ POST /shorten  ├──┐   ┌──────────────────┐   ┌──────────────────────────────┐  │
│  └────────────────┘  ├──▶│  API Gateway     │   │ VPC (2 AZs)                  │  │
│  ┌────────────────┐  │   │  (REST)          ├──▶│  ┌────────────────────────┐  │  │
│  │  GET /{code}   ├──┘   └──────────────────┘   │  │    Private Subnet      │  │  │    ┌──────────────┐ 
│  └────────────────┘                             │  │  ┌──────────────────┐  │  │  │    | S3 Bucket    |
│                                                 │  │  │ Lambda (Py 3.12) ├──┼──┼──┼──▶ | (URL store)  |
│                                                 │  │  └──────────────────┘  │  │  │    └──────────────┘
│                                                 │  └────────────────────────┘  │  │           ▲
│                                                 └──────────────────────────────┘  │           |
│                                                        S3 Gateway endpoint        │           |
│                                                        (no NAT required) ─────────┼───--──────┘
└───────────────────────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub
  ├── plan (manual, env dropdown)
  │     ├── Checkov    →  blocks on misconfiguration
  │     ├── Infracost  →  posts cost estimate to job summary + PR comment
  │     └── Terragrunt plan  →  posts plan diff as PR comment
  │
  ├── apply (manual, env dropdown)
  │     └── terragrunt run-all apply  →  AWS (VPC → S3 → Lambda → API Gateway)
  │
  ├── destroy (manual, requires typing DESTROY)
  │     └── terragrunt run-all destroy  →  AWS
  │
  └── drift-detect (manual)
        └── plan -detailed-exitcode against dev + prod in parallel
            opens GitHub issue if live AWS ≠ Terraform state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why These Choices
&lt;/h2&gt;

&lt;p&gt;Before diving into the code, it is worth explaining the design decisions. These come up in every review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OIDC over static secrets&lt;/strong&gt; — Static keys do not expire, cannot be scoped to a single workflow run, and require a rotation process most teams skip. OIDC tokens are single-use and expire in minutes. The only tradeoff is the one-time bootstrap cost, which this article addresses directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terragrunt over raw Terraform&lt;/strong&gt; — Every Terraform module needs a backend block and a provider block. With three environments and four modules, that is twenty-four places to keep in sync. Terragrunt generates both from a single root config. The &lt;code&gt;dependency&lt;/code&gt; block also makes cross-module output references explicit, which is better than copy-pasting ARNs into &lt;code&gt;tfvars&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REST API Gateway over HTTP API&lt;/strong&gt; — HTTP API is cheaper and faster, but REST API supports per-stage deployments and WAF attachment without extra configuration. For a starter repo that people will extend, REST API is the safer default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 as the URL datastore&lt;/strong&gt; — DynamoDB would be more appropriate at scale, with indexing and single-digit millisecond reads. S3 is used here deliberately to keep the infrastructure surface small so the article stays focused on the IaC patterns rather than database provisioning. The tradeoff is that S3 object reads add ~10–30ms of latency compared to DynamoDB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 VPC endpoint over NAT for S3 traffic&lt;/strong&gt; — Lambda runs in private subnets and needs to reach S3. Without a VPC endpoint, every read and write goes through the NAT gateway at $0.045 per GB. An S3 Gateway endpoint is free and routes traffic directly on the AWS network. This is a practical detail most tutorials skip.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repository Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── bootstrap/                  ← run once from your local machine
│   ├── main.tf                 # OIDC provider, IAM role, state bucket, lock table
│   ├── variables.tf
│   ├── outputs.tf
│   └── terraform.tfvars        # ⚠️  update before running
│
├── terraform/modules/
│   ├── vpc/                    # VPC, subnets, IGW, NAT GW, S3 endpoint, Lambda SG
│   ├── s3/                     # App bucket (versioned, encrypted, private)
│   ├── lambda/                 # Function, IAM role, CloudWatch log group
│   └── apigw/                  # REST API Gateway → Lambda proxy
│
├── terragrunt/
│   ├── terragrunt.hcl          # root: remote state config + provider generation
│   ├── account.hcl             # ⚠️  update account ID, region, bucket name
│   └── environments/
│       ├── dev/  (vpc → s3 → lambda → apigw)
│       └── prod/ (same layout, different CIDRs and retention)
│
├── lambda/index.py
│
└── .github/workflows/
    ├── plan.yml                # PR: security scan + plan + cost estimate
    ├── apply.yml               # push to develop/main: apply
    ├── destroy.yml             # manual only, requires typing DESTROY
    └── drift-detection.yml     # daily scheduled plan against live infra
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Part 1: Breaking the Bootstrap Loop
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;bootstrap/&lt;/code&gt; folder is plain Terraform, no Terragrunt. You run it once from your machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it creates
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;GitHub OIDC provider&lt;/strong&gt; — registers &lt;code&gt;token.actions.githubusercontent.com&lt;/code&gt; as a trusted identity provider in your AWS account. This is what lets GitHub tokens be verified by AWS STS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IAM role with a repo-scoped trust policy:&lt;/strong&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"github_actions"&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="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role_name&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&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;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&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;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Federated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_openid_connect_provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github&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;Action&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:aud"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts.amazonaws.com"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;StringLike&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"token.actions.githubusercontent.com:sub"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"repo:${var.github_org}/${var.github_repo}:*"&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="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 &lt;code&gt;sub&lt;/code&gt; condition is the important part. Each GitHub token includes a &lt;code&gt;sub&lt;/code&gt; claim like &lt;code&gt;repo:org/repo:ref:refs/heads/main&lt;/code&gt;. The condition uses &lt;code&gt;StringLike&lt;/code&gt; with a wildcard so any branch or tag in your repo can assume the role, but no other repo can — even within the same GitHub organization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 state bucket and DynamoDB lock table&lt;/strong&gt; — Terragrunt's remote state backend must exist before any &lt;code&gt;terragrunt run-all&lt;/code&gt; command can run. Bootstrap creates these so the pipeline never hits a "bucket does not exist" error on its first run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on the IAM permissions&lt;/strong&gt; — The role policy is broad for a demo. It uses &lt;code&gt;lambda:*&lt;/code&gt;, &lt;code&gt;apigateway:*&lt;/code&gt;, and similar wildcards. In a production setup you would scope each action down to specific resource ARNs, and use IAM Access Analyzer to generate a least-privilege policy from actual usage after your first successful deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running it
&lt;/h3&gt;

&lt;p&gt;Update &lt;code&gt;bootstrap/terraform.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;github_org&lt;/span&gt;             &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-github-username"&lt;/span&gt;
&lt;span class="nx"&gt;github_repo&lt;/span&gt;            &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-repo-name"&lt;/span&gt;
&lt;span class="nx"&gt;terraform_state_bucket&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-unique-bucket-name"&lt;/span&gt;  &lt;span class="c1"&gt;# globally unique across all AWS accounts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update &lt;code&gt;terragrunt/account.hcl&lt;/code&gt; with your account ID and the same bucket name:&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;aws_region&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="nx"&gt;account_id&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"123456789012"&lt;/span&gt;
  &lt;span class="nx"&gt;terraform_state_bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-unique-bucket-name"&lt;/span&gt;
  &lt;span class="nx"&gt;terraform_lock_table&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-lock"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run:&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;bootstrap
terraform init
terraform apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output prints the role ARN. Add it to GitHub as a secret named &lt;code&gt;AWS_ROLE_ARN&lt;/code&gt;. That is the last time you touch AWS credentials manually.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Terraform Modules
&lt;/h2&gt;

&lt;p&gt;Each service gets its own module under &lt;code&gt;terraform/modules/&lt;/code&gt;. Modules are pure Terraform with no Terragrunt code, which keeps them reusable and independently testable.&lt;/p&gt;

&lt;h3&gt;
  
  
  VPC module — S3 endpoint is the detail most tutorials miss
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# S3 VPC endpoint routes Lambda → S3 traffic on the AWS network,&lt;/span&gt;
&lt;span class="c1"&gt;# avoiding NAT gateway data charges for every object read/write.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_endpoint"&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;vpc_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&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;service_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"com.amazonaws.${data.aws_region.current.region}.s3"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Gateway"&lt;/span&gt;
  &lt;span class="nx"&gt;route_table_ids&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gateway endpoints are free. The alternative — routing S3 traffic through NAT — costs $0.045 per GB. For a URL shortener with thousands of reads per day that adds up quickly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lambda module — source_code_hash forces redeployment on code changes
&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="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;function_name&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;lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&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;"python3.12"&lt;/span&gt;
  &lt;span class="nx"&gt;filename&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;filename&lt;/span&gt;
  &lt;span class="nx"&gt;source_code_hash&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;source_hash&lt;/span&gt;

  &lt;span class="nx"&gt;vpc_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnet_ids&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;private_subnet_ids&lt;/span&gt;
    &lt;span class="nx"&gt;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;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambda_security_group_id&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;Without &lt;code&gt;source_code_hash&lt;/code&gt;, Terraform only redeploys the function if its declared variables change. The hash ensures any change to the source code triggers a redeployment.&lt;/p&gt;

&lt;p&gt;One subtle point: hashing the zip file (&lt;code&gt;filebase64sha256(var.filename)&lt;/code&gt;) causes false positives because zip files embed timestamps. A zip rebuilt in CI from identical source will have a different hash than the previously deployed one, so Terraform always sees a change even when the code has not changed. The fix is to hash the source file directly and pass it as an input variable:&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;# terragrunt/environments/dev/lambda/terragrunt.hcl&lt;/span&gt;
&lt;span class="nx"&gt;inputs&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;filename&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${get_repo_root()}/lambda/handler.zip"&lt;/span&gt;
  &lt;span class="nx"&gt;source_hash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filebase64sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${get_repo_root()}/lambda/index.py"&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;Now the hash only changes when &lt;code&gt;index.py&lt;/code&gt; actually changes, and drift detection stops reporting false positives on every CI run.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Gateway module — proxy integration handles routing in Lambda
&lt;/h3&gt;

&lt;p&gt;The module uses a single &lt;code&gt;{proxy+}&lt;/code&gt; resource with &lt;code&gt;AWS_PROXY&lt;/code&gt; integration type, forwarding all paths and methods to Lambda. This means the function owns its own routing logic — adding a new endpoint requires no API Gateway changes, only a code update.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: Terragrunt — One Config, Two Environments
&lt;/h2&gt;

&lt;p&gt;The root &lt;code&gt;terragrunt.hcl&lt;/code&gt; generates the backend and provider blocks for every module automatically. No copy-pasting:&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;remote_state&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="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;"${local.environment}/${path_relative_to_include()}/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="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_region&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="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;terraform_lock_table&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;&lt;code&gt;path_relative_to_include()&lt;/code&gt; is what gives each module its own state file automatically. &lt;code&gt;dev/lambda/terragrunt.hcl&lt;/code&gt; gets the key &lt;code&gt;dev/lambda/terraform.tfstate&lt;/code&gt;. You do not configure this per module.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dependency&lt;/code&gt; block is what makes Terragrunt genuinely useful for multi-module setups:&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;# terragrunt/environments/dev/lambda/terragrunt.hcl&lt;/span&gt;
&lt;span class="nx"&gt;dependency&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;config_path&lt;/span&gt; &lt;span class="p"&gt;=&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;dependency&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;config_path&lt;/span&gt; &lt;span class="p"&gt;=&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;inputs&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dependency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;
  &lt;span class="nx"&gt;lambda_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dependency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lambda_security_group_id&lt;/span&gt;
  &lt;span class="nx"&gt;s3_bucket_arn&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dependency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket_arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;terragrunt run-all apply&lt;/code&gt; reads these dependencies, determines the correct order (VPC → S3 → Lambda → API Gateway), and applies them in sequence. No Makefile, no manual ordering.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: The URL Shortener
&lt;/h2&gt;

&lt;p&gt;The Lambda handler uses S3 as a simple key-value store — &lt;code&gt;POST /shorten&lt;/code&gt; generates a 6-character code and writes the original URL as an S3 object body, &lt;code&gt;GET /{code}&lt;/code&gt; reads that object and returns a 301 redirect. If the key does not exist, it returns a 404.&lt;/p&gt;

&lt;p&gt;boto3 is included in the AWS-managed Python 3.12 runtime, so the Lambda package is just the source file zipped: &lt;code&gt;zip -j lambda/handler.zip lambda/index.py&lt;/code&gt;. No dependency bundling needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: The Pipelines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  plan.yml — three jobs, manually triggered per environment
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Checkov security scan&lt;/strong&gt; runs first and independently of the plan. It scans the Terraform modules statically — no AWS API calls, no credentials needed. If it finds a misconfiguration (open security group, unencrypted bucket, Lambda without VPC config), the PR is blocked before any plan runs.&lt;/p&gt;

&lt;p&gt;Intentional suppressions live in &lt;code&gt;.checkov.yaml&lt;/code&gt; with a comment explaining why, so the next person does not re-investigate them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;skip-check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CKV_AWS_116&lt;/span&gt;   &lt;span class="c1"&gt;# Lambda DLQ — not needed for synchronous API use case&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CKV_AWS_272&lt;/span&gt;   &lt;span class="c1"&gt;# Lambda code signing — out of scope for this demo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Terragrunt plan&lt;/strong&gt; authenticates via OIDC — this is the first time the pipeline actually touches AWS — and posts the diff as a PR comment when triggered from a pull request, and to the job summary otherwise. The environment is selected from a dropdown when triggering the workflow manually, so you can plan against dev or prod independently of which branch you are on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS credentials via OIDC&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AWS_ROLE_ARN }}&lt;/span&gt;
    &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.AWS_REGION }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Infracost&lt;/strong&gt; posts a monthly cost estimate to the job summary on every run, and additionally as a PR comment when triggered from a pull request — updating the existing comment on re-runs rather than stacking duplicates.&lt;/p&gt;

&lt;p&gt;A reviewer now sees — in a single PR — what security posture changes, what infrastructure changes, and what the monthly bill changes by.&lt;/p&gt;

&lt;h3&gt;
  
  
  apply.yml — unattended on merge
&lt;/h3&gt;

&lt;p&gt;The apply pipeline is intentionally simple. The PR review is the approval gate. Pushing to &lt;code&gt;develop&lt;/code&gt; deploys to dev; pushing to &lt;code&gt;main&lt;/code&gt; deploys to prod. Once merged, &lt;code&gt;terragrunt run-all apply&lt;/code&gt; runs without prompts in the correct dependency order.&lt;/p&gt;

&lt;h3&gt;
  
  
  destroy.yml — manual only
&lt;/h3&gt;

&lt;p&gt;Destroy is &lt;code&gt;workflow_dispatch&lt;/code&gt; only — no branch trigger, no schedule. It requires navigating to the Actions tab, selecting the environment, and typing &lt;code&gt;DESTROY&lt;/code&gt; into a confirmation field before the job will run. There is no path that destroys infrastructure automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  drift-detection.yml — the practical one
&lt;/h3&gt;

&lt;p&gt;This pipeline runs on demand from the Actions tab using &lt;code&gt;plan -detailed-exitcode&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;exit 0 = no changes, live AWS matches state
exit 1 = error
exit 2 = changes detected, drift found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When drift is detected it opens a GitHub issue with the full plan diff. If an issue for that environment is already open, it adds a comment instead of creating a duplicate — so after a week of ignored drift you have one issue with comments, not seven identical issues.&lt;/p&gt;

&lt;p&gt;The most common source of drift in practice: someone made a manual fix in the AWS console under pressure, noted they would "put it in Terraform later," and did not. Drift detection is what catches that before it causes an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enabling a schedule&lt;/strong&gt; is a one-line change in the workflow file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;   &lt;span class="c1"&gt;# daily at 06:00 UTC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know before you enable it. First, scheduled GitHub Actions workflows do not have access to environment-scoped secrets — &lt;code&gt;AWS_ROLE_ARN&lt;/code&gt; must be set as a repository-level secret, not scoped to the &lt;code&gt;dev&lt;/code&gt; or &lt;code&gt;prod&lt;/code&gt; environment. Second, each run downloads providers and plans all modules in both environments, which takes around five minutes. If that generates too much noise, limit the matrix to prod only or switch to weekly (&lt;code&gt;0 6 * * 1&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  Production Tradeoffs
&lt;/h2&gt;

&lt;p&gt;Things this repo deliberately simplifies that a real production setup would revisit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IAM permissions&lt;/strong&gt; — The bootstrap role policy uses service-level wildcards (&lt;code&gt;lambda:*&lt;/code&gt;, &lt;code&gt;apigateway:*&lt;/code&gt;). Fine for a demo. For production, scope to specific resource ARNs and use IAM Access Analyzer on real usage logs to generate the minimum required policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NAT gateway vs VPC endpoint&lt;/strong&gt; — The VPC module includes an S3 Gateway endpoint to avoid NAT costs for S3 traffic. For other AWS services Lambda calls (SSM, Secrets Manager, STS), you would add Interface endpoints and remove NAT gateways entirely, reducing both cost and attack surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REST API Gateway vs HTTP API&lt;/strong&gt; — HTTP API is ~70% cheaper per million requests and has lower latency. REST API is used here because it supports WAF, per-stage throttling, and usage plans without additional configuration. If you do not need those features, switch to HTTP API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S3 vs DynamoDB for URL storage&lt;/strong&gt; — S3 object reads add ~10–30ms of latency. DynamoDB would be more appropriate at scale, with consistent single-digit millisecond reads and native support for TTL-based expiry of short codes. The tradeoff is another module and a DynamoDB table in every environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Module versioning&lt;/strong&gt; — The Terragrunt configs reference local modules directly with a relative path. In a multi-team setup, you would publish modules to a private registry or reference tagged Git releases so environments can pin to a specific version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drift detection scheduling&lt;/strong&gt; — Drift detection in this repo is manual by default. To run it on a schedule, uncomment the &lt;code&gt;schedule&lt;/code&gt; block in &lt;code&gt;drift-detection.yml&lt;/code&gt;. Teams that frequently change resources outside Terraform, or that use feature flags, often find daily scheduled runs noisy. A practical alternative is to run drift detection only on prod, or switch to weekly. See the note in the workflow file for the scheduling considerations.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Can Go Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;State bucket name collision&lt;/strong&gt; — S3 bucket names are globally unique across all AWS accounts. If someone already has your chosen bucket name, bootstrap will fail with &lt;code&gt;BucketAlreadyExists&lt;/code&gt;. Make the name specific: include your GitHub username and a project identifier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OIDC subject condition mismatch&lt;/strong&gt; — The trust policy uses &lt;code&gt;StringLike&lt;/code&gt; with &lt;code&gt;repo:org/repo:*&lt;/code&gt;. If you fork the repo, the fork's organization or username will not match. Pull requests from forks will not have access to secrets and the plan job will fail silently. This is expected behavior for security reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terragrunt dependency ordering surprise&lt;/strong&gt; — If you add a new module and forget to declare a &lt;code&gt;dependency&lt;/code&gt; block for it, &lt;code&gt;run-all apply&lt;/code&gt; may apply modules in parallel in an order that fails. The error message from Terragrunt is usually clear, but it can be confusing the first time. Always declare dependencies explicitly even if the ordering seems obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan output exceeding GitHub comment limits&lt;/strong&gt; — GitHub PR comments are capped at 65536 characters. The pipeline truncates plan output and appends &lt;code&gt;...(truncated)&lt;/code&gt; if it exceeds the limit. For very large plans, use the Actions run log instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DynamoDB lock not released after a failed run&lt;/strong&gt; — If a pipeline run is cancelled mid-apply, the DynamoDB lock may not be released. The next run will fail with &lt;code&gt;Error acquiring the state lock&lt;/code&gt;. Release it with &lt;code&gt;terraform force-unlock &amp;lt;LOCK_ID&amp;gt;&lt;/code&gt; run from the relevant module directory.&lt;/p&gt;




&lt;h2&gt;
  
  
  End-to-End Test
&lt;/h2&gt;

&lt;p&gt;Once deployed, get the API Gateway URL:&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;terragrunt/environments/dev/apigw
terragrunt output invoke_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shorten a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;invoke-url&amp;gt;/dev/shorten &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"url": "https://devto.com"}'&lt;/span&gt;

&lt;span class="c"&gt;# {"code": "aB3xYz", "short_url": "https://&amp;lt;invoke-url&amp;gt;/dev/aB3xYz"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolve it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-L&lt;/span&gt; https://&amp;lt;invoke-url&amp;gt;/dev/aB3xYz
&lt;span class="c"&gt;# 301 → https://devto.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No static credentials&lt;/td&gt;
&lt;td&gt;GitHub OIDC → temporary STS tokens per run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bootstrap chicken-and-egg&lt;/td&gt;
&lt;td&gt;One-time local &lt;code&gt;terraform apply&lt;/code&gt; in &lt;code&gt;bootstrap/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DRY multi-environment config&lt;/td&gt;
&lt;td&gt;Terragrunt root config + &lt;code&gt;dependency&lt;/code&gt; blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security misconfigs caught early&lt;/td&gt;
&lt;td&gt;Checkov on every PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost visibility before merge&lt;/td&gt;
&lt;td&gt;Infracost posts breakdown as PR comment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT costs for S3 traffic&lt;/td&gt;
&lt;td&gt;S3 VPC Gateway endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual changes caught&lt;/td&gt;
&lt;td&gt;On-demand drift detection, GitHub issue on drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accidental destroy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;workflow_dispatch&lt;/code&gt; only + DESTROY confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What to Add Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terratest&lt;/strong&gt; — write Go tests that deploy to a throwaway environment, hit the API, and destroy. Gives you infrastructure integration tests, not just plan validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domain&lt;/strong&gt; — ACM + Route 53 + API Gateway custom domain so short codes are on your own domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAF&lt;/strong&gt; — attach a Web ACL to API Gateway to rate-limit the &lt;code&gt;/shorten&lt;/code&gt; endpoint against abuse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Least-privilege IAM&lt;/strong&gt; — use IAM Access Analyzer on the first successful deploy to generate a scoped policy for the GitHub Actions role.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Full source: &lt;a href="https://github.com/krishph/terragrunt-aws-secure-starter" rel="noopener noreferrer"&gt;github.com/krishph/terragrunt-aws-secure-starter&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Hari Krishna Pokala&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>github</category>
      <category>security</category>
      <category>terraform</category>
    </item>
  </channel>
</rss>
