<?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: Nnamdi Felix Ibe</title>
    <description>The latest articles on DEV Community by Nnamdi Felix Ibe (@ndcodes).</description>
    <link>https://dev.to/ndcodes</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%2F3994627%2F3c1e9fc3-98cd-45c4-9e34-3ef72ece6604.jpg</url>
      <title>DEV Community: Nnamdi Felix Ibe</title>
      <link>https://dev.to/ndcodes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ndcodes"/>
    <language>en</language>
    <item>
      <title>Day 38: A Tag Is a Name, Not a Copy, and RUNNING Is Not Reachable</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Thu, 10 Sep 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-38-a-tag-is-a-name-not-a-copy-and-running-is-not-reachable-19op</link>
      <guid>https://dev.to/ndcodes/day-38-a-tag-is-a-name-not-a-copy-and-running-is-not-reachable-19op</guid>
      <description>&lt;p&gt;I failed today's AWS task on the first attempt, with every resource created correctly and every status field green. That is the more useful half of this post, so it gets the space.&lt;/p&gt;

&lt;p&gt;The Docker half sets it up neatly, because both are about the gap between a label and the thing it labels.&lt;/p&gt;

&lt;p&gt;One Docker task, one AWS task. Pull an image and give it a second tag, then build and run a container on ECS Fargate. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  docker tag copies nothing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull &amp;lt;image&amp;gt;:&amp;lt;tag&amp;gt;
docker tag &amp;lt;image&amp;gt;:&amp;lt;tag&amp;gt; &amp;lt;image&amp;gt;:&amp;lt;new-tag&amp;gt;
docker images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that and &lt;code&gt;docker images&lt;/code&gt; shows two rows, two different tags, and one identical &lt;code&gt;IMAGE ID&lt;/code&gt;. Disk usage does not move. &lt;code&gt;docker tag&lt;/code&gt; did not duplicate anything; it added a second name pointing at the same image.&lt;/p&gt;

&lt;p&gt;Once that lands, two other behaviours stop being surprising. &lt;code&gt;docker rmi&lt;/code&gt; on one of two tags prints &lt;code&gt;Untagged:&lt;/code&gt; rather than &lt;code&gt;Deleted:&lt;/code&gt;, because removing a name is not removing an image, and the layers survive while anything else references them. And an image reference is really &lt;code&gt;[registry/][namespace/]repository[:tag]&lt;/code&gt;, so &lt;code&gt;docker pull ubuntu&lt;/code&gt; silently means &lt;code&gt;docker.io/library/ubuntu:latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last one is the bridge to the AWS half, and it is worth stating plainly: the registry is part of the name. Not a setting, not a flag, part of the string.&lt;/p&gt;

&lt;p&gt;The other thing worth carrying: a tag is a mutable pointer. The same tag can point at a different image next month, and &lt;code&gt;docker pull&lt;/code&gt; on an unchanged tag can return different layers. &lt;code&gt;image@sha256:...&lt;/code&gt; pins a digest and is the only reference that is genuinely immutable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every resource correct, task failed
&lt;/h2&gt;

&lt;p&gt;Six resources: an ECR repository, an image built and pushed, a Fargate cluster, a task definition, a service, and a running task.&lt;/p&gt;

&lt;p&gt;All six came up clean. The service read ACTIVE. &lt;code&gt;runningCount&lt;/code&gt; matched &lt;code&gt;desiredCount&lt;/code&gt;. The task read &lt;code&gt;RUNNING&lt;/code&gt; with no &lt;code&gt;stoppedReason&lt;/code&gt;. The container had pulled from ECR without complaint. I checked all of that, concluded it worked, and submitted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application is not accessible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing had opened port 80. The service used the default security group, which permits inbound traffic only from itself. &lt;code&gt;assignPublicIp=ENABLED&lt;/code&gt; had given the task a public IP, so it could reach out to ECR to pull the image, but security groups are stateful and directional: outbound worked, inbound never did. nginx was listening on a routable address that no packet could arrive at.&lt;/p&gt;

&lt;p&gt;One rule, and it belongs before the service is created rather than as a repair afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 authorize-security-group-ingress &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$DEF_SG&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="nt"&gt;--cidr&lt;/span&gt; 0.0.0.0/0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same rule I wrote about two days ago as the load-bearing part of the ALB task. Recognising it in one task and not applying it in the next is the actual failure here. Nothing about it is ECS-specific.&lt;/p&gt;

&lt;h3&gt;
  
  
  What RUNNING actually claims
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;RUNNING&lt;/code&gt; is a statement about a container's lifecycle. The image pulled, the process started, it has not exited. It says nothing whatsoever about whether anything can reach it. Two independent facts, and only one of them was what the task asked for.&lt;/p&gt;

&lt;p&gt;The rule I took from it: the final check has to exercise the thing the task promises, not the thing that is easiest to query. A status field is easy to query. It is not the deliverable.&lt;/p&gt;

&lt;p&gt;For Fargate that check is slightly awkward, which is probably why it gets skipped. A task does not carry its IP; it gets its own network interface, and the address is two lookups away:&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="nv"&gt;ENI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ecs describe-tasks &lt;span class="nt"&gt;--cluster&lt;/span&gt; datacenter-cluster &lt;span class="nt"&gt;--tasks&lt;/span&gt; &lt;span class="nv"&gt;$TASK_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"tasks[0].attachments[0].details[?name=='networkInterfaceId'].value"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;TASK_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-network-interfaces &lt;span class="nt"&gt;--network-interface-ids&lt;/span&gt; &lt;span class="nv"&gt;$ENI&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'NetworkInterfaces[0].Association.PublicIp'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 http://&lt;span class="nv"&gt;$TASK_IP&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With EC2 the public IP is one field on the instance. Here it is task to ENI, ENI to address. Worth building the habit anyway, because without a load balancer in front it is the only way to prove the deployment works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ECS specifics that cost real time
&lt;/h2&gt;

&lt;p&gt;The registry is the address. &lt;code&gt;docker push datacenter-ecr:latest&lt;/code&gt; is not a misconfigured push to ECR, it is a push to Docker Hub under a repository of that name, and it fails on authorization moments after you watched ECR say &lt;code&gt;Login Succeeded&lt;/code&gt;. Re-tag with the full URI first. Same fact as the Docker half, in a place where it bites.&lt;/p&gt;

&lt;p&gt;Two roles, and they are not the same job. &lt;code&gt;executionRoleArn&lt;/code&gt; is used by the Fargate infrastructure before your container exists: it pulls the image and creates log streams. &lt;code&gt;taskRoleArn&lt;/code&gt; is used by your application code once it is running. The trust principal for both is &lt;code&gt;ecs-tasks.amazonaws.com&lt;/code&gt;, not &lt;code&gt;ecs.amazonaws.com&lt;/code&gt;. Both principals exist and mean different things, and naming the wrong one produces a role that looks correct in the console and can never be assumed by a task.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CannotPullContainerError&lt;/code&gt; has two unrelated causes. A missing or wrong execution role produces it. So does a task with no route to ECR, because Fargate pulls the image over the network like any other client. The distinguishing detail is the wording underneath: an authorization failure means the role, a timeout means networking. Reading only the headline sends you to the wrong half of the problem.&lt;/p&gt;

&lt;p&gt;And one piece of pure trivia that is not trivia when it fails. &lt;code&gt;awsvpcConfiguration={subnets=[...]}&lt;/code&gt; takes a comma-separated list. &lt;code&gt;--subnets&lt;/code&gt; on &lt;code&gt;create-load-balancer&lt;/code&gt; takes space-separated values. Two conventions in the same CLI, and mixing them gives you a parse error rather than anything that explains itself. I found that one by getting it wrong rather than by reading it anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The label is not the thing
&lt;/h2&gt;

&lt;p&gt;A tag names an image, and adding one copies nothing. &lt;code&gt;RUNNING&lt;/code&gt; names a lifecycle state, and it promises nothing about reachability. In both cases the label is accurate and answers a narrower question than the one being asked of it.&lt;/p&gt;

&lt;p&gt;The cost of the first is a moment of confusion in &lt;code&gt;docker images&lt;/code&gt;. The cost of the second was a failed submission on work that was otherwise complete.&lt;/p&gt;

&lt;p&gt;So here is the Day 38 question. When you call something done, is it because the deliverable responded, or because a field said the word you were hoping to see?&lt;/p&gt;

&lt;p&gt;Day 38 down. Sixty-two to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>aws</category>
      <category>containers</category>
    </item>
    <item>
      <title>Day 37: The Role Is Not What You Attach, and the Bucket Is Not the Objects</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-37-the-role-is-not-what-you-attach-and-the-bucket-is-not-the-objects-3moi</link>
      <guid>https://dev.to/ndcodes/day-37-the-role-is-not-what-you-attach-and-the-bucket-is-not-the-objects-3moi</guid>
      <description>&lt;p&gt;Two things today turned out to be one thing wrapped in another. You do not attach an IAM role to an EC2 instance, you attach an instance profile that holds the role. And an S3 bucket and the objects inside it are separate resources with separate ARNs, which is why a policy that looks complete can only do half the work.&lt;/p&gt;

&lt;p&gt;One Docker task, one AWS task. Copy a file into a running container, then give an EC2 instance permission to use an S3 bucket without putting a single credential on disk. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  docker cp, and where the file actually goes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;cp&lt;/span&gt; /tmp/&amp;lt;file&amp;gt; ubuntu_latest:/opt/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no direction flag. The colon decides it: &lt;code&gt;docker cp SRC container:DEST&lt;/code&gt; copies in, &lt;code&gt;docker cp container:SRC DEST&lt;/code&gt; copies out. That is the entire syntax.&lt;/p&gt;

&lt;p&gt;Two behaviours worth knowing before you rely on it. Docker does not create parent directories for the destination, so &lt;code&gt;/opt&lt;/code&gt; has to already exist inside the container, which is why checking first is worth a command. And ownership is not preserved: files copied into a container are created owned by root, and files copied out are owned by whoever ran the command. &lt;code&gt;docker cp -a&lt;/code&gt; preserves the source's uid and gid instead. An application running as a non-root user inside the container can find the file present and unreadable, which is a confusing five minutes if you did not expect it.&lt;/p&gt;

&lt;p&gt;Then the verification, which is the habit rather than the command:&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;sha256sum&lt;/span&gt; /tmp/&amp;lt;file&amp;gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;ubuntu_latest &lt;span class="nb"&gt;sha256sum&lt;/span&gt; /opt/&amp;lt;file&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Comparing &lt;code&gt;ls -l&lt;/code&gt; on both sides proves the sizes match. It does not prove the bytes match. One extra command turns "it looks like it worked" into "it worked".&lt;/p&gt;

&lt;p&gt;One structural point that leads into the next two days. The copied file lands in the container's writable layer. It is not part of the image, it does not survive &lt;code&gt;docker rm&lt;/code&gt;, and a second container from the same image starts without it. Making a file part of an image means &lt;code&gt;COPY&lt;/code&gt; in a Dockerfile, or committing the container. For a file that has to outlive the container and be shared with the host, the answer is a volume at run time, not a copy afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrapper nobody mentions
&lt;/h2&gt;

&lt;p&gt;The AWS task was to let an EC2 instance read and write to one S3 bucket, via a role, with no access keys anywhere.&lt;/p&gt;

&lt;p&gt;The console makes this feel like one step. Create a role, pick EC2 as the trusted entity, attach it to the instance. On the CLI, it is three resources, because you cannot attach a role to an instance at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam create-role &lt;span class="nt"&gt;--role-name&lt;/span&gt; xfusion-role &lt;span class="nt"&gt;--assume-role-policy-document&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;
aws iam create-instance-profile &lt;span class="nt"&gt;--instance-profile-name&lt;/span&gt; xfusion-role
aws iam add-role-to-instance-profile &lt;span class="nt"&gt;--instance-profile-name&lt;/span&gt; xfusion-role &lt;span class="nt"&gt;--role-name&lt;/span&gt; xfusion-role

aws ec2 associate-iam-instance-profile &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-id&lt;/span&gt; &lt;span class="nv"&gt;$EC2_ID&lt;/span&gt; &lt;span class="nt"&gt;--iam-instance-profile&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xfusion-role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An instance profile is a container that holds exactly one role, and it is the thing EC2 actually accepts. The console creates one silently and gives it the same name as the role, which is why almost nobody knows it exists until the CLI says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Invalid IAM Instance Profile name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error reads like a typo. It means the resource does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ARNs, because they are two resources
&lt;/h2&gt;

