<?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: Derek Jackson</title>
    <description>The latest articles on DEV Community by Derek Jackson (@derek_jackson_0507a460026).</description>
    <link>https://dev.to/derek_jackson_0507a460026</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%2F4071884%2F594d47c6-5075-43be-bc86-157e0c024e5d.png</url>
      <title>DEV Community: Derek Jackson</title>
      <link>https://dev.to/derek_jackson_0507a460026</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/derek_jackson_0507a460026"/>
    <language>en</language>
    <item>
      <title>I Built a Serverless Resume Site on AWS. Here's Everything That Broke Along the Way</title>
      <dc:creator>Derek Jackson</dc:creator>
      <pubDate>Tue, 08 Sep 2026 18:19:10 +0000</pubDate>
      <link>https://dev.to/derek_jackson_0507a460026/i-built-a-serverless-resume-site-on-aws-heres-everything-that-broke-along-the-way-4lml</link>
      <guid>https://dev.to/derek_jackson_0507a460026/i-built-a-serverless-resume-site-on-aws-heres-everything-that-broke-along-the-way-4lml</guid>
      <description>&lt;p&gt;After a decade in insurance operations, I decided to make my pivot into cloud engineering official by doing the &lt;a href="https://cloudresumechallenge.dev/" rel="noopener noreferrer"&gt;Cloud Resume Challenge&lt;/a&gt;! I wanted to build it according to real-world best practices, so in addition to the steps the challenge provides, I incorporated a private S3 bucket secured with CloudFront Origin Access Control, a dedicated least-privilege IAM user scoped to only the permissions my CI/CD pipeline needed, and mocked unit tests with moto so nothing touches real AWS resources during testing.&lt;/p&gt;

