<?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: Vivian Chiamaka Okose</title>
    <description>The latest articles on DEV Community by Vivian Chiamaka Okose (@vivian_okose).</description>
    <link>https://dev.to/vivian_okose</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%2F1544809%2Fa6ff14f1-d604-4864-9aef-1dd849205106.png</url>
      <title>DEV Community: Vivian Chiamaka Okose</title>
      <link>https://dev.to/vivian_okose</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vivian_okose"/>
    <language>en</language>
    <item>
      <title>I Destroyed My Entire Cloud Environment on Purpose. One Command Brought It Back.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:05:47 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-destroyed-my-entire-cloud-environment-on-purpose-one-command-brought-it-back-3oop</link>
      <guid>https://dev.to/vivian_okose/i-destroyed-my-entire-cloud-environment-on-purpose-one-command-brought-it-back-3oop</guid>
      <description>&lt;p&gt;I ran one command and deleted my whole AWS environment, the network, the servers, the&lt;br&gt;
database, the storage, a running Kubernetes cluster, all of it. Then I ran one more command&lt;br&gt;
and it came back. Same network, same servers, same security rules, identical.&lt;/p&gt;

&lt;p&gt;That round trip is the entire point of this module, and it is the test of whether your&lt;br&gt;
infrastructure genuinely lives in code or just mostly does.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A company I was modelling this on built their AWS environment by clicking through the console.&lt;br&gt;
It worked, until a new engineer joined and spent two days trying to recreate the dev&lt;br&gt;
environment from screenshots and a colleague's memory. Nobody could reproduce it, nobody could&lt;br&gt;
review a change before it happened, and tearing it down was a hand-written list of steps you&lt;br&gt;
hoped was in the right order.&lt;/p&gt;

&lt;p&gt;I had felt every piece of that myself. An earlier module where my teardown instructions failed&lt;br&gt;
because I had written the delete order from memory. Another where a whole cluster lived in a&lt;br&gt;
pile of commands that only existed in my terminal history.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: describe it, do not click it
&lt;/h2&gt;

&lt;p&gt;Terraform lets you describe infrastructure in files, then creates, changes, or destroys it to&lt;br&gt;
match. You do not click "make a VPC." You write "there should be a VPC with these subnets,"&lt;br&gt;
run one command, and it builds all of it in the right order.&lt;/p&gt;

&lt;p&gt;If that sounds like Kubernetes, it is exactly the same idea, declare the desired state and let&lt;br&gt;
the tool reconcile reality to match it, pointed one level down at the cloud itself instead of&lt;br&gt;
at containers.&lt;/p&gt;

&lt;p&gt;The daily rhythm has one step that makes it genuinely safer than the console:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; shows you precisely what it would do before it does anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan: 18 to add, 0 to change, 0 to destroy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every risky change I ever made blind in a console, Terraform previews first. That one feature&lt;br&gt;
is most of the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  State, and why it goes in S3
&lt;/h2&gt;

&lt;p&gt;Terraform remembers what it built, in a file called the state. It is how it tells "create new"&lt;br&gt;
from "change existing." That state is precious, it holds the record of everything you own, and&lt;br&gt;
it is also where secrets end up: the database password sits in it in plain text.&lt;/p&gt;

&lt;p&gt;So the first thing I built, before any infrastructure, was a remote home for it: an S3 bucket,&lt;br&gt;
versioned and encrypted and locked down, with a DynamoDB table holding a lock so two people&lt;br&gt;
cannot apply at once and corrupt it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiqfdzzqyw4sh0vwwlsb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkiqfdzzqyw4sh0vwwlsb.png" alt="Remote state" width="796" height="75"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The state in S3, not on my laptop. The empty local listing is the point.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture, as modules
&lt;/h2&gt;

&lt;p&gt;I split the infrastructure into four modules, networking, compute, database, storage, each a&lt;br&gt;
folder with its own inputs and outputs. The networking module hands its subnet and&lt;br&gt;
security-group IDs to the others. Terraform reads those references and figures out the build&lt;br&gt;
order itself; I never sequenced anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F173km2zv6o2uv145u3i9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F173km2zv6o2uv145u3i9.png" alt="Applied" width="800" height="299"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Eighteen resources, an entire production-shaped architecture, from one command.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha the lab did not mention
&lt;/h2&gt;

&lt;p&gt;I scaled the server count from two to three just to watch the plan, and Terraform stopped me&lt;br&gt;
cold: the third server tried to use a third subnet that did not exist, and &lt;code&gt;plan&lt;/code&gt; caught it&lt;br&gt;
before building anything. A real limitation of counting resources by index, and a console&lt;br&gt;
would have let me walk straight into it.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  A whole EKS cluster from one block
&lt;/h2&gt;

&lt;p&gt;The part that made me laugh. An earlier module took me a long saga to stand up one EKS cluster&lt;br&gt;
with a command-line tool, through two failures. Here it was one module block from the community&lt;br&gt;
registry, fifty-two resources from about forty lines of my own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08k3q0ufbwatuoelh3yz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08k3q0ufbwatuoelh3yz.png" alt="EKS from Terraform" width="800" height="107"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A full managed Kubernetes cluster, nodes ready, from a module someone else already tested.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching reality drifting from code
&lt;/h2&gt;

&lt;p&gt;Two things that make Terraform trustworthy in a real, messy account. &lt;code&gt;import&lt;/code&gt; adopts a&lt;br&gt;
resource that already exists into management, without recreating it. And drift, someone&lt;br&gt;
changing a resource by hand, gets caught by &lt;code&gt;plan&lt;/code&gt;, which shows you the gap between your code&lt;br&gt;
and what is actually there.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9ojkvzmijgrmzyf58id.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx9ojkvzmijgrmzyf58id.png" alt="Drift" width="800" height="396"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A manual change, surfaced. The real fix is stopping manual changes, not just re-applying.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  CI that reviews the infrastructure, not just the app
&lt;/h2&gt;

&lt;p&gt;Here is a part I did not expect to enjoy. Every pull request that touches a Terraform file now&lt;br&gt;
runs five checks before anything merges: formatting, syntax validation, a linter, and two&lt;br&gt;
security scanners (tfsec and checkov).&lt;/p&gt;

&lt;p&gt;The first time it ran, it failed. Not on anything clever, on formatting. The scanner found my&lt;br&gt;
files were not laid out in Terraform's canonical style and stopped the merge with a plain exit&lt;br&gt;
code. One command fixed it (&lt;code&gt;terraform fmt -recursive&lt;/code&gt;), but the lesson stuck: the pipeline&lt;br&gt;
holds a line I would have let slide by hand.&lt;/p&gt;

&lt;p&gt;Then the security scanners spoke up, and this is the interesting bit. They flagged ten things.&lt;br&gt;
Not failures, findings, reported but not blocking. Instance metadata still allowing the older,&lt;br&gt;
weaker version. A disk not explicitly set to encrypt. A community module pinned to a version&lt;br&gt;
tag instead of a commit hash. I read each one and made a call: some are real hardening I would&lt;br&gt;
do before this carried traffic, some are not worth it on a server that lives for an hour.&lt;/p&gt;

&lt;p&gt;That triage is the actual skill. Running a scanner is easy. Knowing which of its findings&lt;br&gt;
matter for the situation in front of you is the part that takes understanding, and it is the&lt;br&gt;
difference between a report nobody reads and a decision you can defend.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwob6ls5dtcz0fmzb180.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwob6ls5dtcz0fmzb180.png" alt="CI checks" width="800" height="262"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Green pipeline, security findings posted as notes. Both true at once, and that is the healthy state.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The round trip
&lt;/h2&gt;

&lt;p&gt;And then the headline. Destroy everything, apply it back, get an identical environment. If it&lt;br&gt;
survives that, your infrastructure truly is code. If it does not, you have drift, unmanaged&lt;br&gt;
resources, or a dependency your files do not capture, things you have been pretending do not&lt;br&gt;
exist.&lt;/p&gt;

&lt;p&gt;Mine came back identical. That is the whole module in one gesture.&lt;/p&gt;

&lt;h2&gt;
  
  
  One last honest note, on teardown
&lt;/h2&gt;

&lt;p&gt;Cleaning up had its own small trap. The state bucket had versioning switched on, which I had&lt;br&gt;
set on purpose so I could recover old state. When I went to delete it, the normal "force empty&lt;br&gt;
and remove" command refused, the bucket still had every old version and a pile of delete&lt;br&gt;
markers underneath. Versioning keeps everything, including the things you thought you deleted.&lt;br&gt;
I had to clear the versions explicitly before the bucket would go. A good reminder that the&lt;br&gt;
feature protecting you is the same feature standing in your way at cleanup, and that teardown&lt;br&gt;
deserves as much care as the build.&lt;/p&gt;

&lt;p&gt;Full build, every module, the remote-state setup, the EKS block, the CI workflow, and the&lt;br&gt;
discipline runbook:&lt;br&gt;
&lt;a href="https://github.com/vivianokose/nexaops-operations-lab/tree/main/12-terraform" rel="noopener noreferrer"&gt;https://github.com/vivianokose/nexaops-operations-lab/tree/main/12-terraform&lt;/a&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>devops</category>
      <category>iac</category>
    </item>
    <item>
      <title>My Pods Got AWS Permissions From Thin Air. No Keys Anywhere.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Wed, 23 Sep 2026 22:25:38 +0000</pubDate>
      <link>https://dev.to/vivian_okose/my-pods-got-aws-permissions-from-thin-air-no-keys-anywhere-43h</link>
      <guid>https://dev.to/vivian_okose/my-pods-got-aws-permissions-from-thin-air-no-keys-anywhere-43h</guid>
      <description>&lt;p&gt;I opened a shell inside one of my pods and asked AWS who it was.&lt;/p&gt;

&lt;p&gt;It answered with an IAM role and a set of temporary credentials. No access keys anywhere. Not in an environment variable, not in a Kubernetes Secret, not in git.&lt;/p&gt;

&lt;p&gt;The pod had real AWS permissions, and there was nothing I could point to and call a password.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwzfhvhau63qmn4unjhv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwzfhvhau63qmn4unjhv.png" alt="sts get-caller-identity returning an assumed role ARN from inside the pod" width="800" height="198"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An assumed role ARN, not a node role, not a static key. The pod proving its own identity.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is IRSA, and it's the idea this whole build is anchored on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;ClearOps, my three-service app (frontend, API, Redis), was running on a local cluster while I proved out the Kubernetes migration.&lt;/p&gt;