&lt;p&gt;Here is the policy, and the shape of it is the lesson:&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: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:GetObject"&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:::xfusion-s3-458492027197/*"&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="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:::xfusion-s3-458492027197"&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;Two statements for three actions, because those actions operate on two different resource types. &lt;code&gt;GetObject&lt;/code&gt; and &lt;code&gt;PutObject&lt;/code&gt; act on objects, so the ARN carries &lt;code&gt;/*&lt;/code&gt;. &lt;code&gt;ListBucket&lt;/code&gt; acts on the bucket itself, so the ARN does not.&lt;/p&gt;

&lt;p&gt;Collapse them into one statement, and exactly half of it works. With only the &lt;code&gt;/*&lt;/code&gt; ARN, &lt;code&gt;aws s3 cp&lt;/code&gt; succeeds and &lt;code&gt;aws s3 ls&lt;/code&gt; returns &lt;code&gt;AccessDenied&lt;/code&gt;. With only the bare ARN, the reverse. Both failures look like a broken policy in general rather than a resource-granularity mistake, which is what makes this worth committing to memory.&lt;/p&gt;

&lt;p&gt;Note what is not in there: no &lt;code&gt;s3:DeleteObject&lt;/code&gt;, no &lt;code&gt;s3:*&lt;/code&gt;, no wildcard bucket. Put, get and list in one bucket.&lt;/p&gt;

&lt;p&gt;It goes on as an inline policy rather than a managed one, because it is scoped to a single bucket and has no reason to be reusable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam put-role-policy &lt;span class="nt"&gt;--role-name&lt;/span&gt; xfusion-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-name&lt;/span&gt; xfusion-s3-access &lt;span class="nt"&gt;--policy-document&lt;/span&gt; file://policy.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Ask who you are before you ask what you can do
&lt;/h2&gt;

&lt;p&gt;The instinct after attaching a role is to try the thing you wanted to do. Better to ask a smaller question first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@&lt;span class="nv"&gt;$EC2_IP&lt;/span&gt; &lt;span class="s1"&gt;'aws sts get-caller-identity'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"Arn"&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:sts::458492027197:assumed-role/xfusion-role/i-04e16a83d51bd745a"&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;&lt;code&gt;assumed-role/xfusion-role/&amp;lt;instance-id&amp;gt;&lt;/code&gt; is the confirmation. The instance pulled temporary credentials from the metadata service, and the session name is its own instance ID.&lt;/p&gt;

&lt;p&gt;Splitting the check this way separates two failure modes that produce an identical &lt;code&gt;AccessDenied&lt;/code&gt;: the role is not attached or has not propagated, so there is no identity at all; or the role is attached, and the policy is wrong. One extra command turns an ambiguous error into a specific one.&lt;/p&gt;

&lt;p&gt;Then the actual proof:&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;upload: ./testfile.txt to s3://xfusion-s3-458492027197/testfile.txt
2026-08-28 04:25:41         23 testfile.txt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;~/.aws/credentials&lt;/code&gt; on that instance, and &lt;code&gt;aws configure&lt;/code&gt; was never run. The credential chain works through a list of sources in precedence order, with environment variables near the top, the shared credentials and config files in the middle, and EC2 instance metadata last of all. Everything above IMDS is empty here, so it falls through to the metadata service and gets temporary credentials that rotate on their own.&lt;/p&gt;

&lt;p&gt;That is the real point of the task. A long-lived access key on an instance can leak through a backup, an AMI, a log, or a compromised process, and it stays valid until somebody notices. Role credentials expire by themselves and are scoped to one instance.&lt;/p&gt;

&lt;p&gt;One regional oddity while we are in S3, since it costs people time:&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="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="nt"&gt;--bucket&lt;/span&gt; &lt;span class="nv"&gt;$BUCKET&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;--create-bucket-configuration&lt;/code&gt;. us-east-1 is the API default and passing a location constraint for it returns &lt;code&gt;InvalidLocationConstraint&lt;/code&gt;. Every other region requires it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Granularity is the whole game
&lt;/h2&gt;

&lt;p&gt;The instance profile exists because EC2 needs a wrapper around a role. The two ARNs exist because a bucket and its contents are genuinely different things. In both cases, the abstraction that felt like one object is two, and the error message when you get it wrong describes the symptom rather than the structure.&lt;/p&gt;

&lt;p&gt;So here is the Day 37 question. In the last IAM policy you wrote, do you know which statements act on a container and which act on its contents, or did it work the first time and you moved on?&lt;/p&gt;

&lt;p&gt;Day 37 down. Sixty-three to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>aws</category>
      <category>security</category>
    </item>
    <item>
      <title>I asked Gemini what skill a charity was missing. It got it wrong</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sun, 06 Sep 2026 21:39:38 +0000</pubDate>
      <link>https://dev.to/ndcodes/handover-small-charities-know-what-hurts-not-what-skill-they-are-missing-4jpf</link>
      <guid>https://dev.to/ndcodes/handover-small-charities-know-what-hurts-not-what-skill-they-are-missing-4jpf</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-09-03"&gt;Weekend Challenge: Generosity Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Handover takes a plain description of what is going wrong inside a small charity and works out the role that would fix it.&lt;/p&gt;

&lt;p&gt;Not the role they asked for. The one they actually need.&lt;/p&gt;

&lt;p&gt;You type something like "our books are a mess, and we have missed two filing deadlines". It comes back with a full trustee role: the diagnosis, what the person would do, a deliberately short list of essential skills, an honest time commitment, and an advert you can paste straight into your newsletter.&lt;/p&gt;

&lt;p&gt;Then a volunteer pastes their CV, badly, and gets scored against every open role with a reason and an honest note on where the fit is thin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why
&lt;/h3&gt;

&lt;p&gt;A couple of days ago I got an email saying Reach Volunteering is closing after 45 years. It genuinely hurt to read.&lt;/p&gt;

&lt;p&gt;Reach connected small UK charities with people who wanted to give them professional skills. Last year it placed 5,996 volunteers and trustees across 2,440 organisations. The people it placed contributed around £60 million in expertise. Ninety-six per cent of those organisations ran on under £1 million a year, and nearly half on under £50,000.&lt;/p&gt;

&lt;p&gt;It is not closing because the work stopped mattering. It is closing because funding for the infrastructure that helps small charities build capacity has dried up. Reach was the largest single source of trustees in the sector, and it is shutting at the peak of its impact.&lt;/p&gt;

&lt;p&gt;I volunteer as a digital navigator, which mostly means sitting with people who have been handed a system that assumes a confidence nobody ever gave them. You watch someone decide they are the problem, when the thing in front of them was just badly built. Reach existed to stop small charities from feeling like that about their own gaps, and now it is shutting down.&lt;/p&gt;

&lt;p&gt;I cannot rebuild 45 years of relationships in a weekend. So I picked the one piece of what Reach did that was pure expertise rather than headcount, and rebuilt that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The thing everyone gets wrong
&lt;/h3&gt;

&lt;p&gt;Every volunteering board opens with the same question. What role are you advertising?&lt;/p&gt;

&lt;p&gt;Small charities cannot answer it. That is the actual problem.&lt;/p&gt;

&lt;p&gt;A trustee who says "our books are a mess" is describing a symptom. The missing capability is financial oversight. Until somebody names that, the charity advertises for a bookkeeper, gets a bookkeeper, and still cannot answer a funder's question about restricted money. So it stops applying for the grants it could win, and nobody ever connects the two things.&lt;/p&gt;

&lt;p&gt;Reach's real value was having someone experienced enough to hear the symptom and name the gap. That is the part I wanted to keep working.&lt;/p&gt;

&lt;p&gt;So Handover refuses to ask what role you need. It asks what is going wrong, and reasons upward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://handover-nd-codes.vercel.app" rel="noopener noreferrer"&gt;https://handover-nd-codes.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The live site works with no API key at all. Six example organisations are already on the board, spread across the four nations, and you can read any of them aloud straight away.&lt;/p&gt;

&lt;p&gt;Every canned result is labelled "demo output" on screen. A tool asking you to trust a diagnosis cannot pass off fixtures as one, so the labelling is not optional.&lt;/p&gt;

&lt;p&gt;To see the real thing, paste a Gemini key into the Settings tab and describe a problem of your own. Keys stay in your browser and go straight to Google. There is no backend in this project, so there is nowhere else for them to go.&lt;/p&gt;

&lt;p&gt;If you want the fastest honest test of whether this works, give it this and see what comes back:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We run a lunch club for older people twice a week out of a church hall. There are six of us, all volunteers, all over seventy. Our secretary died in March, and she was the one who knew how everything worked, including the booking with the church and where the insurance documents are. We are still running the lunches, but I do not think anyone could take over if I stopped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/ND-codes" rel="noopener noreferrer"&gt;
        ND-codes
      &lt;/a&gt; / &lt;a href="https://github.com/ND-codes/handover" rel="noopener noreferrer"&gt;
        handover
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Handover&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Small charities rarely know which skill they are missing. They know what hurts.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Handover reads the symptom, names the capability underneath it, and writes the trustee or volunteer role that closes the gap.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges" rel="nofollow"&gt;DEV Weekend Challenge: Generosity Edition&lt;/a&gt;, September 2026.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this exists&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;On the week I started this, Reach Volunteering announced it is closing after 45 years.&lt;/p&gt;
&lt;p&gt;Reach connected small UK charities with people who wanted to give them their professional skills. In 2025 alone it placed 5,996 volunteers and trustees, worked with 2,440 organisations, and the people it placed contributed around £60 million in expertise. Ninety-six per cent of those organisations had income under £1 million. Nearly half were under £50,000.&lt;/p&gt;
&lt;p&gt;It is closing at the peak of its impact, because funding for infrastructure that helps small charities build capacity has dried up.&lt;/p&gt;
&lt;p&gt;Handover is not a replacement for Reach. A weekend project…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/ND-codes/handover" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;React 19, TypeScript and Vite, with no backend at all. Prompts and response schemas live in one module that both the app and the test suite import, for reasons I will get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Three Gemini calls, each with its own response schema. Diagnose a need, profile a volunteer, match one against the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Google AI: the first version did not work
&lt;/h3&gt;

&lt;p&gt;I built the whole app against demo fixtures because I had no key yet. Scaffolding, matching UI, tiered results, all of it looking great on canned data. When I finally ran a real call, I gave it that lunch club above.&lt;/p&gt;

&lt;p&gt;Gemini answered "Secretary".&lt;/p&gt;

&lt;p&gt;It handed back the job title of the person who died. Which is exactly the reflex the entire project exists to interrupt. Refill the chair, leave the organisation just as fragile, wait for it to happen again.&lt;/p&gt;

&lt;p&gt;Three things were wrong, and two of them were mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No worked examples.&lt;/strong&gt; Nothing anchored the reasoning from symptom to gap, so the model did the obvious thing. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Most of the behaviour lives in the schema, not the prompt. The field descriptions are where the actual instructions are:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;```&lt;/span&gt;&lt;span class="nl"&gt;ts
&lt;/span&gt;&lt;span class="nx"&gt;essentialSkills&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ARRAY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRING&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;At most three. Only what someone genuinely cannot do the role without. &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Every extra item here shrinks the pool.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;```&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I added two worked examples showing the lazy answer next to the good one, plus a hard rule against ever returning a title the charity already used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A voice instruction bleeding across fields.&lt;/strong&gt; My house style said, "write as the charity, using we". That was meant for the advert. It leaked into the diagnosis, which came back as "We lack documented records". That is the charity talking to itself, not an adviser telling a board something it had missed. Voice is scoped per field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A spec stated in the wrong place.&lt;/strong&gt; The advert length lived only in a schema description, and the model returned 55 words against a 150 to 220 word target. Restating it in the prompt body fixed it.&lt;/p&gt;

&lt;p&gt;It now answers "Governance and Continuity Trustee" and gets there properly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The missing capability is documented operational continuity and compliance oversight, not administrative secretarial support.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the difference between a tool and a toy, and I only caught it because I tested with a real key before writing this post.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing something that has no right answer
&lt;/h3&gt;

&lt;p&gt;You cannot unit test a diagnosis. But you can test whether it avoids the obvious wrong one.&lt;/p&gt;

&lt;p&gt;There is a smoke script with 13 assertions running against live Gemini. The one that matters feeds in the bereaved lunch club and asserts the answer is not "Secretary".&lt;/p&gt;

&lt;p&gt;One check defeated regular expressions entirely. I wanted to confirm the diagnosis names the continuity risk, but the model phrases the same correct idea as "institutional memory", then "unwritten personal memory", then "one key resignation away". Keyword matching either missed good answers or had to be widened until it passed anything at all.&lt;/p&gt;

&lt;h1&gt;
  
  
  So the check became another model call, given both the shallow answer and the correct one:
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;```&lt;/span&gt;&lt;span class="nl"&gt;js
&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;judge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A small charity said this:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;NEED&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;A tool was asked to name the capability they are actually missing,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rather than the vacancy they described. It answered:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Title: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Diagnosis: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;diagnosis&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The shallow answer here is 'they need a new secretary to do the admin'.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The correct insight is that the organisation has no continuity:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;knowledge lived in one person, nothing is written down, and it is one&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;more departure away from collapsing.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Does the answer above reach the correct insight, rather than the&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shallow one? Judge the substance, not the wording.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;responseMimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;responseSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OBJECT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;reachesInsight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BOOLEAN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRING&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reachesInsight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reason&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;temperature&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="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;So that one check is a second Gemini call acting as a judge, given the shallow answer and the correct insight and asked which one the output reached. Slower and non-deterministic, but it tests the thing I actually care about instead of the words that happened to appear.&lt;/p&gt;

&lt;p&gt;That is also why the prompts sit in a module both the app and the test import. A test that drifts from the prompt is worse than no test, because it reports on something nobody runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  ElevenLabs: reading it aloud is the accessibility path
&lt;/h3&gt;

&lt;p&gt;The role pack can be read aloud, and this is not decoration.&lt;/p&gt;

&lt;p&gt;The people running £20,000 charities are often not confident readers of formal documents. A trustee advert is precisely the kind of text that makes somebody decide the role is not for them before they finish it. Hearing it spoken plainly moves that line.&lt;/p&gt;

&lt;p&gt;I picked Alice, a British voice, over the usual default. This is a UK tool and the listener is often somebody who already feels talked down to by officialdom. An American narrator would be one more small signal that this was not built for them.&lt;/p&gt;

&lt;p&gt;Testing with a real key caught a second bug. My hardcoded default was a library voice, and ElevenLabs returns 402 "Free users cannot use library voices" on a free plan. So the whole voice feature would have failed for anybody setting it up fresh, including any judge who tries it.&lt;/p&gt;

&lt;p&gt;It now recovers instead of failing. On a 400, 402 or 404 it asks the account which voices it genuinely has, prefers a British one, retries, and remembers the answer. A 401 is a bad key and is not worth retrying.&lt;/p&gt;

&lt;p&gt;There is also a Web Speech API fallback, so the accessibility feature is never the thing behind the paywall. That fallback had its own bug: it reported success without waiting for voices to load, which meant it could show "reading aloud" over complete silence. On the accessibility path that is the worst possible way to fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decisions I will defend
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Essential skills are capped at three.&lt;/strong&gt; Every unnecessary requirement excludes somebody who could have done the job. The pool Reach built was over 40% under 40 and over 40% from ethnic minorities, well ahead of national trends, and bloated requirement lists are one of the mechanisms that quietly narrows a pool like that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match scores are forced to spread.&lt;/strong&gt; A matcher returning everything at 78 has not judged anything. Real scores come back between 92 and 18, which is why the results split into three tiers instead of one ranked list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every match carries a "where it is thin" note.&lt;/strong&gt; A volunteer who finds out in month three that the fit was wrong does not stay, and that costs the charity more than never recruiting them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The weak matches stay visible.&lt;/strong&gt; Collapsed, not hidden. Handing somebody six roles they cannot do is asking them to do the triage the tool was supposed to do for them, but hiding the rejections entirely is just flattery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The example organisations are invented, and the app says so.&lt;/strong&gt; Attaching made-up recruitment needs to real registered charities would put words in their mouths. The names are fictional. The shapes follow the real distribution Reach reported.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it is not
&lt;/h3&gt;

&lt;p&gt;The diagnosis is a starting point for a board conversation, not governance or legal advice. The model can be confidently wrong about which capability is missing, so the role pack is built to be edited rather than accepted.&lt;/p&gt;

&lt;p&gt;Matching runs against roles held in one browser. This demonstrates the mechanism, it is not a live marketplace.&lt;/p&gt;

&lt;p&gt;And it is not a replacement for Reach. Not affiliated with them either. It is one piece of what they did, kept working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Google AI.&lt;/strong&gt; Three schema-constrained Gemini calls carry the whole product, and the diagnosis prompt is the product. Most of the quality lives in the response schema field descriptions and two worked examples that stop the model from restating the charity's problem back at it. Tested by 13 live assertions, one of which uses Gemini itself as a judge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best Use of ElevenLabs.&lt;/strong&gt; Text to speech as an accessibility route rather than a feature, aimed at people who find formal documents off-putting, with a British voice chosen deliberately, graceful recovery when an account cannot use a given voice, and a Web Speech API fallback so the accessible path is never the paid one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that bothers me
&lt;/h2&gt;

&lt;p&gt;Reach closed because nobody would fund the unglamorous infrastructure underneath the sector. Matching software does not fix that. A funder deciding capacity building is worth paying for fixes that.&lt;/p&gt;

&lt;p&gt;But the need did not disappear when the charity did. There are still thousands of small organisations who know exactly what hurts and cannot name the skill that would stop it hurting.&lt;/p&gt;

&lt;p&gt;If you have run a budget, chaired anything, written a policy, or built a system other people depended on, a grassroots charity somewhere needs precisely that and has no way left to find you.&lt;/p&gt;

&lt;p&gt;So which is it? Are you going to paste your CV into something like this and find out where you would actually be useful, or is that a job for somebody with more time than you?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>gemini</category>
    </item>
    <item>
      <title>Day 36: A Running Container Is Not a Published One, and an ALB Needs a Listener</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sun, 06 Sep 2026 04:57:57 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-36-a-running-container-is-not-a-published-one-and-an-alb-needs-a-listener-2jh3</link>
      <guid>https://dev.to/ndcodes/day-36-a-running-container-is-not-a-published-one-and-an-alb-needs-a-listener-2jh3</guid>
      <description>&lt;p&gt;Both of today's tasks end with something that is definitely running and definitely unreachable, for two different reasons. A container serving nginx that no packet on the host can reach. A load balancer with a working DNS name that refuses every connection.&lt;/p&gt;

&lt;p&gt;Neither is broken. Both are missing a connector nobody told you to create.&lt;/p&gt;

&lt;p&gt;One Docker task, one AWS task. Run an nginx container on App Server 2, then front an EC2 instance with an Application Load Balancer. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  docker run, and the flag that is not there
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; nginx_2 nginx:alpine
docker ps &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three operations in one. &lt;code&gt;docker run&lt;/code&gt; pulls the image if it is not local, creates a container from it, and starts that container. &lt;code&gt;docker create&lt;/code&gt; plus &lt;code&gt;docker start&lt;/code&gt; is the long form, worth knowing because it lets you configure a container before it ever runs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-d&lt;/code&gt; detaches. Without it, the container's output takes over your terminal and Ctrl+C stops the container, which is a memorable way to discover what the flag does.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--name&lt;/code&gt; is not cosmetic. Skip it and Docker invents something like &lt;code&gt;dazzling_kepler&lt;/code&gt;, and every command afterwards references an ID you have to go and look up. Names are also unique, so reusing one needs a &lt;code&gt;docker rm&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;What is not in that command is &lt;code&gt;-p&lt;/code&gt;. The task did not ask for it, so I did not add it, and the result is a container where nginx is genuinely listening on port 80 inside its own network namespace, and nothing on the host can reach it. Publishing is &lt;code&gt;-p &amp;lt;host&amp;gt;:&amp;lt;container&amp;gt;&lt;/code&gt;, and it is the only thing that opens a host port. An &lt;code&gt;EXPOSE&lt;/code&gt; line in an image does not do it either; that is documentation.&lt;/p&gt;

&lt;p&gt;Two smaller things worth carrying. &lt;code&gt;docker ps&lt;/code&gt; lists running containers only, and &lt;code&gt;docker ps -a&lt;/code&gt; includes the ones that exited. A container that started and died instantly is invisible to the first and obvious in the second, which is the first thing to check when &lt;code&gt;docker run&lt;/code&gt; appears to have done nothing at all. And &lt;code&gt;alpine&lt;/code&gt; variants are the same software on a much smaller base: faster to pull, fewer packages to have vulnerabilities in, and noticeably barer when you exec in and discover there is no &lt;code&gt;bash&lt;/code&gt; and no &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ALB task is a security group task wearing a costume
&lt;/h2&gt;

&lt;p&gt;Five resources: a security group, an instance running nginx from user data, a target group, a load balancer, and a listener. All five were straightforward. The task actually lives in two lines that look like boilerplate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet  --80--&amp;gt;  default SG (on the ALB)  --80--&amp;gt;  devops-sg (on the EC2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 authorize-security-group-ingress &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$DEVOPS_SG&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="nt"&gt;--source-group&lt;/span&gt; &lt;span class="nv"&gt;$DEF_SG&lt;/span&gt;

aws ec2 authorize-security-group-ingress &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$DEF_SG&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="nt"&gt;--cidr&lt;/span&gt; 0.0.0.0/0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is the stated instruction: open port 80 for the default security group. --source-group rather than a CIDR, so nothing on the internet reaches the instance directly. That much is real, and it's most of the point of putting a load balancer in front of something.&lt;/p&gt;

&lt;p&gt;It's worth being precise about what the rule actually permits, though. --source-group names a set, not the load balancer. An instance launched without a specified security group goes into the VPC default group automatically, so that set grows by omission rather than by decision, and the second rule below makes every member of it reachable from the internet on 80. The cleaner shape is a dedicated security group for the ALB, which costs one extra command and makes --source-group refer to exactly one thing. The task nudges you away from it by saying "make appropriate changes in the default security group if necessary" rather than "give the ALB its own. Corrected after a good catch from &lt;a class="mentioned-user" href="https://dev.to/vinhnguyenthanhdn"&gt;@vinhnguyenthanhdn&lt;/a&gt; in the comment section.&lt;/p&gt;

&lt;p&gt;The second comes from the throwaway clause "make appropriate changes in the default security group if necessary". It is necessary. The ALB carries the default SG, and a default security group only permits inbound traffic from itself. Without that rule the ALB resolves in DNS and then nothing happens. Security groups drop disallowed packets rather than rejecting them, so the SYN vanishes and &lt;code&gt;curl&lt;/code&gt; sits on &lt;code&gt;connect&lt;/code&gt; until it times out instead of coming back refused. Every resource you can inspect looks correct.&lt;/p&gt;

&lt;p&gt;Reading a task for the rule it implies rather than the rule it states is most of the skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The listener is the only thing joining two halves
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 create-listener &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &lt;span class="nv"&gt;$ALB_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt; HTTP &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--default-actions&lt;/span&gt; &lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;forward,TargetGroupArn&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$TG_ARN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A load balancer and a target group created separately have no relationship at all. The listener is what connects them, and there is no default. Skip it and &lt;code&gt;describe-load-balancers&lt;/code&gt; looks healthy, &lt;code&gt;describe-target-health&lt;/code&gt; looks healthy, and nothing works.&lt;/p&gt;

&lt;p&gt;The console creates the listener as part of the ALB form, which is exactly why it is easy to miss from the CLI.&lt;/p&gt;

&lt;p&gt;Two more ALB facts worth having in advance. It requires subnets in at least two availability zones, because it is a distributed service and will not create with one. And registering a target does not wait for the target to be ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 register-targets &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &lt;span class="nv"&gt;$TG_ARN&lt;/span&gt; &lt;span class="nt"&gt;--targets&lt;/span&gt; &lt;span class="nv"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$EC2_ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That succeeded seconds after &lt;code&gt;run-instances&lt;/code&gt;, while the instance was still &lt;code&gt;pending&lt;/code&gt;. Registration is a membership record, not a readiness check. The target sits &lt;code&gt;initial&lt;/code&gt; with reason &lt;code&gt;Elb.RegistrationInProgress&lt;/code&gt;, moves to &lt;code&gt;unhealthy&lt;/code&gt; with &lt;code&gt;Target.Timeout&lt;/code&gt; once checks start failing, and only reaches &lt;code&gt;healthy&lt;/code&gt; when they pass. So register immediately and let the health checker do its job rather than adding dead time.&lt;/p&gt;

&lt;p&gt;While we are here, the health check path deserves more thought than it usually gets:&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="nt"&gt;--health-check-protocol&lt;/span&gt; HTTP &lt;span class="nt"&gt;--health-check-path&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/&lt;/code&gt; works because nginx ships a default index page. Deploy an application that returns 404 at &lt;code&gt;/&lt;/code&gt; and every target goes unhealthy while the application runs perfectly. The health check asks a different question than "is the process up".&lt;/p&gt;

&lt;h2&gt;
  
  
  Two habits from the finish
&lt;/h2&gt;

&lt;p&gt;First, chain the waiters so the terminal is unattended until there is a result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 &lt;span class="nb"&gt;wait &lt;/span&gt;load-balancer-available &lt;span class="nt"&gt;--load-balancer-arns&lt;/span&gt; &lt;span class="nv"&gt;$ALB_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; aws elbv2 &lt;span class="nb"&gt;wait &lt;/span&gt;target-in-service &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &lt;span class="nv"&gt;$TG_ARN&lt;/span&gt; &lt;span class="nt"&gt;--targets&lt;/span&gt; &lt;span class="nv"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$EC2_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://&lt;span class="nv"&gt;$ALB_DNS&lt;/span&gt;/ | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;target-in-service&lt;/code&gt; is the one that matters. It polls until the target reads &lt;code&gt;healthy&lt;/code&gt;, which is the first moment the entire path is proven rather than assumed.&lt;/p&gt;

&lt;p&gt;Second, when it times out, read the reason rather than guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 describe-target-health &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &lt;span class="nv"&gt;$TG_ARN&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TargetHealthDescriptions[].{State:TargetHealth.State,Reason:TargetHealth.Reason}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Target.Timeout&lt;/code&gt; means the security group chain is broken or nginx is not listening. &lt;code&gt;Target.ResponseCodeMismatch&lt;/code&gt; means it answered with the wrong status. &lt;code&gt;Elb.RegistrationInProgress&lt;/code&gt; means wait. Three very different problems that all present as "not working yet" if you only look at the state.&lt;/p&gt;

&lt;p&gt;One last detail I have started applying everywhere. Resolve the AMI rather than hardcoding it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ssm get-parameters &lt;span class="nt"&gt;--names&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /aws/service/canonical/ubuntu/server/22.04/stable/current/amd64/hvm/ebs-gp2/ami-id &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Parameters[0].Value'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Canonical publishes current Ubuntu AMI IDs as public SSM parameters. AMI IDs differ per region and change with every image release, so a hardcoded one is wrong somewhere, or eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running is not reachable
&lt;/h2&gt;

&lt;p&gt;The container was running and unpublished. The ALB was provisioned and unlistened. In both cases every component existed and the thing joining it to the outside did not.&lt;/p&gt;

&lt;p&gt;The tell is the same in both: a status check that looks fine. &lt;code&gt;docker ps&lt;/code&gt; says Up. &lt;code&gt;describe-load-balancers&lt;/code&gt; says active. Neither is a claim about whether a packet can arrive.&lt;/p&gt;

&lt;p&gt;So here is the Day 36 question. For the last thing you deployed, what did you check at the end, and was it a status field or an actual request?&lt;/p&gt;

&lt;p&gt;Day 36 down. Sixty-four to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>aws</category>
      <category>networking</category>
    </item>
    <item>
      <title>Day 35: Start and Enable Are Different Promises, and the Slow Thing Goes First</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 04 Sep 2026 04:34:11 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-35-start-and-enable-are-different-promises-and-the-slow-thing-goes-first-3jn1</link>
      <guid>https://dev.to/ndcodes/day-35-start-and-enable-are-different-promises-and-the-slow-thing-goes-first-3jn1</guid>
      <description>&lt;p&gt;Docker starts today. After fourteen days of Git, the track moves to containers, and it opens with the least glamorous task available: install the packages and start the service.&lt;/p&gt;

&lt;p&gt;Both halves of today turned out to be about time. When something starts, how long it takes, and what still holds after a reboot.&lt;/p&gt;

&lt;p&gt;One Docker task, one AWS task. Install &lt;code&gt;docker-ce&lt;/code&gt; and Compose on App Server 2, then build an EC2 and private RDS application stack that actually serves a page. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Docker, and the two verbs people conflate
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;yum &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; yum-utils &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;yum-config-manager &lt;span class="nt"&gt;--add-repo&lt;/span&gt; https://download.docker.com/linux/centos/docker-ce.repo &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;yum &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-ce docker-ce-cli containerd.io docker-compose-plugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker-ce&lt;/code&gt; is not in the base RHEL or CentOS repositories, so the repo has to be added first, which is why those three commands are chained rather than pasted separately. Run the install first, and you get "No match for argument: docker-ce", which sends you hunting for a typo instead of a missing repository.&lt;/p&gt;

&lt;p&gt;Four packages, four jobs. &lt;code&gt;docker-ce&lt;/code&gt; is the daemon, &lt;code&gt;docker-ce-cli&lt;/code&gt; is the client you type at, &lt;code&gt;containerd.io&lt;/code&gt; is the runtime the daemon delegates to, and &lt;code&gt;docker-compose-plugin&lt;/code&gt; is what makes &lt;code&gt;docker compose&lt;/code&gt; a subcommand. That last one matters: &lt;code&gt;docker compose&lt;/code&gt; with a space is Compose v2 shipped as a plugin, and &lt;code&gt;docker-compose&lt;/code&gt; with a hyphen is the old standalone binary. Different programs. Any guide using the hyphenated form is telling you about v1.&lt;/p&gt;

&lt;p&gt;Then:&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;sudo &lt;/span&gt;systemctl start docker &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are two different promises. &lt;code&gt;start&lt;/code&gt; says the daemon is running now. &lt;code&gt;enable&lt;/code&gt; says it comes back after a reboot. A task that says "start the service" almost always means both, and &lt;code&gt;systemctl enable --now docker&lt;/code&gt; does the pair in one call.&lt;/p&gt;

&lt;p&gt;Worth verifying each claim with the command that actually tests it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;-v&lt;/span&gt;                &lt;span class="c"&gt;# the client is installed&lt;/span&gt;
docker compose version   &lt;span class="c"&gt;# the plugin is installed&lt;/span&gt;
systemctl status docker  &lt;span class="c"&gt;# the daemon is running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker -v&lt;/code&gt; answering does not mean the daemon is up. The client is a separate binary and never talks to it. Two different failures get conflated here: &lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?&lt;/code&gt; means the service is not started, while &lt;code&gt;permission denied while trying to connect to the Docker daemon socket&lt;/code&gt; means it is running and your user is not in the &lt;code&gt;docker&lt;/code&gt; group. Same symptom, opposite fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  An hour and eight minutes of it provisioning
&lt;/h2&gt;

&lt;p&gt;The AWS half was four things that only count if all four work together: a private MySQL RDS instance, security groups joining it to an EC2, password-less root SSH, and an &lt;code&gt;index.php&lt;/code&gt; that renders &lt;code&gt;Connected successfully&lt;/code&gt; in a browser.&lt;/p&gt;

&lt;p&gt;The lab window was an hour, and RDS takes five to ten minutes to provision. So the first command was the RDS create, and everything else happened while it built. Roughly eight minutes of provisioning cost about thirty seconds of actual waiting, because the SSH and Apache work filled the gap.&lt;/p&gt;

&lt;p&gt;That generalises further than it looks: in any time-boxed environment, find the slowest asynchronous resource and start it before you start thinking about anything else.&lt;/p&gt;

&lt;p&gt;One flag saved a whole step. The task lists "create a database named &lt;code&gt;devops_db&lt;/code&gt;" separately, which reads like connecting a MySQL client to an endpoint you cannot yet reach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds create-db-instance ... &lt;span class="nt"&gt;--db-name&lt;/span&gt; devops_db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RDS creates the schema during provisioning. Note the naming trap while you are there: &lt;code&gt;--db-instance-identifier&lt;/code&gt; is the RDS instance (&lt;code&gt;devops-rds&lt;/code&gt;, hyphens) and &lt;code&gt;--db-name&lt;/code&gt; is the MySQL schema (&lt;code&gt;devops_db&lt;/code&gt;, underscore). Both read as "the database name" in English.&lt;/p&gt;

&lt;p&gt;For the security group, reference the group rather than an address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 authorize-security-group-ingress &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-id&lt;/span&gt; &lt;span class="nv"&gt;$RDS_SG&lt;/span&gt; &lt;span class="nt"&gt;--protocol&lt;/span&gt; tcp &lt;span class="nt"&gt;--port&lt;/span&gt; 3306 &lt;span class="nt"&gt;--source-group&lt;/span&gt; &lt;span class="nv"&gt;$EC2_SG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--source-group&lt;/code&gt; means anything wearing that security group can connect. It survives the instance being replaced or its private IP changing, and grants nothing to anything else in the VPC. A hardcoded &lt;code&gt;/32&lt;/code&gt; works today and breaks the first time the instance is recycled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sixty seconds
&lt;/h3&gt;

&lt;p&gt;The task says to connect from the console, which means the EC2 Instance Connect button. Its CLI equivalent has a property the console hides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2-instance-connect send-ssh-public-key &lt;span class="nt"&gt;--region&lt;/span&gt; &lt;span class="nv"&gt;$REGION&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-id&lt;/span&gt; &lt;span class="nv"&gt;$EC2_ID&lt;/span&gt; &lt;span class="nt"&gt;--instance-os-user&lt;/span&gt; ubuntu &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssh-public-key&lt;/span&gt; file:///root/.ssh/id_rsa.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That injects your public key into the user's &lt;code&gt;authorized_keys&lt;/code&gt; for sixty seconds. It is a temporary grant, not an install, so the SSH has to be chained onto the same command with &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;. Paste them as two commands, and you will usually miss the window. The pattern is to spend that one login installing the key permanently.&lt;/p&gt;

&lt;p&gt;Also, do not guess the username:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-images &lt;span class="nt"&gt;--image-ids&lt;/span&gt; &lt;span class="nv"&gt;$AMI&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Images[0].Name'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="c"&gt;# ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-20240701&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ubuntu, so &lt;code&gt;ubuntu&lt;/code&gt;. My instinct was &lt;code&gt;ec2-user&lt;/code&gt;, which is Amazon Linux, and it would have failed with &lt;code&gt;Permission denied (publickey)&lt;/code&gt;, the identical error you get from a genuinely broken key. Two unrelated problems, one message, one &lt;code&gt;describe-images&lt;/code&gt; call to tell them apart.&lt;/p&gt;

&lt;p&gt;And when installing the root key, overwrite rather than append. Cloud Ubuntu images ship &lt;code&gt;/root/.ssh/authorized_keys&lt;/code&gt; pre-populated with a forced-command entry that prints "Please login as the user ubuntu" and hangs up. Appending leaves it in place and it fires first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that gets everyone
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;index.php&lt;/code&gt; was in &lt;code&gt;/var/www/html/&lt;/code&gt;, correct, and the browser kept showing the old default page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;DirectoryIndex&lt;/span&gt; index.html index.cgi index.pl index.php ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index.html&lt;/code&gt; is listed first, and it already existed. Apache served it and never looked at the PHP file.&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;mv&lt;/span&gt; /var/www/html/index.html /var/www/html/index.html.bak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No reload needed. &lt;code&gt;DirectoryIndex&lt;/code&gt; is evaluated per request against what is on disk, so the next request picks up &lt;code&gt;index.php&lt;/code&gt; immediately.&lt;/p&gt;

&lt;p&gt;The symptom to file away: a PHP file that is definitely present, definitely correct, and definitely not being served is a &lt;code&gt;DirectoryIndex&lt;/code&gt; problem, not a PHP problem.&lt;/p&gt;

&lt;p&gt;Finish from the shell rather than the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds &lt;span class="nb"&gt;wait &lt;/span&gt;db-instance-available &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; devops-rds &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://&lt;span class="nv"&gt;$EC2_IP&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connected successfully&amp;lt;br /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line proves Apache is serving, PHP is executing, &lt;code&gt;php-mysql&lt;/code&gt; is loaded, the security group is open, RDS is reachable, the credentials are valid and &lt;code&gt;devops_db&lt;/code&gt; exists. A browser proves the same thing while adding caching and your own network as extra variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything here was a clock
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;start&lt;/code&gt; versus &lt;code&gt;enable&lt;/code&gt; is a question about time. Firing the RDS create first is a question about time. The sixty-second key grant is a question about time. Even &lt;code&gt;DirectoryIndex&lt;/code&gt; is ordering, just in a config file rather than a schedule.&lt;/p&gt;

&lt;p&gt;So here is the Day 35 question. In the thing you deployed most recently, which parts survive a reboot, and do you know that because you checked or because it has not been rebooted yet?&lt;/p&gt;

&lt;p&gt;Day 35 down. Sixty-five to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>aws</category>
      <category>database</category>
    </item>
    <item>
      <title>Day 34: A Hook That Cannot Fail, and an Error Three Commands From Its Cause</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Tue, 01 Sep 2026 21:44:08 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-34-a-hook-that-cannot-fail-and-an-error-three-commands-from-its-cause-13oi</link>
      <guid>https://dev.to/ndcodes/day-34-a-hook-that-cannot-fail-and-an-error-three-commands-from-its-cause-13oi</guid>
      <description>&lt;p&gt;Today's theme is failures that report themselves somewhere other than where they happened. A Git hook with the wrong permissions does nothing and says nothing. A heredoc with a trailing space produces an error about a missing zip file, three commands downstream.&lt;/p&gt;

&lt;p&gt;One Git task, one AWS task. Install a &lt;code&gt;post-update&lt;/code&gt; hook that tags every push to master, then deploy a Python Lambda from the CLI. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;p&gt;This is also the last Git task in the track. Days 21 to 34 covered the lot: repository setup, forks, branches, merges, remotes, revert, cherry-pick, pull requests, hard reset, stash, rebase, conflicts, and now hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook that runs after it is too late to object
&lt;/h2&gt;

&lt;p&gt;The task was to make every push to master create a release tag automatically. That work belongs on the server, in the bare repository:&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; /opt/apps.git/hooks
vi post-update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;ref &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;do
    if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ref&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"refs/heads/master"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nv"&gt;TODAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;
        &lt;span class="nv"&gt;COMMIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git rev-parse refs/heads/master&lt;span class="si"&gt;)&lt;/span&gt;
        git tag &lt;span class="s2"&gt;"release-&lt;/span&gt;&lt;span class="nv"&gt;$TODAY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COMMIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
    &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x post-update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;post-update&lt;/code&gt; receives the name of every ref that was just updated, one per argument, which is what &lt;code&gt;"$@"&lt;/code&gt; is walking. Two details in that script are worth more than they look. &lt;code&gt;git rev-parse&lt;/code&gt; needs no repository path because &lt;code&gt;GIT_DIR&lt;/code&gt; is already set when a hook runs. Redirecting the tag error to /dev/null keeps the hook quiet on the second push of the day, but it isn't idempotent. git tag refuses to move a name that already exists, so the tag keeps pointing at the first commit of the day while later pushes land untagged. The redirect suppresses the only message that would have said so. The fix is a name that can't collide.&lt;/p&gt;

&lt;p&gt;Then the part that decides whether any of this is a good idea. Git's documentation says &lt;code&gt;post-update&lt;/code&gt; is meant primarily for notification and cannot affect the outcome of &lt;code&gt;git receive-pack&lt;/code&gt;. It runs after the refs have already moved. Exit non-zero, and the push still succeeds.&lt;/p&gt;

&lt;p&gt;So if you want a hook that rejects bad pushes, this is the wrong hook. &lt;code&gt;pre-receive&lt;/code&gt; and &lt;code&gt;update&lt;/code&gt; run before refs move and can fail the push. &lt;code&gt;post-receive&lt;/code&gt; and &lt;code&gt;post-update&lt;/code&gt; run afterwards and cannot. Pick the wrong one, and you have written a validation rule that logs its objection into the void while the push lands anyway.&lt;/p&gt;

&lt;p&gt;And the quiet one:&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;chmod&lt;/span&gt; +x post-update
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;post-update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hook file with perfect contents and no execute bit is skipped in silence. Nothing in the push output mentions it. This is also why Git ships its samples with a &lt;code&gt;.sample&lt;/code&gt; suffix, and why hooks never travel: they live in the repository directory, and neither &lt;code&gt;clone&lt;/code&gt; nor &lt;code&gt;push&lt;/code&gt; carries them. A hook that has to run on the server has to be put on the server.&lt;/p&gt;

&lt;p&gt;One last thing, because it looks like the hook failed when it did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch &lt;span class="nt"&gt;--tags&lt;/span&gt;
git tag
&lt;span class="c"&gt;# release-2026-08-23&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag was created on the server. Your clone does not know about it until you ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trailing space
&lt;/h2&gt;

&lt;p&gt;The AWS half was Day 33's Lambda again, with a different name and a pre-existing role.&lt;/p&gt;

&lt;p&gt;Two useful things before the mistake. &lt;code&gt;aws iam get-role&lt;/code&gt; before &lt;code&gt;create-role&lt;/code&gt;, because this lab pre-provisions the role and &lt;code&gt;create-role&lt;/code&gt; is not idempotent, so a blind create returns &lt;code&gt;EntityAlreadyExists&lt;/code&gt; and aborts a chained script. Read before write when a resource might already exist. Worth knowing that &lt;code&gt;attach-role-policy&lt;/code&gt; is idempotent, so re-attaching a policy is safe.&lt;/p&gt;

&lt;p&gt;Now the mistake:&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/lambda-build/lambda_function.py &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
def lambda_handler(event, context):
    ...
&lt;/span&gt;&lt;span class="no"&gt;EOF 
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a space after that final &lt;code&gt;EOF&lt;/code&gt;. The shell never recognises its terminator, keeps reading, and drops the prompt to &lt;code&gt;&amp;gt;&lt;/code&gt;. Ctrl+C out and the file was never written.&lt;/p&gt;

&lt;p&gt;Three commands later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to load paramfile fileb:///root/function.zip: [Errno 2] No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zip step had been skipped because the source did not exist, so the zip did not exist, so &lt;code&gt;create-function&lt;/code&gt; failed. The error names a missing archive. The cause is an invisible character in a command that appeared to succeed three steps earlier.&lt;/p&gt;

&lt;p&gt;The fix I have adopted since is to stop pasting heredocs for short files:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'def lambda_handler(event, context):'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'    return {'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'        "statusCode": 200,'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'        "body": "Welcome to KKE AWS Labs!"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'    }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/lambda-build/lambda_function.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each argument becomes a line, single quotes prevent expansion, and there is no terminator to get wrong. Then &lt;code&gt;cat&lt;/code&gt; the file rather than assuming.&lt;/p&gt;

&lt;p&gt;The same principle applies to the artifact:&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; /root/lambda-build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zip &lt;span class="nt"&gt;-q&lt;/span&gt; /root/function.zip lambda_function.py &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; unzip &lt;span class="nt"&gt;-l&lt;/span&gt; /root/function.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Length      Date    Time    Name
      125  2026-08-25 07:44   lambda_function.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name column has no directory prefix, which is the only thing that matters. &lt;code&gt;lambda-build/lambda_function.py&lt;/code&gt; inside the archive would deploy fine and fail at invoke time with an import error. Check the artifact, not the exit code of the tool that made it.&lt;/p&gt;

&lt;p&gt;And one habit worth stealing outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws lambda &lt;span class="nb"&gt;wait &lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-active&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="nt"&gt;--function-name&lt;/span&gt; datacenter-lambda-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;create-function&lt;/code&gt; returns with &lt;code&gt;"State": "Pending"&lt;/code&gt; and invoking a pending function fails. There is a waiter for that, and there are waiters for far more things than most people expect: &lt;code&gt;ec2 wait instance-running&lt;/code&gt;, &lt;code&gt;rds wait db-instance-available&lt;/code&gt;, &lt;code&gt;cloudformation wait stack-create-complete&lt;/code&gt;. Any &lt;code&gt;sleep 30&lt;/code&gt; in a script is worth one search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look upstream of the error
&lt;/h2&gt;

&lt;p&gt;The hook and the heredoc are the same problem wearing different clothes. In both cases, the thing that broke was silent, and the thing that spoke up was downstream of it and blameless.&lt;/p&gt;

&lt;p&gt;The habit that helps is separating "this command exited zero" from "this command did what I wanted", and checking the second one deliberately: &lt;code&gt;ls -la&lt;/code&gt; on the hook, &lt;code&gt;unzip -l&lt;/code&gt; on the archive, &lt;code&gt;git fetch --tags&lt;/code&gt; before concluding the tag was never made.&lt;/p&gt;

&lt;p&gt;So here is the Day 34 question. When something in your pipeline fails, do you check the step that reported the error, or the last step that anyone actually verified?&lt;/p&gt;

&lt;p&gt;Day 34 down. Sixty-six to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>aws</category>
      <category>serverless</category>
    </item>
    <item>
      <title>16 Posts, Started Commenting, then got the Shield</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 28 Aug 2026 20:53:31 +0000</pubDate>
      <link>https://dev.to/ndcodes/16-posts-started-commenting-then-got-the-shield-bk3</link>
      <guid>https://dev.to/ndcodes/16-posts-started-commenting-then-got-the-shield-bk3</guid>
      <description>&lt;p&gt;I joined DEV in June and started doing what I have been avoiding for a long time. Now and then, I hit the publish button and closed the DEV window tab.&lt;/p&gt;

&lt;p&gt;Sixteen posts went out that way. They averaged 1.6 reactions. Several of them got none at all. I told myself the work was good and the readers would find it, which was the comfortable thing to believe while nothing was happening.&lt;/p&gt;

&lt;p&gt;Fast forward to 24th July, I started actually reading other people's posts and leaving my thoughts in their comment sections.&lt;/p&gt;

&lt;p&gt;The twelve posts since then have averaged an increase in reactions and followers. &lt;/p&gt;

&lt;p&gt;Then this week, DEV granted me Trustee Member status. I did not ask. I researched what this actually means. I found out their guide lists three ways it happens, and two of them are things I have never done. The one left I suspect is that somebody noticed.&lt;/p&gt;

&lt;p&gt;They did not notice my post; I guess sixteen of those had gone out to almost nobody. &lt;/p&gt;

&lt;p&gt;Maybe they noticed how I turned up in other people's threads.&lt;/p&gt;

&lt;p&gt;That is the thing I would tell my June self. The writing was never the bottleneck. Showing up was.&lt;/p&gt;

&lt;p&gt;Thank you to &lt;a class="mentioned-user" href="https://dev.to/xulingfeng"&gt;@xulingfeng&lt;/a&gt;, whose Stratagems series I have followed comment by comment. To &lt;a class="mentioned-user" href="https://dev.to/technogamerz"&gt;@technogamerz&lt;/a&gt;, who leaves every thread warmer than she found it. And to &lt;a class="mentioned-user" href="https://dev.to/vinhnguyenthanhdn"&gt;@vinhnguyenthanhdn&lt;/a&gt;, who answered a question at the end of one of my posts with a better debugging story than the post itself.&lt;/p&gt;

&lt;p&gt;None of you was a strategy. I have found real family here in this community. Thank you all &lt;a class="mentioned-user" href="https://dev.to/fellowdevteam"&gt;@fellowdevteam&lt;/a&gt; , for the trust. I intend to use the thumbs down about as rarely as that guide implies I should.&lt;/p&gt;

&lt;p&gt;Full guide, if you want the detail: &lt;a href="https://dev.to/trusted-member"&gt;https://dev.to/trusted-member&lt;/a&gt;&lt;/p&gt;

</description>
      <category>meta</category>
      <category>community</category>
      <category>beginners</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Day 33: A Conflict Is Git Refusing to Guess, and Lambda's 200 Isn't the One That Matters</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 28 Aug 2026 19:26:23 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-33-a-conflict-is-git-refusing-to-guess-and-lambdas-200-isnt-the-one-that-matters-26nb</link>
      <guid>https://dev.to/ndcodes/day-33-a-conflict-is-git-refusing-to-guess-and-lambdas-200-isnt-the-one-that-matters-26nb</guid>
      <description>&lt;p&gt;Today was a lesson in reading what a system is actually telling you. Git stopped and said it could not decide, which is the most honest thing a tool can do. Lambda said 200, which sounds like success and is not quite a claim about success at all.&lt;/p&gt;

&lt;p&gt;One Git task, one AWS task. Resolve a merge conflict after a rejected push, then deploy a Python Lambda function under a purpose-built IAM role. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rejected push is the warning, not the problem
&lt;/h2&gt;

&lt;p&gt;The setup was ordinary. Log in as a different user this time, finish a half-done edit in a shared blog repository, commit it, and push.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh max@ststor01
&lt;span class="nb"&gt;cd&lt;/span&gt; /home/max/story-blog

vi story-index.txt
git status
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add the fourth story to the index"&lt;/span&gt;
git push
&lt;span class="c"&gt;# ! [rejected]  master -&amp;gt; master (fetch first)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another developer had already pushed to master. My push was not a fast-forward, so Git refused it.&lt;/p&gt;

&lt;p&gt;It is worth sitting with that refusal for a second, because the instinct is to treat it as an obstacle. It is not. Git is telling you that accepting this push would drop commits that already exist on the remote. Forcing here would delete a colleague's work. Fetching and merging first is the only correct response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git pull
&lt;span class="c"&gt;# Auto-merging story-index.txt&lt;/span&gt;
&lt;span class="c"&gt;# CONFLICT (content): Merge conflict in story-index.txt&lt;/span&gt;
&lt;span class="c"&gt;# Automatic merge failed; fix conflicts and then commit the result.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We had both edited the same line. Git merges changes to different parts of the same file without any drama. A conflict means two people had an opinion about the same region, and Git will not pick one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt; HEAD
&lt;/span&gt;&lt;span class="p"&gt;4. The Lion and the Mooshika : Ankit
&lt;/span&gt;&lt;span class="gh"&gt;=======
&lt;/span&gt;&lt;span class="p"&gt;4. Mowgli and the Wolves : Sarah
&lt;/span&gt;&lt;span class="gi"&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt; 8f2c1a9b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f89
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three marker lines, not two. &lt;code&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt; HEAD&lt;/code&gt; opens your side, &lt;code&gt;=======&lt;/code&gt; divides, &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; closes theirs. All three have to be deleted. A stray &lt;code&gt;=======&lt;/code&gt; left behind is one of the most common ways a conflict resolution gets committed broken, because the file still parses fine to a human skimming it.&lt;/p&gt;

&lt;p&gt;Resolving means writing the text you actually want. It is not a choice between two buttons. Sometimes it is your line, sometimes theirs, and often it is both kept or a rewrite of the two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vi story-index.txt      &lt;span class="c"&gt;# edit and remove all three markers&lt;/span&gt;

git add story-index.txt
git status              &lt;span class="c"&gt;# the file leaves "Unmerged paths"&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Resolve merge conflict in story-index.txt"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git add&lt;/code&gt; on a conflicted file is how you tell Git you are done with it. That is the only signal it has, which is why &lt;code&gt;git status&lt;/code&gt; during a conflict is worth reading properly: "Unmerged paths" is your remaining work, and the list shrinks as you stage.&lt;/p&gt;

&lt;p&gt;Two things worth having ready before you need them. &lt;code&gt;git merge --abort&lt;/code&gt; puts everything back to the pre-merge state, and it is the right move the moment a conflict looks bigger than you expected. And &lt;code&gt;git config --global rerere.enabled true&lt;/code&gt; makes Git record how you resolved a conflict and replay that resolution when the same one reappears, which it will on any long-lived branch.&lt;/p&gt;

&lt;p&gt;The cheapest fix, of course, sits upstream of all of it. Pull before you start work, not after you finish it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lambda: four parts, and two status codes that mean different things
&lt;/h2&gt;

&lt;p&gt;The AWS task was a Python function returning a fixed greeting, running under a role called &lt;code&gt;lambda_execution_role&lt;/code&gt;. The task said to use the console; the CLI produces identical resources and is repeatable, so I did it there.&lt;/p&gt;

&lt;p&gt;A Lambda deployment from nothing is always four parts, in order, and missing any one of them fails: a role Lambda is allowed to assume, a permissions policy on that role, zipped code, and the function wired to both.&lt;/p&gt;

&lt;p&gt;The first two get conflated constantly. The trust policy answers who is allowed to become this role, and the answer has to be the Lambda service principal:&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="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;"Principal"&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;"Service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lambda.amazonaws.com"&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;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&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;It grants no permissions at all. Those come separately, and &lt;code&gt;AWSLambdaBasicExecutionRole&lt;/code&gt; is the AWS-managed minimum: create a log group, create a log stream, put log events, nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam attach-role-policy &lt;span class="nt"&gt;--role-name&lt;/span&gt; lambda_execution_role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;service-role/&lt;/code&gt; in that ARN. Dropping it produces a "policy does not exist" error that sends you looking in entirely the wrong place.&lt;/p&gt;

&lt;p&gt;Then the packaging details, both of which fail quietly rather than loudly:&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="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /root/nautilus-lambda &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; zip &lt;span class="nt"&gt;-q&lt;/span&gt; /root/nautilus-lambda.zip lambda_function.py&lt;span class="o"&gt;)&lt;/span&gt;

aws lambda create-function &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--function-name&lt;/span&gt; nautilus-lambda &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--runtime&lt;/span&gt; python3.12 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROLE_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--handler&lt;/span&gt; lambda_function.lambda_handler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zip-file&lt;/span&gt; fileb:///root/nautilus-lambda.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.py&lt;/code&gt; has to be at the root of the archive. Zip the parent directory and you get &lt;code&gt;nautilus-lambda/lambda_function.py&lt;/code&gt; inside, which Lambda cannot import. The handler string is &lt;code&gt;module.function&lt;/code&gt; with no extension, so &lt;code&gt;lambda_function.lambda_handler&lt;/code&gt; means call &lt;code&gt;lambda_handler&lt;/code&gt; inside &lt;code&gt;lambda_function.py&lt;/code&gt;; get it wrong and the deploy succeeds and the invoke fails. And it is &lt;code&gt;fileb://&lt;/code&gt;, not &lt;code&gt;file://&lt;/code&gt;, because the zip is binary and reading it as text corrupts it.&lt;/p&gt;

&lt;p&gt;There is also a race worth knowing. Call &lt;code&gt;create-function&lt;/code&gt; immediately after &lt;code&gt;create-role&lt;/code&gt; and you get "The role defined for the function cannot be assumed by Lambda". The role exists, IAM is eventually consistent, and Lambda cannot see it yet. It clears in five to fifteen seconds. A retry loop beats a guessed &lt;code&gt;sleep&lt;/code&gt;, and the same race turns up any time you create an IAM role and immediately hand it to ECS, CodeBuild or EC2.&lt;/p&gt;

&lt;p&gt;Now the part I actually want to flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws lambda invoke &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="nt"&gt;--function-name&lt;/span&gt; nautilus-lambda &lt;span class="se"&gt;\&lt;/span&gt;
  /root/lambda-out.json &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'StatusCode'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;span class="nb"&gt;cat&lt;/span&gt; /root/lambda-out.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome to KKE AWS Labs!"&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;Two 200s, and they are not the same claim.&lt;/p&gt;

&lt;p&gt;The outer one is the Lambda service saying it received the request and ran the function. AWS states it plainly in the Invoke API reference: the status code in the API response does not reflect function errors, and error codes are reserved for problems that stop the function executing at all, such as permissions or configuration. A function that throws an unhandled exception still returns 200 there.&lt;/p&gt;

&lt;p&gt;The inner &lt;code&gt;statusCode&lt;/code&gt; is your own code's return value. That is the one that proves the function did what you wrote.&lt;/p&gt;

&lt;p&gt;If the function had failed, the response would also carry a &lt;code&gt;FunctionError&lt;/code&gt; field and the payload would hold a stack trace. Which means checking only the outer status code is a false positive waiting to happen, and it is exactly the check a hurried health script tends to make.&lt;/p&gt;

&lt;p&gt;One small trap alongside it: &lt;code&gt;invoke&lt;/code&gt; writes the payload to the file argument, it does not print to stdout. Leave the path off and you get a usage error rather than your output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read what it said, not what you expected
&lt;/h2&gt;

&lt;p&gt;A merge conflict and a 200 look like opposites. One is a stop, the other is the number you were hoping for. They are the same lesson from two directions.&lt;/p&gt;

&lt;p&gt;Git refused to guess and told you exactly which lines it could not decide about, which is more information than a silent success would have given you. Lambda answered a narrower question than the one you were asking, accurately, and it is on you to know which question that was.&lt;/p&gt;

&lt;p&gt;So here is the Day 33 question. The green check in your pipeline: do you know precisely which claim it is making, and which one it is not?&lt;/p&gt;

&lt;p&gt;Day 33 down. Sixty-seven to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>aws</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Day 32: Rebase Replays Your Commits, and a Restore Inherits Everything You Don't Override</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 26 Aug 2026 21:21:02 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-32-rebase-replays-your-commits-and-a-restore-inherits-everything-you-dont-override-5aik</link>
      <guid>https://dev.to/ndcodes/day-32-rebase-replays-your-commits-and-a-restore-inherits-everything-you-dont-override-5aik</guid>
      <description>&lt;p&gt;Today's two tasks are both about a new base. A feature branch that needs to sit on top of a master that has moved. A database instance that needs to come back from a snapshot taken when things were fine. In each case, the interesting question is the same: what carries over, and what do you have to say out loud?&lt;/p&gt;

&lt;p&gt;One Git task, one AWS task. Rebase a feature branch onto master without creating a merge commit, then snapshot an RDS instance and restore it into a new one. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebase: not moving commits, replaying them
&lt;/h2&gt;

&lt;p&gt;The requirement was specific, and the specificity is the lesson. A developer's feature branch was behind master. Bring it up to date without losing any feature work, and without a merge commit.&lt;/p&gt;

&lt;p&gt;That second clause rules out &lt;code&gt;git merge master&lt;/code&gt;. Merge joins two histories and records the join, which is the merge commit. Rebase does something else entirely.&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; /usr/src/kodekloudrepos/media

git branch
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--graph&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--decorate&lt;/span&gt;

git checkout feature
git rebase master

git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--graph&lt;/span&gt; &lt;span class="nt"&gt;--decorate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git's own documentation describes what happens under &lt;code&gt;git rebase master&lt;/code&gt;: it lists the commits on your branch that are not on master, checks out master, and then replays each of your commits on top of it, one at a time, in a way it compares to running &lt;code&gt;git cherry-pick&lt;/code&gt; for each one.&lt;/p&gt;

&lt;p&gt;Replays. Not moves. Every commit that comes out the other side has a new hash, because a commit's identity includes its parent, and the parent is different now. Your work is preserved, the commits carrying it are not the same objects they were.&lt;/p&gt;

&lt;p&gt;That is exactly why there is no merge commit. Rebase does not join two histories, it rewrites yours so it looks like it was always based on master's current tip. You get a straight line, at the cost of a history that is no longer a record of what actually happened.&lt;/p&gt;

&lt;p&gt;Two things I had to be deliberate about.&lt;/p&gt;

&lt;p&gt;Direction. Rebase applies to the branch you are standing on and takes the branch you name as the new base. &lt;code&gt;git checkout feature&lt;/code&gt; then &lt;code&gt;git rebase master&lt;/code&gt; means replay feature onto master. Run it the other way round, and you have rewritten master, which is a much worse afternoon.&lt;/p&gt;

&lt;p&gt;Then the push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;History was rewritten, so a normal push is refused. &lt;code&gt;--force-with-lease&lt;/code&gt; requires the remote ref to still match your remote-tracking branch and refuses if it does not, so it will not silently flatten something a colleague pushed while you were rebasing.&lt;/p&gt;

&lt;p&gt;Worth reading the caveat in Git's own docs, though, because it is not the guarantee people assume. The lease is checked against your remote-tracking ref, so anything that quietly runs &lt;code&gt;git fetch&lt;/code&gt; in the background, a cron job or an IDE, updates that ref and defeats the protection. &lt;code&gt;--force-if-includes&lt;/code&gt;, which additionally requires the remote tip to be reachable from your branch's reflog, closes that gap.&lt;/p&gt;

&lt;p&gt;The conflict experience also differs from merge in a way nobody warns you about. A merge conflicts once, with both complete sides in front of you. A rebase can conflict once per replayed commit, each time showing you one commit's worth of change against a base that has moved. More stops, smaller pieces, and &lt;code&gt;git rebase --abort&lt;/code&gt; puts everything back exactly as it was if it stops being worth it.&lt;/p&gt;

&lt;p&gt;And the rule that matters most, straight from the documentation: rebasing a branch that others have based work on forces everyone downstream to fix their history by hand. Rebase your own unshared branches freely. Leave shared ones alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restore: you only type what you want to be different
&lt;/h2&gt;

&lt;p&gt;The AWS task was four steps: wait for the source instance, snapshot it, restore the snapshot into a new instance, and force the new one to &lt;code&gt;db.t3.micro&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;aws rds &lt;span class="nb"&gt;wait &lt;/span&gt;db-instance-available &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; datacenter-rds

aws rds create-db-snapshot &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; datacenter-rds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-snapshot-identifier&lt;/span&gt; datacenter-snapshot

aws rds &lt;span class="nb"&gt;wait &lt;/span&gt;db-snapshot-available &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-snapshot-identifier&lt;/span&gt; datacenter-snapshot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leading waiter is not padding. &lt;code&gt;create-db-snapshot&lt;/code&gt; refuses outright if the source instance is in any state other than &lt;code&gt;available&lt;/code&gt;, mid-modification or still backing up included, and the waiter absorbs that instead of making you retry by hand. Note also that there are two different waiters in that block. &lt;code&gt;db-instance-available&lt;/code&gt; watches an instance, &lt;code&gt;db-snapshot-available&lt;/code&gt; watches a snapshot, and using the wrong one silently watches the wrong resource while you assume it is working.&lt;/p&gt;

&lt;p&gt;Then the restore, which is a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds restore-db-instance-from-db-snapshot &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; datacenter-snapshot-restore &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-snapshot-identifier&lt;/span&gt; datacenter-snapshot &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-class&lt;/span&gt; db.t3.micro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One override. That is the whole thing, and it is worth understanding why.&lt;/p&gt;

&lt;p&gt;A restore is not an empty instance that happens to receive data. It clones the source's configuration. Engine and version, allocated storage, storage type, VPC and subnet group, parameter group, character set, master username and the data all come across without being asked for. The restored instance in this lab came out as MySQL 8.4.5 on 5 GiB of gp2, and none of those three were typed anywhere.&lt;/p&gt;

&lt;p&gt;What you can change at restore time is a specific list: instance class, subnet group, availability zone, port, multi-AZ, public accessibility, storage type, tags, and auto-minor-version-upgrade. The task wanted one of those, so the command has one flag.&lt;/p&gt;

&lt;p&gt;The master password is on neither list. It comes from the snapshot and cannot be set during the restore. Changing it is a &lt;code&gt;modify-db-instance --master-user-password&lt;/code&gt; afterwards.&lt;/p&gt;

&lt;p&gt;One distinction that matters more than it looks. &lt;code&gt;create-db-snapshot&lt;/code&gt; makes a manual snapshot, and manual snapshots live until you delete them. Automated snapshots age out with the backup retention window and disappear when the instance is deleted. For a pre-upgrade safety net you want the one that outlives the thing it is protecting. The flip side is that manual snapshots keep billing for storage after the instance is long gone, and they are exactly the thing people forget to clean up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes along for free
&lt;/h2&gt;

&lt;p&gt;Rebase and restore both start from someone else's state and put your intent on top of it. Rebase inherits master's history and replays your commits onto it. A restore inherits the snapshot's entire configuration and takes only the overrides you name.&lt;/p&gt;

&lt;p&gt;The failure mode is the same in both directions too. Re-specifying engine and storage on a restore either duplicates what the snapshot already carries or quietly diverges from it. Rebasing a branch other people are standing on rewrites history they already have.&lt;/p&gt;

&lt;p&gt;So here is the Day 32 question. When you rebuild something from a known-good starting point, do you know which of its properties you inherited and which you actually chose?&lt;/p&gt;

&lt;p&gt;Day 32 down. Sixty-eight to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>aws</category>
      <category>database</category>
    </item>
    <item>
      <title>Day 31: Stash Keeps What Status Won't Show, and Private Is a Flag, Not a Subnet</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Mon, 24 Aug 2026 20:12:31 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-31-stash-keeps-what-status-wont-show-and-private-is-a-flag-not-a-subnet-1hkc</link>
      <guid>https://dev.to/ndcodes/day-31-stash-keeps-what-status-wont-show-and-private-is-a-flag-not-a-subnet-1hkc</guid>
      <description>&lt;p&gt;Both of today's tasks involved something real that the interface never mentions. Git keeps work in a place that &lt;code&gt;git status&lt;/code&gt; will not tell you about. The RDS console wizard builds a resource on your behalf and never says it did. Neither is a bug, and both cost you time the first time you meet them.&lt;/p&gt;

&lt;p&gt;One Git task, one AWS task. Park uncommitted work with stash and bring a specific entry back, then create a private MySQL RDS instance on the free tier. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stash: the drawer that nothing points you to
&lt;/h2&gt;

&lt;p&gt;Stash is for the moment when you are halfway through something and have to be somewhere else. A hotfix, a colleague's branch, a review. You do not want a commit that says "wip", so you park it.&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; /usr/src/kodekloudrepos/&amp;lt;repo&amp;gt;

git stash list
&lt;span class="c"&gt;# stash@{0}: WIP on master: 3a1f2b9 earlier commit&lt;/span&gt;
&lt;span class="c"&gt;# stash@{1}: WIP on master: 3a1f2b9 earlier commit&lt;/span&gt;

git stash show &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'stash@{1}'&lt;/span&gt;
git stash apply &lt;span class="s1"&gt;'stash@{1}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stash@{0}&lt;/code&gt; is the newest and &lt;code&gt;stash@{1}&lt;/code&gt; is the one before it, and the bare integer works too, so &lt;code&gt;git stash apply 1&lt;/code&gt; is the same call. The braces need quoting in zsh, which reads them as globbing characters.&lt;/p&gt;

&lt;p&gt;Then the step that catches people, including me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status
git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"changes of stash@{1}"&lt;/span&gt;
git push origin master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;apply&lt;/code&gt; restores the working tree, not the index. Your files come back modified, not staged, so a bare &lt;code&gt;git commit -m&lt;/code&gt; has nothing to commit. &lt;code&gt;git stash apply --index&lt;/code&gt; restores the staged state as well, if that is what you parked.&lt;/p&gt;

&lt;p&gt;Two more things worth knowing before you rely on it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;apply&lt;/code&gt; and &lt;code&gt;pop&lt;/code&gt; are not interchangeable. &lt;code&gt;pop&lt;/code&gt; restores the entry and deletes it. &lt;code&gt;apply&lt;/code&gt; restores it and leaves it in the list. Use &lt;code&gt;apply&lt;/code&gt; when you are not yet certain the restore is clean, then &lt;code&gt;git stash drop&lt;/code&gt; once you are. And dropping renumbers everything below it, so never drop entries in a loop by fixed index.&lt;/p&gt;

&lt;p&gt;Untracked files are not stashed at all by default. A new file that seems to have vanished into a stash was never in it. &lt;code&gt;git stash push -u&lt;/code&gt; includes untracked files, and &lt;code&gt;-a&lt;/code&gt; includes ignored ones too.&lt;/p&gt;

&lt;p&gt;The part that actually matters, though, is that none of this is visible. A stash is a real commit under &lt;code&gt;refs/stash&lt;/code&gt;, but it is not in your branch, not in your log, not in &lt;code&gt;git status&lt;/code&gt;, and not pushed anywhere. It is local, and it stays local. Work parked in a stash on a server you lose access to is work you have lost. Treat stash as a few hours of parking, never as storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The private RDS instance: the flag, and the thing the wizard did for you
&lt;/h2&gt;

&lt;p&gt;The task read like a console walkthrough. Free tier template, full configuration, MySQL 8.4.x, &lt;code&gt;db.t3.micro&lt;/code&gt;, 20 GiB of gp2, autoscaling capped at 22 GiB, private. Doing it on the CLI means translating each of those, and two of them do not translate at all.&lt;/p&gt;

&lt;p&gt;"Full configuration" is a console-only creation method. The CLI is always full configuration. "Free tier" is a console preset that means &lt;code&gt;db.t3.micro&lt;/code&gt; plus single-AZ, which you pin with &lt;code&gt;--no-multi-az&lt;/code&gt;. Storage autoscaling and its maximum threshold collapse into one flag, &lt;code&gt;--max-allocated-storage 22&lt;/code&gt;, and passing it is what turns autoscaling on.&lt;/p&gt;

&lt;p&gt;And "private" is the interesting one. It is not about which subnet the instance lands in. It is an attribute on the instance itself:&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="nt"&gt;--no-publicly-accessible&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That flag is what decides whether RDS gives the instance a public DNS name that resolves to a routable address. Put a database in a private subnet with &lt;code&gt;--publicly-accessible&lt;/code&gt;, and you have a private-subnet database that AWS is still advertising publicly.&lt;/p&gt;

&lt;p&gt;Then the CLI stopped and told me something the console never would have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds describe-db-subnet-groups &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing. There was no DB subnet group in the account, and RDS will not create an instance without one. The console wizard makes one silently while you are choosing an engine. The CLI expects you to have done it.&lt;/p&gt;

&lt;p&gt;It also has a requirement that surprises people: the subnet group needs subnets in at least two availability zones, and AWS documents that as applying to Single-AZ deployments too, so the instance can be converted to Multi-AZ later. A single-AZ database still needs a multi-AZ subnet group.&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="nv"&gt;VPC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-vpcs &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=isDefault,Values=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Vpcs[0].VpcId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;SUBNETS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-subnets &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=vpc-id,Values=&lt;/span&gt;&lt;span class="nv"&gt;$VPC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Subnets[].SubnetId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

aws rds create-db-subnet-group &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-subnet-group-name&lt;/span&gt; xfusion-rds-subnet-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-subnet-group-description&lt;/span&gt; &lt;span class="s2"&gt;"Subnet group for xfusion-rds"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-ids&lt;/span&gt; &lt;span class="nv"&gt;$SUBNETS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$SUBNETS&lt;/code&gt; is deliberately unquoted there. &lt;code&gt;--subnet-ids&lt;/code&gt; wants a space-separated list, and &lt;code&gt;--output text&lt;/code&gt; produces exactly that.&lt;/p&gt;

&lt;p&gt;One more habit worth stealing. The task said "8.4.x" without pinning a patch level, so rather than guess, I asked AWS which 8.4 versions are actually orderable on that instance class in that region:&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="nv"&gt;VER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws rds describe-orderable-db-instance-options &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--engine&lt;/span&gt; mysql &lt;span class="nt"&gt;--db-instance-class&lt;/span&gt; db.t3.micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"OrderableDBInstanceOptions[?starts_with(EngineVersion, '8.4')].EngineVersion"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-uV&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;describe-orderable-db-instance-options&lt;/code&gt; beats &lt;code&gt;describe-db-engine-versions&lt;/code&gt; here, because a version can exist and still not be available on &lt;code&gt;db.t3.micro&lt;/code&gt;. And &lt;code&gt;sort -V&lt;/code&gt; is version sort, which puts 8.4.11 after 8.4.9. Plain &lt;code&gt;sort&lt;/code&gt; would not.&lt;/p&gt;

&lt;p&gt;That query cost me a detour first. Written with backticks around the version prefix, it fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;In function starts_with(), invalid type for value: 8.4,
expected one of: ['string'], received: "number"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In JMESPath, backticks delimit JSON literals, so &lt;code&gt;`8.4`&lt;/code&gt; is the number 8.4. String literals use single quotes. Backticks appear to work for most words only because invalid JSON degrades to a string, and they break the moment the content parses as a number, a boolean, or null. Use single quotes for strings every time.&lt;/p&gt;

&lt;p&gt;Everything after that was one call and a wait of five to ten minutes with no output, which is normal. Do your discovery during the wait, not before it.&lt;/p&gt;

&lt;h2&gt;
  
  
  State you have to go looking for
&lt;/h2&gt;

&lt;p&gt;The stash and the missing subnet group are the same shape of problem. Something exists, or does not exist, and the tool in front of you has no opinion about telling you. &lt;code&gt;git status&lt;/code&gt; is silent about parked work. The console is silent about the resource it built for you, right up until you try the same thing without it.&lt;/p&gt;

&lt;p&gt;So here is the Day 31 question. What is true about your environment right now that nothing on your screen is telling you?&lt;/p&gt;

&lt;p&gt;Day 31 down. Sixty-nine to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>aws</category>
      <category>database</category>
    </item>
    <item>
      <title>Day 30: Hard Reset Forgets on Purpose, and NAT Needs a Guardrail Switched Off</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sat, 22 Aug 2026 05:52:22 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-30-hard-reset-forgets-on-purpose-and-nat-needs-a-guardrail-switched-off-22ko</link>
      <guid>https://dev.to/ndcodes/day-30-hard-reset-forgets-on-purpose-and-nat-needs-a-guardrail-switched-off-22ko</guid>
      <description>&lt;p&gt;Thirty days in, and today both tasks required deliberately switching off something that exists to protect you. Git refuses to let you overwrite shared history. EC2 refuses to forward packets that aren't addressed to it. Both refusals are correct. Both had to go.&lt;/p&gt;

&lt;p&gt;One Git task, one AWS task. Hard reset a branch and force-push it, then build a NAT instance so a private EC2 instance can reach S3. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard reset: the undo with no record
&lt;/h2&gt;

&lt;p&gt;Three days ago on Day 27, I wrote, in bold, that you should never rewrite history you have already pushed. Today's task is doing precisely that. So let me deal with that head-on rather than pretend the two posts don't touch.&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; /usr/src/kodekloudrepos/official

git branch &lt;span class="nt"&gt;--show-current&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;

git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; &amp;lt;commit-id&amp;gt;

git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;
git status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reset --hard&lt;/code&gt; moves the branch pointer backwards and takes the working tree and index with it. Everything after that commit is gone from the branch. Compare that to revert, which writes a &lt;em&gt;new&lt;/em&gt; commit undoing an old one and leaves both on the record. Revert adds. Reset removes.&lt;/p&gt;

&lt;p&gt;There are three modes, and only one is dangerous. &lt;code&gt;--soft&lt;/code&gt; moves the pointer and leaves your changes staged. &lt;code&gt;--mixed&lt;/code&gt;, the default, moves it and unstages them. &lt;code&gt;--hard&lt;/code&gt; moves it and throws away uncommitted work entirely. That last part has no undo — the reflog can recover &lt;em&gt;commits&lt;/em&gt;, but nothing recovers changes you never committed.&lt;/p&gt;

&lt;p&gt;Then the push is rejected because the branch is no longer a fast-forward. Git is not being difficult; it is telling you that accepting this push would destroy commits sitting on the remote.&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="c"&gt;# What the task asked for&lt;/span&gt;
git push origin master &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;span class="c"&gt;# What you should almost always type instead&lt;/span&gt;
git push origin master &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;--force-with-lease&lt;/code&gt; is the one worth building into your fingers.&lt;/strong&gt; It checks that the remote is still where you last saw it and refuses if anyone has pushed since. Plain &lt;code&gt;--force&lt;/code&gt; overwrites their work silently. When you're working alone, the two are identical. When you're not, one of them costs a colleague their afternoon.&lt;/p&gt;

&lt;p&gt;So does Day 27's rule still hold? Yes. Force-pushing a branch only you use, or a lab repo, is fine. Force-pushing a shared branch rewrites history other people already pulled — their next pull produces divergent branches and a merge that reintroduces exactly what you removed. The rule was never "never force-push." It was "know whose history you're rewriting."&lt;/p&gt;

&lt;h2&gt;
  
  
  The NAT instance: an invisible default that drops everything
&lt;/h2&gt;

&lt;p&gt;A private EC2 instance had a cron job uploading a file to S3 every minute, and every upload was failing. No internet access. The fix is a NAT instance in a public subnet — cheaper than a NAT Gateway, and considerably more educational.&lt;/p&gt;

&lt;p&gt;The first lesson arrived before I built anything. The task said the VPC, private subnet and private EC2 already existed, implying I only needed the public subnet and the NAT instance. So I ran discovery anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-internet-gateways &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=attachment.vpc-id,Values=&lt;/span&gt;&lt;span class="nv"&gt;$VPC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'InternetGateways[].InternetGatewayId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty. There was &lt;strong&gt;no internet gateway attached to the VPC at all.&lt;/strong&gt; A NAT instance in a public subnet with no route to an IGW is useless — the NAT instance needs internet access before it can hand any out. The task description was wrong about its own environment, and discovery is what caught it.&lt;/p&gt;

&lt;p&gt;With the IGW, public subnet and route table in place, the NAT configuration itself is three things in user-data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; iptables-services
sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.ip_forward&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ip &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-4&lt;/span&gt; route show to default | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $5}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; MASQUERADE
service iptables save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ip_forward&lt;/code&gt; is off by default and lets the kernel route packets between interfaces. &lt;code&gt;MASQUERADE&lt;/code&gt; rewrites the source IP of outbound packets to the NAT instance's own address and reverses it on the way back — that is literally the network address translation. And &lt;code&gt;IFACE&lt;/code&gt; is detected rather than hardcoded, because AL2023 names interfaces &lt;code&gt;ens5&lt;/code&gt;, not &lt;code&gt;eth0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then the line that decides whether any of it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-instance-attribute &lt;span class="nt"&gt;--instance-id&lt;/span&gt; &lt;span class="nv"&gt;$NAT_ID&lt;/span&gt; &lt;span class="nt"&gt;--no-source-dest-check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;By default, EC2 drops any packet whose source or destination IP is not the instance's own address.&lt;/strong&gt; That is a sensible anti-spoofing guardrail, and it is exactly what a NAT instance does all day long. Leave it on, and the instance discards every forwarded packet. No error. No log line. The cron job just keeps failing and everything you built looks correct.&lt;/p&gt;

&lt;p&gt;That is what makes it the classic NAT instance necessity: it is not a mistake you can see. It is a default you have to know about in advance.&lt;/p&gt;

&lt;p&gt;One more detail worth stealing. When routing the private subnet through the instance, target the &lt;strong&gt;ENI&lt;/strong&gt;, not the instance ID:&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="nv"&gt;ENI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 describe-instances &lt;span class="nt"&gt;--instance-ids&lt;/span&gt; &lt;span class="nv"&gt;$NAT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Reservations[0].Instances[0].NetworkInterfaces[0].NetworkInterfaceId'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;

aws ec2 create-route &lt;span class="nt"&gt;--route-table-id&lt;/span&gt; &lt;span class="nv"&gt;$PRIV_RTB&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--destination-cidr-block&lt;/span&gt; 0.0.0.0/0 &lt;span class="nt"&gt;--network-interface-id&lt;/span&gt; &lt;span class="nv"&gt;$ENI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if that route shows &lt;code&gt;blackhole&lt;/code&gt; rather than &lt;code&gt;active&lt;/code&gt;, the instance wasn't running when you created it. Run &lt;code&gt;aws ec2 wait instance-running&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;For the record: NAT instances are a cost play and an exam topic. In production, you use a NAT Gateway, which is managed, highly available per AZ, and scales to 100 Gbps. You pay per gigabyte for the privilege of never thinking about &lt;code&gt;ip_forward&lt;/code&gt; again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails you turn off knowingly
&lt;/h2&gt;

&lt;p&gt;Git's push rejection and EC2's source/destination check are the same kind of thing: a default that says no to something that is usually a mistake. Both tasks needed that not to be overridden. The difference between a professional and an incident is whether you knew what the guardrail was for before you switched it off.&lt;/p&gt;

&lt;p&gt;So here is the Day 30 question, a third of the way in. When you disable a safety check, is it because you understand why it exists, or because it was in the way?&lt;/p&gt;

&lt;p&gt;Day 30 down. Seventy to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>aws</category>
      <category>networking</category>
    </item>
    <item>
      <title>Day 29: A Pull Request Needs Two People, and Peering Needs Two Routes</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 19 Aug 2026 19:51:41 +0000</pubDate>
      <link>https://dev.to/ndcodes/a-pull-request-needs-two-people-and-peering-needs-two-routes-5emj</link>
      <guid>https://dev.to/ndcodes/a-pull-request-needs-two-people-and-peering-needs-two-routes-5emj</guid>
      <description>&lt;p&gt;Some things cannot be finished alone, by design. A change that only you have seen is not reviewed. A network connection that only works in one direction is not a connection. Day 29 was two versions of the same idea: both sides have to agree, and half of it is worse than none because half of it looks like it should work.&lt;/p&gt;

&lt;p&gt;One Git task, one AWS task. Open a pull request, get it reviewed and merged, then peer a public VPC with a private one so instances on each side can talk. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pull requests: the gate, not the merge
&lt;/h2&gt;

&lt;p&gt;The first thing worth saying is that a pull request is not a Git feature. Git has no idea what one is. It is a workflow layer that hosting platforms bolt on top — Gitea here, GitHub and GitLab elsewhere — and underneath, a merged PR is &lt;code&gt;git merge&lt;/code&gt; and nothing more.&lt;/p&gt;

&lt;p&gt;Which raises the obvious question: if the merge is trivial, what is the PR for?&lt;/p&gt;

&lt;p&gt;The gate. A named reviewer, a recorded approval, a discussion attached to the change, and a permanent record of who agreed to what. On this task, that meant opening the request against master from a feature branch, adding a second user as reviewer, then logging in as that user to read the diff, approve it, and merge with a merge commit.&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="c"&gt;# Everything before and after the PR is ordinary git&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; fox-grapes        &lt;span class="c"&gt;# what you are proposing&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; master            &lt;span class="c"&gt;# what it is going into&lt;/span&gt;

&lt;span class="c"&gt;# ...PR happens in the web UI...&lt;/span&gt;

git checkout master
git pull origin master
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--graph&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--decorate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things catch people out. &lt;strong&gt;Base and compare get reversed constantly&lt;/strong&gt; — base is where the code is going, compare is where it comes from. Flip them, and you propose merging master into your feature branch, which usually shows nothing and reads as a broken UI rather than a mistake.&lt;/p&gt;

&lt;p&gt;And the reviewer has to be a genuinely different user. Approving your own pull request is the one move that empties the mechanism of all meaning, which is why most platforms refuse it.&lt;/p&gt;

&lt;p&gt;Worth knowing the third option too: "create a merge commit" preserves the branch shape, exactly the &lt;code&gt;--no-ff&lt;/code&gt; behaviour from Day 25. Squash flattens the branch to one commit. Rebase replays them with no merge commit at all. Same code, three different stories in the history.&lt;/p&gt;

&lt;p&gt;One small thing that trips everyone once: merging on the server does nothing to your local clone. &lt;code&gt;git pull&lt;/code&gt; afterwards or your master sits silently behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  VPC peering: one connection, two route tables
&lt;/h2&gt;

&lt;p&gt;The AWS task was peering a public VPC with a private one so an instance in each could reach the other. And it opened with a problem that had nothing to do with peering at all.&lt;/p&gt;

&lt;p&gt;SSH to the public instance simply hung. No refusal, no error — just silence. The cause was in the security group:&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="nl"&gt;"IpPermissions"&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;"IpProtocol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"FromPort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ToPort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"IpRanges"&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;"UserIdGroupPairs"&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;"GroupId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sg-xxxxxxxx"&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;That rule says "allow SSH from anything already inside this security group" — and the group it references is the one attached to the instance itself. Traffic from another instance in that group would match. My client host, sitting outside AWS entirely, never could.&lt;/p&gt;

&lt;p&gt;That is the tell worth memorising: &lt;strong&gt;&lt;code&gt;UserIdGroupPairs&lt;/code&gt; populated and &lt;code&gt;IpRanges&lt;/code&gt; empty means the rule is SG-referenced, not CIDR-based.&lt;/strong&gt; Nothing external gets in, and because security groups drop rather than reject, you get a hang instead of a message.&lt;/p&gt;

&lt;p&gt;Then a second problem, which I liked. The task wanted my public key in the instance's &lt;code&gt;authorized_keys&lt;/code&gt;. But writing that file needs SSH, and SSH needs that file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EC2 Instance Connect&lt;/strong&gt; breaks the loop. It pushes a public key to the instance out-of-band through the AWS API, valid for sixty seconds — long enough to get in and write the key permanently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2-instance-connect send-ssh-public-key &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--instance-id&lt;/span&gt; &amp;lt;instance-id&amp;gt; &lt;span class="nt"&gt;--instance-os-user&lt;/span&gt; ec2-user &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--availability-zone&lt;/span&gt; us-east-1b &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssh-public-key&lt;/span&gt; file:///root/.ssh/id_rsa.pub &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="nt"&gt;-i&lt;/span&gt; /root/.ssh/id_rsa ec2-user@&amp;lt;public-ip&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"mkdir -p ~/.ssh &amp;amp;&amp;amp; chmod 700 ~/.ssh &amp;amp;&amp;amp; echo '&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /root/.ssh/id_rsa.pub&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;' &amp;gt;&amp;gt; ~/.ssh/authorized_keys &amp;amp;&amp;amp; chmod 600 ~/.ssh/authorized_keys"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; is not stylistic. Sixty seconds is not enough to run two commands you are typing by hand, and the second one failing on an expired key looks exactly like a permissions problem.&lt;/p&gt;

&lt;p&gt;With access sorted, the peering itself is four checks, and this is the part that matters:&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="c"&gt;# 1. The connection must be ACTIVE, not pending-acceptance&lt;/span&gt;
aws ec2 accept-vpc-peering-connection &lt;span class="nt"&gt;--vpc-peering-connection-id&lt;/span&gt; &amp;lt;pcx-id&amp;gt;

&lt;span class="c"&gt;# 2 and 3. A route on EACH side, pointing to the other's CIDR&lt;/span&gt;
aws ec2 create-route &lt;span class="nt"&gt;--route-table-id&lt;/span&gt; &lt;span class="nv"&gt;$DEF_RTB&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--destination-cidr-block&lt;/span&gt; 10.1.0.0/16 &lt;span class="nt"&gt;--vpc-peering-connection-id&lt;/span&gt; &amp;lt;pcx-id&amp;gt;

aws ec2 create-route &lt;span class="nt"&gt;--route-table-id&lt;/span&gt; &lt;span class="nv"&gt;$PRIV_RTB&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--destination-cidr-block&lt;/span&gt; 172.31.0.0/16 &lt;span class="nt"&gt;--vpc-peering-connection-id&lt;/span&gt; &amp;lt;pcx-id&amp;gt;

&lt;span class="c"&gt;# 4. The target SG must allow the traffic type from the source CIDR&lt;/span&gt;
aws ec2 authorize-security-group-ingress &lt;span class="nt"&gt;--group-id&lt;/span&gt; &amp;lt;private-sg&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt; icmp &lt;span class="nt"&gt;--port&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nt"&gt;--cidr&lt;/span&gt; 172.31.0.0/16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Peering is bidirectional by nature but &lt;strong&gt;not&lt;/strong&gt; by configuration. One connection object, two route tables, and you have to edit both. A route on only one side produces traffic that arrives and cannot reply, which presents as a total failure rather than a partial one.&lt;/p&gt;

&lt;p&gt;Three details worth carrying forward. &lt;code&gt;--port -1&lt;/code&gt; with ICMP means all ICMP types — ping is ICMP, not TCP, so opening TCP ports does precisely nothing for it. If all four checks pass and it still fails, look at network ACLs, which, unlike security groups, are stateless and need explicit rules in both directions. And peering is &lt;strong&gt;not transitive&lt;/strong&gt;: A peered to B and B peered to C does not let A reach C.&lt;/p&gt;

&lt;p&gt;One honest note. I opened port 22 to &lt;code&gt;0.0.0.0/0&lt;/code&gt; to get moving in a throwaway lab, which directly contradicts what I wrote on Day 22 about scoping SSH to &lt;code&gt;/32&lt;/code&gt;. In a lab, that is a shortcut. Anywhere real, it is the thing you get audited for, and I would rather flag it than leave it in a code block.&lt;/p&gt;

&lt;p&gt;Two small mistakes that cost me time, both embarrassing and both instructive. I pasted a placeholder straight into the shell — &lt;code&gt;&amp;lt;sg-id&amp;gt;&lt;/code&gt; makes bash attempt an input redirect and throw &lt;code&gt;No such file or directory&lt;/code&gt;. The angle brackets are notation, not syntax. And I ran &lt;code&gt;ping "$PRIVATE_IP"&lt;/code&gt; on the remote host, where that variable had never been set. Local shell variables do not travel over SSH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half a handshake is not a handshake
&lt;/h2&gt;

&lt;p&gt;An approval you gave yourself is not a review. A route on one side is not a path. Both tasks fail in the same quiet way: everything you configured is correct, and the thing still does not work, because the other half was never done.&lt;/p&gt;

&lt;p&gt;So here is the Day 29 question. Where in your setup have you built one direction of something and assumed the other side agreed?&lt;/p&gt;

&lt;p&gt;Day 29 down. Seventy-one to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>git</category>
      <category>aws</category>
      <category>networking</category>
    </item>
  </channel>
</rss>