&lt;p&gt;The site is live at &lt;a href="https://derekjackson.click" rel="noopener noreferrer"&gt;derekjackson.click&lt;/a&gt;. It's a static resume served over &lt;strong&gt;HTTPS&lt;/strong&gt; through &lt;strong&gt;CloudFront&lt;/strong&gt;, backed by a &lt;strong&gt;Lambda&lt;/strong&gt;-powered visitor counter, fully codified in &lt;strong&gt;Terraform&lt;/strong&gt;, and deployed automatically via &lt;strong&gt;GitHub Actions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's how I built it and, more importantly, everything that went wrong along the way. &lt;/p&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Frontend:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; stores the static site, fully private with &lt;strong&gt;Block Public Access&lt;/strong&gt; on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront&lt;/strong&gt; sits in front of S3, using &lt;strong&gt;Origin Access Control (OAC)&lt;/strong&gt; so CloudFront can read the private bucket. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Certificate Manager&lt;/strong&gt;  issues the HTTPS certificate (requested in &lt;strong&gt;us-east-1&lt;/strong&gt;, a hard CloudFront requirement regardless of where the rest of the infrastructure is deployed.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route 53&lt;/strong&gt; hosts the domain and holds the alias record pointing it at CloudFront.&lt;/li&gt;
&lt;/ul&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%2F50wphmm4qiqje6ic56us.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%2F50wphmm4qiqje6ic56us.png" alt="Diagram showing the frontend request path: visitor to Route 53 to CloudFront to S3" width="799" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB&lt;/strong&gt; has a single item, on-demand table holds the visitor count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lambda&lt;/strong&gt; (Python) atomically increments that count on every request, avoiding race conditions from concurrent visitors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Gateway&lt;/strong&gt; (HTTP API) exposes the Lambda over a public &lt;code&gt;GET /visitor-count&lt;/code&gt; endpoint the frontend calls when the page is loaded.&lt;/li&gt;
&lt;/ul&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%2F7njhlq8fw2ckcl3kii85.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%2F7njhlq8fw2ckcl3kii85.png" alt="Diagram showing the backend visitor counter path: browser to API Gateway to Lambda to DynamoDB" width="799" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operations:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Code is written, stored and edited locally.
2.&lt;strong&gt;pytest&lt;/strong&gt; and &lt;strong&gt;moto&lt;/strong&gt; test the Lambda function against a fully mocked DynamoDB before anything touches AWS.
3.After successful testing, &lt;code&gt;git push&lt;/code&gt; commits the code to &lt;code&gt;main&lt;/code&gt; initiating the pipeline.
4.&lt;strong&gt;GitHub Actions&lt;/strong&gt; deploys frontend changes to S3 and backend changes to Lambda on every push to &lt;code&gt;main&lt;/code&gt;.
5.&lt;strong&gt;Terraform&lt;/strong&gt; codifies and imports all 13 live resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Hiccup #1: My Domain Registration Just... Failed
&lt;/h3&gt;

&lt;p&gt;Before I could even get to work, my &lt;strong&gt;Route 53&lt;/strong&gt; domain registration failed with a very unhelpful error: &lt;em&gt;"We can't finish registering your domain."&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;Turns out the cause was that upgrading my account from the free tier to a paid plan had triggered a &lt;strong&gt;fraud alert&lt;/strong&gt; on AWS's side, which blocked the registration.&lt;/p&gt;

&lt;p&gt;I opened a support case and it sat unassigned for two more days with no response. Instead of waiting around indefinitely, I signed up for a free trial of the &lt;strong&gt;Business Support+&lt;/strong&gt; plan specifically to get a faster response time, and kept building everything that didn't depend on DNS (Terraform, tests, and GitHub) in the meantime. Once Support resolved the fraud flag on their end and the domain came through clean, i was ready for my next step!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Do not let a third party's blocked dependency stop your progress. Real projects often depend on outside factors that you don't control. Focus on what you can control and hopefully, by the time you finish, the impediment will have been resolved.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hiccup #2: My SSL Certificate Died While I Was Waiting
&lt;/h3&gt;

&lt;p&gt;The good times continued to roll as while I was waiting on the domain to resolve, I requested an &lt;strong&gt;ACM certificate&lt;/strong&gt; for &lt;strong&gt;DNS validation&lt;/strong&gt;. As you may know, DNS validation requires a &lt;strong&gt;Route 53 hosted zone&lt;/strong&gt;, which didn't exist yet because the domain wasn't registered. Chicken meet egg.&lt;/p&gt;

&lt;p&gt;By the time the domain finally came through, &lt;strong&gt;ACM&lt;/strong&gt; had given up and marked my original certificate request as &lt;strong&gt;FAILED&lt;/strong&gt;. Of course I didn't realize this until &lt;strong&gt;Terraform&lt;/strong&gt; tried to import it and AWS rejected the import outright "no object exists with the given id."&lt;/p&gt;

&lt;p&gt;I verified the failure directly with the &lt;strong&gt;AWS CLI&lt;/strong&gt; (&lt;code&gt;aws acm list-certificates&lt;/code&gt;) rather than trusting the Terraform error alone, confirmed it was genuinely dead, and requested a fresh certificate once the hosted zone existed. Clean validation, no drama the second time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Always verify errors at the source. Also a resource can fail silently while you wait on something else. Whenever you are ready to take your next step its never a bad idea to ensure any dependent resources are in their correct state. &lt;/p&gt;

&lt;h3&gt;
  
  
  Hiccup #3: Terraform and the Console infrastructure did not align
&lt;/h3&gt;

&lt;p&gt;I wanted to build the infrastructure in the console first, for practice and so that I could visualize each service and their setup before importing everything into Terraform. As I'm sure you can imagine, there were a few small discrepancies between what Terraform assumed and what AWS actually had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My Lambda's &lt;strong&gt;IAM role&lt;/strong&gt; lived under &lt;code&gt;/service-role/&lt;/code&gt;, but my Terraform block didn't specify a path, so Terraform used the default IAM path &lt;code&gt;/&lt;/code&gt; and wanted to destroy and recreate the role that was set up in the console. This was caught in the terraform plan output before being applied.&lt;/li&gt;
&lt;li&gt;A transcription error (irntxzj vs. irnntxzj) caused an import to fail with 'non-existent remote object'. This was resolved by querying AWS directly via the CLI for the correct role.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;-Terraform initiated to &lt;code&gt;hashicorp/aws&lt;/code&gt; v5.0, but I was using the Python 3.14 Lambda runtime which required upgrading to v6, since v5.0 did not recognize it as a valid runtime value. That upgrade caused &lt;code&gt;terraform plan&lt;/code&gt; to want to recreate my already-imported ACM certificate, even though &lt;code&gt;terraform state show&lt;/code&gt; confirmed the state entry was fully intact. I learned that this is a known issue with major provider version jumps and was able to fix it with a &lt;code&gt;terraform state rm&lt;/code&gt; and re-import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Always run &lt;code&gt;terraform plan&lt;/code&gt; before &lt;code&gt;terraform apply&lt;/code&gt;, and review its output closely, especially any line showing a destroy or replace. Treat a mismatch between the plan and what is actually deployed as a signal to fix the &lt;code&gt;.tf&lt;/code&gt; file, not a signal to let Terraform "correct" a resource that already works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hiccup #4: My Text Editor Corrupted My Code
&lt;/h3&gt;

&lt;p&gt;This one is a total rookie mistake. Initially, I saved my &lt;code&gt;script.js&lt;/code&gt; file as an Apple &lt;strong&gt;Pages&lt;/strong&gt; document instead of using &lt;strong&gt;TextEdit&lt;/strong&gt; and its &lt;strong&gt;Plain Text&lt;/strong&gt; mode. Rich text formats embed hidden formatting metadata, so the "code" that got uploaded was unreadable. Thankfully, it was an easy fix. I just changed the format to "Make Plain Text".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Always confirm the format before saving anything code-related, and to run &lt;code&gt;cat filename&lt;/code&gt; in Terminal after every edit to verify things were saved as intended.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hiccup #5: My Local Python Environment Fought Back
&lt;/h3&gt;

&lt;p&gt;I ran &lt;code&gt;pip install&lt;/code&gt; to install my test dependencies. Homebrew labels its system Python as externally managed, so &lt;code&gt;pip&lt;/code&gt; refused to install packages into it directly and threw an &lt;code&gt;externally-managed-environment&lt;/code&gt; error. To resolve this, I created an isolated Python virtual environment (venv) to get past it. &lt;br&gt;
My first local test run inside the venv then failed with &lt;code&gt;botocore.exceptions.NoRegionError&lt;/code&gt;. When Lambda runs a function, AWS automatically sets the region for boto3 to use. My local test ran outside of Lambda, so that automatic region setting did not exist, and boto3 raised the error. To resolve this, I added an explicit &lt;code&gt;region_name="us-east-1"&lt;/code&gt; to the boto3 client in the Lambda function itself, then synced that same change into the deployed Lambda code so the local and live environments matched.&lt;/p&gt;

&lt;p&gt;Lesson: The venv let my test run without an error, but that didn't mean my code was actually correct. My code assumed AWS would supply a region, which Lambda does automatically in production but my local environment did not. The venv fixed a separate problem: pip being blocked, and never touched the missing region. Only adding &lt;code&gt;region_name="us-east-1"&lt;/code&gt; fixed that. I learned that a test passing only confirms nothing crashed. It does not confirm that the code's assumptions about its environment, like where the region comes from, are actually correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hiccup #6: My Own GitHub Push Got Rejected
&lt;/h3&gt;

&lt;p&gt;The push that added my GitHub Actions workflow file got rejected with this error: "refusing to allow a Personal Access Token to create or update workflow &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt; without workflow scope." As a security measure, GitHub requires a token to have workflow scope before it can add or change a workflow file. &lt;/p&gt;

&lt;p&gt;To fix it, I generated a new token with the workflow scope added. I then cleared the old cached token from macOS Keychain, so Git would use the new token instead of the old one. The push went through after that.&lt;/p&gt;

&lt;p&gt;To ensure the CI/CD pipeline was secure, I made sure it did not use my personal AWS credentials, which have broad permissions across my account. Instead, I created a dedicated &lt;code&gt;github-actions-deploy&lt;/code&gt; IAM user with a custom policy scoped to three actions: S3 operations on one named bucket, &lt;code&gt;lambda:UpdateFunctionCode&lt;/code&gt; on one named function, and &lt;code&gt;loudfront:CreateInvalidation&lt;/code&gt; on one named distribution. The policy grants nothing beyond those three actions, so if there was ever a leak, an attacker could only affect this one project, not my entire AWS account.&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%2Fawuf97r1cdfqkcmcq2x4.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%2Fawuf97r1cdfqkcmcq2x4.png" alt="Screenshot of GitHub Actions showing two successful workflow runs with green checkmarks" width="800" height="197"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What's Next
&lt;/h3&gt;

&lt;p&gt;This is the first of three projects I'm building to round out my AWS portfolio:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloud Resume Challenge&lt;/strong&gt; (this one) — serverless fundamentals&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;automated insurance claims document processor&lt;/strong&gt; using S3, Lambda, Textract, and DynamoDB — connecting my actual insurance operations background to AWS AI/data engineering&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;high-availability, multi-tier web app&lt;/strong&gt; on a custom VPC with an ALB, Auto Scaling, and Multi-AZ RDS — proving out traditional enterprise networking fundamentals&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're doing the Cloud Resume Challenge yourself, or you've hit any of these same walls, I'd love to hear about it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Proof of work:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live demo: &lt;a href="https://derekjackson.click" rel="noopener noreferrer"&gt;derekjackson.click&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/DIJSAA/cloud-resume-challenge" rel="noopener noreferrer"&gt;github.com/DIJSAA/cloud-resume-challenge&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Connect with me: &lt;a href="https://linkedin.com/in/derekijackson89" rel="noopener noreferrer"&gt;linkedin.com/in/derekijackson89&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>cloudresumechallenge</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built an Automated Insurance Claims Processor on AWS. Terraform Broke It in an Interesting Way.</title>
      <dc:creator>Derek Jackson</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:08:31 +0000</pubDate>
      <link>https://dev.to/derek_jackson_0507a460026/i-built-an-automated-insurance-claims-processor-on-aws-terraform-broke-it-in-an-interesting-way-47j5</link>
      <guid>https://dev.to/derek_jackson_0507a460026/i-built-an-automated-insurance-claims-processor-on-aws-terraform-broke-it-in-an-interesting-way-47j5</guid>
      <description>&lt;p&gt;I built an automated insurance claims document processor on AWS. A claim PDF is uploaded and you get back a clean, structured database record with no manual data entry. Then I rebuilt the whole system in Terraform. The rebuild caused tricky bugs at several stages. This post walks through those bugs in detail. Each bug followed the same pattern: everything looks fine, nothing works, and no error appears anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this project&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As someone with a background in insurance operations, I wanted at least one of my AWS portfolio projects to reflect that. I chose a build that bridges my operational expertise with Fintech, creating a natural translation of my real-world domain knowledge into the cloud ecosystem. &lt;/p&gt;

&lt;p&gt;The pipeline processes a Long-Term Disability (LTD) claim form. This form is modeled on the structure of a real LTD claim and extracts an allowlist of the 14 fields necessary for claims processing: policy number, date last worked, coverage start date, etc. &lt;/p&gt;

&lt;p&gt;I decided to build it twice. First manually through the AWS Console, to visualize each service and their relationship with one another. Then I rebuilt the entire thing as IaC using Terraform, so the infrastructure is fully reproducible. I split the code into modules for compute, database ,iam, messaging and storage, and used the rebuild to add an SQS queue and DLQ that the manual build did not have. That second pass is where things got interesting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture&lt;/strong&gt;&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%2Fvd13zcntn9iw4ufjbck3.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%2Fvd13zcntn9iw4ufjbck3.png" alt=" " width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A claim form is uploaded s3://bucket/incoming/. The upload triggers Lambda #1. Lambda #1 starts an asynchronous Textract job and returns immediately.&lt;/p&gt;

&lt;p&gt;Why asynchronous? This is a multi-page document. Textract's synchronous API only handles single-page documents and it's bound by Lambda's execution timeout. So an asynchronous job was the only option.&lt;/p&gt;

&lt;p&gt;Textract reads the S3 object. When the job finishes, Textract publishes a completion message to an SNS topic. SNS delivers this message to an SQS queue. A dead-letter queue (DLQ) sits behind this SQS queue to catch failed messages.&lt;/p&gt;

&lt;p&gt;The SQS layer adds automatic retries (up to 3 attempts), and a safety net for messages that fail permanently. Without the DLQ, an unread form would be lost.&lt;/p&gt;

&lt;p&gt;Once the message is delivered to SQS, it triggers Lambda #2. Lambda #2 retrieves the full Textract results, filters the results down to the 14-field allowlist and writes an item to DynamoDB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rebuilding in Terraform: three hiccups&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Console-built version worked without any of the problems below. Each problem I encountered was a result of the additions I made in the Terraform version. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problems and Fixes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. S3 bucket name collision&lt;/strong&gt;&lt;br&gt;
Although my original bucket was destroyed during the teardown, the S3 namespace can take time to release a deleted name. This triggering a temporary collision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;**Error**: creating S3 Bucket (derek-jackson-claims-processor-2026): BucketAlreadyExists

  with module.storage.aws_s3_bucket.claims,
  on modules/storage/main.tf line 1, in resource "aws_s3_bucket" "claims":
   1: resource "aws_s3_bucket" "claims" {
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: I removed the hardcoded bucket name and generated a random suffix with Terraform's random_id resource instead. A small tweak, but a permanent fix for any future rebuild.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A three-stage KMS permission chain&lt;/strong&gt;&lt;br&gt;
The console version used AWS's default encryption everywhere. When I did the Terraform build, I decided to use a Customer Managed Key (CMK) — rather than AWS's default managed keys — for S3, DynamoDB, SNS, and SQS, in order to control exactly which services and roles could use the key. Compliance-driven environments commonly require CMKs for this reason. Although I used a single key for simplicity, in a live environment, the principle of least privilege would dictate using isolated keys per resource to minimize the blast radius.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson here&lt;/strong&gt;: a KMS key's own policy is a separate trust boundary from IAM, which wasn't obvious to me until I hit it. You can grant a Lambda's IAM role &lt;code&gt;kms:Decrypt&lt;/code&gt; on the key. But that grant does not let another AWS service, acting on your behalf, use that key. The key's own policy must name that service directly because, by default, a CMK only trusts standard users and roles within your account and automatically blocks automated AWS services.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pipeline touches three separate points where a service other than a Lambda role needs to work with KMS-encrypted data, and each failure was completely silent; nothing threw a visible error, things just stopped working one step further down the chain:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Textract → S3 input (read)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bug&lt;/strong&gt;: Textract's async jobs run in Textract's own backend using it's own service identity, not the Lambda's temporary credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix&lt;/strong&gt;: I granted &lt;code&gt;textract.amazonaws.com&lt;/code&gt; the &lt;code&gt;kms:Decrypt&lt;/code&gt; permission directly in the CMK's key policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Textract → SNS topic (publish)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bug&lt;/strong&gt;: I confirmed via the CLI that the Textract job itself was succeeded — &lt;code&gt;JobStatus: SUCCEEDED&lt;/code&gt; — but nothing was reaching SQS. The dedicated &lt;code&gt;textract-sns-role&lt;/code&gt; had &lt;code&gt;sns:Publish&lt;/code&gt; permission but no KMS permission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix&lt;/strong&gt;: I added &lt;code&gt;kms:GenerateDataKey&lt;/code&gt; and &lt;code&gt;kms:Decrypt&lt;/code&gt; to &lt;code&gt;textract-sns-role&lt;/code&gt;'s IAM policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SNS → SQS queue (delivery)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bug&lt;/strong&gt;: this is a documented AWS architectural requirement, not something I found by trial and error. The SNS service principal itself needs an explicit KMS grant to deliver messages into an encrypted SQS queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix&lt;/strong&gt;: I granted &lt;code&gt;sns.amazonaws.com&lt;/code&gt; the same two KMS actions in the key's policy. After applying this fix, a message finally reached the dead-letter queue! Showing progress, and confirming the full Textract → SNS → SQS chain was finally delivering messages. It landed in the DLQ rather than succeeding outright because Lambda #2 still had a separate bug which I addressed next.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lesson here&lt;/strong&gt;: whenever Service A hands data to Service B, and B's resource is encrypted with a customer managed key, Service A needs its own grant on that key. Being able to call the API (&lt;code&gt;sns:Publish&lt;/code&gt;, &lt;code&gt;sqs:SendMessage&lt;/code&gt;) and being able to use the KMS key the resource is encrypted with are separate permissions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;3. SQS vs. SNS event shape&lt;/strong&gt;&lt;br&gt;
With the KMS chain resolved, a message finally reached SQS, but Lambda #2 was still failing. Fortunately, now with a visible error:&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Sns&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="nc"&gt;Traceback &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;most&lt;/span&gt; &lt;span class="n"&gt;recent&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
  &lt;span class="n"&gt;File&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/task/lambda_function.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lambda_handler&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="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;Sns&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;Message&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;Lambda #2's code assumed a direct SNS trigger. But this architecture routes through SQS and since the SNS→SQS subscription doesn't have raw message delivery enabled, SQS wraps the entire SNS envelope as a JSON string inside its own &lt;code&gt;body&lt;/code&gt; field leaving two layers to unwrap, not one:&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="n"&gt;sns_envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="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;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sns_envelope&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Message&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;This is a good example of an implicit contract bug: two pieces of code each made a reasonable assumption independently, and those assumptions quietly contradicted each other.  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging method&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I verified each hop in the chain on its own, starting closest to the source:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Queried Textract's job status directly via the CLI. This step bypasses the entire pipeline, and confirmed the Textract job itself succeeded, ruling that stage out and pointing to a problem in the message delivery.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Checked SQS and DLQ message counts. This step confirms whether a message ever reached SQS. It showed no message arriving. This pointed to a delivery failure between SNS and SQS, caused by the missing KMS grants.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;After adding the missing grants, I checked CloudWatch logs which surfaced the &lt;code&gt;KeyError: 'Sns'&lt;/code&gt; error. This error pointed to the SQS/SNS event-shape mismatch in Lambda #2.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;4.Fixed the event-shape mismatch by unwrapping the SQS body before the SNS message inside it. This change let Lambda #2 correctly parse the message. It processed the claim data without error. The pipeline then ran end to end, from upload to a clean DynamoDB record, with no further issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof it works&lt;/strong&gt;&lt;br&gt;
The original console-built pipeline, tested against a real-structured LTD claim form:&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%2Fjpr89nrohll6zjcosk5o.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%2Fjpr89nrohll6zjcosk5o.png" alt=" " width="800" height="357"&gt;&lt;/a&gt;&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%2Fvxzg37hqgdnopnvgdvs5.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%2Fvxzg37hqgdnopnvgdvs5.png" alt=" " width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rebuilt Terraform pipeline, tested end-to-end against a second mock document with intentionally obvious fake data:&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%2Fuca4dwc5kfl4d2yn9vbu.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%2Fuca4dwc5kfl4d2yn9vbu.png" alt=" " width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;DynamoDB item from the Terraform rebuild, part 1 — claim_id, city: Sampleville, coverage_start_date, date_last_worked, date_of_birth, earnings, employer_contact_name, employer_name: Acme Test Corp&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%2Flmmkl6n8qz6dcgz3pjag.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%2Flmmkl6n8qz6dcgz3pjag.png" alt=" " width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;DynamoDB item from the Terraform rebuild, part 2 — full_name: John Q. Testperson, job_title, policy_number: TEST-000000, social_security_number: 123-45-6789, state, street_address, zip_code: 00000&lt;/p&gt;

&lt;p&gt;And the dead-letter queue moment that confirmed the KMS chain was finally resolved — the first message to make it all the way from Textract through SNS to SQS:&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%2Fssvjfzrrx1sdgo2fo6qi.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%2Fssvjfzrrx1sdgo2fo6qi.png" alt=" " width="799" height="148"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;SQS console showing the callback queue at 0 messages and the dead-letter queue with 1 message available&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd do next time: AWS Lambda durable functions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AWS released Lambda durable functions in December 2025. This feature fits the "start a task, wait for a callback" pattern used in this project.&lt;/p&gt;

&lt;p&gt;Using durable functions would merge Lambda #1 and Lambda #2 into a single function which would use a waitForCallback() operation. This change would remove the SQS/DLQ layer, because durable functions would handle retry and durability themselves.&lt;/p&gt;

&lt;p&gt;A thin relay lambda would still be necessary since Textract can't call the durable execution callback API directly. The relay function would translate the SNS notification into that call.&lt;/p&gt;

&lt;p&gt;The tradeoff with this approach is the SQS/DLQ layer is no longer needed, which also removes the need for the SNS to SQS KMS grant. This simplified infrastructure would only require a relay function rather than a queue-based messaging layer with its own encryption requirements.&lt;/p&gt;

&lt;p&gt;Code and Terraform modules for this project are on my GitHub: &lt;a href="https://github.com/DIJSAA/claims-processor-terraform" rel="noopener noreferrer"&gt;https://github.com/DIJSAA/claims-processor-terraform&lt;/a&gt;&lt;/p&gt;

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