&lt;p&gt;This build takes it to production: Amazon EKS, AWS's managed Kubernetes.&lt;/p&gt;

&lt;p&gt;The surprising part is how little changed. My Deployments, Services, ConfigMap, and autoscaler moved over almost untouched. EKS just means AWS runs the Kubernetes control plane, the API server, etcd, the scheduler, so I don't have to.&lt;/p&gt;

&lt;p&gt;I keep the worker nodes and my manifests. Same Kubernetes, different management underneath. Same logic as using a managed database instead of running your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images move to a real registry
&lt;/h2&gt;

&lt;p&gt;The local cluster built images straight into its own daemon.&lt;/p&gt;

&lt;p&gt;EKS nodes are EC2 instances that can be replaced at any time, so they pull images from a registry instead. ECR is AWS's private registry for this, the same idea as Docker Hub or Nexus, with vulnerability scanning on push turned on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgij8imywg2oww1ppun9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxgij8imywg2oww1ppun9.png" alt="ECR scan-on-push results for the ClearOps images" width="800" height="308"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Every finding sits in the Debian base image, not my own code. The same base-image CVE pattern I'd already hardened against earlier in this build.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one genuinely new idea
&lt;/h2&gt;

&lt;p&gt;Before this, pods never needed AWS permissions, because there was no AWS in the picture.&lt;/p&gt;

&lt;p&gt;On EKS, that becomes the central question. How does a pod get permission to do AWS things without a key baked into it somewhere?&lt;/p&gt;

&lt;p&gt;The wrong answer is keys sitting in a Secret, a mistake I'd already caught and fixed once before. The too broad answer is a role attached to the node, because then every pod on that node inherits it, whether it needs it or not.&lt;/p&gt;

&lt;p&gt;IRSA, IAM Roles for Service Accounts, is the actual answer. A pod proves its Kubernetes identity through the cluster's OIDC provider, and gets back temporary, auto rotating credentials scoped to just that pod. No keys stored anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgt4387044kwg3lq8jbxl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgt4387044kwg3lq8jbxl.png" alt="Environment variables injected into the pod by IRSA" width="800" height="109"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An injected role ARN and a web identity token. That's the pod's entire AWS identity, and there's no key in sight.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This same pattern does the real work three separate times in this build: the load balancer controller, the app's own S3 access, and External Secrets. Each one is a pod that needs AWS permission, and each gets it the same keyless way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real load balancing from three lines
&lt;/h2&gt;

&lt;p&gt;I'd built an Application Load Balancer by hand before. Created it, made a target group, set health checks, wired up security groups. An afternoon of console work.&lt;/p&gt;

&lt;p&gt;The AWS Load Balancer Controller turns all of that into three lines of annotation on an Ingress. It's a pod that watches for Ingress objects and creates real ALBs to match, using its own IRSA identity for permission.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fva64j86vabinjfec0yxu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fva64j86vabinjfec0yxu.png" alt="ClearOps reachable through a real AWS ALB" width="800" height="198"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The app reachable through a real ALB, provisioned straight from my Ingress. An afternoon of manual console work, reduced to a manifest.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets that never touch git
&lt;/h2&gt;

&lt;p&gt;Kubernetes Secrets are only base64 encoded, not encrypted.&lt;/p&gt;

&lt;p&gt;External Secrets Operator keeps the real value in AWS Secrets Manager and syncs it into the cluster on demand. What lives in git is only a pointer to the secret, never the value itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freaqe9emh3r7y25c6vxg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freaqe9emh3r7y25c6vxg.png" alt="ExternalSecret synced from AWS Secrets Manager" width="800" height="125"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The value pulled live from Secrets Manager into a Kubernetes Secret. Nothing sensitive sitting in any manifest.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pods with no nodes
&lt;/h2&gt;

&lt;p&gt;A Fargate profile runs pods with no node of mine involved at all, billed per pod per minute. Good for short or spiky work that would otherwise mean paying for a node that mostly sits idle.&lt;/p&gt;

&lt;p&gt;I ran a batch Job and watched it land on infrastructure that didn't exist a moment earlier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9blbvc6s26ypcocqm1zy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9blbvc6s26ypcocqm1zy.png" alt="A Job pod running on a Fargate node" width="798" height="107"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A Job running on a Fargate node, not on my regular node group.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually needs attention: cost
&lt;/h2&gt;

&lt;p&gt;EKS bills by the hour. The control plane has no free tier and no pause button, only delete.&lt;/p&gt;

&lt;p&gt;My first cluster build failed outright, because the account was restricted to free tier instance types, and the size I picked wasn't one of them. The CloudFormation error said so plainly. Switching to an eligible size fixed it in one line.&lt;/p&gt;

&lt;p&gt;So the whole build runs spin up and tear down in a single session. Budget alarm first, before anything else exists.&lt;/p&gt;

&lt;p&gt;Teardown happens in strict order: the app and its Ingress first, so the controller removes the ALB on its own, confirmed gone, then the cluster, then the leftovers. Get that order wrong and the ALB orphans itself, billing on with nothing left to remove it.&lt;/p&gt;

&lt;p&gt;A full session cost me under a dollar. A cluster left running by accident is where the scary numbers live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thread running through all of it
&lt;/h2&gt;

&lt;p&gt;It's Kubernetes I already knew, wrapped in AWS plumbing, with IRSA as the identity glue and cost discipline as the constant.&lt;/p&gt;

&lt;p&gt;Same manifests. Managed underneath. Keyless pod permissions. A meter you never take your eye off.&lt;/p&gt;

&lt;p&gt;Full build, the &lt;code&gt;eksctl&lt;/code&gt; config, the manifests, and the teardown runbook: &lt;a href="https://github.com/vivianokose/nexaops-operations-lab/tree/main/11-eks" rel="noopener noreferrer"&gt;github.com/vivianokose/nexaops-operations-lab/tree/main/11-eks&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>kubernetes</category>
      <category>eks</category>
      <category>security</category>
    </item>
    <item>
      <title>I Deleted the Same Pod Twice. Only One of Them Remembered.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Fri, 18 Sep 2026 15:17:11 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-deleted-the-same-pod-twice-only-one-of-them-remembered-2g9g</link>
      <guid>https://dev.to/vivian_okose/i-deleted-the-same-pod-twice-only-one-of-them-remembered-2g9g</guid>
      <description>&lt;p&gt;I wrote a value to Redis, deleted its pod, and it was gone. Did the same to a different Redis, deleted the pod, and it was still there. Same command, opposite result, and the difference explains what Kubernetes is actually doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shift: describe, don't instruct
&lt;/h2&gt;

&lt;p&gt;Every tool before this took commands that run once. Kubernetes takes a description of what should be true and keeps reality matching it. You say "I want two API pods," and the control plane makes and keeps that true.&lt;/p&gt;

&lt;p&gt;The catch: you cannot fix things by hand. Delete a pod, it comes back, because the description still says two. To change what runs, change the description.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-healing, proven
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmficbk0g7pusntsr8fm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmficbk0g7pusntsr8fm.png" alt="Self-healing" width="782" height="472"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Delete a pod and a replacement is scheduled before the old one finishes terminating.&lt;br&gt;
~12 seconds to serving traffic. Nothing responded to the delete specifically, the control loop just saw 1 where it wanted 2.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pieces
&lt;/h2&gt;

&lt;p&gt;Pods wrap containers and are disposable. Deployments hold the replica count and manage pods.&lt;br&gt;
Services give a stable address in front of changing pods, reached by DNS name (&lt;code&gt;redis&lt;/code&gt;, not an IP). Labels and selectors are the glue, a Service fronts "anything labelled &lt;code&gt;app: redis&lt;/code&gt;", and a label/selector mismatch is the classic beginner bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autoscaling
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft99h20hsnalhs5313oh2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft99h20hsnalhs5313oh2.png" alt="HPA" width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;CPU hit 92% under load, pods went 2 to 4 in seconds. Needs metrics-server, and needs pods to declare CPU requests or there's no baseline to measure.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two Redis pods
&lt;/h2&gt;

&lt;p&gt;The API is stateless, interchangeable, perfect for a Deployment. Redis holds data. A plain Deployment loses it on restart. A StatefulSet gives a stable identity (&lt;code&gt;redis-store-0&lt;/code&gt;) and a PersistentVolumeClaim that outlives the pod, so the value survives.&lt;/p&gt;

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

&lt;p&gt;Caveat: real replicated databases in Kubernetes are hard (leader election, failover). Most teams use a managed database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config, updates, isolation
&lt;/h2&gt;

&lt;p&gt;ConfigMap injects settings as env vars. Rolling updates replace pods gradually, gated by the readiness probe, so a broken version stops the rollout instead of taking the service down.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9hvuqcgxa9s4fzz77zrr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9hvuqcgxa9s4fzz77zrr.png" alt="Rollout" width="800" height="221"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Rollback adds a revision rather than deleting history, and history is finite. The manifest in git is the real source of truth.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;By default every pod can reach every other pod. A NetworkPolicy fixes it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fay10sjc2n5cyww4n0884.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fay10sjc2n5cyww4n0884.png" alt="NetworkPolicy" width="797" height="114"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Unrelated pod: timed out. API with the allowed label: connects. Same chained isolation as AWS security groups, by labels not IPs. The moment a policy selects a pod it goes deny-by-default, so forgetting to allow needed traffic breaks your own app.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Helm vs Kustomize
&lt;/h2&gt;

&lt;p&gt;Helm: templated manifests with variables, versioned releases, one-command rollback, but the files aren't valid YAML alone. Kustomize: plain YAML with per-environment patches, no release history, built into kubectl. Helm for third-party software, Kustomize for your own apps. Many teams use both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging
&lt;/h2&gt;

&lt;p&gt;STATUS gives the category, &lt;code&gt;describe&lt;/code&gt; Events give the cause, logs only help once a container starts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foz4cdjssyj6fq0t4c6en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foz4cdjssyj6fq0t4c6en.png" alt="Debug events" width="800" height="561"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A deliberate bad-image break. Events name it in one line. Reading Events first is the habit beginners skip.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea
&lt;/h2&gt;

&lt;p&gt;Declare the desired state, a control loop keeps reality matching it. Self-healing, autoscaling, rolling updates, rollback, reboot recovery, all the same loop closing a gap.&lt;/p&gt;

&lt;p&gt;Full build: &lt;a href="https://github.com/vivianokose/nexaops-operations-lab/tree/main/10-kubernetes" rel="noopener noreferrer"&gt;https://github.com/vivianokose/nexaops-operations-lab/tree/main/10-kubernetes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>containers</category>
      <category>cloud</category>
    </item>
    <item>
      <title>My Server Had Zero Inbound Ports. I Still Got a Shell.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Wed, 02 Sep 2026 16:44:46 +0000</pubDate>
      <link>https://dev.to/vivian_okose/my-server-had-zero-inbound-ports-i-still-got-a-shell-5fdc</link>
      <guid>https://dev.to/vivian_okose/my-server-had-zero-inbound-ports-i-still-got-a-shell-5fdc</guid>
      <description>&lt;p&gt;A shell prompt on a server with no SSH key, no port 22, and no inbound firewall rules at all.&lt;br&gt;
From it, a query against a database my own laptop cannot reach, not because of a password,&lt;br&gt;
but because no route to it exists.&lt;/p&gt;

&lt;p&gt;That is the end state. Here is the build.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A SaaS company, 500 paying customers, everything on one server: web app, MySQL, and file&lt;br&gt;
uploads on one box with one public IP. It went down for eleven minutes when the server&lt;br&gt;
restarted. Worse, the logs showed thousands of automated login attempts nightly, and the&lt;br&gt;
database sat at the same address as the app.&lt;/p&gt;

&lt;p&gt;The fix is not a better password. It is making the database unreachable.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually makes a subnet public
&lt;/h2&gt;

&lt;p&gt;A VPC with four subnets across two availability zones. Public for things that face customers,&lt;br&gt;
private for things that must not.&lt;/p&gt;

&lt;p&gt;The precise version, and it is a common interview question: &lt;strong&gt;a subnet is public because its&lt;br&gt;
route table sends 0.0.0.0/0 to an internet gateway.&lt;/strong&gt; Private subnets have no such route.&lt;br&gt;
Not a checkbox, just the presence or absence of a line.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lnpzxe4bg0wo3jziuro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6lnpzxe4bg0wo3jziuro.png" alt="Route tables" width="800" height="334"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Public points at the IGW. Private points at a NAT gateway, outbound only.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Security groups referencing security groups
&lt;/h2&gt;

&lt;p&gt;Three groups, chained: the ALB accepts HTTP from anywhere, app servers accept 3000 only from&lt;br&gt;
the ALB's security group, the database accepts 3306 only from the app servers' group.&lt;/p&gt;

&lt;p&gt;The rules name &lt;strong&gt;groups, not IPs&lt;/strong&gt;. Replace a server tomorrow with a different address and&lt;br&gt;
the rule still works, because it grants access to anything wearing that badge.&lt;/p&gt;

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

&lt;p&gt;Hitting an app server directly on 3000 from a browser &lt;strong&gt;timed out&lt;/strong&gt;. Not refused. Timeout&lt;br&gt;
means packets silently dropped, usually a firewall or missing route. Refused means you&lt;br&gt;
reached the machine but nothing was listening. Useful distinction when debugging.&lt;/p&gt;
&lt;h2&gt;
  
  
  Redundancy plus health checks
&lt;/h2&gt;

&lt;p&gt;Two app servers in two availability zones, physically separate data centres. An ALB in front&lt;br&gt;
spreads traffic and, more importantly, requests &lt;code&gt;/health&lt;/code&gt; every thirty seconds. Three&lt;br&gt;
consecutive failures and that server stops receiving traffic. The failure still happens; it&lt;br&gt;
stops being an outage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy6bdb9ugo7rfpvf7hmp4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy6bdb9ugo7rfpvf7hmp4.png" alt="Server A" width="800" height="262"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frybmg6ro9ffqli8p0t2s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frybmg6ro9ffqli8p0t2s.png" alt="Server B" width="777" height="220"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same URL, different servers, neither reachable directly.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  IAM roles, not access keys
&lt;/h2&gt;

&lt;p&gt;Access keys in a config file end up on disk, in the repo, on every laptop that cloned it, and&lt;br&gt;
in backups. They do not expire. Bots scan public repos for them constantly and find them&lt;br&gt;
within minutes.&lt;/p&gt;

&lt;p&gt;A role attached to the instance gets temporary credentials, rotated automatically. Nothing to&lt;br&gt;
leak, nothing to rotate, and it cannot be lifted off the machine the way a stolen key can.&lt;/p&gt;

&lt;p&gt;The least-privilege policy needs two statements, which is where most first attempts fail:&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="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-bucket"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::my-bucket/*"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="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;Bucket actions take the bucket ARN. Object actions take &lt;code&gt;/*&lt;/code&gt;. Grant one without the other&lt;br&gt;
and you get confusing half-failures.&lt;/p&gt;

&lt;p&gt;After swapping the broad managed policy for that one, uploads still worked and &lt;code&gt;aws s3 ls&lt;/code&gt;&lt;br&gt;
returned AccessDenied. &lt;strong&gt;The denial is the win.&lt;/strong&gt; A compromised server now reaches one bucket&lt;br&gt;
and nothing else.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  RDS: no shell, and that is the point
&lt;/h2&gt;

&lt;p&gt;AWS handles backups, patching, storage, hardware. There is no SSH into an RDS instance,&lt;br&gt;
because the shell existed to do maintenance that is no longer yours. You get a stable&lt;br&gt;
endpoint that survives hardware replacement and failover.&lt;/p&gt;

&lt;p&gt;Private subnets, public access off, app servers only.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Session Manager instead of SSH
&lt;/h2&gt;

&lt;p&gt;No key pair, no SSH rule. An agent on the instance holds an outbound connection to AWS, and&lt;br&gt;
sessions come back through it.&lt;/p&gt;

&lt;p&gt;No port 22 to scan. No key to store, copy, lose, or rotate. Authentication through your IAM&lt;br&gt;
identity, so removing someone's AWS access locks them out of every server at once. Every&lt;br&gt;
session logged in CloudTrail under a real name. And it reaches private subnets, so no bastion&lt;br&gt;
host.&lt;/p&gt;

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

&lt;p&gt;Trap worth knowing: Amazon Linux ships the MariaDB client, which uses&lt;br&gt;
&lt;code&gt;--ssl-verify-server-cert&lt;/code&gt;, not MySQL's &lt;code&gt;--ssl-mode=VERIFY_IDENTITY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost, and the runbook that failed
&lt;/h2&gt;

&lt;p&gt;Cloud bills for existing, not for using. An idle NAT gateway is the same price as a busy one,&lt;br&gt;
roughly a dollar a day.&lt;/p&gt;

&lt;p&gt;Budget alarm before any resource existed. Tags on creation. Teardown runbook written up front,&lt;br&gt;
then tested, and testing it found a real gap: deleting the VPC failed because the NAT gateway&lt;br&gt;
and its network interface had to go first, individually. That is exactly how orphaned&lt;br&gt;
resources end up billing for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  On method
&lt;/h2&gt;

&lt;p&gt;I zoned out on the videos for this module, repeatedly. So I changed approach: every concept&lt;br&gt;
as a specific problem first, named plainly, then built. Ten concepts before a single&lt;br&gt;
resource. By the time I was in the console I was confirming something I understood rather&lt;br&gt;
than following steps I did not.&lt;/p&gt;

&lt;p&gt;If videos are not landing for you, that is worth knowing about yourself early.&lt;/p&gt;

&lt;p&gt;Full build, screenshots, diagram, retro, and the tested teardown runbook:&lt;br&gt;
&lt;a href="https://github.com/vivianokose/nexaops-operations-lab/tree/main/09-cloud-fundamentals" rel="noopener noreferrer"&gt;https://github.com/vivianokose/nexaops-operations-lab/tree/main/09-cloud-fundamentals&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
      <category>security</category>
    </item>
    <item>
      <title>I Was Scared of Jenkins. Then I Built a Full CI/CD Pipeline From One git push</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:43:02 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-was-scared-of-jenkins-then-i-built-a-full-cicd-pipeline-from-one-git-push-1j64</link>
      <guid>https://dev.to/vivian_okose/i-was-scared-of-jenkins-then-i-built-a-full-cicd-pipeline-from-one-git-push-1j64</guid>
      <description>&lt;p&gt;I watched an entire Jenkins video series and understood almost none of it. The moment&lt;br&gt;
Groovy appeared, I zoned out, convinced I needed to learn a programming language to do this&lt;br&gt;
job. I was wrong, and figuring out why unlocked everything.&lt;/p&gt;

&lt;p&gt;By the end I had a pipeline that, from one &lt;code&gt;git push&lt;/code&gt;, computes the version, lints, tests,&lt;br&gt;
builds a Docker image, pushes it to a registry, deploys it, verifies it, and tags the&lt;br&gt;
release. Automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fear-buster
&lt;/h2&gt;

&lt;p&gt;A Jenkinsfile is a fill-in-the-blanks form, not a language. The structure&lt;br&gt;
&lt;code&gt;pipeline { stages { stage { steps { } } } }&lt;/code&gt; is fixed. You fill in stage names and shell&lt;br&gt;
commands you already know. That is it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two servers
&lt;/h2&gt;

&lt;p&gt;A Jenkins server (the factory, runs the pipeline) and an app server (the shop, runs the&lt;br&gt;
app). Jenkins builds and ships; it never runs the app.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Secrets in a vault
&lt;/h2&gt;

&lt;p&gt;Every secret (SSH key, server IP, Docker Hub token, GitHub token) lives in Jenkins'&lt;br&gt;
credential store with a label. The Jenkinsfile references the label, never the secret.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Eight stages
&lt;/h2&gt;

&lt;p&gt;Checkout, Compute Version, Lint, Test, Build and Push, Deploy, Verify, Tag Release. Runs&lt;br&gt;
top to bottom, stops on any failure so broken code never deploys.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffoawky0zt7hbioijnfb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffoawky0zt7hbioijnfb0.png" alt="All green" width="799" height="441"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A full run, every stage green.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook makes it automatic
&lt;/h2&gt;

&lt;p&gt;A doorbell: GitHub pings Jenkins on every push, so the pipeline runs on its own. I opened&lt;br&gt;
Jenkins only to GitHub's webhook IP ranges, not the whole internet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tesxc83kvnqipztvdez.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7tesxc83kvnqipztvdez.png" alt="Auto-triggered" width="800" height="539"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A build that started itself after a push.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Multibranch and PR checks
&lt;/h2&gt;

&lt;p&gt;Every branch and pull request builds automatically, and the PR shows a pass/fail check on&lt;br&gt;
GitHub. Deploy runs only on main, so feature branches are tested but never shipped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1euixwhqecu3se6cigjh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1euixwhqecu3se6cigjh.png" alt="PR check" width="799" height="578"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The check stamped on the pull request.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared library
&lt;/h2&gt;

&lt;p&gt;The build-and-push logic lives in a separate repo. The Jenkinsfile calls it in one line.&lt;br&gt;
Fix once, every project gets the fix.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Dynamic versioning
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;feat:&lt;/code&gt; commit bumps the minor version, a &lt;code&gt;fix:&lt;/code&gt; bumps the patch. The pipeline reads&lt;br&gt;
the commit and tags the release. No human picks a version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3br4oygncrmsfr8hkwwn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3br4oygncrmsfr8hkwwn.png" alt="Auto tags" width="800" height="339"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;feat produced v0.1.0, fix produced v0.1.1.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs that taught me most
&lt;/h2&gt;

&lt;p&gt;Cloud account suspended mid-build (rebuilt on a new provider, faster the second time).&lt;br&gt;
Deleted my SSH keys with a careless command. Jenkins refusing to start on the wrong Java&lt;br&gt;
version. A stage hanging because my lint command started a never-ending server. A first run&lt;br&gt;
failing on a missing tool. Every one was a real lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Jenkins is a form, not a language. The full code and screenshots:&lt;br&gt;
&lt;a href="https://github.com/vivianokose/nexaops-operations-lab/tree/main/08-jenkins" rel="noopener noreferrer"&gt;https://github.com/vivianokose/nexaops-operations-lab/tree/main/08-jenkins&lt;/a&gt;&lt;/p&gt;

</description>
      <category>jenkins</category>
      <category>cicd</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>I Shrank My Docker Image From 1.11GB to 133MB, and That Was the Easy Part</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 13 Aug 2026 18:45:39 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-shrank-my-docker-image-from-111gb-to-133mb-and-that-was-the-easy-part-463f</link>
      <guid>https://dev.to/vivian_okose/i-shrank-my-docker-image-from-111gb-to-133mb-and-that-was-the-easy-part-463f</guid>
      <description>&lt;p&gt;My first Docker image was 1.11GB. The app inside it was a tiny delivery tracker that could&lt;br&gt;
have fit in a fraction of that. By the end of this module the image was 133MB, signed,&lt;br&gt;
scanned, non-root, and sitting in two registries. Here is the whole journey, including the&lt;br&gt;
parts that fought me.&lt;/p&gt;

&lt;p&gt;I gave myself four questions, borrowed from how real teams judge a production image. A&lt;br&gt;
naive image fails all four. A good one passes all four.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How small is it?&lt;/li&gt;
&lt;li&gt;What is in it?&lt;/li&gt;
&lt;li&gt;Who built it?&lt;/li&gt;
&lt;li&gt;Will it run as root?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;p&gt;SwiftMove Logistics: a Node.js server reading deliveries from MySQL. Small enough to&lt;br&gt;
understand, real enough to need a database, a private network, volumes, and health checks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcc1w4bs7gb08rmgutq5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcc1w4bs7gb08rmgutq5.png" alt="The app running" width="799" height="369"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Live data from the MySQL container beside it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 1: How small is it?
&lt;/h2&gt;

&lt;p&gt;I built the worst reasonable Dockerfile first, on purpose, to see the cost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffts8hwp8t4ylbtuyw01j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffts8hwp8t4ylbtuyw01j.png" alt="Naive image size" width="800" height="93"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;1.11GB. A whole build toolchain that the running app never touches.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fix is a multi-stage build. Ship the sandwich, not the whole kitchen: do the messy&lt;br&gt;
building in a throwaway stage, copy only the finished app into a tiny clean stage.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  Question 4: Will it run as root?
&lt;/h2&gt;

&lt;p&gt;No. A non-root user, created in the Dockerfile and switched to, so the app has almost no&lt;br&gt;
power inside its own container.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  One command for the whole stack
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncwqiajd65ux9esyilpr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncwqiajd65ux9esyilpr.png" alt="compose up" width="800" height="260"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;App plus database, one command.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F64mlmzyqgjekrzjrqt91.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F64mlmzyqgjekrzjrqt91.png" alt="Both healthy" width="800" height="65"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;MySQL has no port exposed to the host. Secure by not opening a door.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first run showed "table doesn't exist", the app started before MySQL was ready. The&lt;br&gt;
fix was a retry loop so the app waits patiently for the database. That bug taught me more&lt;br&gt;
than a clean run would have.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rj4ounpjpmnk212wd0q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rj4ounpjpmnk212wd0q.png" alt="Volume persistence" width="800" height="463"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Data survived a full container destroy and recreate, because it lives in a volume.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: What is in it?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qezu0uuynxpk0w8qdi6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qezu0uuynxpk0w8qdi6.png" alt="Trivy before" width="800" height="425"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The first scan. Dozens of findings from the base image.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most shared one root cause: outdated OpenSSL. I bumped the base image and rescanned.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7huryv81bqfep14zxp9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7huryv81bqfep14zxp9.png" alt="Trivy after" width="800" height="478"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;OS findings dropped from 50 to 2. My own dependencies scan clean.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The goal is not zero. It is: know, fix what you can, document the rest in a SECURITY.md.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: Who built it?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwpure7z874vsjj15z2c0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwpure7z874vsjj15z2c0.png" alt="Cosign verified" width="800" height="216"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Signed and verified with cosign. A wax seal proving origin and integrity.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two registries
&lt;/h2&gt;

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

&lt;p&gt;The Nexus push threw a 403 first: my least-privilege deploy user could not create a new&lt;br&gt;
repo, exactly as designed. Least privilege, felt from the other side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg46kaxyrq1f8ssfa8px.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg46kaxyrq1f8ssfa8px.png" alt="In Nexus" width="799" height="281"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same image in my private Nexus registry.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The naive image took minutes. The production one took hours. That gap is the whole module,&lt;br&gt;
and the bugs along the way were the best teachers.&lt;/p&gt;

&lt;p&gt;Full code and screenshots: &lt;a href="https://github.com/vivianokose/nexaops-operations-lab/tree/main/07-docker" rel="noopener noreferrer"&gt;https://github.com/vivianokose/nexaops-operations-lab/tree/main/07-docker&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>containers</category>
      <category>security</category>
    </item>
    <item>
      <title>I Built a Private Software Registry, and Four Different Tools Now Publish to It</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Wed, 05 Aug 2026 08:15:17 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-built-a-private-software-registry-and-four-different-tools-now-publish-to-it-2cjp</link>
      <guid>https://dev.to/vivian_okose/i-built-a-private-software-registry-and-four-different-tools-now-publish-to-it-2cjp</guid>
      <description>&lt;p&gt;I will be honest about where this one started. I watched the videos for this module, and&lt;br&gt;
at the end I was completely lost. Not "need to review" lost. "The words make sense one at&lt;br&gt;
a time but the whole picture will not form" lost.&lt;/p&gt;

&lt;p&gt;So I threw the videos out and rebuilt it from a single idea. Once that idea landed,&lt;br&gt;
everything else fell into place. Here is the whole thing, the way it finally made sense to&lt;br&gt;
me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one idea: a registry is a pantry for software
&lt;/h2&gt;

&lt;p&gt;When developers build software, their code needs ingredients: libraries other people&lt;br&gt;
wrote. Normally every build runs out to the public internet to fetch those ingredients&lt;br&gt;
fresh, every single time. That is slow, and if the public site is down or rate-limits you,&lt;br&gt;
your build breaks.&lt;/p&gt;

&lt;p&gt;No real kitchen works that way. Kitchens have a pantry. Ingredients get stocked once and&lt;br&gt;
kept on a shelf, and cooks grab what they need from there.&lt;/p&gt;

&lt;p&gt;An artifact registry is a pantry for software. It stores copies of the ingredients your&lt;br&gt;
builds use, and it stores your own finished artifacts too. Nexus is one popular brand of&lt;br&gt;
this pantry. That is the whole concept. Everything else is detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of shelf
&lt;/h2&gt;

&lt;p&gt;A registry stores things three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hosted: your own artifacts. The jam you made yourself.&lt;/li&gt;
&lt;li&gt;Proxy: cached copies of public libraries. Shop flour kept on your shelf, so you do not
run to the shop every time, and you still have some when the shop is closed.&lt;/li&gt;
&lt;li&gt;Group: one address that serves from both, so a build only needs one URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftt99dph6drjvf107ld4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftt99dph6drjvf107ld4q.png" alt="All repositories" width="800" height="410"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The shelves in my registry. You can see the types right there: hosted, proxy, group.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it as a limited user
&lt;/h2&gt;

&lt;p&gt;Before publishing anything, the most important security decision. Nexus runs on a Linux&lt;br&gt;
machine, and every program runs as some user. That user decides what the program can touch.&lt;/p&gt;

&lt;p&gt;You never run something like this as root, the all-powerful user. You make a limited user,&lt;br&gt;
literally called &lt;code&gt;nexus&lt;/code&gt;, that can only touch its own files. If the application is ever&lt;br&gt;
broken into, the attacker is trapped in that small world instead of owning the whole&lt;br&gt;
machine. It is the difference between giving a cleaner the key to one room versus the&lt;br&gt;
master key to the building.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1tkjzb0hxam3n993hba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1tkjzb0hxam3n993hba.png" alt="Nexus running as the nexus user" width="799" height="260"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Proof it runs as &lt;code&gt;nexus&lt;/code&gt;, not root. That first column is the entire security lesson.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where it fought me
&lt;/h2&gt;

&lt;p&gt;Real infrastructure work is never the happy path. The lab specified an old version of&lt;br&gt;
Nexus that wanted an ancient Java, but I had a modern one. Then the download links kept&lt;br&gt;
returning 404, because the vendor had rotated the versions and even changed the filename&lt;br&gt;
format since the lab was written.&lt;/p&gt;

&lt;p&gt;I learned to stop trusting a hardcoded version number and go to the official download page&lt;br&gt;
for the current link. The newer version shipped with its own Java bundled inside, which&lt;br&gt;
made the whole conflict disappear. Frustrating in the moment, but this is the actual job:&lt;br&gt;
read the error, adjust, keep going.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing four kinds of artifact
&lt;/h2&gt;

&lt;p&gt;Here is the part that surprised me most. I published four completely different artifact&lt;br&gt;
types, and they all work the same way underneath.&lt;/p&gt;

&lt;p&gt;Java, with Maven:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujuny19enstdayxjkzjv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fujuny19enstdayxjkzjv.png" alt="Maven deploy" width="800" height="230"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Pushing my Java JAR to the registry.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Node, with npm. Python, with a tool called twine, and for that one I built a Python&lt;br&gt;
package from scratch, something I had never done: a wheel file, described by a&lt;br&gt;
pyproject.toml, built and uploaded.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwt2fs009uygsoilmr9m1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwt2fs009uygsoilmr9m1.png" alt="Python wheel built" width="800" height="287"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;My first Python package, sealed into a wheel.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And Docker, an image pushed to the registry:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgu883h6a6jpaswi54rz0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgu883h6a6jpaswi54rz0.png" alt="Docker push" width="799" height="201"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A Docker image, the fourth and final type.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once I had done the first one, the other three were variations on a pattern I already&lt;br&gt;
knew: point the tool at Nexus, give it a credential, run a publish command. Four different&lt;br&gt;
ecosystems, one shape. That was the moment it stopped being four things to memorise and&lt;br&gt;
became one idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  Least privilege, and the errors that taught it
&lt;/h2&gt;

&lt;p&gt;I published everything as admin at first, which is fine for setup and wrong for a real&lt;br&gt;
build pipeline. A pipeline's credentials leak constantly; you never hand it the keys to&lt;br&gt;
everything.&lt;/p&gt;

&lt;p&gt;So I made a deploy user that can only push, no admin, no delete. And setting it up, I kept&lt;br&gt;
hitting "403 Forbidden" errors. One for the jar, another for the metadata. Each one taught&lt;br&gt;
me that access control is iterative: grant the minimum, test, find you are one privilege&lt;br&gt;
short, grant exactly that, and no more. The errors were the lesson.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywpvr21r3xztdz52b756.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywpvr21r3xztdz52b756.png" alt="Deploy user cannot admin" width="800" height="171"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Logged in as the deploy user: the admin controls are simply gone. It can do its job, and&lt;br&gt;
only its job.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating the inventory
&lt;/h2&gt;

&lt;p&gt;Clicking through a UI is fine for a human. Real operations automate. So I wrote a small&lt;br&gt;
bash script that asks the registry's API what it holds and prints an inventory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft59go640i9vvflcgvy54.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft59go640i9vvflcgvy54.png" alt="API inventory" width="800" height="385"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One command, and the whole registry reports itself: every artifact, across all four&lt;br&gt;
types.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That is the leap from using a tool to automating infrastructure, and it is the difference&lt;br&gt;
between clicking buttons and doing the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;Before this, if you had asked me how teams share build artifacts, I would have said they upload them to a repository like Nexus or JFrog for distribution and versioning, and for&lt;br&gt;
control over what stays in the repository. That was right. But building it taught me the part I had not appreciated: how much of a registry is about control and safety, not just storage. The limited user. Anonymous access off. A deploy user that can push but not administer. A record of who uploaded what.&lt;/p&gt;

&lt;p&gt;A registry is not a shared folder. It is a controlled, auditable gate that everything passes through. And a topic that completely lost me at the start became something I can now build with my own hands and explain to someone else. That is the part I am proud of.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>nexus</category>
      <category>docker</category>
      <category>aws</category>
    </item>
    <item>
      <title>I Locked Myself Out of My Own Cloud Server. Here Is How I Got Back In.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Wed, 29 Jul 2026 14:35:00 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-locked-myself-out-of-my-own-cloud-server-here-is-how-i-got-back-in-2c96</link>
      <guid>https://dev.to/vivian_okose/i-locked-myself-out-of-my-own-cloud-server-here-is-how-i-got-back-in-2c96</guid>
      <description>&lt;p&gt;I did something this week that sounds like a mistake and was actually the most useful thing I have learned in months.&lt;/p&gt;

&lt;p&gt;I locked myself out of my own server. On purpose. A server that was running fine, serving a live website, with no way back in. No SSH, no password, and no keyboard to plug in, because it lives in an Amazon data centre I will never enter.&lt;/p&gt;

&lt;p&gt;Then I recovered it. And now I understand something most engineers only read about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43yapl60552d90lfiif9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F43yapl60552d90lfiif9.png" alt="Back in after the disk-swap recovery" width="800" height="389"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Logged back into the server that had locked me out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let me walk through the whole thing, because the recovery only makes sense once you see how the server was built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renting a computer by the hour
&lt;/h2&gt;

&lt;p&gt;The cloud is someone else's computer that you rent by the hour. You get a slice of a machine in a warehouse, use it, and hand it back when you are done. Amazon calls this service EC2, and one rented machine is an "instance."&lt;/p&gt;

&lt;p&gt;The mental shift that matters: you pay for the machine existing, not for using it. A server you forgot about costs the same as one serving thousands of people. That single fact shapes every decision in this module, and it is why cost discipline shows up at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking the front door before building anything
&lt;/h2&gt;

&lt;p&gt;The very first step had nothing to do with servers. It was locking down the account itself.&lt;/p&gt;

&lt;p&gt;When you sign up for AWS, you get a "root" account that can do anything, including run up unlimited charges if the credentials leak. There is a whole economy of bots scanning the internet for leaked AWS keys, and they spin up expensive servers within minutes of finding one.&lt;/p&gt;

&lt;p&gt;So the first move is to lock root away behind multi-factor authentication and create a weaker, everyday identity to actually work with. That everyday identity is an IAM user. Root is the master key you lock in a drawer; the IAM user is the normal key you carry.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4253liauesrghlz59f2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4253liauesrghlz59f2.png" alt="Root account with MFA enabled" width="800" height="402"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Root account secured with multi-factor authentication.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q27emclu4hatgcnuhq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q27emclu4hatgcnuhq0.png" alt="IAM user inside its group" width="799" height="374"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;An everyday IAM user, in an Administrators group, so I never work as root.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The server that would not let me in
&lt;/h2&gt;

&lt;p&gt;With the account secured, I launched my first instance. And immediately hit a wall.&lt;/p&gt;

&lt;p&gt;SSH timed out. Not "permission denied," which would mean I reached the server but was not allowed in. A timeout, which means the knock got no answer at all.&lt;/p&gt;

&lt;p&gt;My security group, AWS's firewall, was correct: SSH open to my IP, web traffic open to everyone. So why the silence?&lt;/p&gt;

&lt;p&gt;I worked through it one layer at a time. Was my key wrong? A wrong key gets rejected, not ignored. Had my home IP changed? It matched. Was the subnet missing its route to the internet? The gateway was there.&lt;/p&gt;

&lt;p&gt;Then I found it. A network ACL, a second firewall sitting above the security group, set to deny all traffic. Someone had configured this account's network in a non-standard way long before I arrived. My rules were perfect; the layer above them blocked everything.&lt;/p&gt;

&lt;p&gt;I made a call that felt like the professional one: stop patching someone else's confusing setup, and build my own clean network from scratch. A fresh VPC, one public subnet, a proper gateway, all wired correctly. I launched into that, and SSH worked instantly.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;The instance running in my own clean network, finally reachable.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The lesson stuck harder than any happy-path tutorial could have taught it. A timeout is not one problem, it is a checklist: security group, your IP, the route, the network ACL. And when you inherit a mess, rebuilding clean often beats untangling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardening, against real threats this time
&lt;/h2&gt;

&lt;p&gt;I had hardened Linux servers before, but always on my own laptop where the threats were hypothetical. This one had a public address, which means bots start probing it within minutes of it going live.&lt;/p&gt;

&lt;p&gt;Same baseline, real stakes: patch everything first, put up a firewall that denies everything by default and opens only SSH and the web ports, and run fail2ban to jail anything that hammers the login repeatedly.&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;The firewall: everything denied by default, only SSH and the web ports open.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96n9mp7uudwmj4djuvoq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F96n9mp7uudwmj4djuvoq.png" alt="fail2ban active" width="723" height="262"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;fail2ban standing guard, ready to jail repeat login offenders.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving it a name and a padlock
&lt;/h2&gt;

&lt;p&gt;A raw IP address is no way to reach a website. So I pointed a subdomain of my own domain, app.viviancloud.site, at the server, while carefully leaving my main domain on its existing site. One domain, different subdomains for different things, which is exactly how real companies organise this.&lt;/p&gt;

&lt;p&gt;Then HTTPS. For years, certificates cost money and were fiddly. A nonprofit called Let's Encrypt made them free and automatic. One tool, one command, and my site loaded with a padlock and a certificate that renews itself. The plain-HTTP postcard became a sealed envelope.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F62cm9d5tr9z0yh6qejmb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F62cm9d5tr9z0yh6qejmb.png" alt="My own page live" width="799" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;My own page, served from the EC2 instance.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw6f9wb9q1flqn056yt51.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw6f9wb9q1flqn056yt51.png" alt="The site on my real domain" width="800" height="441"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Reachable by name at app.viviancloud.site.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsspecn2nn9qqsfno6xmt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsspecn2nn9qqsfno6xmt.png" alt="The HTTPS padlock in the address bar" width="800" height="324"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The padlock: HTTPS live, certificate valid and auto-renewing.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Data on its own disk
&lt;/h2&gt;

&lt;p&gt;I attached a second disk, separate from the one the operating system lives on. The reason is practical: keep your data apart from your OS, and you can rebuild the OS whenever you like without touching the data. Format it, mount it, and add it to the system's startup config so the mount survives a reboot. I proved it by actually rebooting and watching the disk come back on its own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszxxo0c1y93n7m3b0axn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszxxo0c1y93n7m3b0axn.png" alt="The data disk mounted and surviving a reboot" width="567" height="92"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The separate data disk, still mounted after a full reboot.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The lockout, and the way back
&lt;/h2&gt;

&lt;p&gt;Now the part I opened with.&lt;/p&gt;

&lt;p&gt;To learn recovery for real, I created the disaster. I emptied the file that lists which keys are allowed to log in, disconnected, and confirmed I was locked out. Permission denied, on my own running server.&lt;/p&gt;

&lt;p&gt;But before breaking anything, I took a snapshot: a full backup of the disk, my undo button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvo6z42l941lvzewz6mpc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvo6z42l941lvzewz6mpc.png" alt="The safety snapshot taken before the drill" width="799" height="370"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A snapshot first. Never break something risky without a backup.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is the recovery, and the single idea that makes it possible: the file that decides who can log in lives on the disk, and a disk is a movable object.&lt;/p&gt;

&lt;p&gt;So I stopped the server, detached its disk, and attached that disk to a second "rescue" instance as a spare drive. From the rescue instance, I could reach into the locked server's files, and I wrote my key back onto its login list, with the exact ownership and permissions SSH insists on. Then I detached the disk, reattached it to the original server as its boot drive, and started it up.&lt;/p&gt;

&lt;p&gt;I logged in. The same key that got "permission denied" ten minutes earlier now worked.&lt;/p&gt;

&lt;p&gt;It is exactly like being locked out of your house, unbolting the front door, carrying it to a locksmith who fits a new lock you have a key for, and rehanging it. The house never changed. You fixed the lock by taking the door somewhere you could work on it.&lt;/p&gt;

&lt;p&gt;The deeper realisation: a server accepts any key whose public half is written in its login file. So a lost key is never a dead end. You do not recover the old key, you generate a new one and install it. The lock is always replaceable.&lt;/p&gt;

&lt;p&gt;There are faster recovery routes when you set them up in advance, but the disk-swap always works, with no agent and no preparation. It is the universal fallback, which is why it is the one worth learning first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building carefully, tearing down completely
&lt;/h2&gt;

&lt;p&gt;The module ended where cloud work always should: accounting for the cost and knowing how to remove everything.&lt;/p&gt;

&lt;p&gt;Total spend for all of this: zero dollars, by staying on free-tier resources and stopping the instance between sessions. I set a billing alarm as a tripwire, and wrote a teardown runbook listing every resource and how to remove it in order, because a resource you forget is a resource you keep paying for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiy26a2v011wjrqk544xh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiy26a2v011wjrqk544xh.png" alt="The billing alarm set as a cost tripwire" width="800" height="352"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A billing alarm: emails me if estimated charges ever cross five dollars.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;Not just the steps. The principles underneath them.&lt;/p&gt;

&lt;p&gt;That cloud bills on existence, not use. That you default everything to closed and open only what each service needs. That a lost key is a lock to replace, not a catastrophe. And that knowing how to cleanly destroy infrastructure matters as much as knowing how to build it.&lt;/p&gt;

&lt;p&gt;I locked myself out of a server and got back in by hand. I will not forget how that works.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
      <category>security</category>
    </item>
    <item>
      <title>Your Build Output Is Not an Artifact Yet. Here Are the Four Things It Needs.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 23 Jul 2026 12:24:56 +0000</pubDate>
      <link>https://dev.to/vivian_okose/your-build-output-is-not-an-artifact-yet-here-are-the-four-things-it-needs-969</link>
      <guid>https://dev.to/vivian_okose/your-build-output-is-not-an-artifact-yet-here-are-the-four-things-it-needs-969</guid>
      <description>&lt;p&gt;In December 2021, a flaw was found in a Java logging library called Log4j. Not a famous library. The boring thing that writes messages to a log file.&lt;/p&gt;

&lt;p&gt;Then every company on earth asked the same question at once: &lt;strong&gt;are we using it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most couldn't answer. Not out of carelessness, but because nobody keeps a list of what is actually inside their software. Their app pulled in a library, which pulled in another, which quietly pulled in Log4j four levels down. Teams spent entire weekends grepping through servers trying to work out whether they were exposed.&lt;/p&gt;

&lt;p&gt;The companies that answered in ten minutes had one thing the others didn't. A list.&lt;/p&gt;

&lt;p&gt;That list is called an SBOM, and it's one of four things I learned to attach to my own artifacts this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, what a build actually does
&lt;/h2&gt;

&lt;p&gt;Source code is text. &lt;code&gt;UserService.java&lt;/code&gt; is a file full of words like &lt;code&gt;public class&lt;/code&gt; and &lt;code&gt;return&lt;/code&gt;. A processor cannot run that. It speaks something much lower level.&lt;/p&gt;

&lt;p&gt;Building is what closes that gap. It's the cooking, and source code is the recipe.&lt;/p&gt;

&lt;p&gt;Nobody serves a recipe card to a customer. They serve the dish. Nobody deploys source code to a production server either; they deploy the built artifact.&lt;/p&gt;

&lt;p&gt;I built two of them. A Java service with Maven, which produced a 4.8K JAR file. A Node service with npm, which produced a 1.7K .tgz. Different languages, same idea: source in, shippable thing out.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Every build tool is the same tool wearing different clothes
&lt;/h2&gt;

&lt;p&gt;Java uses Maven. Node uses npm. Python uses pip. Three tools, three sets of commands, and it looks like three separate things to learn.&lt;/p&gt;

&lt;p&gt;It isn't. They all answer the same five questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A config file.&lt;/strong&gt; What is this project and what does it need? (&lt;code&gt;pom.xml&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;requirements.txt&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A lock file.&lt;/strong&gt; Which exact versions did we actually install? (&lt;code&gt;package-lock.json&lt;/code&gt;, pinned versions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A registry.&lt;/strong&gt; Where do the libraries come from? (Maven Central, npmjs.com, PyPI)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cache.&lt;/strong&gt; Where do downloaded libraries live locally? (&lt;code&gt;~/.m2&lt;/code&gt;, &lt;code&gt;node_modules/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An artifact.&lt;/strong&gt; What comes out at the end? (&lt;code&gt;.jar&lt;/code&gt;, &lt;code&gt;.tgz&lt;/code&gt;, &lt;code&gt;.whl&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Config, lock, registry, cache, artifact. Once you see those five boxes, learning the third tool takes minutes, because it's just the first tool with different filenames.&lt;/p&gt;

&lt;p&gt;One genuine difference worth knowing. Java compiles everything upfront, translating the whole book before anyone reads it. Node and Python translate live, line by line, as the program runs. That's why my first Maven build took 64 seconds and &lt;code&gt;npm install&lt;/code&gt; took 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four questions that turn a build output into an artifact
&lt;/h2&gt;

&lt;p&gt;Here's the part most tutorials skip. A file sitting in a &lt;code&gt;target&lt;/code&gt; folder isn't ready to ship. A real artifact answers four questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's in it?
&lt;/h3&gt;

&lt;p&gt;An SBOM, a Software Bill of Materials, is a complete itemised list of every component inside your artifact. It's the ingredients label on food packaging.&lt;/p&gt;

&lt;p&gt;I generated mine with a tool called Syft. My Node service listed two entries: my own code, and one library called dotenv.&lt;/p&gt;

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

&lt;p&gt;Small, but the version was interesting. My config file asked for &lt;code&gt;^16.4.5&lt;/code&gt;, meaning "16.4.5 or anything compatible." What actually got installed was 16.6.1. Fuzzy request, specific reality. That gap is precisely why lock files exist.&lt;/p&gt;

&lt;p&gt;Real applications run this and get three hundred entries. Most of which nobody on the team chose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe?
&lt;/h3&gt;

&lt;p&gt;Knowing what's inside is step one. Step two is checking whether any of it is known to be broken. Security researchers maintain public databases of flaws found in software libraries, and a tool called Grype cross-checks your ingredients list against them.&lt;/p&gt;

&lt;p&gt;It's a food safety inspection. You have the ingredients; now someone checks them against the recall list.&lt;/p&gt;

&lt;p&gt;Both my artifacts came back clean, which was true and slightly boring. So I deliberately installed an old version of a popular library called lodash to see what a real finding looks like.&lt;/p&gt;

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

&lt;p&gt;Six findings from one library. Three rated High. Read one row as a sentence: you have lodash 4.17.15, it has a high severity flaw, version 4.17.21 fixes it.&lt;/p&gt;

&lt;p&gt;Two columns earn their place. EPSS estimates how likely the flaw is to be exploited in the wild, and the top result sat at 22.4%, the 97th percentile. That's not theoretical; attackers are actively using it. RISK blends severity with that likelihood so you know what to fix on Monday morning.&lt;/p&gt;

&lt;p&gt;And every single one had a fix already available. Most vulnerabilities are not unsolvable mysteries. They're someone forgetting to update a library for two years.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it the one I built?
&lt;/h3&gt;

&lt;p&gt;In 2016, attackers broke into the servers of Linux Mint and swapped the download file for their own version with a backdoor in it. Same filename, same page, same site. Anyone downloading that day got malware.&lt;/p&gt;

&lt;p&gt;Underneath the download link sat a line of gibberish characters. Anyone who ran one command against their file and compared it would have known instantly.&lt;/p&gt;

&lt;p&gt;That line is a checksum: a fingerprint calculated from a file's exact contents. It's the tamper-evident seal on a medicine bottle.&lt;/p&gt;

&lt;p&gt;I made one for my JAR, verified it passed, then appended a single character to the file and checked again.&lt;/p&gt;

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

&lt;p&gt;One character. FAILED, immediately. There is no such thing as small tampering; any change at all breaks the seal, which is exactly what makes it worth publishing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who made it?
&lt;/h3&gt;

&lt;p&gt;A checksum proves the file didn't change. It doesn't prove who made it. An attacker who controls the download page can replace both the file and the checksum, and everything matches perfectly. It's just their seal now.&lt;/p&gt;

&lt;p&gt;Signing closes that gap. A signed release carries cryptographic proof it came from a specific person, and it cannot be forged without their private key.&lt;/p&gt;

&lt;p&gt;I signed my release tag with the same SSH key I set up months ago for signed commits, and got back: &lt;code&gt;Good "git" signature for vivian-signing-2026&lt;/code&gt;. On GitHub it shows a green Verified badge.&lt;/p&gt;

&lt;p&gt;A checksum is a seal anyone can apply. A signature is your handwriting, notarised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I stopped relying on my memory
&lt;/h2&gt;

&lt;p&gt;Producing all of that took eight separate commands in the right order with the right flags. Which is fine on a Tuesday morning and useless at 5pm on a Friday when you need to ship a fix and the scan feels optional.&lt;/p&gt;

&lt;p&gt;So I wrapped the whole path in one script. Build, SBOM, scan, checksum, in that order, stopping dead if anything fails.&lt;/p&gt;

&lt;p&gt;![The full build and verify run]&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhgc9ny1e0bnsm9b7jrrs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhgc9ny1e0bnsm9b7jrrs.png" alt=" " width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One command, four steps, three files at the end: the artifact, its ingredients list, and its seal. You can't skip a step because there are no steps to skip.&lt;/p&gt;

&lt;p&gt;That's also the bridge to what comes next. A CI pipeline is essentially this script running on a server, triggered by a push. Write it now, hand it to a robot later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that caught me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A command finishing without error is not proof it did the right thing.&lt;/strong&gt; My Node tests ran green while testing nothing at all, because I'd pointed the test runner at the wrong path. Five tests written, one thing ran. I only caught it because the count said 1 and I expected 5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node's package doesn't contain its dependencies.&lt;/strong&gt; Scanning the .tgz found zero packages, which looked broken. It wasn't. npm deliberately excludes &lt;code&gt;node_modules&lt;/code&gt;, so the box holds the recipe and the ingredients get fetched on arrival. Java bakes them in; Node doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build output does not belong in git.&lt;/strong&gt; My first commit attempt swept in compiled classes, test reports, and the JAR itself. All of it reproducible from source with one command. Commit the recipe, not the meal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I can answer now
&lt;/h2&gt;

&lt;p&gt;If someone hands me a JAR with no SBOM, what should I assume about it?&lt;/p&gt;

&lt;p&gt;That nobody knows what's inside it. Including the person who built it.&lt;/p&gt;

&lt;p&gt;It might be perfectly fine. But there's no way to answer the Log4j question about it, no way to know whether it's carrying something with a public exploit, and no way to prove it's the file they think it is.&lt;/p&gt;

&lt;p&gt;That's not ready to ship. It's just bytes.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>java</category>
      <category>nodejs</category>
    </item>
    <item>
      <title>I Deleted My Database on Purpose. It Was Back in 47 Seconds.</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Fri, 17 Jul 2026 06:11:01 +0000</pubDate>
      <link>https://dev.to/vivian_okose/i-deleted-my-database-on-purpose-it-was-back-in-47-seconds-2dm0</link>
      <guid>https://dev.to/vivian_okose/i-deleted-my-database-on-purpose-it-was-back-in-47-seconds-2dm0</guid>
      <description>&lt;p&gt;There's a sentence I can't stop thinking about:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A backup you have never restored is a wish, not a backup.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most engineers set up backups and never test them. The backup job runs every night, everyone feels safe, and then one day a real disaster hits, they reach for the backup, and discover it's been silently broken for months. Now the data is genuinely gone, at the worst possible moment.&lt;/p&gt;

&lt;p&gt;So this week I stopped wishing. I destroyed my own database on purpose and proved I could bring it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, I learned before I built
&lt;/h2&gt;

&lt;p&gt;I did something different this module: I asked to understand the concepts &lt;em&gt;before&lt;/em&gt; touching a single command. What a database actually is. Why SQL and NoSQL both exist. What containers really are. What migrations do.&lt;/p&gt;

&lt;p&gt;It changed everything. Instead of copying steps and hoping, I knew &lt;em&gt;why&lt;/em&gt; each command mattered. If you're learning DevOps and you find yourself typing commands you don't understand, stop and go learn the idea underneath. The labs land completely differently after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, from one command
&lt;/h2&gt;

&lt;p&gt;I built a four-container stack that starts with a single command: Postgres (the strict, organized store for important data), Redis (the fast, flexible one), Adminer (a web view of the data), and pgBouncer (connection pooling).&lt;/p&gt;

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

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

&lt;p&gt;The idea that made containers click for me: a container is a sealed lunchbox holding software &lt;em&gt;plus everything it needs to run&lt;/em&gt;. It works identically on my laptop, your laptop, or a server in a data center. No more "but it works on my machine."&lt;/p&gt;

&lt;p&gt;And one crucial piece: a &lt;strong&gt;volume&lt;/strong&gt;. Containers are disposable, you throw them away and make new ones. A volume is the storage box that survives that. Without one, your data dies with the container. That distinction matters more than anything else in this setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version control, but for your database's shape
&lt;/h2&gt;

&lt;p&gt;Your database's structure changes constantly as an app grows. Add a column, add a table, add an index. The tempting way is to just log in and type the change. The problem: nobody has a record of what changed or when, and now your database, your teammate's, and production are all subtly different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrations&lt;/strong&gt; fix that. They're numbered, recorded files, V1, V2, V3, applied in order, with the tool keeping a history of exactly what ran and when.&lt;/p&gt;

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

&lt;p&gt;They are, quite literally, git for your database structure. Which is why the concept felt instantly familiar after last month's Git module.&lt;/p&gt;

&lt;p&gt;One detail worth stealing: I added a column in V2, and backfilled its values in V3, deliberately two separate migrations, not one. Because if an app is live while you change the database, adding &lt;em&gt;and&lt;/em&gt; filling in one step means a moment where the app and database disagree. Additive first, destructive later. That staging is how you change a database without breaking the thing using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The refusal that impressed me most
&lt;/h2&gt;

&lt;p&gt;Here's my favourite moment. In the real world, you almost never get to start a database from scratch. You inherit one that's been running for years, full of customer data you cannot wipe. So I simulated that: an existing database, real rows, no migration history.&lt;/p&gt;

&lt;p&gt;I pointed the migration tool at it, and it &lt;strong&gt;refused&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Found non-empty schema but no schema history table.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was saying: &lt;em&gt;there's data here I didn't create, I don't know what version this is, and I won't guess and risk breaking it.&lt;/em&gt; That refusal is a feature. The tool protecting two years of data from a stranger's assumptions.&lt;/p&gt;

&lt;p&gt;The fix is called &lt;strong&gt;baselining&lt;/strong&gt;: you tell it "whatever exists now, call that version 1, count my changes from version 2." Like joining a company mid-project and saying "I'm not recreating what happened before I arrived. Today is my starting line."&lt;/p&gt;

&lt;p&gt;That's the version of this skill you'll actually use at work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I destroyed everything
&lt;/h2&gt;

&lt;p&gt;The main event. Here's the drill:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count the rows: &lt;strong&gt;3 authors, 3 posts&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Take a backup (8.2K file)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drop the entire database&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Restore it&lt;/li&gt;
&lt;li&gt;Verify the counts match&lt;/li&gt;
&lt;li&gt;Time it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 feels wrong to type. You're deleting the thing. And then:&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;FATAL: database "lumberyard_dev" does not exist&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the message on someone's screen at 3am when a room goes quiet. My data was genuinely gone.&lt;/p&gt;

&lt;p&gt;Then I restored it from that 8.2K file.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;3 authors, 3 posts.&lt;/strong&gt; Exact match. Nothing lost. &lt;strong&gt;47 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That number matters more than it looks. In a real incident, everyone asks "how long until we're back?" Most people guess. I can answer, because I measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Three things I actually learned, beyond the commands:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the file size.&lt;/strong&gt; A zero-byte backup file is a silent catastrophe that only surfaces when you desperately need it. Always look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"No errors" is not "it worked."&lt;/strong&gt; Verify the row counts. A restore that finishes quietly but incompletely is worse than one that fails loudly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backups don't belong in git.&lt;/strong&gt; I committed my dump file, then realized: backups contain real data, they bloat the repo forever, and in a real job that's publishing customer data. They belong in object storage. I removed it and gitignored &lt;code&gt;*.dump&lt;/code&gt;. A small fix, but the instinct matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real takeaway
&lt;/h2&gt;

&lt;p&gt;Code is replaceable. You lose it, you redeploy from git. Data is not. Lose it and it may be gone forever.&lt;/p&gt;

&lt;p&gt;Which is why the most valuable thing I did this week wasn't building the stack or writing migrations. It was deleting everything and proving I could get it back. Most junior engineers have never restored a backup. They've just trusted one exists.&lt;/p&gt;

&lt;p&gt;Don't trust. Drill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Next up: containers, properly.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>The Week Git Stopped Being Scary: Secrets, Conflicts, and Recovery</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Mon, 13 Jul 2026 06:41:37 +0000</pubDate>
      <link>https://dev.to/vivian_okose/the-week-git-stopped-being-scary-secrets-conflicts-and-recovery-4k8m</link>
      <guid>https://dev.to/vivian_okose/the-week-git-stopped-being-scary-secrets-conflicts-and-recovery-4k8m</guid>
      <description>&lt;p&gt;&lt;em&gt;Most people learn just enough Git to &lt;strong&gt;&lt;code&gt;add&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;commit&lt;/code&gt;&lt;/strong&gt;, and &lt;strong&gt;&lt;code&gt;push&lt;/code&gt;&lt;/strong&gt;. I used to be one of them. This module was about crossing the gap between "I use Git" and "I trust Git with my work", the gap that, on most teams, separates a junior from a mid-level engineer. Here is everything I built, including the part where I got completely lost and how I found my way back.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it impossible to commit a secret
&lt;/h2&gt;

&lt;p&gt;The single most valuable thing I set up: a pre-commit hook chain that scans every commit &lt;em&gt;before&lt;/em&gt; it happens. I tested it by deliberately trying to commit a fake AWS key.&lt;/p&gt;

&lt;p&gt;The commit never happened. The scanner (gitleaks) spotted the key, pointed to the exact file and line, and blocked it cold. Nothing leaked. Nothing to rotate.&lt;/p&gt;

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

&lt;p&gt;This matters because leaked cloud keys get found by attackers within seconds of hitting a public repo, and the cleanup is genuinely painful. The whole philosophy here: turn discipline into automation. Not "remember to check for secrets," but "make it physically impossible to commit one." The same chain also enforces good commit messages, so a lazy "fixed stuff" gets rejected at the door.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Surviving my first merge conflict
&lt;/h2&gt;

&lt;p&gt;I used to think merge conflicts were a disaster. They're not. A conflict is just Git saying "two people changed the same spot, you decide which wins."&lt;/p&gt;

&lt;p&gt;I created one on purpose in a throwaway sandbox: changed the same line on two branches, then tried to combine them. Git stopped and marked the clash with &lt;code&gt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&amp;lt;&lt;/code&gt; and &lt;code&gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt; markers.&lt;/p&gt;

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

&lt;p&gt;Resolving it was simpler than the reputation suggests: edit the file to what I actually want, delete the marker lines, tell Git it's resolved. That's the entire skill. Once I'd done it once, conflicts stopped being frightening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The concept that unlocked everything
&lt;/h2&gt;

&lt;p&gt;Halfway through, I got completely lost. Moving a commit between branches made no sense to me. I stopped and admitted I didn't understand, rather than pretending.&lt;/p&gt;

&lt;p&gt;The fix was reframing what a branch actually &lt;em&gt;is&lt;/em&gt;. A branch is not a copy of your code. It's a sticky note stuck on one commit. "Moving a commit to another branch" is really just sliding sticky notes around while the commit itself stays put. The moment that clicked, branching, resetting, and recovery all suddenly made sense.&lt;/p&gt;

&lt;p&gt;Admitting I was lost was the best decision I made all module.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovering deleted work
&lt;/h2&gt;

&lt;p&gt;Then I broke things on purpose. I ran &lt;code&gt;git reset --hard&lt;/code&gt; and wiped three commits, including a merge. They vanished. In a real panic, this is the stomach-drop moment.&lt;/p&gt;

&lt;p&gt;Except they weren't gone. Git keeps a diary called the reflog, a record of everywhere I've been. My "deleted" commits were still in it. One command, and everything came back.&lt;/p&gt;

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

&lt;p&gt;This is the calm that experienced engineers have and beginners don't. &lt;code&gt;git log&lt;/code&gt; only shows my current path; &lt;code&gt;git reflog&lt;/code&gt; remembers the paths I left, including the lost work. Knowing that safety net exists changes how it feels to make a mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking down main like a real team
&lt;/h2&gt;

&lt;p&gt;Finally, I protected my main branch: no direct pushes, changes must come through a pull request, commits must be signed, history must stay linear. I proved it worked by trying to push straight to main, and watching GitHub reject it.&lt;/p&gt;

&lt;p&gt;Then I did the full professional flow: made a branch, pushed it, opened a pull request, watched my automated checks run on GitHub's servers, and merged through the proper door.&lt;/p&gt;

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

&lt;p&gt;That flow, branch, push, PR, pass checks, merge, is exactly what I'll do every day in a DevOps job. Building it on my own repo, with no pressure, is the best practice I could ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually learned
&lt;/h2&gt;

&lt;p&gt;The commands matter, but the mindset matters more. Git isn't a thing to fear; it's a safety net that remembers almost everything. The real skills are: make good habits automatic so they don't depend on memory, and know your recovery moves before you need them.&lt;/p&gt;

&lt;p&gt;And the most important lesson had nothing to do with Git: when I was lost, saying so out loud, instead of nodding along, was what let me actually learn. That's a habit worth keeping.&lt;/p&gt;

&lt;p&gt;Next up: databases.&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>devsecops</category>
      <category>beginners</category>
    </item>
    <item>
      <title>From Blank VM to Bastion: Everything I Learned Hardening Linux</title>
      <dc:creator>Vivian Chiamaka Okose</dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:49:25 +0000</pubDate>
      <link>https://dev.to/vivian_okose/from-blank-vm-to-bastion-everything-i-learned-hardening-linux-26ok</link>
      <guid>https://dev.to/vivian_okose/from-blank-vm-to-bastion-everything-i-learned-hardening-linux-26ok</guid>
      <description>&lt;p&gt;&lt;em&gt;A full walkthrough of a hands-on Linux module: hardening a server, building a multi-user permission system, writing a diagnostic toolkit, and setting up a real jump host. Plus the seven times the docs lied and the delete command that scared me.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every server I will ever touch in DevOps runs Linux. Every container is a Linux process. Every CI runner shells out to bash. So I spent a module getting genuinely fluent at the command line, not copy-pasting my way through, but understanding every step. Here is everything I built, and more importantly, everything that went wrong on the way, because that is where the real learning was.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A hardened Ubuntu 24.04 server.&lt;/strong&gt; Starting from a blank VM, I brought it to a production baseline: a deny-by-default firewall (UFW) with only SSH allowed, fail2ban guarding against brute-force login attempts, automatic security updates, a correct timezone, and SSH locked down to key-only auth with no root login.&lt;/p&gt;

&lt;p&gt;The order matters more than it looks. Enable the firewall before allowing SSH, and you lock yourself out of your own server. Allow SSH first, then enable. That sequence has saved more careers than any clever command.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A multi-tenant permission system.&lt;/strong&gt; Four users, each with a different level of access: two full developers with sudo constrained to specific commands, a read-only user who can look but not touch, and a contractor sealed into her own sandbox with an account set to auto-expire in 30 days. Then I tested every boundary by becoming each user and confirming the walls held.&lt;/p&gt;

&lt;p&gt;This is where Linux permissions stopped being abstract. Watching a read-only user get "Permission denied" when they tried to write, and watching a "restricted" admin get blocked from reading the password file, made access control real in a way no diagram could.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A four-tool diagnostic toolkit.&lt;/strong&gt; I got tired of typing the same six commands every time something felt slow, so I wrote tools that do it for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;sysnap&lt;/code&gt;: a one-screen snapshot of a server's health (load, memory, top processes, disk, open ports, failed logins)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;logscan&lt;/code&gt;: search the system logs for a pattern, with context, scoped to a time or service&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;diskmap&lt;/code&gt;: find what's actually eating the disk, biggest-first&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;netcheck&lt;/code&gt;: confirm I'm really online and reaching the things I depend on&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is 30 to 45 lines of bash, all shellcheck-clean, all covered by a smoke test.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisgyicgn13i9qy5mv2z9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisgyicgn13i9qy5mv2z9.png" alt="sysnap running on a live server" width="800" height="590"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyb9cvoczlt07is0ap7b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqyb9cvoczlt07is0ap7b.png" alt="All four scripts pass shellcheck" width="800" height="159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real jump host (bastion).&lt;/strong&gt; Production servers don't sit on the open internet. They hide behind a gateway you have to hop through. I built that with three VMs: a client, a bastion, and a private server with no direct route from outside. With one line of SSH config (&lt;code&gt;ProxyJump&lt;/code&gt;) and one command, &lt;code&gt;ssh bedrock-private&lt;/code&gt;, SSH tunneled through the bastion to reach the hidden box.&lt;/p&gt;

&lt;p&gt;The proof it worked: SSH printed &lt;code&gt;&amp;lt;no hostip for proxy command&amp;gt;&lt;/code&gt; during the handshake, its way of saying "I reached this through a proxy, not directly." That phrase is the fingerprint of a working jump host.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The seven times the docs lied
&lt;/h2&gt;

&lt;p&gt;Here is what the tutorials never prepare you for: the documentation and my actual system disagreed seven separate times. Every time, the system was right. Learning to read what the machine tells me, instead of trusting what the guide claims, was the single most valuable skill of the module.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The OS version was wrong.&lt;/strong&gt; I launched "22.04" and got 24.04. &lt;code&gt;lsb_release -a&lt;/code&gt; is the source of truth, not the launch command.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A find-and-replace silently did nothing.&lt;/strong&gt; My &lt;code&gt;sed&lt;/code&gt; pattern didn't match, so it changed nothing, with no error. I only caught it by verifying with &lt;code&gt;grep&lt;/code&gt; afterward. Silent no-ops are the most dangerous kind.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A command flag didn't exist.&lt;/strong&gt; The guide used &lt;code&gt;adduser --expiredate&lt;/code&gt;; my version had no such flag. The fix was a different tool (&lt;code&gt;chage -E&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A missing package.&lt;/strong&gt; &lt;code&gt;setfacl&lt;/code&gt; didn't exist until I installed &lt;code&gt;acl&lt;/code&gt;. "Command not found" is not always a typo.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;My restricted user wasn't restricted.&lt;/strong&gt; A user I'd limited to three commands still had full admin power, because &lt;code&gt;sudo&lt;/code&gt; group membership silently overrode my rule. Checking effective permissions, not intended ones, revealed it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mutually exclusive flags.&lt;/strong&gt; &lt;code&gt;df -T&lt;/code&gt; and &lt;code&gt;df --output&lt;/code&gt; couldn't be combined on my build.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;grep&lt;/code&gt; &lt;strong&gt;broke my test.&lt;/strong&gt; My smoke test failed because &lt;code&gt;grep&lt;/code&gt; returns a failure exit code when it finds zero matches, which my &lt;code&gt;set -euo pipefail&lt;/code&gt; correctly caught. Zero failed logins is good news, but &lt;code&gt;grep&lt;/code&gt; calls it a failure. Guard it with &lt;code&gt;|| true&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The mistake that taught me the most
&lt;/h2&gt;

&lt;p&gt;Near the end, I ran a recursive delete with a folder path accidentally on the list, and wiped my entire screenshots folder. No recycle bin. No undo.&lt;/p&gt;

&lt;p&gt;Except the work survived, because it lived in two other places: committed in git, and backed up separately. One &lt;code&gt;git checkout&lt;/code&gt; and a re-copy, and everything was back in under a minute.&lt;/p&gt;

&lt;p&gt;That scare taught me more than any smooth step. Three habits I now hold without exception:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Commit often.&lt;/strong&gt; Recovery took a minute instead of hours because the work was already saved somewhere safe.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;ls&lt;/code&gt; &lt;strong&gt;before you&lt;/strong&gt; &lt;code&gt;rm&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; Look at exactly what you are about to delete.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify, don't assume.&lt;/strong&gt; A command running without error does not mean it did what you intended.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The real takeaway
&lt;/h2&gt;

&lt;p&gt;The gap between "works in the tutorial" and "works on my actual machine" is where the real skill lives. Run the command, read the error, understand what the system is telling you, adapt, repeat. Tutorials cannot teach that loop, because tutorials never fail. Your real system will, and that is a gift.&lt;/p&gt;

&lt;p&gt;Next up: version control done properly. On to Git.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>bash</category>
      <category>ssh</category>
    </item>
  </channel>
</rss>
