<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/feed/styles.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Mohammad Abu Mattar | DevTips</title><description>The latest DevTips from Mohammad Abu Mattar.</description><link>https://devtips.mkabumattar.com/</link><item><title>Terraform Workspaces vs. Directory-Based Environments: What Actually Scales</title><link>https://devtips.mkabumattar.com/post/terraform-workspaces-vs-directory-environments/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/terraform-workspaces-vs-directory-environments/</guid><description>Workspaces look like the easy way to split dev, staging, and prod, but they quietly stop scaling. Here is when workspaces bite, why most teams move to a folder per environment, and how to switch without breaking live infrastructure.</description><pubDate>Wed, 19 Aug 2026 13:50:21 GMT</pubDate><content:encoded>&lt;h2&gt;Why this choice matters&lt;/h2&gt;
&lt;h3&gt;Hey, want to stop sweating every prod apply?&lt;/h3&gt;
&lt;p&gt;The way you split dev, staging, and prod in Terraform decides how much damage a single mistake can do. Get it right and a bad apply is annoying. Get it wrong and the same command that fixes dev can wreck prod, because they share too much.&lt;/p&gt;
&lt;h3&gt;It is a decision you make early and live with&lt;/h3&gt;
&lt;p&gt;Most teams pick an environment strategy on day one, before they have a prod worth protecting, and never revisit it. By the time it hurts, there is real state to migrate. So it is worth understanding the trade-off now.&lt;/p&gt;
&lt;h2&gt;The problem with workspaces at scale&lt;/h2&gt;
&lt;h3&gt;What&amp;#39;s the issue?&lt;/h3&gt;
&lt;p&gt;Terraform workspaces share a single backend and split your state by name. You run &lt;code&gt;terraform workspace select prod&lt;/code&gt;, and the same root config now points at the prod state. The config is identical across environments; only the state differs.&lt;/p&gt;
&lt;p&gt;That is exactly what makes them risky. Every environment runs the same &lt;code&gt;.tf&lt;/code&gt; files, so there is no room for prod to legitimately differ from dev, and switching environments is a single command with no folder to remind you where you are.&lt;/p&gt;
&lt;p&gt;&lt;DrawIO
  client:load
  src=&quot;/assets/devtips/0018-terraform-workspaces-vs-directory-environments/workspaces-vs-directories.drawio&quot;
  title=&quot;Workspaces vs. a folder per environment&quot;
  caption=&quot;Workspaces share one config and one backend, splitting state by name, so prod is one command away and a config change hits every environment. A directory per environment gives each its own backend and state, containing the blast radius, with shared modules keeping it DRY.&quot;
  height={520}
/&gt;&lt;/p&gt;
&lt;h3&gt;The real-world consequence&lt;/h3&gt;
&lt;p&gt;The classic incident is running &lt;code&gt;terraform apply&lt;/code&gt; thinking you are in dev while the workspace is still set to prod. Nothing in the file tells you which one you are on. Add per-environment differences (a bigger instance in prod, an extra region) and you start bolting &lt;code&gt;count&lt;/code&gt; and &lt;code&gt;var.environment&lt;/code&gt; conditionals into shared code until it is a tangle nobody wants to touch.&lt;/p&gt;
&lt;h2&gt;A folder per environment&lt;/h2&gt;
&lt;h3&gt;Here&amp;#39;s how to fix it&lt;/h3&gt;
&lt;p&gt;Give each environment its own directory with its own backend and its own state. &lt;code&gt;envs/dev/&lt;/code&gt;, &lt;code&gt;envs/staging/&lt;/code&gt;, &lt;code&gt;envs/prod/&lt;/code&gt;, each initialized separately. Now the blast radius stops at one folder. You cannot accidentally apply prod from the dev directory, and each environment can differ honestly without conditionals smeared through shared code.&lt;/p&gt;
&lt;h3&gt;Implementing it without copy-paste&lt;/h3&gt;
&lt;p&gt;The obvious objection is duplication. If every folder has its own config, are you not repeating yourself? That is what modules are for. The real resources live in &lt;code&gt;modules/&lt;/code&gt;, and each environment folder is a thin wrapper that calls them with its own variables.&lt;/p&gt;
&lt;h3&gt;Tools and platforms&lt;/h3&gt;
&lt;p&gt;Terragrunt is the common answer for keeping the directory approach DRY. It generates the backend config per environment and lets each folder stay tiny, so you get isolated state without copy-pasting backend blocks. Plain Terraform with modules works too; Terragrunt just removes the last of the boilerplate.&lt;/p&gt;
&lt;h2&gt;Quick implementation steps&lt;/h2&gt;
&lt;h3&gt;Quick takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Workspaces: one backend, state split by name, config shared. Fine when small.&lt;/li&gt;
&lt;li&gt;Directories: one folder and one state per environment. Scales because the blast radius is contained.&lt;/li&gt;
&lt;li&gt;Migrate one environment at a time, dev first and prod last.&lt;/li&gt;
&lt;li&gt;Trust the move only when &lt;code&gt;terraform plan&lt;/code&gt; shows zero changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Migrating without breaking things&lt;/h3&gt;
&lt;p&gt;You do not have to do this all at once. Move one environment at a time, starting with dev, using &lt;code&gt;state pull&lt;/code&gt; to grab the current state and &lt;code&gt;state push&lt;/code&gt; (or targeted &lt;code&gt;import&lt;/code&gt;) to load it into the new per-folder backend.&lt;/p&gt;
&lt;p&gt;&lt;DrawIO
  client:load
  src=&quot;/assets/devtips/0018-terraform-workspaces-vs-directory-environments/migration-flow.drawio&quot;
  title=&quot;Migrating one environment off workspaces&quot;
  caption=&quot;Do it one environment at a time, dev first and prod last: select the workspace, pull its state, create the new per-environment folder and backend, push the state in, and trust the move only once a plan shows zero changes.&quot;
  height={360}
/&gt;&lt;/p&gt;
&lt;h2&gt;Benefits you feel quickly&lt;/h2&gt;
&lt;h3&gt;Why it helps&lt;/h3&gt;
&lt;p&gt;The payoff is a smaller blast radius. When prod has its own folder and its own state, there is no single command that flips you into prod by accident, and a mistake in the dev folder simply cannot reach it.&lt;/p&gt;
&lt;h3&gt;Cleaner diffs and real differences&lt;/h3&gt;
&lt;p&gt;Because environments no longer share one config, per-environment differences become explicit and readable instead of hidden behind conditionals. Your prod folder says what prod is, in plain HCL, and a code review shows exactly which environment a change touches.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your approach?&lt;/h2&gt;
&lt;h3&gt;Community discussion&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s your take?&lt;/strong&gt; Are you still running everything through workspaces, or have you moved to a folder per environment? I am curious where the line is for other teams.&lt;/p&gt;
&lt;h3&gt;Share your experience&lt;/h3&gt;
&lt;p&gt;If you have migrated off workspaces, I would love to hear how the &lt;code&gt;state pull&lt;/code&gt; / &lt;code&gt;state push&lt;/code&gt; dance went and whether Terragrunt earned its place in your setup.&lt;/p&gt;
</content:encoded><category>Cloud &amp; Infrastructure Automation</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0018-terraform-workspaces-vs-directory-environments/hero.png" length="0" type="image/jpeg"/></item><item><title>GitHub Actions Secrets and Environment Variables: Handle Config the Right Way</title><link>https://devtips.mkabumattar.com/post/github-actions-secrets-environment-variables-guide/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/github-actions-secrets-environment-variables-guide/</guid><description>Stop leaking credentials in your workflows. This dev tip shows how to scope GitHub Actions secrets, swap long-lived keys for OIDC, mask sensitive output, and pass config between jobs without it ending up in your logs.</description><pubDate>Sat, 08 Aug 2026 13:50:18 GMT</pubDate><content:encoded>&lt;h2&gt;Why secrets handling matters&lt;/h2&gt;
&lt;h3&gt;Most CI leaks are config mistakes, not attacks&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Hey, want to stop leaking credentials in your pipelines?&lt;/strong&gt; Most secret leaks in CI are not the result of some clever attacker. They happen because a key got pasted into a plain environment variable, echoed into a log, or left sitting in repo settings for two years with no rotation. GitHub Actions gives you good tools to avoid all of that, but only if you use them on purpose. Handling config the right way is mostly about scoping secrets tightly and never letting them touch a log.&lt;/p&gt;
&lt;h3&gt;A workflow runs with real access&lt;/h3&gt;
&lt;p&gt;A workflow is a program that runs with production credentials. It talks to your cloud, your registry, and your deploy targets, often with credentials that can do real damage. Anyone who can open a pull request can trigger workflows, and anyone with repo access can read your logs and artifacts. That means the way you store and pass secrets is a security boundary, not a convenience setting.&lt;/p&gt;
&lt;h2&gt;The problem with sloppy config&lt;/h2&gt;
&lt;h3&gt;What&amp;#39;s the issue?&lt;/h3&gt;
&lt;p&gt;The usual pattern is to dump every secret into repository settings and reference them everywhere. Long-lived AWS keys, database passwords, and API tokens all live in one flat pile with no scope. Then someone echoes a variable to debug a failing step, or passes a secret to a job as a plain artifact, and now that value is sitting in the log output where it stays for as long as the run is retained.&lt;/p&gt;
&lt;h3&gt;Real-world consequences&lt;/h3&gt;
&lt;p&gt;Once a secret lands in a log or an unmasked output, treat it as compromised. Logs get shared in bug reports, artifacts get downloaded, and forks can sometimes see more than you expect. Long-lived credentials make it worse because a leaked key stays valid until someone remembers to rotate it, which is usually after the incident. A single careless &lt;code&gt;echo&lt;/code&gt; can mean an emergency key rotation across every service that used it.&lt;/p&gt;
&lt;h2&gt;Scope, mask, and go short-lived&lt;/h2&gt;
&lt;h3&gt;Here&amp;#39;s how to fix it&lt;/h3&gt;
&lt;p&gt;Fixing this comes down to three habits. Scope secrets so each one is only visible where it is actually needed, mask any sensitive value so it never renders in a log, and replace long-lived cloud keys with OIDC so your workflow gets a short-lived token instead of a permanent credential. Do those three things and most of the ways a secret can escape are closed.&lt;/p&gt;
&lt;h3&gt;Implementing it&lt;/h3&gt;
&lt;p&gt;Start with scope. Repository secrets are for values shared across the whole repo, while environment secrets are tied to a specific environment like &lt;code&gt;production&lt;/code&gt; and can sit behind required reviewers. For cloud access, use OIDC instead of stored keys.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write # required for OIDC
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/deploy
          aws-region: us-east-1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No AWS keys are stored anywhere. The workflow requests an OIDC token, AWS trusts it, and hands back short-lived credentials that expire when the job ends. If you do generate a secret at runtime, mask it right away with &lt;code&gt;echo &amp;quot;::add-mask::$TOKEN&amp;quot;&lt;/code&gt; so it shows up as &lt;code&gt;***&lt;/code&gt; in the log.&lt;/p&gt;
&lt;h3&gt;Tools and platforms&lt;/h3&gt;
&lt;p&gt;The core tools are built into GitHub Actions: repository secrets, environment secrets with protection rules, and the &lt;code&gt;::add-mask::&lt;/code&gt; workflow command. For cloud auth, the official &lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt;, &lt;code&gt;google-github-actions/auth&lt;/code&gt;, and &lt;code&gt;azure/login&lt;/code&gt; actions all support OIDC. For a deeper audit, tools like &lt;code&gt;gitleaks&lt;/code&gt; or &lt;code&gt;trufflehog&lt;/code&gt; can scan your history for secrets that already slipped through.&lt;/p&gt;
&lt;h2&gt;Quick implementation steps&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Quick takeaways&lt;/strong&gt; to lock down your config:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use repository secrets for shared values and environment secrets for per-stage keys.&lt;/li&gt;
&lt;li&gt;Put sensitive environments behind required reviewers for a manual gate.&lt;/li&gt;
&lt;li&gt;Swap long-lived cloud keys for OIDC with a scoped IAM role.&lt;/li&gt;
&lt;li&gt;Mask any runtime-generated secret with &lt;code&gt;::add-mask::&lt;/code&gt; before using it.&lt;/li&gt;
&lt;li&gt;Pass secrets between jobs through masked outputs, never plain artifacts.&lt;/li&gt;
&lt;li&gt;Pass secrets into composite actions as explicit inputs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Mind the composite action gap&lt;/h3&gt;
&lt;p&gt;Composite actions do not automatically inherit the secrets of the workflow that calls them. If your composite action needs a token, you have to pass it in as an input from the caller. Forgetting this leads to confusing empty values, and the fix is not to loosen anything, just to wire the secret through explicitly.&lt;/p&gt;
&lt;h3&gt;Never echo to debug&lt;/h3&gt;
&lt;p&gt;When a step fails, the temptation is to print the variable to see what it holds. Do not do that with anything sensitive. Use &lt;code&gt;::add-mask::&lt;/code&gt; first, or check the length and a hash instead of the raw value. A masked value stays masked even if you accidentally print it later in the same run.&lt;/p&gt;
&lt;h2&gt;Benefits of doing it right&lt;/h2&gt;
&lt;h3&gt;Why it helps&lt;/h3&gt;
&lt;p&gt;You shrink the blast radius of any single mistake. Scoped secrets mean a leaked value only affects one environment. OIDC means there is no permanent key to steal in the first place, since tokens expire in minutes. Masking means a careless log line does not turn into an incident. Each habit is small, but together they take most credential leaks off the table.&lt;/p&gt;
&lt;h3&gt;Less rotation, less panic&lt;/h3&gt;
&lt;p&gt;Long-lived keys are a standing liability that someone has to remember to rotate. OIDC removes that chore entirely for cloud access, because there is nothing stored to rotate. Environment protection rules add a human checkpoint before production secrets are ever used, so a bad change cannot quietly deploy itself. The result is fewer 2am rotations and a lot less guessing about who could have seen what.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your approach?&lt;/h2&gt;
&lt;h3&gt;Community discussion&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s your take?&lt;/strong&gt; Secrets handling is one of those things that feels fine until the day it very much is not. If you have moved a pipeline from stored cloud keys to OIDC, how did the rollout go, and did it simplify your rotation story as much as you hoped?&lt;/p&gt;
&lt;h3&gt;Share your experience&lt;/h3&gt;
&lt;p&gt;If you have a favorite pattern for scoping secrets across a lot of environments, or a tool that caught a leak before it shipped, I would love to hear it. Especially how you handle secrets in reusable and composite actions without turning every caller into boilerplate.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions&quot;&gt;GitHub Actions: using secrets in a workflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect&quot;&gt;About security hardening with OpenID Connect&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment&quot;&gt;Using environments for deployment&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#masking-a-value-in-log&quot;&gt;Workflow commands: masking a value&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/aws-actions/configure-aws-credentials&quot;&gt;configure-aws-credentials action&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>DevOps &amp; DevSecOps</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0017-github-actions-secrets-environment-variables-guide/hero.png" length="0" type="image/jpeg"/></item><item><title>Docker Multi-Stage Builds: Smaller, Safer Images for Production</title><link>https://devtips.mkabumattar.com/post/docker-multi-stage-builds-smaller-production-images/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/docker-multi-stage-builds-smaller-production-images/</guid><description>Ship lean, secure containers by splitting your build from your runtime. This dev tip shows how Docker multi-stage builds drop the compilers and dev dependencies, so your production image is smaller and has far less to attack.</description><pubDate>Mon, 27 Jul 2026 16:00:07 GMT</pubDate><content:encoded>&lt;h2&gt;Why multi-stage builds matter&lt;/h2&gt;
&lt;h3&gt;Image size is really about what is inside&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Hey, want to stop shipping a toolshed to production?&lt;/strong&gt; If your Dockerfile builds and runs the app in one stage, your final image is carrying everything you used to build it: the compiler, the package manager caches, the dev dependencies, all of it. None of that runs in production, but all of it ships anyway. That means slower pulls, slower cold starts, and a lot more code that a scanner has to worry about. Multi-stage builds let you build with all your tools, then throw the tools away and keep only the finished app.&lt;/p&gt;
&lt;h3&gt;Build tools and runtime are different jobs&lt;/h3&gt;
&lt;p&gt;Building your app and running it need completely different things. The build needs compilers, headers, and dev packages. The runtime just needs your binary or bundled code and maybe a couple of shared libraries. Mixing the two into one image is the root of most bloated, insecure containers. Separating them is the whole idea here.&lt;/p&gt;
&lt;h2&gt;The problem with single-stage Dockerfiles&lt;/h2&gt;
&lt;h3&gt;What&amp;#39;s the issue?&lt;/h3&gt;
&lt;p&gt;A single-stage Dockerfile does everything in one place. You start from a full base image like &lt;code&gt;node:20&lt;/code&gt; or &lt;code&gt;golang:1.22&lt;/code&gt;, install dependencies, compile or bundle, and that same fat image becomes what you deploy. So your production container includes gcc, npm, git, and every dev dependency you only needed for a few seconds during the build. You are shipping a build machine and calling it a runtime.&lt;/p&gt;
&lt;h3&gt;Real-world consequences&lt;/h3&gt;
&lt;p&gt;The costs pile up quietly. Images balloon to a gigabyte or more, which slows every deploy and every autoscale event. Worse, every extra package is another thing that can have a CVE. When your scanner flags fifty vulnerabilities, most of them are usually in build tools your app never even calls at runtime. You end up patching things that should not have been in the image in the first place.&lt;/p&gt;
&lt;h2&gt;The multi-stage fix&lt;/h2&gt;
&lt;h3&gt;Here&amp;#39;s how to fix it&lt;/h3&gt;
&lt;p&gt;A multi-stage build splits your Dockerfile into named stages using &lt;code&gt;FROM ... AS&lt;/code&gt;. You do all the heavy work in a build stage, then start a fresh, minimal final stage and pull across only the artifact you need with &lt;code&gt;COPY --from&lt;/code&gt;. Everything left behind in the build stage, the compilers and dev dependencies, never makes it into the image you ship.&lt;/p&gt;
&lt;h3&gt;Implementing it&lt;/h3&gt;
&lt;p&gt;Here is a Go example. The first stage compiles the binary, and the second stage starts from a tiny base and copies just that binary.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# Build stage: has the full Go toolchain
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

# Production stage: minimal, no compiler
FROM gcr.io/distroless/static
COPY --from=build /app /app
ENTRYPOINT [&amp;quot;/app&amp;quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The final image has no Go toolchain, no shell, and no package manager. It is just your binary on a distroless base. The same pattern works for Node: build and bundle in a &lt;code&gt;node:20&lt;/code&gt; stage, then copy the &lt;code&gt;dist&lt;/code&gt; folder and production &lt;code&gt;node_modules&lt;/code&gt; into a slim runtime stage.&lt;/p&gt;
&lt;h3&gt;Tools and platforms&lt;/h3&gt;
&lt;p&gt;BuildKit is the modern Docker builder and it runs independent stages in parallel and caches them well, so multi-stage builds stay fast. For final bases, reach for &lt;code&gt;distroless&lt;/code&gt; images from Google or an &lt;code&gt;alpine&lt;/code&gt; variant when you need a shell. Pair the build with a scanner like Trivy or Grype so you can see the vulnerability count drop after you strip the build tools out.&lt;/p&gt;
&lt;h2&gt;Quick implementation steps&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Quick takeaways&lt;/strong&gt; to slim down your images:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Split your Dockerfile into a build stage and a final stage with &lt;code&gt;FROM ... AS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Do all compiling and bundling in the build stage.&lt;/li&gt;
&lt;li&gt;Start the final stage from a minimal base like distroless or alpine.&lt;/li&gt;
&lt;li&gt;Copy only the finished artifact across with &lt;code&gt;COPY --from&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Enable BuildKit so stages build in parallel and cache well.&lt;/li&gt;
&lt;li&gt;Scan the final image and compare the size and CVE count before and after.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Copy only what runs&lt;/h3&gt;
&lt;p&gt;The trick is being strict about what crosses the &lt;code&gt;COPY --from&lt;/code&gt; line. Bring over the compiled binary, the bundled assets, and production dependencies only. Leave source files, test files, and dev dependencies in the build stage where they belong. If you are not sure something is needed at runtime, it probably is not.&lt;/p&gt;
&lt;h3&gt;Measure the before and after&lt;/h3&gt;
&lt;p&gt;Run &lt;code&gt;docker images&lt;/code&gt; to see the size difference, and run your scanner on both versions. It is not unusual to go from over a gigabyte down to under a hundred megabytes, with the vulnerability count dropping right along with it. That comparison is the easiest way to convince a team the change is worth it.&lt;/p&gt;
&lt;h2&gt;Benefits of multi-stage builds&lt;/h2&gt;
&lt;h3&gt;Why it helps&lt;/h3&gt;
&lt;p&gt;You get a production image that only contains what it needs to run. That means faster pulls, faster cold starts, and a much smaller attack surface. With no compilers, package managers, or dev dependencies inside, there is simply less for an attacker to use and less for a scanner to flag.&lt;/p&gt;
&lt;h3&gt;Faster and safer deploys&lt;/h3&gt;
&lt;p&gt;Smaller images move faster through your whole pipeline. Registries store less, nodes pull quicker, and autoscaling responds sooner because there is less to download before a pod starts. On the security side, a distroless final stage with no shell makes a compromised container far harder to pivot from, since there is barely anything in there to run.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your approach?&lt;/h2&gt;
&lt;h3&gt;Community discussion&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s your take?&lt;/strong&gt; Multi-stage builds are one of those changes that feel small but pay off every single deploy. If you have converted an old single-stage Dockerfile, how much smaller did the image get, and did your scan results improve?&lt;/p&gt;
&lt;h3&gt;Share your experience&lt;/h3&gt;
&lt;p&gt;If you are running multi-stage builds in production, I would love to hear which final base you settled on. Distroless, alpine, or something custom, and how you handle the cases where you still need a shell for debugging.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/build/building/multi-stage/&quot;&gt;Docker multi-stage builds documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/build/buildkit/&quot;&gt;Docker BuildKit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/GoogleContainerTools/distroless&quot;&gt;Distroless base images&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://trivy.dev/&quot;&gt;Trivy vulnerability scanner&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/build/building/best-practices/&quot;&gt;Best practices for writing Dockerfiles&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>Kubernetes &amp; Containers</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0016-docker-multi-stage-builds-smaller-production-images/hero.png" length="0" type="image/jpeg"/></item><item><title>ArgoCD GitOps: Sync Kubernetes Deployments Automatically from Git</title><link>https://devtips.mkabumattar.com/post/argocd-gitops-kubernetes-deployments-git-sync/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/argocd-gitops-kubernetes-deployments-git-sync/</guid><description>Stop running kubectl apply by hand. This dev tip shows how ArgoCD watches a Git repo and keeps your Kubernetes cluster matching it, with automatic sync, self-heal, and drift detection.</description><pubDate>Wed, 22 Jul 2026 15:18:26 GMT</pubDate><content:encoded>&lt;h2&gt;Why GitOps for Kubernetes?&lt;/h2&gt;
&lt;h3&gt;From kubectl apply to Git as the source of truth&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Hey, want to stop deploying to Kubernetes by hand?&lt;/strong&gt; If your releases still come from someone running &lt;code&gt;kubectl apply&lt;/code&gt; on their laptop, you already know the downsides. Nobody is quite sure what&amp;#39;s actually running, changes are hard to audit, and a rollback means remembering what the old YAML looked like. GitOps flips that around. You keep all your manifests in Git, and a controller in the cluster continuously makes the live state match what&amp;#39;s in the repo. Git becomes the one place that describes what should be running.&lt;/p&gt;
&lt;h3&gt;What GitOps actually means&lt;/h3&gt;
&lt;p&gt;The whole model rests on one habit. You never change the cluster directly. You change Git, and the cluster follows. That means every deploy is a commit, every rollback is a revert, and your audit trail is just the repository history. ArgoCD is one of the most popular tools that make this real for Kubernetes.&lt;/p&gt;
&lt;h2&gt;The problem with manual and push-based deploys&lt;/h2&gt;
&lt;h3&gt;What&amp;#39;s the issue?&lt;/h3&gt;
&lt;p&gt;Push-based pipelines hand your CI system cluster-admin credentials and let it run &lt;code&gt;kubectl apply&lt;/code&gt; or &lt;code&gt;helm upgrade&lt;/code&gt; from the outside. That works until it doesn&amp;#39;t. The credentials live in your CI, which is a juicy target, and CI only touches the cluster when a pipeline runs. In between, nothing is watching whether the cluster still matches what you think you deployed.&lt;/p&gt;
&lt;h3&gt;Real-world consequences&lt;/h3&gt;
&lt;p&gt;The quiet killer is drift. Someone hotfixes a Deployment live during an incident, forgets to put it in Git, and now the repo and the cluster disagree. The next pipeline either clobbers the fix or silently leaves the difference in place. Multiply that across a few engineers and three environments and nobody can answer the basic question: what is actually running right now, and why.&lt;/p&gt;
&lt;h2&gt;The ArgoCD approach&lt;/h2&gt;
&lt;h3&gt;Here&amp;#39;s how to fix it&lt;/h3&gt;
&lt;p&gt;ArgoCD runs inside the cluster and pulls, rather than being pushed to. It watches a Git repo, compares the desired state there against the live state in the cluster, and reconciles the two. Because it lives in the cluster, it does not need to hand external systems admin credentials, and because it runs continuously, it notices drift instead of waiting for the next pipeline.&lt;/p&gt;
&lt;h3&gt;Implementing it&lt;/h3&gt;
&lt;p&gt;Install ArgoCD with Helm, then point it at a repo by creating an Application.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd --namespace argocd --create-namespace
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An Application tells ArgoCD which repo and path to watch and where to deploy it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: web
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/your-org/deploy.git
    targetRevision: main
    path: envs/prod
  destination:
    server: https://kubernetes.default.svc
    namespace: web
  syncPolicy:
    automated:
      selfHeal: true # revert manual changes back to Git
      prune: true # delete resources removed from Git
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Tools and platforms&lt;/h3&gt;
&lt;p&gt;ArgoCD is part of the Argo project alongside Argo Rollouts for progressive delivery. Flux is the other well-known GitOps controller if you prefer a more CLI-first model. Both pair naturally with Helm and Kustomize, so you keep the templating you already use.&lt;/p&gt;
&lt;h2&gt;Quick implementation steps&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Quick takeaways&lt;/strong&gt; to get GitOps running:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Put your manifests or Helm charts in a Git repo, one path per environment.&lt;/li&gt;
&lt;li&gt;Install ArgoCD in the cluster with Helm.&lt;/li&gt;
&lt;li&gt;Create an Application pointing at the repo path.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;automated&lt;/code&gt; sync so merges deploy on their own.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;selfHeal&lt;/code&gt; so live edits snap back to Git.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;prune&lt;/code&gt; so deleting from Git deletes from the cluster.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Sync policies: manual vs automatic&lt;/h3&gt;
&lt;p&gt;Start in manual sync while you build trust. ArgoCD shows you the diff and you click sync. Once you trust it, switch on &lt;code&gt;automated&lt;/code&gt; so a merge to &lt;code&gt;main&lt;/code&gt; deploys itself. &lt;code&gt;selfHeal&lt;/code&gt; is what closes the drift gap, because any change made directly to the cluster is reverted to match Git within minutes.&lt;/p&gt;
&lt;h3&gt;Multiple environments&lt;/h3&gt;
&lt;p&gt;Give each environment its own path (or its own repo) and its own Application. Promotion becomes a pull request that moves a change from &lt;code&gt;envs/staging&lt;/code&gt; to &lt;code&gt;envs/prod&lt;/code&gt;, so the same reviewed manifests flow through each stage.&lt;/p&gt;
&lt;h2&gt;Benefits of GitOps with ArgoCD&lt;/h2&gt;
&lt;h3&gt;Why it helps&lt;/h3&gt;
&lt;p&gt;You get one honest answer to &amp;quot;what&amp;#39;s running,&amp;quot; because the answer is always &amp;quot;whatever is in Git.&amp;quot; Rollbacks stop being scary, since reverting a commit reverts the deploy. And you shrink your attack surface, because the cluster pulls its own config instead of trusting an external pipeline with admin keys.&lt;/p&gt;
&lt;h3&gt;Confidence and recovery&lt;/h3&gt;
&lt;p&gt;The health and sync dashboard turns a fuzzy question into a clear status per app and per environment. When something drifts or degrades, you see it, and self-heal often fixes it before anyone notices.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your approach?&lt;/h2&gt;
&lt;h3&gt;Community discussion&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s your take?&lt;/strong&gt; GitOps is a real shift in how a team thinks about deploys, and the move from push to pull takes some getting used to. If you have made the switch, what finally sold you on it?&lt;/p&gt;
&lt;h3&gt;Share your experience&lt;/h3&gt;
&lt;p&gt;If you are running ArgoCD or Flux in production, I would love to hear how you handle promotion between environments and whether you let self-heal run everywhere or hold it back for some workloads.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://argo-cd.readthedocs.io/&quot;&gt;Argo CD documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/getting_started/&quot;&gt;Argo CD getting started&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/argoproj/argo-helm&quot;&gt;Argo Helm charts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://opengitops.dev/&quot;&gt;OpenGitOps principles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fluxcd.io/&quot;&gt;Flux (alternative GitOps controller)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>Kubernetes &amp; Containers</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0015-argocd-gitops-kubernetes-deployments-git-sync/hero.png" length="0" type="image/jpeg"/></item><item><title>Kubernetes Namespaces: Organize, Isolate, and Secure Multi-Team Clusters</title><link>https://devtips.mkabumattar.com/post/kubernetes-namespaces-organize-isolate-multi-team/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/kubernetes-namespaces-organize-isolate-multi-team/</guid><description>Sharing one Kubernetes cluster across teams without the chaos. This dev tip walks through layered namespace isolation: ResourceQuotas, LimitRanges, default-deny NetworkPolicies, and namespace-scoped RBAC, with copy-paste manifests and a Terraform example.</description><pubDate>Thu, 28 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why cluster isolation matters&lt;/h2&gt;
&lt;h3&gt;The multi-tenant reality&lt;/h3&gt;
&lt;p&gt;If you&amp;#39;re running a separate cluster for every environment and every dev team, you have already seen the bill and the amount of upgrade work that comes with it. Sharing a single cluster is a lot like staying in a hotel. Everyone gets their own secure, private room in the same building. You share the plumbing and foundation, but your space is entirely yours. You pack more workloads onto the same nodes, logging and monitoring live in one place, and there is one control plane to upgrade instead of thirty.&lt;/p&gt;
&lt;p&gt;To get started with logical separation, you can declare a namespace with a few clear labels to keep things organized:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# namespace-basic.yaml
# A simple, labeled namespace to partition our cluster
apiVersion: v1
kind: Namespace
metadata:
  name: team-frontend
  labels:
    team: frontend
    environment: production
    managed-by: platform-team
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Soft vs. hard multi-tenancy&lt;/h3&gt;
&lt;p&gt;Decide which model you are building before you write any manifests. Soft multi-tenancy is enough for trusted internal teams in the same company, where you only need logical boundaries. Hard multi-tenancy is what you need for untrusted external users or regulated workloads, and it costs more: dedicated node pools with taints and tolerations, and sometimes a sandboxed runtime like gVisor or Kata Containers so a host kernel exploit stays inside the sandbox.&lt;/p&gt;
&lt;h2&gt;The problem with default-allow clusters&lt;/h2&gt;
&lt;h3&gt;The illusion of isolation&lt;/h3&gt;
&lt;p&gt;Most teams think that simply creating different namespaces for different teams keeps things isolated. It doesn&amp;#39;t. Out of the box, Kubernetes is designed as an open, single-tenant system, and a namespace is really just a logical boundary for the API. Resource names can overlap across namespaces, which is handy for keeping things organized, but the scheduling and network layers stay wide open by default.&lt;/p&gt;
&lt;h3&gt;Real-world collateral damage&lt;/h3&gt;
&lt;p&gt;Without controls, one runaway batch job in a staging namespace can take all the memory on a shared node. That triggers the Out-of-Memory (OOM) killer, and the kernel does not care that the pod it picks belongs to production next door. Because pods allow all traffic by default, a compromised container in your frontend namespace can port-scan and query a database in your backend namespace. The worst of the three is a single tenant flooding the API server with thousands of Secrets or ConfigMaps until etcd runs out of storage and the control plane stops answering for everyone.&lt;/p&gt;
&lt;Notice type=&quot;warning&quot; title=&quot;The blast radius is real&quot;&gt;
  Three real failure modes from a default-allow cluster: an OOM kill that takes
  down a neighbor&apos;s production pods, a compromised frontend pod that pivots
  straight to a backend database, and one tenant exhausting etcd by creating
  thousands of Secrets. All preventable with the controls below.
&lt;/Notice&gt;&lt;h2&gt;Layered namespace isolation&lt;/h2&gt;
&lt;h3&gt;Stacking the controls&lt;/h3&gt;
&lt;p&gt;No single setting protects a shared cluster. Isolation is something you build one control at a time, and it takes all five: namespaces, ResourceQuotas, LimitRanges, RBAC, and NetworkPolicies.&lt;/p&gt;
&lt;p&gt;The table below shows how these controls work together for real defense-in-depth:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Control Type&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Core Enforcement Mechanism&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Mitigated Risk&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Failure Mode if Omitted&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Namespace&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Logical / API&lt;/td&gt;
&lt;td&gt;API Server name scoping&lt;/td&gt;
&lt;td&gt;Naming collisions and basic management sprawl&lt;/td&gt;
&lt;td&gt;Inability to separate administrative concerns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ResourceQuota&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Namespace total&lt;/td&gt;
&lt;td&gt;Admission controller validation&lt;/td&gt;
&lt;td&gt;Cluster-wide resource starvation and etcd storage exhaustion&lt;/td&gt;
&lt;td&gt;A single runaway tenant exhausts whole cluster capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LimitRange&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Individual pod/container&lt;/td&gt;
&lt;td&gt;Admission controller injection&lt;/td&gt;
&lt;td&gt;Single container monopolizing namespace resources&lt;/td&gt;
&lt;td&gt;Pods without resource declarations are rejected or run unbounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NetworkPolicy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pod network&lt;/td&gt;
&lt;td&gt;Container Network Interface (CNI)&lt;/td&gt;
&lt;td&gt;Lateral movement and cross-namespace port scanning&lt;/td&gt;
&lt;td&gt;Full network reachability; compromised pods attack any internal target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RBAC Roles&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Identity / Access&lt;/td&gt;
&lt;td&gt;API authorization engine&lt;/td&gt;
&lt;td&gt;Unauthorized credential exploit and cross-tenant tampering&lt;/td&gt;
&lt;td&gt;Attackers exploit cluster-wide credentials to compromise all workloads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;Resource control&lt;/h3&gt;
&lt;p&gt;To stop &amp;quot;noisy neighbors&amp;quot; from taking over your cluster, apply a ResourceQuota to every namespace. That sets a hard limit on the total CPU, memory, and object counts a team can use. There is a catch. Once a quota exists, the API server rejects any pod that doesn&amp;#39;t explicitly state its own resource requests and limits, which breaks deploys for every team that hasn&amp;#39;t retrofitted its manifests. A LimitRange fixes that by injecting default values at admission time when a developer forgets to set them.&lt;/p&gt;
&lt;Notice type=&quot;tip&quot; title=&quot;Always pair Quota with LimitRange&quot;&gt;
  A ResourceQuota on its own will reject any pod that doesn&apos;t declare its own
  requests/limits, which silently breaks deploys for any team that hasn&apos;t
  retrofitted their manifests. Pair every Quota with a LimitRange so missing
  values get sane defaults injected automatically.
&lt;/Notice&gt;&lt;p&gt;Here&amp;#39;s how to set up a ResourceQuota to keep resource usage in check:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# compute-quota.yaml
# Caps the total resources used by all pods in this namespace
apiVersion: v1
kind: ResourceQuota
metadata:
  name: compute-quota
  namespace: team-frontend
spec:
  hard:
    requests.cpu: &amp;#39;4&amp;#39;
    requests.memory: 8Gi
    limits.cpu: &amp;#39;8&amp;#39;
    limits.memory: 16Gi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pair that quota with a container-level LimitRange in the same namespace to establish default fallback values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# container-limits.yaml
# Automatically injects resource defaults for containers that don&amp;#39;t declare them
apiVersion: v1
kind: LimitRange
metadata:
  name: container-limits
  namespace: team-frontend
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 256Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Network and access paths&lt;/h3&gt;
&lt;p&gt;To block lateral movement, change the network default from &amp;quot;allow-all&amp;quot; to &amp;quot;deny-all&amp;quot;. A default-deny NetworkPolicy that matches every pod shuts down unauthorized traffic immediately. Then you open only the paths you trust. Allow DNS resolution and traffic from your ingress controller explicitly, or your apps cannot resolve internal services and nothing reaches them from outside.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s your baseline default-deny policy to secure a namespace:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# default-deny-all.yaml
# Shuts down all incoming and outgoing network traffic by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: team-frontend
spec:
  podSelector: {} # An empty selector matches every pod in the namespace
  policyTypes:
    - Ingress
    - Egress
&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;&lt;Notice type=&quot;warning&quot; title=&quot;Default-deny will break DNS if you forget this&quot;&gt;
  Once a default-deny Egress policy is active, pods can no longer reach CoreDNS,
  and every service lookup starts failing in confusing ways. Always pair the
  deny with an allow-DNS policy in the same change.
&lt;/Notice&gt;&lt;p&gt;Once everything is blocked, add a policy to allow DNS resolution so your pods can find other services:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# allow-dns.yaml
# Selectively allows outbound DNS queries to CoreDNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: team-frontend
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {} # Matches any namespace hosting the DNS pods
      ports:
        - protocol: UDP
          port: 53
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For control plane access, keep roles scoped to namespaces with Role and RoleBinding rather than cluster-wide bindings. A team that can only see its own namespace cannot delete someone else&amp;#39;s Deployment by pasting the wrong context.&lt;/p&gt;
&lt;h3&gt;Tools and platforms&lt;/h3&gt;
&lt;p&gt;You can automate this whole setup with Terraform and declare namespaces, quotas, and network policies alongside the rest of your infrastructure. If you need Layer 7 filtering or traffic you can actually watch flowing, a CNI like Cilium gives you both. For high-security workloads, gVisor or Kata Containers put a user-space kernel between the container and the host, so a breakout lands in the sandbox.&lt;/p&gt;
&lt;h2&gt;Quick implementation steps&lt;/h2&gt;
&lt;h3&gt;Step-by-step hardening&lt;/h3&gt;
&lt;p&gt;Here&amp;#39;s the checklist for hardening a shared environment:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Deploy a default-deny NetworkPolicy in all non-system namespaces to shut down unauthorized lateral traffic.&lt;/li&gt;
&lt;li&gt;Set up a global LimitRange to automatically assign safe CPU and memory fallback defaults.&lt;/li&gt;
&lt;li&gt;Enforce a ResourceQuota to cap total namespace consumption and keep your etcd storage from getting exhausted.&lt;/li&gt;
&lt;li&gt;Keep developer access strictly within their designated namespace boundaries using group-based RBAC bindings.&lt;/li&gt;
&lt;li&gt;Use minimal base images like Alpine or container sandboxes to shrink your host-level attack surface.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Namespaces in Terraform&lt;/h3&gt;
&lt;p&gt;Manage namespaces as code so every one of them ends up with the same controls. Here&amp;#39;s a Terraform block that provisions a namespace and attaches its resource quota in the same apply:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# main.tf
# Provisions a team namespace and immediately pairs it with a resource quota

resource &amp;quot;kubernetes_namespace&amp;quot; &amp;quot;team_backend&amp;quot; {
  metadata {
    name = &amp;quot;team-backend&amp;quot;
    labels = {
      team        = &amp;quot;backend&amp;quot;
      environment = &amp;quot;production&amp;quot;
      managed-by  = &amp;quot;terraform&amp;quot;
    }
  }
}

resource &amp;quot;kubernetes_resource_quota&amp;quot; &amp;quot;backend_quota&amp;quot; {
  metadata {
    name      = &amp;quot;backend-quota&amp;quot;
    namespace = kubernetes_namespace.team_backend.metadata.name
  }
  spec {
    hard = {
      &amp;quot;requests.cpu&amp;quot;    = &amp;quot;4&amp;quot;
      &amp;quot;requests.memory&amp;quot; = &amp;quot;8Gi&amp;quot;
      &amp;quot;limits.cpu&amp;quot;      = &amp;quot;8&amp;quot;
      &amp;quot;limits.memory&amp;quot;   = &amp;quot;16Gi&amp;quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Automating default policies&lt;/h3&gt;
&lt;p&gt;Use Kustomize or a GitOps pipeline to roll out the default-deny policy and the quota with every new namespace. Otherwise the namespace somebody created by hand six months ago is still sitting there wide open.&lt;/p&gt;
&lt;h2&gt;Benefits of layered isolation&lt;/h2&gt;
&lt;h3&gt;Predictable performance and security&lt;/h3&gt;
&lt;p&gt;Getting namespace isolation right pays off in two places. The bill goes down, because workloads consolidate onto fewer nodes and you no longer need a control plane per team. And your ops team monitors one cluster instead of thirty, which is the difference between an upgrade being a Tuesday and an upgrade being a quarter. The third benefit shows up later: once the defaults are codified, developers can create their own isolated staging namespace without waiting on an approval, because the guardrails come with it.&lt;/p&gt;
&lt;h3&gt;A smaller blast radius&lt;/h3&gt;
&lt;p&gt;A layered setup shrinks your blast radius if things go sideways. Even if a container gets compromised, the attacker is stuck inside a locked room. They can&amp;#39;t access other tenants&amp;#39; data, query neighboring services, or starve the rest of the cluster of resources.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your approach?&lt;/h2&gt;
&lt;h3&gt;Community discussion&lt;/h3&gt;
&lt;p&gt;The trade-off never fully goes away. Every control here makes the cluster safer and makes somebody&amp;#39;s first deploy fail in a way they did not expect. Where have you landed on that?&lt;/p&gt;
&lt;h3&gt;Share your experience&lt;/h3&gt;
&lt;p&gt;Do you like managing your namespaces and quotas through Terraform, or do you rely on dynamic operators to do the heavy lifting? Have you ever run into a case where a default-deny network policy accidentally blocked something critical?&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://northflank.com/blog/kubernetes-multi-tenancy&quot;&gt;Kubernetes multi-tenancy: A 2026 guide to secure shared infrastructure - Northflank&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://oneuptime.com/blog/post/2026-02-09-multi-tenancy-namespace-isolation/view&quot;&gt;How to Implement Multi-Tenancy with Namespace Isolation and Resource Quotas - OneUptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.io/docs/concepts/security/multi-tenancy/&quot;&gt;Multi-tenancy - Kubernetes docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.cloud.google.com/kubernetes-engine/docs/best-practices/enterprise-multitenancy&quot;&gt;Best practices for enterprise multi-tenancy - Google Kubernetes Engine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dev.to/muskan_8abedcc7e12/kubernetes-multi-tenancy-namespace-isolation-rbac-and-network-policies-explained-3jjm&quot;&gt;Kubernetes Multi-Tenancy: Namespace Isolation, RBAC, and Network Policies Explained - DEV Community&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://oneuptime.com/blog/post/2026-02-20-kubernetes-namespace-resource-quotas/view&quot;&gt;How to Set Up Kubernetes Namespace Resource Quotas and LimitRanges - OneUptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://oneuptime.com/blog/post/2026-02-20-kubernetes-network-policies-deny-all/view&quot;&gt;How to Implement Default Deny Network Policies in Kubernetes - OneUptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.io/docs/concepts/policy/resource-quotas/&quot;&gt;Resource Quotas - Kubernetes docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.io/docs/concepts/policy/limit-range/&quot;&gt;Limit Ranges - Kubernetes docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.tigera.io/calico/latest/network-policy/get-started/kubernetes-default-deny&quot;&gt;Enable a default deny policy for Kubernetes pods - Calico Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/ahmetb/kubernetes-network-policy-recipes/blob/master/03-deny-all-non-whitelisted-traffic-in-the-namespace.md&quot;&gt;kubernetes-network-policy-recipes: deny-all-non-whitelisted-traffic - GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://oneuptime.com/blog/post/2026-02-23-how-to-create-kubernetes-namespaces-with-terraform/view&quot;&gt;How to Create Kubernetes Namespaces with Terraform - OneUptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://controlplane.com/blog/post/orchestrating-kubernetes-with-terraform&quot;&gt;Orchestrating Kubernetes with Terraform: A Step-by-Step Guide - Control Plane&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
</content:encoded><category>Kubernetes &amp; Cloud Native</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0014-kubernetes-namespaces-organize-isolate-multi-team/hero.png" length="0" type="image/jpeg"/></item><item><title>Helm Charts: Templating &amp; Multi-Environment Kubernetes Deployments</title><link>https://devtips.mkabumattar.com/post/helm-charts-kubernetes-multi-environment/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/helm-charts-kubernetes-multi-environment/</guid><description>How Helm templates Kubernetes manifests for multi-environment deployments: values overrides per environment, conditional logic, chart dependencies, and GitOps rollout with ArgoCD.</description><pubDate>Mon, 30 Mar 2026 20:02:59 GMT</pubDate><content:encoded>&lt;h2&gt;Why Helm matters&lt;/h2&gt;
&lt;h3&gt;The Kubernetes manifest problem&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Managing Kubernetes manifests at scale becomes a nightmare.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You have a deployment for dev, staging and production. Each one is 90% identical: same containers, same services, different replicas and resource limits and environment variables. Copy the YAML, edit a few lines, deploy. That works right up until someone edits dev and forgets staging, or a typo lands in production and nobody notices until users report the outage.&lt;/p&gt;
&lt;p&gt;Helm packages a Kubernetes application as a chart with templates, values and a version number. The YAML you deploy is generated from one source instead of maintained in three places.&lt;/p&gt;
&lt;h3&gt;Where hand-written YAML breaks down&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Environment Fragmentation:
prod-deployment.yaml  ← Different values hardcoded
staging-deployment.yaml  ← Needs 3 edits for prod
dev-deployment.yaml  ← Inconsistent structure

Result:
• Configurations drift
• Changes in one place aren&amp;#39;t replicated
• Rollback = manually revert files
• New environments = copy-paste hell
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The fix: Helm charts&lt;/h2&gt;
&lt;h3&gt;What Helm is&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Helm is package management for Kubernetes&lt;/strong&gt;, like npm for Node.js or pip for Python.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Charts&lt;/strong&gt;: Helm packages containing templated Kubernetes manifests&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Values&lt;/strong&gt;: Configuration that gets injected into templates (replicas, image tags, resources)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Releases&lt;/strong&gt;: Deployed instances of charts, tracked with versions for easy rollback&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repos&lt;/strong&gt;: Central repositories where teams share charts&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;What you get&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single chart, multiple environments&lt;/strong&gt;: Use template variables instead of duplicating YAML&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Templating system&lt;/strong&gt;: &lt;code&gt;{{ .Values.replicas }}&lt;/code&gt; → substituted with values from env-specific file&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency management&lt;/strong&gt;: Charts can depend on other charts (PostgreSQL, Redis, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Versioning &amp;amp; rollback&lt;/strong&gt;: Every deployment tracked, instant rollback to previous version&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validation&lt;/strong&gt;: Helm validates charts before deployment&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitOps ready&lt;/strong&gt;: Store charts in Git, deploy from Git&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Chart structure&lt;/h2&gt;
&lt;h3&gt;Directory layout&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;my-app/
├── Chart.yaml                 # Chart metadata (name, version, description)
├── values.yaml                # Default values
├── values-dev.yaml            # Dev environment overrides
├── values-staging.yaml        # Staging overrides
├── values-prod.yaml           # Production overrides
├── templates/
│   ├── deployment.yaml        # Templated Kubernetes Deployment
│   ├── service.yaml           # Service manifest
│   ├── configmap.yaml         # ConfigMap for config
│   ├── hpa.yaml               # Horizontal Pod Autoscaler (prod only)
│   └── _helpers.tpl           # Template helpers/functions
└── README.md                  # Chart documentation
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Chart.yaml&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: v2
name: my-app
description: A Helm chart for my microservice
type: application
version: 1.0.0 # Chart version
appVersion: &amp;#39;1.2.3&amp;#39; # Application version
maintainers:
  - name: Your Team
    email: team@example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Templating&lt;/h2&gt;
&lt;h3&gt;Basic values templating&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Default values for all environments
replicaCount: 1

image:
  repository: myregistry.azurecr.io/my-app
  tag: &amp;#39;1.2.3&amp;#39;
  pullPolicy: IfNotPresent

resources:
  requests:
    memory: &amp;#39;128Mi&amp;#39;
    cpu: &amp;#39;100m&amp;#39;
  limits:
    memory: &amp;#39;256Mi&amp;#39;
    cpu: &amp;#39;500m&amp;#39;

environment: &amp;#39;dev&amp;#39;
debug: true
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Production overrides
replicaCount: 3 # More replicas for load

resources:
  requests:
    memory: &amp;#39;512Mi&amp;#39;
    cpu: &amp;#39;500m&amp;#39;
  limits:
    memory: &amp;#39;1Gi&amp;#39;
    cpu: &amp;#39;2000m&amp;#39;

environment: &amp;#39;production&amp;#39;
debug: false # Disable debug logging
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;A templated deployment&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{.Release.Name}}
  namespace: {{.Release.Namespace}}
spec:
  replicas: {{.Values.replicaCount}} # Injected from values
  selector:
    matchLabels:
      app: {{.Chart.Name}}
  template:
    metadata:
      labels:
        app: {{.Chart.Name}}
    spec:
      containers:
        - name: {{.Chart.Name}}
          image: &amp;#39;{{ .Values.image.repository }}:{{ .Values.image.tag }}&amp;#39;
          imagePullPolicy: {{.Values.image.pullPolicy}}
          env:
            - name: ENVIRONMENT
              value: {{.Values.environment}}
            - name: DEBUG
              value: &amp;#39;{{ .Values.debug }}&amp;#39;
          ports:
            - containerPort: 8080
          resources:
            requests:
              memory: {{.Values.resources.requests.memory | quote}}
              cpu: {{.Values.resources.requests.cpu | quote}}
            limits:
              memory: {{.Values.resources.limits.memory | quote}}
              cpu: {{.Values.resources.limits.cpu | quote}}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conditional logic in templates&lt;/h2&gt;
&lt;h3&gt;Environment-specific configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spec:
  {{- if eq .Values.environment &amp;quot;production&amp;quot; }}
  replicas: 3
  {{- else if eq .Values.environment &amp;quot;staging&amp;quot; }}
  replicas: 2
  {{- else }}
  replicas: 1
  {{- end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Resources that exist only in some environments&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;{{- if eq .Values.environment &amp;quot;production&amp;quot; }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ .Release.Name }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ .Release.Name }}
  minReplicas: 3
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
{{- end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Conditional security settings&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;spec:
  {{- if eq .Values.environment &amp;quot;production&amp;quot; }}
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    fsReadOnlyRootFilesystem: true
  {{- end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Managing multiple environments&lt;/h2&gt;
&lt;h3&gt;Environment-specific values files&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Deploy to dev:
helm install my-app . -f values.yaml -f values-dev.yaml

Deploy to staging:
helm install my-app . -f values.yaml -f values-staging.yaml

Deploy to prod:
helm install my-app . -f values.yaml -f values-prod.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Values priority (the last one wins)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Later files override earlier ones
helm install my-app \
  -f values.yaml \           # Base defaults
  -f values-prod.yaml \      # Override for prod
  --set environment=production # Override from CLI (highest priority)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Chart dependencies&lt;/h2&gt;
&lt;h3&gt;Depending on PostgreSQL&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;dependencies:
  - name: postgresql
    version: &amp;#39;13.0.0&amp;#39;
    repository: https://charts.bitnami.com/bitnami
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Installing dependencies&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Download dependencies
helm dependency update

# Then deploy (PostgreSQL chart auto-installs)
helm install my-app . -f values-prod.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Values for a sub-chart&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# My app values
replicaCount: 3

# PostgreSQL sub-chart values
postgresql:
  enabled: true
  auth:
    password: &amp;#39;prod-secure-password&amp;#39;
  primary:
    persistence:
      size: 100Gi
  metrics:
    enabled: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Release management&lt;/h2&gt;
&lt;h3&gt;The basic commands&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install a release
helm install my-app ./chart -f values-prod.yaml

# Upgrade to new version
helm upgrade my-app ./chart -f values-prod.yaml

# Check release history
helm history my-app

# Rollback to previous version (instant!)
helm rollback my-app 2

# Delete release
helm uninstall my-app
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;A deployment run, start to finish&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. Make changes to chart
# 2. Test locally
helm install test-release . -f values-dev.yaml

# 3. Test succeeded, upgrade
helm uninstall test-release

# 4. Deploy to production
helm upgrade my-app . -f values-prod.yaml

# 5. Verify
kubectl get pods -l app=my-app

# 6. If something&amp;#39;s wrong, instant rollback
helm rollback my-app
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;GitOps integration&lt;/h2&gt;
&lt;h3&gt;Storing charts in Git&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;git repository structure:
├── charts/
│   ├── my-app/
│   │   ├── Chart.yaml
│   │   ├── values.yaml
│   │   ├── values-dev.yaml
│   │   ├── values-prod.yaml
│   │   └── templates/
│   └── other-app/
├── .gitignore
└── README.md
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;GitOps with ArgoCD&lt;/h3&gt;
&lt;p&gt;ArgoCD watches your Git repo and automatically keeps your Kubernetes cluster in sync:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app-prod
spec:
  project: default
  source:
    repoURL: https://github.com/myteam/infrastructure
    targetRevision: main
    path: charts/my-app
    helm:
      values: |
        environment: production
        replicaCount: 3
  destination:
    server: https://kubernetes.default.svc
    namespace: prod
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deploy workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Developer updates Chart in Git
2. Commits and pushes
3. ArgoCD detects change
4. Automatically syncs to Kubernetes
5. Helm applies new deployment
6. Services updated with zero downtime
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Going further with templates&lt;/h2&gt;
&lt;h3&gt;Helper functions&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;{{- define &amp;quot;my-app.labels&amp;quot; -}}
helm.sh/chart: {{ include &amp;quot;my-app.chart&amp;quot; . }}
app.kubernetes.io/name: {{ include &amp;quot;my-app.name&amp;quot; . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}

{{- define &amp;quot;my-app.name&amp;quot; -}}
{{ .Chart.Name }}
{{- end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Reuse helper
metadata:
  labels: {{- include &amp;quot;my-app.labels&amp;quot; . | nindent 4}}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Loops&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;env:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
  value: {{ $value | quote }}
{{- end }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Best practices&lt;/h2&gt;
&lt;h3&gt;1. Version charts semantically&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;version: 2.1.0 # MAJOR.MINOR.PATCH
# MAJOR: Breaking changes
# MINOR: New features
# PATCH: Bug fixes
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Use namespaces&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Deploy each environment to different namespace
helm install app . -n production -f values-prod.yaml
helm install app . -n staging -f values-staging.yaml
helm install app . -n dev -f values-dev.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Lint before you deploy&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Validate chart syntax
helm lint ./chart

# Dry-run to see what will be deployed
helm install --dry-run my-app ./chart -f values-prod.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. Test charts&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;helm test my-app  # Run chart tests
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. Validate values against a schema&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;{
  &amp;#39;$schema&amp;#39;: &amp;#39;https://json-schema.org/draft-07/schema#&amp;#39;,
  &amp;#39;type&amp;#39;: &amp;#39;object&amp;#39;,
  &amp;#39;properties&amp;#39;:
    {
      &amp;#39;replicaCount&amp;#39;: {&amp;#39;type&amp;#39;: &amp;#39;integer&amp;#39;, &amp;#39;minimum&amp;#39;: 1},
      &amp;#39;image&amp;#39;: {&amp;#39;type&amp;#39;: &amp;#39;object&amp;#39;, &amp;#39;properties&amp;#39;: {&amp;#39;tag&amp;#39;: {&amp;#39;type&amp;#39;: &amp;#39;string&amp;#39;}}},
    },
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;A complete minimal example&lt;/h2&gt;
&lt;h3&gt;Chart structure&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;my-service/
├── Chart.yaml
├── values.yaml
├── values-prod.yaml
└── templates/
    ├── deployment.yaml
    └── service.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Deploying it&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Dev
helm install my-service . -f values.yaml -f values-dev.yaml

# Production
helm upgrade my-service . -f values.yaml -f values-prod.yaml --install

# Rollback if needed
helm rollback my-service
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Helm replaces four copies of a deployment with one chart and four values files.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Templated manifests, values per environment and numbered releases mean a rollback is one command instead of an archaeology session in Git history. Pair it with ArgoCD and the cluster follows the repo. The templating language is the price of admission, and it is a real cost, but it buys you environments that actually match.&lt;/p&gt;
&lt;h2&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://helm.sh/docs/&quot;&gt;Helm Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://helm.sh/docs/chart_template_guide/&quot;&gt;Chart Template Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://helm.sh/docs/chart_best_practices/&quot;&gt;Helm Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://artifacthub.io/&quot;&gt;ArtifactHub: Pre-built Charts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/user-guide/helm/&quot;&gt;ArgoCD + Helm Integration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>Kubernetes &amp; DevOps</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0013-helm-charts-kubernetes-multi-environment/hero.png" length="0" type="image/jpeg"/></item><item><title>Structured Logging &amp; Log Aggregation with ELK Stack</title><link>https://devtips.mkabumattar.com/post/structured-logging-elk-stack/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/structured-logging-elk-stack/</guid><description>Centralized logging for microservices with Elasticsearch, Logstash, and Kibana: structured JSON logging, the Logstash pipeline, Kibana dashboards, alerting rules, and index lifecycle policies for production.</description><pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why centralized logging matters&lt;/h2&gt;
&lt;h3&gt;When services fail, where do you look first?&lt;/h3&gt;
&lt;p&gt;In a distributed system, logs scatter across servers, containers and regions. One request might touch five services. When it breaks, you&amp;#39;re opening log files on several machines, without the context to connect them, and losing whatever the restarted container was holding.&lt;/p&gt;
&lt;p&gt;Centralized logging puts all of it in one searchable index, with the fields you need to correlate one request across services.&lt;/p&gt;
&lt;h3&gt;What poor logging costs you&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Slow debugging&lt;/strong&gt;: 30+ minutes to find what went wrong 5 minutes ago&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lost logs&lt;/strong&gt;: Container restarts = logs disappear and are never recovered&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No correlation&lt;/strong&gt;: Can&amp;#39;t trace a request across multiple services&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Manual hunting&lt;/strong&gt;: SSH + grep through millions of lines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No alerting&lt;/strong&gt;: You wake up to customer complaints, not alerts&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The problem: distributed logs&lt;/h2&gt;
&lt;h3&gt;Why per-server logs aren&amp;#39;t enough&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Server 1: /var/log/app.log
2026-03-21 10:15:23 Error: Database connection refused

# Server 2: /var/log/app.log (you don&amp;#39;t see this for 15 minutes)
2026-03-21 10:15:22 Error: Database connection refused

# Server 3: Combined, these tell a story, but:
# - They&amp;#39;re on 3 different machines
# - You can&amp;#39;t search them together
# - Container restart and logs are gone
# - You have no context (which user? which request?)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The fix: the ELK stack&lt;/h2&gt;
&lt;h3&gt;What ELK is&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Elasticsearch&lt;/strong&gt;: Distributed search and analytics engine. Stores logs as searchable documents with full-text indexing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logstash&lt;/strong&gt;: Log processing pipeline. Collects, parses, enriches, and routes logs to Elasticsearch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kibana&lt;/strong&gt;: Visualization and exploration platform. Query logs with SQL-like syntax, build dashboards, set alerts.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;What you get&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Centralized&lt;/strong&gt;: All logs in one place, searchable in milliseconds&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scalable&lt;/strong&gt;: Handles billions of logs without slowdown&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured&lt;/strong&gt;: JSON-based searching and filtering&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Correlated&lt;/strong&gt;: Trace requests across multiple services&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Persistent&lt;/strong&gt;: No data loss when services restart&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alertable&lt;/strong&gt;: Triggered notifications on patterns&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;How the pieces fit together&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;Services → Filebeat/Logstash → Elasticsearch ← Kibana (Query/Visualize)
 ↓           ↓                    ↓
App logs    Parse, enrich        Index, store, analyze
DB logs     Filter, route        Full-text search
System logs Add context          Real-time updates
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Getting started with ELK&lt;/h2&gt;
&lt;h3&gt;Docker Compose setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    container_name: elasticsearch
    environment:
      discovery.type: single-node
      xpack.security.enabled: false
      xpack.security.transport.ssl.enabled: false
    ports:
      - &amp;#39;9200:9200&amp;#39;
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data

  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.0
    container_name: kibana
    ports:
      - &amp;#39;5601:5601&amp;#39;
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    depends_on:
      - elasticsearch

  logstash:
    image: docker.elastic.co/logstash/logstash:8.11.0
    container_name: logstash
    volumes:
      - ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf
    ports:
      - &amp;#39;5000:5000&amp;#39;
    environment:
      discovery.seed_hosts: elasticsearch
      LS_JAVA_OPTS: &amp;#39;-Xmx256m -Xms256m&amp;#39;
    depends_on:
      - elasticsearch

volumes:
  elasticsearch-data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start the stack:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker-compose up -d
# Kibana available at http://localhost:5601
# Elasticsearch at http://localhost:9200
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Structured logging with JSON&lt;/h2&gt;
&lt;h3&gt;Why structure the log line&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// Good: Structured (searchable, filterable)
{&amp;quot;timestamp&amp;quot;: &amp;quot;2026-03-21T10:15:23Z&amp;quot;, &amp;quot;service&amp;quot;: &amp;quot;user-api&amp;quot;, &amp;quot;level&amp;quot;: &amp;quot;ERROR&amp;quot;, &amp;quot;message&amp;quot;: &amp;quot;Database connection failed&amp;quot;, &amp;quot;user_id&amp;quot;: 42, &amp;quot;request_id&amp;quot;: &amp;quot;req-abc-123&amp;quot;, &amp;quot;error_code&amp;quot;: &amp;quot;DB_CONN_REFUSED&amp;quot;, &amp;quot;retry_count&amp;quot;: 3}

// Bad: Unstructured (exact string matching only)
&amp;quot;2026-03-21 10:15:23 ERROR [user-api] Database connection failed for user 42 in request req-abc-123&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Logging from your application&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Python:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import json
import logging
from pythonjsonlogger import jsonlogger

# Configure JSON logging
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(logHandler)
logger.setLevel(logging.INFO)

# Use logging with context
logger.info(&amp;quot;User login&amp;quot;, extra={
    &amp;quot;user_id&amp;quot;: 42,
    &amp;quot;request_id&amp;quot;: &amp;quot;req-abc-123&amp;quot;,
    &amp;quot;service&amp;quot;: &amp;quot;user-api&amp;quot;,
    &amp;quot;ip_address&amp;quot;: &amp;quot;192.168.1.1&amp;quot;
})

logger.error(&amp;quot;Database connection failed&amp;quot;, extra={
    &amp;quot;user_id&amp;quot;: 42,
    &amp;quot;request_id&amp;quot;: &amp;quot;req-abc-123&amp;quot;,
    &amp;quot;service&amp;quot;: &amp;quot;user-api&amp;quot;,
    &amp;quot;error_code&amp;quot;: &amp;quot;DB_CONN_REFUSED&amp;quot;,
    &amp;quot;retry_count&amp;quot;: 3
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Node.js:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;import winston from &amp;#39;winston&amp;#39;;

const logger = winston.createLogger({
  format: winston.format.json(),
  defaultMeta: {service: &amp;#39;api-gateway&amp;#39;},
  transports: [new winston.transports.Console()],
});

// Log with context
logger.info(&amp;#39;User authenticated&amp;#39;, {
  user_id: 42,
  request_id: &amp;#39;req-abc-123&amp;#39;,
  ip_address: &amp;#39;192.168.1.1&amp;#39;,
});

logger.error(&amp;#39;Database connection failed&amp;#39;, {
  user_id: 42,
  request_id: &amp;#39;req-abc-123&amp;#39;,
  error_code: &amp;#39;DB_CONN_REFUSED&amp;#39;,
  retry_count: 3,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Logstash configuration&lt;/h2&gt;
&lt;h3&gt;A basic pipeline&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;input {
  tcp {
    port =&amp;gt; 5000
    codec =&amp;gt; json
  }

  # Read from files
  file {
    path =&amp;gt; &amp;quot;/var/log/app/*.log&amp;quot;
    codec =&amp;gt; json
  }
}

filter {
  # Parse and enrich logs
  if [service] == &amp;quot;api-gateway&amp;quot; {
    mutate {
      add_field =&amp;gt; { &amp;quot;service_tier&amp;quot; =&amp;gt; &amp;quot;frontend&amp;quot; }
    }
  }

  # Extract request ID from logs for correlation
  grok {
    match =&amp;gt; { &amp;quot;message&amp;quot; =&amp;gt; &amp;quot;request_id=%{NOTSPACE:request_id}&amp;quot; }
  }

  # Add timestamp if missing
  date {
    match =&amp;gt; [ &amp;quot;timestamp&amp;quot;, &amp;quot;ISO8601&amp;quot; ]
    target =&amp;gt; &amp;quot;@timestamp&amp;quot;
  }
}

output {
  elasticsearch {
    hosts =&amp;gt; [&amp;quot;elasticsearch:9200&amp;quot;]
    index =&amp;gt; &amp;quot;logs-%{+YYYY.MM.dd}&amp;quot;
  }

  # Also output to stdout for debugging
  stdout {
    codec =&amp;gt; rubydebug
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Parsing logs from several services&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-conf&quot;&gt;input {
  tcp {
    port =&amp;gt; 5000
    codec =&amp;gt; json
  }
}

filter {
  # Normalize service names
  translate {
    field =&amp;gt; &amp;quot;service&amp;quot;
    destination =&amp;gt; &amp;quot;service_normalized&amp;quot;
    dictionary =&amp;gt; {
      &amp;quot;user-api&amp;quot; =&amp;gt; &amp;quot;user-service&amp;quot;
      &amp;quot;user_api&amp;quot; =&amp;gt; &amp;quot;user-service&amp;quot;
      &amp;quot;users&amp;quot; =&amp;gt; &amp;quot;user-service&amp;quot;
    }
  }

  # Add environment if not present
  if ![environment] {
    mutate {
      add_field =&amp;gt; { &amp;quot;environment&amp;quot; =&amp;gt; &amp;quot;production&amp;quot; }
    }
  }

  # Parse error stack traces
  if [level] == &amp;quot;ERROR&amp;quot; and [stack_trace] {
    mutate {
      split =&amp;gt; { &amp;quot;stack_trace&amp;quot; =&amp;gt; &amp;quot;\n&amp;quot; }
    }
  }
}

output {
  elasticsearch {
    hosts =&amp;gt; [&amp;quot;elasticsearch:9200&amp;quot;]
    index =&amp;gt; &amp;quot;logs-%{environment}-%{+YYYY.MM.dd}&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Querying logs in Kibana&lt;/h2&gt;
&lt;h3&gt;Creating index patterns&lt;/h3&gt;
&lt;p&gt;In Kibana:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Stack Management&lt;/strong&gt; → &lt;strong&gt;Index Patterns&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Create pattern: &lt;code&gt;logs-*&lt;/code&gt; (matches &lt;code&gt;logs-2026.03.21&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Set timestamp field to &lt;code&gt;@timestamp&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Basic searches&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Find all ERROR logs
level: ERROR

# Errors in specific service
level: ERROR AND service: &amp;quot;user-api&amp;quot;

# Errors for specific user
level: ERROR AND user_id: 42

# Errors in time range (last 1 hour)
level: ERROR AND @timestamp: [now-1h TO now]

# Request tracing across services
request_id: &amp;quot;req-abc-123&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;More of the Kibana Query Language (KQL)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Multiple conditions
service: &amp;quot;user-api&amp;quot; AND level: &amp;quot;ERROR&amp;quot; AND response_time_ms &amp;gt; 1000

# Wildcard matching
service: &amp;quot;user-*&amp;quot; AND message: &amp;quot;*connection*&amp;quot;

# Range queries
http_status_code: [400 TO 599] AND @timestamp: [now-1d/d TO now]

# Logical operators
(service: &amp;quot;payment-api&amp;quot; OR service: &amp;quot;billing-api&amp;quot;) AND level: &amp;quot;ERROR&amp;quot;

# Exists
error_trace:*
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Building dashboards&lt;/h2&gt;
&lt;h3&gt;A monitoring dashboard&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Dashboard: &amp;quot;Microservices Health&amp;quot;

1. **Error Rate Panel** (Line chart)
   - Query: level: &amp;quot;ERROR&amp;quot;
   - Group by: service (X-axis), time (series)
   - Show: errors per minute

2. **Response Time Panel** (Bar chart)
   - Query: All logs
   - Metric: avg(response_time_ms)
   - Breakdown by: service

3. **Top Errors Panel** (Table)
   - Query: level: &amp;quot;ERROR&amp;quot;
   - Top 10: error_code

4. **Request Volume Panel** (Metric)
   - Query: All logs
   - Show: total request count
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Setting up alerts&lt;/h2&gt;
&lt;h3&gt;Alert: error rate spike&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# In Kibana: Stack Management → Alerting → Create Rule

Condition:
  When: average(level: &amp;quot;ERROR&amp;quot;) is greater than 100
  For: the last 5 minutes

Action:
  Webhook: POST to Slack channel
  Message: &amp;quot;Error rate spiked in production&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Alert: a specific error pattern&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Condition:
  When: count(error_code: &amp;quot;DB_CONN_REFUSED&amp;quot;) is greater than 10
  For: the last 2 minutes

Action:
  Send to PagerDuty
  Message: &amp;quot;Database connection failures detected&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Log retention&lt;/h2&gt;
&lt;h3&gt;Index lifecycle management (ILM)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;policy&amp;quot;: &amp;quot;logs-policy&amp;quot;,
  &amp;quot;phases&amp;quot;: {
    &amp;quot;hot&amp;quot;: {
      &amp;quot;min_age&amp;quot;: &amp;quot;0d&amp;quot;,
      &amp;quot;actions&amp;quot;: {
        &amp;quot;rollover&amp;quot;: {
          &amp;quot;max_primary_store_size&amp;quot;: &amp;quot;50GB&amp;quot;,
          &amp;quot;max_age&amp;quot;: &amp;quot;1d&amp;quot;
        }
      }
    },
    &amp;quot;warm&amp;quot;: {
      &amp;quot;min_age&amp;quot;: &amp;quot;7d&amp;quot;,
      &amp;quot;actions&amp;quot;: {
        &amp;quot;set_replicas&amp;quot;: {
          &amp;quot;number_of_replicas&amp;quot;: 1
        }
      }
    },
    &amp;quot;cold&amp;quot;: {
      &amp;quot;min_age&amp;quot;: &amp;quot;30d&amp;quot;,
      &amp;quot;actions&amp;quot;: {
        &amp;quot;searchable_snapshot&amp;quot;: {
          &amp;quot;snapshot_repository&amp;quot;: &amp;quot;my_repository&amp;quot;
        }
      }
    },
    &amp;quot;delete&amp;quot;: {
      &amp;quot;min_age&amp;quot;: &amp;quot;90d&amp;quot;,
      &amp;quot;actions&amp;quot;: {
        &amp;quot;delete&amp;quot;: {}
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Request tracing with correlation IDs&lt;/h2&gt;
&lt;h3&gt;Adding a request ID&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from fastapi import Request
import uuid
import logging

logger = logging.getLogger(__name__)

async def add_request_id(request: Request, call_next):
    # Generate or extract request ID
    request_id = request.headers.get(&amp;quot;X-Request-ID&amp;quot;) or str(uuid.uuid4())

    # Store in request state
    request.state.request_id = request_id

    # Log with correlation
    logger.info(&amp;quot;Request started&amp;quot;, extra={
        &amp;quot;request_id&amp;quot;: request_id,
        &amp;quot;method&amp;quot;: request.method,
        &amp;quot;path&amp;quot;: request.url.path
    })

    response = await call_next(request)

    # Add to response headers for client
    response.headers[&amp;quot;X-Request-ID&amp;quot;] = request_id

    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Passing the request ID between services&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# When calling another service
import httpx

async def call_user_service(request):
    request_id = request.state.request_id

    async with httpx.AsyncClient() as client:
        response = await client.get(
            &amp;quot;http://user-api/users/42&amp;quot;,
            headers={&amp;quot;X-Request-ID&amp;quot;: request_id}  # Pass it along
        )

    return response.json()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Best practices&lt;/h2&gt;
&lt;h3&gt;1. Log the right amount&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Good: Structured context without redundancy
logger.info(&amp;quot;Payment processed&amp;quot;, extra={
    &amp;quot;user_id&amp;quot;: 42,
    &amp;quot;request_id&amp;quot;: &amp;quot;req-abc&amp;quot;,
    &amp;quot;amount&amp;quot;: 99.99,
    &amp;quot;currency&amp;quot;: &amp;quot;USD&amp;quot;
})

# Bad: Too verbose
logger.info(f&amp;quot;User with ID 42 has processed a payment of 99.99 USD via request req-abc at {timestamp}&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Use the same field names everywhere&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// Across all services, use same field names
{
  &amp;quot;timestamp&amp;quot;: &amp;quot;2026-03-21T10:15:23Z&amp;quot;,
  &amp;quot;level&amp;quot;: &amp;quot;ERROR&amp;quot;,
  &amp;quot;service&amp;quot;: &amp;quot;user-api&amp;quot;,
  &amp;quot;user_id&amp;quot;: 42,
  &amp;quot;request_id&amp;quot;: &amp;quot;req-abc&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Add context to errors&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;try:
    result = db.query(...)
except Exception as e:
    logger.error(&amp;quot;Database query failed&amp;quot;, extra={
        &amp;quot;error_type&amp;quot;: type(e).__name__,
        &amp;quot;error_message&amp;quot;: str(e),
        &amp;quot;query&amp;quot;: query,  # What failed?
        &amp;quot;user_id&amp;quot;: user_id,  # Who was affected?
        &amp;quot;request_id&amp;quot;: request_id  # Trace it
    })
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. Plan your indices&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Keep recent data hot (highly available)
# Archive old data (cost-effective)
# Delete after retention period

Daily indices: logs-2026.03.21, logs-2026.03.22
Retention: 90 days hot + searchable, 1 year archival, then delete
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;One searchable index turns a half-hour of log hunting into a query.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The part that pays for itself is structured JSON with a request ID in every line. Do that before you touch Kibana, because a centralized pile of unstructured strings is still a pile. Add OpenTelemetry traces alongside it and you have both halves: logs for what happened inside a service, traces for the path between them.&lt;/p&gt;
&lt;h2&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html&quot;&gt;Elasticsearch Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.elastic.co/guide/en/kibana/current/kuery-query-language.html&quot;&gt;Kibana Advanced Query Language&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.elastic.co/guide/en/logstash/current/filter-plugins.html&quot;&gt;Logstash Filter Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.elastic.co/guide/en/elasticsearch/reference/current/index-lifecycle-management.html&quot;&gt;Index Lifecycle Management&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>DevOps &amp; Observability</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0012-structured-logging-elk-stack/hero.png" length="0" type="image/jpeg"/></item><item><title>Container Image Vulnerability Scanning in CI/CD with Trivy</title><link>https://devtips.mkabumattar.com/post/container-image-vulnerability-scanning-trivy/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/container-image-vulnerability-scanning-trivy/</guid><description>How to automate container image vulnerability scanning in CI/CD with Trivy: installation, severity thresholds, GitHub Actions and GitLab CI integration, policy enforcement, and remediation workflows.</description><pubDate>Tue, 10 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why container security matters&lt;/h2&gt;
&lt;h3&gt;Where the vulnerabilities hide&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;A container image is one of the largest pieces of untrusted code you ship.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Every image you build carries the base OS layer, runtime libraries, your dependencies and your application code. Any of those layers can hold known CVEs, and most of them you didn&amp;#39;t write. Without scanning, that whole stack reaches production unread.&lt;/p&gt;
&lt;h3&gt;The numbers&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;80% of container images in production contain at least one known vulnerability&lt;/li&gt;
&lt;li&gt;Supply chain attacks targeting container registries are increasing&lt;/li&gt;
&lt;li&gt;Unpatched container vulnerabilities lead to data breaches and service disruptions&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The challenge&lt;/h2&gt;
&lt;h3&gt;Why manual review isn&amp;#39;t enough&lt;/h3&gt;
&lt;p&gt;Nobody is going to read the dependency tree of every image on every build. Without automation, a vulnerable image ships, and you find out about it from a CVE feed or an incident rather than from the pipeline that built it.&lt;/p&gt;
&lt;h3&gt;Common vulnerabilities in containers&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Outdated base images&lt;/strong&gt; with unpatched OS vulnerabilities&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vulnerable dependencies&lt;/strong&gt; pulled in from npm, pip or Maven&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exposed secrets&lt;/strong&gt; accidentally included in image layers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Misconfigurations&lt;/strong&gt; creating insecure defaults&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Malware&lt;/strong&gt; hidden in supply chain attacks&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The fix: Trivy&lt;/h2&gt;
&lt;h3&gt;What Trivy is&lt;/h3&gt;
&lt;p&gt;Trivy is a fast container vulnerability scanner from Aqua Security. It scans container images, filesystems and configuration files for known vulnerabilities, misconfigurations and secrets.&lt;/p&gt;
&lt;h3&gt;Why I reach for Trivy&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Speed&lt;/strong&gt;: Scans images in seconds, not minutes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accuracy&lt;/strong&gt;: Supports multiple vulnerability databases (NVD, GitHub Security, Aqua, Alpine)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Broad coverage&lt;/strong&gt;: Detects OS vulnerabilities, application dependencies, and misconfigurations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero setup&lt;/strong&gt;: Works out of the box without complex configuration&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CI/CD ready&lt;/strong&gt;: Integrates easily into GitHub Actions, GitLab CI, Jenkins&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open-source&lt;/strong&gt;: Free, transparent, and community-driven&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Installation and setup&lt;/h2&gt;
&lt;h3&gt;Installing Trivy&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# macOS
brew install trivy

# Linux (Ubuntu/Debian)
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | apt-key add -
echo &amp;quot;deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main&amp;quot; | tee -a /etc/apt/sources.list.d/trivy.list
apt-get update
apt-get install trivy

# Docker
docker pull aquasec/trivy
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Basic image scanning&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Scan a local image
trivy image my-app:latest

# Scan from registry
trivy image nginx:latest

# Scan with detailed output
trivy image --severity HIGH,CRITICAL my-app:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Setting severity thresholds&lt;/h2&gt;
&lt;h3&gt;Scanning at specific severity levels&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Only show critical and high severity issues
trivy image --severity CRITICAL,HIGH my-app:latest

# Exit with error code if vulnerabilities found
trivy image --severity HIGH,CRITICAL --exit-code 1 my-app:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Output formats&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# JSON output for parsing
trivy image --format json my-app:latest

# SARIF format for GitHub integration
trivy image --format sarif my-app:latest

# Table format (default)
trivy image --format table my-app:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;CI/CD integration&lt;/h2&gt;
&lt;h3&gt;GitHub Actions workflow&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Container Vulnerability Scan

on:
  push:
    branches: [main]
    paths:
      - &amp;#39;Dockerfile&amp;#39;
      - &amp;#39;src/**&amp;#39;
  pull_request:
    branches: [main]

jobs:
  trivy-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Build Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          file: ./Dockerfile
          push: false
          load: true
          tags: my-app:${{ github.sha }}

      - name: Run Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: my-app:${{ github.sha }}
          format: &amp;#39;sarif&amp;#39;
          output: &amp;#39;trivy-results.sarif&amp;#39;
          severity: &amp;#39;CRITICAL,HIGH&amp;#39;

      - name: Upload Trivy results to GitHub Security
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: &amp;#39;trivy-results.sarif&amp;#39;

      - name: Fail if critical vulnerabilities found
        run: |
          trivy image --severity CRITICAL my-app:${{ github.sha }} --exit-code 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;GitLab CI&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;stages:
  - build
  - scan

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

scan:
  stage: scan
  image: aquasec/trivy:latest
  script:
    - trivy image --severity HIGH,CRITICAL --exit-code 1 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  allow_failure: false
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Policy enforcement&lt;/h2&gt;
&lt;h3&gt;Creating a Trivy policy&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Define what constitutes a vulnerability violation
severity: HIGH,CRITICAL

# Ignore specific CVEs for known/accepted risks
ignorefile: .trivyignore

# Policy for failing builds
exit-code: 1

# Require sign-off for medium severity
medium-requires-approval: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Ignoring false positives&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Format: CVE-XXXX-XXXXX [optional: expiration date]

# Known false positive or acceptable risk (expires 2026-12-31)
CVE-2024-1234 2026-12-31

# Permanently ignore (use with caution)
CVE-2024-5678
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Beyond images&lt;/h2&gt;
&lt;h3&gt;Scanning filesystems&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Scan local directory
trivy fs .

# Scan with detailed output
trivy fs --severity HIGH,CRITICAL --format json . &amp;gt; fs-scan.json
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Scanning configuration files&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Detect misconfigurations in Dockerfile
trivy config Dockerfile

# Scan Kubernetes manifests
trivy config k8s-manifests/
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Generating a Software Bill of Materials (SBOM)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Generate SBOM in CycloneDX format
trivy image --format cyclonedx my-app:latest &amp;gt; sbom.xml

# Generate SBOM in SPDX format
trivy image --format spdx my-app:latest &amp;gt; sbom.spdx
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Remediation&lt;/h2&gt;
&lt;h3&gt;When vulnerabilities are found&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Smallest change&lt;/strong&gt;: update the base image&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# Before
FROM ubuntu:20.04

# After
FROM ubuntu:22.04
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;Targeted change&lt;/strong&gt;: update the vulnerable dependency&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM node:18-alpine

# Install with security patches
RUN npm install --no-save my-package@latest
&lt;/code&gt;&lt;/pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;Last resort&lt;/strong&gt;: rebuild the image without cache&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker build --no-cache -t my-app:latest .
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;A scheduled scan across every image&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Production Container Security

on:
  schedule:
    # Run daily scans
    - cron: &amp;#39;0 2 * * *&amp;#39;
  workflow_dispatch:

jobs:
  scan-all-images:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        image:
          - my-app:latest
          - api-gateway:latest
          - worker-service:latest

    steps:
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ matrix.image }}
          format: &amp;#39;json&amp;#39;
          output: &amp;#39;trivy-${{ matrix.image }}.json&amp;#39;
          severity: &amp;#39;CRITICAL,HIGH,MEDIUM&amp;#39;

      - name: Archive results
        uses: actions/upload-artifact@v3
        with:
          name: trivy-reports
          path: trivy-*.json

      - name: Notify security team
        if: failure()
        run: |
          curl -X POST -H &amp;#39;Content-type: application/json&amp;#39; \
            --data &amp;#39;{&amp;quot;text&amp;quot;:&amp;quot;Critical vulnerabilities found in ${{ matrix.image }}&amp;quot;}&amp;#39; \
            ${{ secrets.SLACK_WEBHOOK_URL }}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Monitoring and reporting&lt;/h2&gt;
&lt;h3&gt;Storing results over time&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Generate timestamped reports
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
trivy image --format json my-app:latest &amp;gt; reports/scan-$TIMESTAMP.json
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Tracking vulnerability trends&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Count vulnerabilities by severity
trivy image --format json my-app:latest | \
  jq &amp;#39;[.Results[]?.Vulnerabilities[]?.Severity] | group_by(.) | map({severity: .[0], count: length})&amp;#39;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Best practices&lt;/h2&gt;
&lt;h3&gt;1. Scan early and often&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Scan during development (local images)&lt;/li&gt;
&lt;li&gt;Scan in CI/CD pipeline (before merge)&lt;/li&gt;
&lt;li&gt;Scan in registry (continuous monitoring)&lt;/li&gt;
&lt;li&gt;Scan in production (runtime detection)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. Use minimal base images&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# Reduce attack surface
FROM alpine:3.18 as base
FROM gcr.io/distroless/base-debian11
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Update dependencies regularly&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Update dependencies regularly
npm audit fix --force
python -m pip install --upgrade pip
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. Keep SBOMs&lt;/h3&gt;
&lt;p&gt;Generate and store SBOMs for supply chain transparency:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;trivy image --format cyclonedx my-app:latest &amp;gt; sbom.json
git add sbom.json
git commit -m &amp;quot;Update SBOM for security tracking&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Registry integration&lt;/h2&gt;
&lt;h3&gt;Push only images that passed&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Only push if scan passes
trivy image --severity CRITICAL,HIGH --exit-code 1 my-app:latest &amp;amp;&amp;amp; \
  docker push my-registry/my-app:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;If you ship containers, something has to scan them before the registry does.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Trivy is the cheapest way I know to do that. Add it to the pipeline, pick the severity you&amp;#39;ll fail the build on, and treat the &lt;code&gt;.trivyignore&lt;/code&gt; file as something that gets reviewed rather than something that grows. Start at CRITICAL if HIGH would block every build on day one, then tighten it once the base images are current.&lt;/p&gt;
&lt;h2&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://aquasecurity.github.io/trivy&quot;&gt;Trivy Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/aquasecurity/trivy-action&quot;&gt;GitHub Container Scanning Action&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nvd.nist.gov&quot;&gt;CVE Database References&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/GoogleContainerTools/distroless&quot;&gt;Distroless Images&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>DevOps &amp; DevSecOps</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0011-container-image-vulnerability-scanning-trivy/hero.png" length="0" type="image/jpeg"/></item><item><title>Policy-as-Code Governance with OPA/Rego</title><link>https://devtips.mkabumattar.com/post/policy-as-code-opa-rego/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/policy-as-code-opa-rego/</guid><description>How Open Policy Agent enforces infrastructure standards automatically: writing policies in Rego, wiring them into Terraform and Kubernetes, and blocking non-compliant changes in CI/CD before they merge.</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why policy-as-code matters&lt;/h2&gt;
&lt;h3&gt;The governance problem&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Managing infrastructure at scale gets complicated fast.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;As your infrastructure grows, keeping it consistent and compliant gets harder. Manual reviews don&amp;#39;t scale past a few teams, and configuration drift arrives on its own. What you need is enforcement that runs on every change without a human in the loop.&lt;/p&gt;
&lt;h3&gt;Common infrastructure issues&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Developers accidentally making resources publicly accessible&lt;/li&gt;
&lt;li&gt;Missing required tags on cloud resources&lt;/li&gt;
&lt;li&gt;Non-compliant security group configurations&lt;/li&gt;
&lt;li&gt;Kubernetes deployments without resource limits&lt;/li&gt;
&lt;li&gt;Terraform modules bypassing organizational standards&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The problem: manual governance&lt;/h2&gt;
&lt;h3&gt;Why manual reviews fail&lt;/h3&gt;
&lt;p&gt;Code review and post-deployment checks depend on a tired human noticing one line in a 400-line diff. Standards get missed, security rules get worked around, and the issue surfaces after the apply. Guardrails have to run before the deploy to be worth anything.&lt;/p&gt;
&lt;h3&gt;What it costs&lt;/h3&gt;
&lt;p&gt;Unenforced policy shows up on the invoice and in the incident review:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Security breaches&lt;/strong&gt; from misconfigured resources&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compliance violations&lt;/strong&gt; leading to audits and fines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost overruns&lt;/strong&gt; from unoptimized infrastructure&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Operational chaos&lt;/strong&gt; from inconsistent deployments&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The fix: Open Policy Agent (OPA)&lt;/h2&gt;
&lt;h3&gt;What OPA is&lt;/h3&gt;
&lt;p&gt;Open Policy Agent is a policy engine that keeps policy logic out of your application and infrastructure code. You write rules in Rego, a declarative query language, and OPA evaluates them against structured input: Terraform plans, Kubernetes manifests, CI/CD configuration, or any other JSON you hand it.&lt;/p&gt;
&lt;h3&gt;What you get&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unified enforcement&lt;/strong&gt; across Terraform, Kubernetes, and custom tools&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Declarative policies&lt;/strong&gt; that are easy to understand and maintain&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pre-deployment validation&lt;/strong&gt; to catch issues before they reach production&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit trails&lt;/strong&gt; for compliance and governance requirements&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Organization-wide standards&lt;/strong&gt; enforced consistently&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Getting started with OPA and Rego&lt;/h2&gt;
&lt;h3&gt;Installing OPA&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# macOS
brew install opa

# Linux
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_x86_64
chmod +x opa
sudo mv opa /usr/local/bin/
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;A basic Rego policy&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-rego&quot;&gt;# Deny public S3 buckets
package s3

deny[msg] {
    input.resource_type == &amp;quot;aws_s3_bucket&amp;quot;
    input.acl == &amp;quot;public-read&amp;quot;
    msg := sprintf(&amp;quot;S3 bucket %s cannot be public&amp;quot;, [input.name])
}

deny[msg] {
    input.resource_type == &amp;quot;aws_s3_bucket&amp;quot;
    input.acl == &amp;quot;public-read-write&amp;quot;
    msg := sprintf(&amp;quot;S3 bucket %s cannot be public&amp;quot;, [input.name])
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Integrating OPA with Terraform&lt;/h2&gt;
&lt;h3&gt;Using Conftest for Terraform&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install conftest
brew install conftest

# Validate Terraform plan
terraform plan -json | conftest test -
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Policy example: enforce tags&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-rego&quot;&gt;package terraform

deny[msg] {
    resource := input.resource_changes[_]
    resource.type in [&amp;quot;aws_instance&amp;quot;, &amp;quot;aws_rds_cluster&amp;quot;]
    not resource.change.after.tags.Environment
    msg := sprintf(&amp;quot;Resource %s must have Environment tag&amp;quot;, [resource.address])
}

deny[msg] {
    resource := input.resource_changes[_]
    resource.type in [&amp;quot;aws_instance&amp;quot;, &amp;quot;aws_rds_cluster&amp;quot;]
    not resource.change.after.tags.CostCenter
    msg := sprintf(&amp;quot;Resource %s must have CostCenter tag&amp;quot;, [resource.address])
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Enforcing policies in Kubernetes&lt;/h2&gt;
&lt;h3&gt;Installing OPA Gatekeeper&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Deploy Gatekeeper to your cluster
kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/release-3.14/deploy/gatekeeper.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;A Kubernetes ConstraintTemplate&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredResources
metadata:
  name: require-resource-limits
spec:
  match:
    kinds:
      - apiGroups: [&amp;#39;&amp;#39;]
        kinds: [&amp;#39;Pod&amp;#39;]
    excludedNamespaces: [&amp;#39;kube-system&amp;#39;, &amp;#39;gatekeeper-system&amp;#39;]
---
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequiredresources
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredResources
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredresources

        violation[{&amp;quot;msg&amp;quot;: msg}] {
            container := input.review.object.spec.containers[_]
            not container.resources.limits.cpu
            msg := sprintf(&amp;quot;Container %s must have CPU limit&amp;quot;, [container.name])
        }

        violation[{&amp;quot;msg&amp;quot;: msg}] {
            container := input.review.object.spec.containers[_]
            not container.resources.limits.memory
            msg := sprintf(&amp;quot;Container %s must have memory limit&amp;quot;, [container.name])
        }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;CI/CD pipeline integration&lt;/h2&gt;
&lt;h3&gt;GitHub Actions example&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;name: Policy Validation

on: [pull_request]

jobs:
  policy-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install OPA
        run: |
          curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_x86_64
          chmod +x opa
          sudo mv opa /usr/local/bin/

      - name: Run Policy Tests
        run: |
          opa test policies/ -v

      - name: Validate Terraform
        if: hashFiles(&amp;#39;*.tf&amp;#39;) != &amp;#39;&amp;#39;
        run: |
          terraform init
          terraform plan -json | opa eval -d policies/ &amp;#39;data.terraform.deny&amp;#39; -f pretty
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Best practices&lt;/h2&gt;
&lt;h3&gt;1. Start at the organization level&lt;/h3&gt;
&lt;p&gt;Define policies at the organization level, not project level. Make them discoverable and documented.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rego&quot;&gt;# Start with clear policy namespaces
package org_policies.infrastructure.aws.security
package org_policies.kubernetes.workload
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Fail safely&lt;/h3&gt;
&lt;p&gt;Distinguish between hard denials and warnings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rego&quot;&gt;package my_policies

deny[msg] {
    # Hard deny: completely block this
    input.security_critical_violation == true
    msg := &amp;quot;This violates critical security policy&amp;quot;
}

warn[msg] {
    # Warning: recommend but allow with approval
    input.non_standard_naming == true
    msg := &amp;quot;Consider following naming standards&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Test your policies&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Test policy logic before deployment
opa test policies/ -v
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. Keep policies in version control&lt;/h3&gt;
&lt;p&gt;Keep policies in the same repo as infrastructure code, with proper code review processes.&lt;/p&gt;
&lt;h2&gt;Monitoring and auditing&lt;/h2&gt;
&lt;h3&gt;Log policy violations&lt;/h3&gt;
&lt;p&gt;Capture and log every policy decision for audit trails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rego&quot;&gt;package audit

log_decision[decision] {
    decision := {
        &amp;quot;action&amp;quot;: &amp;quot;denied&amp;quot;,
        &amp;quot;reason&amp;quot;: input.violation_reason,
        &amp;quot;timestamp&amp;quot;: input.timestamp,
        &amp;quot;resource&amp;quot;: input.resource_id
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;A rollout timeline&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;th&gt;Timeline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phase 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic security policies (public access, required tags)&lt;/td&gt;
&lt;td&gt;Week 1-2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phase 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Terraform integration in CI/CD&lt;/td&gt;
&lt;td&gt;Week 3-4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phase 3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Kubernetes Gatekeeper deployment&lt;/td&gt;
&lt;td&gt;Week 5-6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phase 4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full auditing and monitoring rollout&lt;/td&gt;
&lt;td&gt;Week 7-8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Policy-as-code moves the check from after the deploy to before it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With OPA and Rego, the same rules apply across Terraform, Kubernetes and whatever else you can feed JSON. Start with the security policies you&amp;#39;d be embarrassed to explain in an incident review, get those passing, then widen the net. Rolling out fifty policies at once mostly teaches your team how to add exceptions.&lt;/p&gt;
&lt;h2&gt;Resources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.openpolicyagent.org&quot;&gt;OPA Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://play.openpolicyagent.org&quot;&gt;Rego Playground&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://open-policy-agent.github.io/gatekeeper&quot;&gt;Gatekeeper Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.conftest.dev&quot;&gt;Conftest: Testing Framework&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>DevOps &amp; DevSecOps</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0010-policy-as-code-opa-rego/hero.png" length="0" type="image/jpeg"/></item><item><title>Setting Up GitHub Copilot Agent Skills in Your Repository</title><link>https://devtips.mkabumattar.com/post/github-copilot-agent-skills-setup/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/github-copilot-agent-skills-setup/</guid><description>How to build custom Agent Skills for GitHub Copilot on the agentskills.io open standard: folder structure, SKILL.md configuration, the progressive-disclosure loading model, and enabling the feature in VS Code.</description><pubDate>Mon, 26 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why teach Copilot new skills?&lt;/h2&gt;
&lt;h3&gt;What a skill actually is&lt;/h3&gt;
&lt;p&gt;If you&amp;#39;re ready to teach Copilot some new tricks, Agent Skills are your answer. Built on the &lt;a href=&quot;https://agentskills.io&quot;&gt;agentskills.io&lt;/a&gt; open standard, a skill is a folder of instructions and tools that Copilot only opens when it&amp;#39;s relevant to what you&amp;#39;re working on. It&amp;#39;s an assistant who knows which reference book to grab off the shelf, instead of one who dumps the whole library on the desk every time.&lt;/p&gt;
&lt;h3&gt;Loading only what&amp;#39;s needed&lt;/h3&gt;
&lt;p&gt;Loading everything at once would slow Copilot down and fill its context with things that don&amp;#39;t apply to the file you&amp;#39;re in. Agent Skills use &lt;strong&gt;progressive disclosure&lt;/strong&gt; to pull in targeted context only when it matters, so responses stay fast and stay on topic.&lt;/p&gt;
&lt;h2&gt;The problem: generic AI responses&lt;/h2&gt;
&lt;h3&gt;One-size-fits-all answers&lt;/h3&gt;
&lt;p&gt;Out of the box, Copilot is helpful but generic. It doesn&amp;#39;t know your team&amp;#39;s workflows, your project&amp;#39;s naming conventions, or the specialized tasks you run every week. So you type the same explanations over and over, session after session.&lt;/p&gt;
&lt;h3&gt;Context overload&lt;/h3&gt;
&lt;p&gt;Explaining everything upfront causes two problems. It slows every response down. And Copilot&amp;#39;s answers get muddier, because the signal you care about is buried in the context you pasted.&lt;/p&gt;
&lt;h3&gt;Repeating yourself&lt;/h3&gt;
&lt;p&gt;Without skills, every new coding session starts from zero. You re-explain the same project patterns, and the explaining never turns into anything you can reuse.&lt;/p&gt;
&lt;h2&gt;The fix: repository-based Agent Skills&lt;/h2&gt;
&lt;h3&gt;How Agent Skills are organized&lt;/h3&gt;
&lt;p&gt;Agent Skills are folders that live in your repo, or in your user profile. They hold instructions, scripts and tools that Copilot can reach for when needed. The format is portable across agents, including GitHub Copilot in VS Code, the CLI and the coding agent.&lt;/p&gt;
&lt;h3&gt;The three-level loading system (progressive disclosure)&lt;/h3&gt;
&lt;p&gt;To keep things efficient, Agent Skills use a three-level loading system:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Level 1: skill discovery (always on)&lt;/strong&gt;: Copilot reads the &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt; from the YAML frontmatter of every available &lt;code&gt;SKILL.md&lt;/code&gt;. This is lightweight and helps it decide if a skill is relevant.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Level 2: instructions loading&lt;/strong&gt;: When a skill matches your prompt, Copilot loads the full body of the &lt;code&gt;SKILL.md&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Level 3: resource access&lt;/strong&gt;: Copilot only accesses additional files (scripts, templates, examples) in the skill directory as needed.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Knowledge that stays put&lt;/h3&gt;
&lt;p&gt;Skills live in your repository, so the &lt;code&gt;.github/skills&lt;/code&gt; directory travels with a clone and shows up in code review like any other change. Nobody has to configure anything to get them.&lt;/p&gt;
&lt;h2&gt;Setting up your first skill&lt;/h2&gt;
&lt;h3&gt;Step 1: create the skills directory&lt;/h3&gt;
&lt;p&gt;First, pick where your skills folder lives. Recommended locations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Project skills&lt;/strong&gt;: &lt;code&gt;.github/skills/&lt;/code&gt; (recommended) or &lt;code&gt;.claude/skills/&lt;/code&gt; (for backward compatibility).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Personal skills&lt;/strong&gt;: &lt;code&gt;~/.copilot/skills/&lt;/code&gt; (recommended) or &lt;code&gt;~/.claude/skills/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Say you pick &lt;code&gt;.github/skills&lt;/code&gt;. Here&amp;#39;s how you&amp;#39;d set it up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Create the main skills directory
mkdir -p .github/skills

# Create a specific skill folder
mkdir .github/skills/image-resizer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside that main folder, create a subfolder for each specific skill you want. Name them something clear like &lt;code&gt;image-resizer&lt;/code&gt; or &lt;code&gt;webapp-testing&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Step 2: write the SKILL.md file&lt;/h3&gt;
&lt;p&gt;Every skill needs a &lt;code&gt;SKILL.md&lt;/code&gt; file (uppercase) with YAML frontmatter at the top. Spend your time on the &lt;code&gt;description&lt;/code&gt;, because that is the only text level 1 discovery sees when it decides whether to load the skill at all.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s a real example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
name: &amp;#39;Image Resizer&amp;#39;
description: &amp;#39;Automatically resizes and optimizes images for web use. Handles batch processing, maintains aspect ratios, and generates responsive image sets. Use this when working with image assets that need multiple sizes or optimization.&amp;#39;
---

# Image Resizing Workflow

First, I&amp;#39;ll check what you&amp;#39;re starting with:

- Look at source image dimensions and format
- Figure out what target sizes you need
- Make sure the output directory exists

Next, I&amp;#39;ll handle the actual work using the [optimization script](./scripts/optimize.js).

Finally, I&amp;#39;ll verify the output quality and file sizes.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice how the description names concrete things: batch processing, aspect ratios, responsive sets. That specificity is what makes Copilot pick the skill up when you mention images or resizing.&lt;/p&gt;
&lt;h3&gt;Step 3: add scripts and resources&lt;/h3&gt;
&lt;p&gt;This is where skills get more useful than a prompt file. You&amp;#39;re not limited to instructions. You can bundle JavaScript scripts, templates and configuration files alongside your &lt;code&gt;SKILL.md&lt;/code&gt;, then reference them with relative paths.&lt;/p&gt;
&lt;p&gt;Here&amp;#39;s what your folder structure might look like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;.github/skills/image-resizer/
├── SKILL.md
├── scripts/
│   ├── resize-images.js
│   └── optimize.js
└── templates/
    └── config-template.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in your &lt;code&gt;SKILL.md&lt;/code&gt;, you can tell Copilot about these files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;## Tools Available

To resize images, run: `./scripts/resize-images.js`

For optimization, use: `./scripts/optimize.js`

Configuration template: `./templates/config-template.json`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now when you ask Copilot to resize images, it points at the script that already exists and has been tested, instead of writing you a fresh one that has not.&lt;/p&gt;
&lt;h3&gt;Step 4: enable skills in VS Code&lt;/h3&gt;
&lt;p&gt;This feature is currently in preview, so you&amp;#39;ll need VS Code Insiders to try it out. Here&amp;#39;s how to get it working:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open VS Code (or VS Code Insiders)&lt;/li&gt;
&lt;li&gt;Open Settings (press &lt;code&gt;Cmd+,&lt;/code&gt; on Mac or &lt;code&gt;Ctrl+,&lt;/code&gt; on Windows/Linux)&lt;/li&gt;
&lt;li&gt;Search for &amp;quot;Agent Skills&amp;quot;&lt;/li&gt;
&lt;li&gt;Ensure the experimental skill support is enabled&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You can also enable it via &lt;code&gt;.vscode/settings.json&lt;/code&gt; if you prefer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;chat.useAgentSkills&amp;quot;: true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;&lt;Notice type=&quot;info&quot; title=&quot;Note&quot;&gt;&lt;p&gt;Copilot&amp;#39;s &amp;quot;Agent Mode&amp;quot; (Windows &amp;amp; Linux: &lt;code&gt;Ctrl+I&lt;/code&gt;, Mac: &lt;code&gt;Cmd+I&lt;/code&gt;) is highly optimized for using these skills autonomously. For the best experience, try invoking Copilot in Agent Mode when working with your custom skills.&lt;/p&gt;
&lt;/Notice&gt;&lt;p&gt;Once it&amp;#39;s enabled, test it. Open Copilot chat and ask &amp;quot;What skills do you have?&amp;quot; If the wiring is right, Copilot lists your new skill and summarises what it does. If it doesn&amp;#39;t, your frontmatter is usually the problem.&lt;/p&gt;
&lt;h2&gt;Skill examples&lt;/h2&gt;
&lt;h3&gt;Documentation generator&lt;/h3&gt;
&lt;p&gt;Say your team has a style guide for docs. A skill can hold those conventions so generated documentation comes out in your format rather than a generic one.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
name: &amp;#39;Documentation Generator&amp;#39;
description: &amp;quot;Generates API documentation following the team&amp;#39;s style guide. Includes TypeScript examples, parameter descriptions, and usage patterns. Use when documenting new functions or API endpoints.&amp;quot;
---

## Documentation Format

Each function should include:

1. Brief description (one sentence)
2. Parameter table with types and descriptions
3. Return value explanation
4. Code example showing typical usage
5. Common gotchas or edge cases

Example template is in `./templates/api-doc.md`
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Test suite builder&lt;/h3&gt;
&lt;p&gt;Tired of writing the same boilerplate test code? Build a skill that knows your testing patterns and can scaffold a whole suite.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
name: &amp;#39;Test Suite Builder&amp;#39;
description: &amp;#39;Generates comprehensive test suites using Jest and React Testing Library. Covers happy paths, edge cases, and error scenarios. Use when creating tests for new components or utilities.&amp;#39;
---

## Test Structure

For each component/function, generate:

- Setup and teardown blocks
- Happy path tests
- Edge case coverage
- Error handling tests
- Mock setup when needed

Refer to `./examples/sample-test.spec.ts` for the pattern.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Code review checklist&lt;/h3&gt;
&lt;p&gt;Your team&amp;#39;s review standards can go into a skill too, so the checklist runs on the PR instead of living in a wiki page nobody opens.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
name: &amp;#39;Code Review Checklist&amp;#39;
description: &amp;#39;Provides a comprehensive code review checklist based on team standards. Covers code quality, security, performance, and testing. Use when reviewing pull requests.&amp;#39;
---

## Review Criteria

### Code Quality

- [ ] Functions are under 50 lines
- [ ] No console.logs in production code
- [ ] Meaningful variable names
- [ ] Comments explain &amp;quot;why&amp;quot; not &amp;quot;what&amp;quot;

### Security

- [ ] No hardcoded credentials
- [ ] Input validation on all external data
- [ ] Proper error handling without exposing internals

### Testing

- [ ] Unit tests for new functions
- [ ] Integration tests for API endpoints
- [ ] Coverage above 80%
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What you get out of it&lt;/h2&gt;
&lt;h3&gt;Less repetition&lt;/h3&gt;
&lt;p&gt;The saving shows up in the explaining you stop doing. No typing out the same conventions every morning, no digging up the doc you wrote six months ago to paste into chat.&lt;/p&gt;
&lt;p&gt;Tasks that used to take three or four rounds of correction land closer to first try, because the context Copilot needed was already loaded.&lt;/p&gt;
&lt;h3&gt;Knowledge the whole team can use&lt;/h3&gt;
&lt;p&gt;Skills move institutional knowledge out of people&amp;#39;s heads. The pattern the senior dev always uses, the workaround for that one API quirk, the commit message format you keep correcting in review: put it in a skill and it applies to everyone.&lt;/p&gt;
&lt;p&gt;New hires come up to speed faster because the knowledge is in the repo they just cloned.&lt;/p&gt;
&lt;h3&gt;Consistent output&lt;/h3&gt;
&lt;p&gt;Copilot follows the same patterns each time, so you stop getting one suggestion today and a different one tomorrow for the same kind of change.&lt;/p&gt;
&lt;h3&gt;Automation, not just advice&lt;/h3&gt;
&lt;p&gt;Bundling scripts with the instructions is the part I&amp;#39;d not give up. Copilot can run your image optimizer, generate the boilerplate, execute the tests. Advice you have to implement and a script that already works are not in the same category.&lt;/p&gt;
&lt;h2&gt;Quick Reference&lt;/h2&gt;
&lt;Steps&gt;&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create Skills Directory&lt;/strong&gt;: Choose a location (&lt;code&gt;.github/skills/&lt;/code&gt;, &lt;code&gt;.copilot/skills/&lt;/code&gt;, or &lt;code&gt;.claude/skills/&lt;/code&gt;) and create a subfolder for your skill.&lt;/p&gt;
&lt;br /&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p .github/skills/my-skill
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create SKILL.md&lt;/strong&gt;: Inside your skill folder, create a &lt;code&gt;SKILL.md&lt;/code&gt; file with YAML frontmatter including &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;.&lt;/p&gt;
&lt;br /&gt;&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;---
name: &amp;#39;My Skill&amp;#39;
description: &amp;#39;Brief description of what this skill does and when to use it.&amp;#39;
---
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add Resources&lt;/strong&gt;: Include any scripts, templates, or additional files your skill needs in the same folder.&lt;/p&gt;
&lt;br /&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir .github/skills/my-skill/scripts
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reference Assets&lt;/strong&gt;: Use relative paths in &lt;code&gt;SKILL.md&lt;/code&gt; to point to your scripts or templates.&lt;/p&gt;
&lt;br /&gt;&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;To run the script, use: `./scripts/my-script.js`
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enable in VS Code&lt;/strong&gt;: Turn on Agent Skills in VS Code settings by enabling &lt;code&gt;chat.useAgentSkills&lt;/code&gt;.&lt;/p&gt;
&lt;br /&gt;&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;chat.useAgentSkills&amp;quot;: true
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify Setup&lt;/strong&gt;: Open Copilot Chat and ask, &amp;quot;What skills do you have?&amp;quot; to confirm your skill is recognized.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/Steps&gt;&lt;h2&gt;Next steps&lt;/h2&gt;
&lt;p&gt;Don&amp;#39;t try to write the perfect skill first. Start with one small task you do constantly. Generating test files in your team&amp;#39;s format, say, or the checklist you run before a production deploy.&lt;/p&gt;
&lt;p&gt;Get that working. Watch how Copilot uses it, because the first description you write is usually too vague to trigger reliably. Then add scripts and templates once the plain version earns its place.&lt;/p&gt;
&lt;p&gt;As the library grows, Copilot stops behaving like autocomplete and starts behaving like someone who has read your codebase.&lt;/p&gt;
&lt;p&gt;The write-up is a one-time cost, and the skill stays in the repo for whoever clones it next.&lt;/p&gt;
</content:encoded><category>Developer Tools &amp; Productivity</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0009-github-copilot-agent-skills-setup/hero.png" length="0" type="image/jpeg"/></item><item><title>7 Reasons Learning the Linux Terminal is Worth It (Even for Beginners)</title><link>https://devtips.mkabumattar.com/post/7-reasons-learning-linux-terminal-worth-it-beginners/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/7-reasons-learning-linux-terminal-worth-it-beginners/</guid><description>Seven concrete reasons the Linux terminal is worth learning: commands are easier to remember than they look, text beats clicking through GUI menus, commands stay stable across versions, and scripts can automate what a GUI cannot.</description><pubDate>Mon, 05 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why learn the Linux terminal?&lt;/h2&gt;
&lt;h3&gt;Why it still matters&lt;/h3&gt;
&lt;p&gt;Even with the graphical tools and AI assistants available now, the terminal is the most direct way to work with a Linux system. It&amp;#39;s a core skill: real control over the machine, and the ability to automate almost anything you can do by hand.&lt;/p&gt;
&lt;h3&gt;The myth worth killing first&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&amp;quot;The terminal is too hard for beginners&amp;quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;GUIs look easier for the first hour. After that they hide the thing you need and cap what you can do. The terminal hands you the whole system, and it does it the same way every time.&lt;/p&gt;
&lt;h2&gt;Remembering commands is easier than you think&lt;/h2&gt;
&lt;h3&gt;Commands vs menu hunting&lt;/h3&gt;
&lt;p&gt;New users worry about memorizing hundreds of commands. You don&amp;#39;t. Commands are structured text with consistent rules, and there are maybe fifteen you&amp;#39;ll type every day.&lt;/p&gt;
&lt;h3&gt;The learning curve myth&lt;/h3&gt;
&lt;p&gt;The structure makes sense once you see it: the command is the verb, then options, then arguments. &lt;code&gt;sudo apt install -y curl&lt;/code&gt; is the same shape as &lt;code&gt;sudo apt remove -y curl&lt;/code&gt;. Learn the shape and new commands stop looking new.&lt;/p&gt;
&lt;h2&gt;Short commands beat long instructions&lt;/h2&gt;
&lt;h3&gt;Why text is more efficient&lt;/h3&gt;
&lt;p&gt;A GUI tutorial is eight screenshots. &lt;code&gt;sudo apt upgrade -y&lt;/code&gt; is one line, and it tells you what it does. You can read it, paste it, and change one flag when your situation differs.&lt;/p&gt;
&lt;h3&gt;Copy-paste vs clicking through it again&lt;/h3&gt;
&lt;p&gt;Text commands can be copied, shared and scripted. GUI instructions have to be re-performed by hand each time, and step four is where people go wrong.&lt;/p&gt;
&lt;h2&gt;Commands are evergreen&lt;/h2&gt;
&lt;h3&gt;GUI tutorials rot&lt;/h3&gt;
&lt;p&gt;A GUI tutorial breaks the next time someone moves a settings panel. Commands stay stable across distributions and across versions.&lt;/p&gt;
&lt;h3&gt;Knowledge with a long shelf life&lt;/h3&gt;
&lt;p&gt;The commands you learn today still work years from now, no matter how much the desktop environment changes around them.&lt;/p&gt;
&lt;h2&gt;Linux is built out of text&lt;/h2&gt;
&lt;h3&gt;Everything is a file&lt;/h3&gt;
&lt;p&gt;Because configuration and logs are text, &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;sed&lt;/code&gt; and &lt;code&gt;awk&lt;/code&gt; work on all of it. A GUI puts the same information behind a database or a format only that GUI reads.&lt;/p&gt;
&lt;h3&gt;Nothing is hidden from you&lt;/h3&gt;
&lt;p&gt;You can search &lt;code&gt;/etc&lt;/code&gt; with &lt;code&gt;ripgrep&lt;/code&gt;, pipe a log through &lt;code&gt;awk&lt;/code&gt;, and count what you find. There is no GUI equivalent for that, because you&amp;#39;d need the vendor to have anticipated your exact question.&lt;/p&gt;
&lt;h2&gt;GUIs are training wheels&lt;/h2&gt;
&lt;h3&gt;The abstraction gets in the way&lt;/h3&gt;
&lt;p&gt;A graphical interface sits between you and the system, spending screen space and attention on panels and buttons. Good while you&amp;#39;re learning what things are called. Friction once you know.&lt;/p&gt;
&lt;h3&gt;Screen space and attention&lt;/h3&gt;
&lt;p&gt;Modern GUIs eat pixels and make you scan visually for what you already know the name of. Typing the name is faster than finding it.&lt;/p&gt;
&lt;h2&gt;GUIs are not scriptable&lt;/h2&gt;
&lt;h3&gt;The automation ceiling&lt;/h3&gt;
&lt;p&gt;A graphical interface needs a person clicking it. That&amp;#39;s the ceiling, and no amount of GUI polish raises it.&lt;/p&gt;
&lt;h3&gt;Scripts compose&lt;/h3&gt;
&lt;p&gt;Shell scripts chain together, run on a schedule and drop into a CI pipeline. GUI automation, when it exists, breaks when a button moves ten pixels.&lt;/p&gt;
&lt;h2&gt;GUIs don&amp;#39;t teach you much&lt;/h2&gt;
&lt;h3&gt;What you don&amp;#39;t learn by clicking&lt;/h3&gt;
&lt;p&gt;Living only in GUIs keeps your model of the system shallow. The terminal pushes you toward scripting, automation and the parts of the system that actually explain the behaviour you&amp;#39;re seeing.&lt;/p&gt;
&lt;h3&gt;It compounds&lt;/h3&gt;
&lt;p&gt;You start with &lt;code&gt;ls&lt;/code&gt; and &lt;code&gt;cd&lt;/code&gt;. A few months later you&amp;#39;re writing a script that does in one line what used to be a ten-minute chore. That habit of composing small pieces makes you better at everything else too.&lt;/p&gt;
&lt;h2&gt;Making the terminal your primary interface&lt;/h2&gt;
&lt;h3&gt;Start small&lt;/h3&gt;
&lt;p&gt;Start with package management (&lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;dnf&lt;/code&gt;, &lt;code&gt;pacman&lt;/code&gt;), file operations (&lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cd&lt;/code&gt;, &lt;code&gt;cp&lt;/code&gt;, &lt;code&gt;mv&lt;/code&gt;), and text processing (&lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;less&lt;/code&gt;). Each command you learn makes the next one cheaper.&lt;/p&gt;
&lt;h3&gt;Expect a rough week&lt;/h3&gt;
&lt;p&gt;The first week is genuinely annoying. You&amp;#39;ll look up things you already know how to do with a mouse, and that feels like going backwards. Push through it, because the payoff starts the first time you script something you used to do by hand.&lt;/p&gt;
&lt;h3&gt;Where it leaves you&lt;/h3&gt;
&lt;p&gt;The terminal is the most direct and most reliable way to work with Linux, and it hasn&amp;#39;t changed much in decades. That stability is the point: what you learn this month will still be true in ten years.&lt;/p&gt;
</content:encoded><category>DevOps &amp; DevSecOps</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0008-7-reasons-learning-linux-terminal-worth-it-beginners/hero.png" length="0" type="image/jpeg"/></item><item><title>Docker Is Eating Your Disk Space (And How PruneMate Fixes It)</title><link>https://devtips.mkabumattar.com/post/docker-disk-space-prunemate/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/docker-disk-space-prunemate/</guid><description>Your Docker host is slowly filling up with unused images, orphaned volumes, and stale build cache. Manual cleanup feels risky, and you might accidentally delete the wrong thing. Here&apos;s how PruneMate automates Docker maintenance across your home lab with scheduled cleanup, remote host support, and a clean interface that shows exactly what you&apos;re deleting before you commit.
</description><pubDate>Fri, 02 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem: Docker is eating your disk space&lt;/h2&gt;
&lt;h3&gt;What it looks like when it happens&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Your Docker host is running out of space. Again.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You&amp;#39;ve been spinning up containers, testing new services, building images. Everything&amp;#39;s humming along nicely. Then your system starts throwing errors because the root filesystem is full. You check your disk usage and Docker has taken 200GB. Wait, what? How did this even happen?&lt;/p&gt;
&lt;h3&gt;Why Docker holds onto so much&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What&amp;#39;s actually going on&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Docker is trying to be helpful. When you stop a container, you might want to restart it later with the same data, so Docker doesn&amp;#39;t throw anything away. Volumes stick around after containers are gone. Build cache hangs out to make your next build faster. Old images stay put &amp;quot;just in case&amp;quot; you need them again.&lt;/p&gt;
&lt;p&gt;This makes total sense for production. But in a home lab where you&amp;#39;re constantly trying new stuff? It turns into a slow pile-up of junk. That database volume from three months ago? Still there. Build cache from a project you ditched? Yep, still hanging around. Images you pulled once for curiosity and never touched again? All of it adds up.&lt;/p&gt;
&lt;h3&gt;Checking what&amp;#39;s using your space&lt;/h3&gt;
&lt;p&gt;Find out where it went. Run this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker system df
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&amp;#39;ll probably see something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          47        12        23.5GB    15.2GB (64%)     # 15GB we could get back!
Containers      15        8         2.1GB     1.3GB (61%)
Local Volumes   89        24        45.8GB    38.2GB (83%)     # Ouch, 38GB of unused volumes
Build Cache     156       0         12.3GB    12.3GB (100%)    # 100% unused. All of it.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look at that. 66GB sitting there doing nothing. And if your Docker volumes live on your root filesystem, which they probably do, the whole machine goes down with it. Databases refuse connections. Package managers throw errors. Containers can&amp;#39;t write logs.&lt;/p&gt;
&lt;h2&gt;The risks of manual Docker cleanup&lt;/h2&gt;
&lt;h3&gt;The built-in prune commands&lt;/h3&gt;
&lt;p&gt;Sure, you can manually prune stuff with Docker&amp;#39;s built-in commands:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;# These work, but they&amp;#39;re scary to run blindly
docker image prune    # Removes unused images. Pretty safe.
docker volume prune   # Removes unused volumes. WAIT, ARE YOU SURE?
docker system prune   # Nuclear option. Add -a and you&amp;#39;re in danger territory.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Why manual cleanup is risky&lt;/h3&gt;
&lt;p&gt;It always feels risky, though. Are those volumes actually unused? What if you delete something you needed? I made this mistake early on. I thought I&amp;#39;d be clever and deleted folders in &lt;code&gt;/var/lib/docker/volumes&lt;/code&gt; by hand. I destroyed persistent data I cared about, because from the filesystem I couldn&amp;#39;t tell which volume belonged to what.&lt;/p&gt;
&lt;p&gt;The built-in prune commands are safer than my folder deletion, but they&amp;#39;re still blunt. It&amp;#39;s all or nothing. And if you&amp;#39;ve got multiple Docker hosts, you&amp;#39;re SSHing into each one running the same commands over and over.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enter PruneMate: actually sensible Docker cleanup&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;What makes PruneMate worth using&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/anoniemerd/PruneMate&quot;&gt;PruneMate&lt;/a&gt; is an open-source tool that fixes all this. It shows you what&amp;#39;s eating your space, lets you pick exactly what to clean, and runs it all on a schedule so you don&amp;#39;t have to think about it.&lt;/p&gt;
&lt;p&gt;What makes it worth using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Visual dashboard&lt;/strong&gt; that shows where your space is going across all your Docker hosts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Granular control&lt;/strong&gt; pick images, volumes, networks, containers, or build cache. Your choice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Preview mode&lt;/strong&gt; see exactly what&amp;#39;ll get deleted before you pull the trigger&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scheduled cleanup jobs&lt;/strong&gt; that run automatically while you sleep&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remote host support&lt;/strong&gt; using Docker Socket Proxy (no SSH needed)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Notifications&lt;/strong&gt; via Gotify, ntfy, Discord, or Telegram&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead of SSHing around and guessing what&amp;#39;s safe to delete, every host is in one interface.&lt;/p&gt;
&lt;h3&gt;Setting up PruneMate&lt;/h3&gt;
&lt;p&gt;Just deploy the container on one of your Docker hosts with Docker Compose. Here&amp;#39;s all you need:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  prunemate:
    image: anoniemerd/prunemate:latest
    container_name: prunemate
    ports:
      - &amp;#39;7676:8080&amp;#39; # Access the web UI on port 7676
    volumes:
      # Give PruneMate access to Docker on this host
      - /var/run/docker.sock:/var/run/docker.sock
      # Keep logs and config between restarts
      - ./prunemate/logs:/var/log
      - ./prunemate/config:/config
    environment:
      - PRUNEMATE_TZ=America/New_York # Change to your timezone
      - PRUNEMATE_TIME_24H=true # Or false if you prefer AM/PM
      # Optional: Add a password to protect the interface
      # - PRUNEMATE_AUTH_USER=admin
      # - PRUNEMATE_AUTH_PASSWORD_HASH=your_hash_here
    restart: unless-stopped
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bring it up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now just open &lt;code&gt;http://your-host:7676&lt;/code&gt; in your browser and you&amp;#39;re good to go.&lt;/p&gt;
&lt;h3&gt;Managing multiple Docker hosts&lt;/h3&gt;
&lt;p&gt;If you want to manage other Docker hosts remotely, set up a Docker Socket Proxy on each one. PruneMate then connects through the proxy instead of getting full access to the Docker socket, which is effectively root on the host:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  dockerproxy:
    image: ghcr.io/tecnativa/docker-socket-proxy:latest
    environment:
      # These control what PruneMate can do via the proxy
      - CONTAINERS=1 # Let it see and manage containers
      - IMAGES=1 # Let it manage images
      - NETWORKS=1 # Let it manage networks
      - VOLUMES=1 # Let it manage volumes
      - BUILD=1 # Needed for cleaning build cache
      - POST=1 # Needed for actually running prune commands
    ports:
      - &amp;#39;2375:2375&amp;#39; # Standard Docker API port
    volumes:
      # Read-only access to Docker socket. Much safer.
      - /var/run/docker.sock:/var/run/docker.sock:ro
    restart: unless-stopped
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deploy this on each remote host, then add them to PruneMate&amp;#39;s interface with their hostname and port 2375. That&amp;#39;s the whole home lab from one place.&lt;/p&gt;
&lt;h2&gt;Using PruneMate&lt;/h2&gt;
&lt;h3&gt;The interface and cleanup options&lt;/h3&gt;
&lt;p&gt;The interface is a set of checkboxes for what to clean:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All unused containers&lt;/li&gt;
&lt;li&gt;All unused images&lt;/li&gt;
&lt;li&gt;All unused networks&lt;/li&gt;
&lt;li&gt;All unused volumes&lt;/li&gt;
&lt;li&gt;All build cache&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;By default, only unused images are checked, which is the right default. An image you delete by mistake is one &lt;code&gt;docker pull&lt;/code&gt; away. A volume you delete by mistake is gone, so PruneMate leaves volumes alone until you say otherwise.&lt;/p&gt;
&lt;h3&gt;A typical cleanup&lt;/h3&gt;
&lt;p&gt;Here&amp;#39;s what a typical cleanup looks like:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check the boxes for what you want to clean&lt;/li&gt;
&lt;li&gt;Hit &amp;quot;Preview &amp;amp; Run&amp;quot;&lt;/li&gt;
&lt;li&gt;Look at what it&amp;#39;s about to delete (with size estimates)&lt;/li&gt;
&lt;li&gt;If it looks good, hit &amp;quot;Confirm &amp;amp; Execute&amp;quot;&lt;/li&gt;
&lt;li&gt;Get a notification when it&amp;#39;s done&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That preview step is the reason I use it. You&amp;#39;ll see something like &amp;quot;About to delete 50 unused volumes and free up 38GB&amp;quot; before anything happens, and if a number looks wrong you back out.&lt;/p&gt;
&lt;h3&gt;Setting up automated cleanup&lt;/h3&gt;
&lt;p&gt;For automated cleanup, I set up a schedule. Here&amp;#39;s mine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Images and containers&lt;/strong&gt;: Weekly cleanup&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Build cache&lt;/strong&gt;: Weekly (I rebuild often, so cache gets stale fast)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Volumes&lt;/strong&gt;: Manual only (way too risky to automate)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This keeps everything clean without me having to remember. The notifications tell me what got cleaned up, so I know what happened without watching it happen.&lt;/p&gt;
&lt;h2&gt;PruneMate vs manual cleanup&lt;/h2&gt;
&lt;h3&gt;The trade-offs&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Manual Cleanup&lt;/th&gt;
&lt;th&gt;PruneMate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Visibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;docker system df&lt;/code&gt; on each host manually&lt;/td&gt;
&lt;td&gt;Dashboard shows all hosts at once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Safety&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High risk with wrong flags (&lt;code&gt;-a&lt;/code&gt;, &lt;code&gt;--volumes&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Preview before deletion, focused on unused resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Granularity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All-or-nothing (especially with &lt;code&gt;system prune&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Pick exactly what to clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Remote hosts&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SSH to each one individually&lt;/td&gt;
&lt;td&gt;Manage all hosts from one interface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Automation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Requires cron jobs or CI/CD pipelines&lt;/td&gt;
&lt;td&gt;Built-in scheduler with notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Learning curve&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Forces you to understand Docker maintenance&lt;/td&gt;
&lt;td&gt;Hides complexity (good and bad)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time investment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High upfront to script it properly&lt;/td&gt;
&lt;td&gt;10 minutes to deploy and configure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;Why this matters now&lt;/h3&gt;
&lt;p&gt;Manual cleanup teaches you how Docker actually works underneath, and that is worth learning once. But once you&amp;#39;ve learned it, running the same three commands on four hosts forever teaches you nothing new. PruneMate does the repetitive part and tells you what it did.&lt;/p&gt;
&lt;p&gt;Component prices are also brutal right now. A 1TB NVMe that cost $80 last year is closer to $150. Thanks, AI boom. If you can&amp;#39;t throw money at bigger drives, you have to get more out of the ones you have.&lt;/p&gt;
&lt;p&gt;Docker disk space is one of those problems you ignore until it bites you. Then your system is already falling apart. Logs won&amp;#39;t write. Databases run out of room. New deployments fail because there&amp;#39;s no space for the image. By the time you notice, you&amp;#39;re firefighting.&lt;/p&gt;
&lt;p&gt;PruneMate keeps you ahead of that by cleaning up on a schedule. It&amp;#39;s a boring tool for a boring problem, which is exactly what I want in something with delete permissions.&lt;/p&gt;
&lt;h3&gt;Bottom line&lt;/h3&gt;
&lt;p&gt;If you&amp;#39;re running Docker anywhere, home lab or production, disk maintenance isn&amp;#39;t optional. You can do it by hand with discipline and shell scripts, and plenty of people do. Or you can hand it to PruneMate and stop thinking about it.&lt;/p&gt;
&lt;p&gt;Grab it here: &lt;a href=&quot;https://github.com/anoniemerd/PruneMate&quot;&gt;PruneMate on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;How are you handling Docker cleanup? Manual commands? Custom scripts? Already using some automation tool?&lt;/p&gt;
</content:encoded><category>Containers &amp; Docker</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0007-docker-disk-space-prunemate/hero.png" length="0" type="image/jpeg"/></item><item><title>Understanding Kubernetes Services: ClusterIP vs NodePort vs LoadBalancer</title><link>https://devtips.mkabumattar.com/post/kubernetes-services-clusterip-nodeport-loadbalancer/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/kubernetes-services-clusterip-nodeport-loadbalancer/</guid><description>When to use ClusterIP, NodePort, or LoadBalancer for a Kubernetes Service: how each type works, its best-fit use case, and the security and scaling trade-offs of picking the wrong one.
</description><pubDate>Tue, 23 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you&amp;#39;re working with Kubernetes, you&amp;#39;ve probably noticed that Pods come and go, and their IP addresses keep changing. That&amp;#39;s where Services come in. They give you a stable way to keep your apps accessible and reliable. But picking the right type between ClusterIP, NodePort, and LoadBalancer? That can get confusing fast.&lt;/p&gt;
&lt;h3&gt;Why pods need a Service in front of them&lt;/h3&gt;
&lt;p&gt;Pods are temporary. They get created and destroyed, and their IPs change with them. Without a Service, one deployment&amp;#39;s rollout breaks every caller that had cached an IP. Pick the wrong Service type and you either expose something internal to the internet or make your app unreachable, and both of those are worse to debug in production than to think about now.&lt;/p&gt;
&lt;h3&gt;What the choice affects&lt;/h3&gt;
&lt;p&gt;The Service type decides who can reach your workload and what it costs to run. It also decides how much of your cluster&amp;#39;s edge you have to think about, which is why the default is the conservative one.&lt;/p&gt;
&lt;h3&gt;The three types&lt;/h3&gt;
&lt;p&gt;Kubernetes gives you three main Service types. Each one solves a different problem.&lt;/p&gt;
&lt;h2&gt;ClusterIP: internal traffic&lt;/h2&gt;
&lt;h3&gt;How ClusterIP works&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ClusterIP&lt;/strong&gt; is the one you&amp;#39;ll use most. It creates a stable virtual IP and DNS name inside the cluster that only other pods can reach. Backend services, databases, internal APIs: anything that has no business being reachable from outside. It&amp;#39;s the default type, and leaving it as the default is usually the right answer.&lt;/p&gt;
&lt;h3&gt;When to use it&lt;/h3&gt;
&lt;p&gt;Backend services, databases, internal APIs and service-to-service calls inside the cluster. If nothing outside the cluster needs to connect, this is your type.&lt;/p&gt;
&lt;h3&gt;ClusterIP configuration&lt;/h3&gt;
&lt;p&gt;Here&amp;#39;s what a basic ClusterIP Service looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: v1
kind: Service
metadata:
  name: backend-service
spec:
  type: ClusterIP # This is actually optional since it&amp;#39;s the default
  selector:
    app: backend
  ports:
    - port: 8080 # Port the Service listens on
      targetPort: 3000 # Port your Pod listens on
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;NodePort: development and testing&lt;/h2&gt;
&lt;h3&gt;How NodePort works&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;NodePort&lt;/strong&gt; opens the same port (somewhere between 30000 and 32767) on every node in the cluster. Hit any node&amp;#39;s IP on that port and kube-proxy forwards you to a pod, wherever it happens to be running. It takes one line of YAML, which is exactly why it&amp;#39;s the right tool for development.&lt;/p&gt;
&lt;h3&gt;What it&amp;#39;s good for&lt;/h3&gt;
&lt;p&gt;Quick external access in a dev cluster, without provisioning anything from your cloud provider or setting up an ingress controller.&lt;/p&gt;
&lt;h3&gt;Where it falls down&lt;/h3&gt;
&lt;p&gt;You&amp;#39;re opening a port on every node, your clients need to know node IPs, and there&amp;#39;s no health check in front of them. Nodes get replaced, and the IP your teammate bookmarked stops existing. Fine for testing, not something to hand to users.&lt;/p&gt;
&lt;h3&gt;NodePort example&lt;/h3&gt;
&lt;p&gt;Here&amp;#39;s a NodePort example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: v1
kind: Service
metadata:
  name: test-service
spec:
  type: NodePort
  selector:
    app: webapp
  ports:
    - port: 8080
      targetPort: 3000
      nodePort: 30080 # Optional - K8s will assign one if you don&amp;#39;t specify
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can access your app at &lt;code&gt;http://&amp;lt;any-node-ip&amp;gt;:30080&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;LoadBalancer: production external access&lt;/h2&gt;
&lt;h3&gt;How LoadBalancer works&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;LoadBalancer&lt;/strong&gt; is the production answer. Kubernetes asks your cloud provider for a real load balancer (AWS ELB, GCP Load Balancer, Azure Load Balancer) and gives you a public IP for it. Traffic spreads across healthy pods, and an unhealthy node drops out of rotation on its own.&lt;/p&gt;
&lt;h3&gt;Cloud provider integration&lt;/h3&gt;
&lt;p&gt;The cloud controller does the provisioning, so a &lt;code&gt;type: LoadBalancer&lt;/code&gt; Service turns into an actual load balancer with a few lines of YAML and no console clicking.&lt;/p&gt;
&lt;h3&gt;What production gets from it&lt;/h3&gt;
&lt;p&gt;Health checks, failover when a node dies, and a stable IP you can point DNS at. The catch is that each LoadBalancer Service is a separate billed load balancer, which is why teams with many public services end up putting an ingress controller behind a single one instead.&lt;/p&gt;
&lt;h3&gt;LoadBalancer configuration&lt;/h3&gt;
&lt;p&gt;Here&amp;#39;s how to set one up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: v1
kind: Service
metadata:
  name: frontend-service
spec:
  type: LoadBalancer
  selector:
    app: frontend
  ports:
    - port: 80 # External port
      targetPort: 8080 # Container port
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once it&amp;#39;s deployed, Kubernetes talks to your cloud provider and sets everything up. You&amp;#39;ll get an external IP that you can use in DNS records or share with users.&lt;/p&gt;
&lt;h2&gt;Comparing the three&lt;/h2&gt;
&lt;h3&gt;They stack on each other&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Use ClusterIP for internal services like databases, backend APIs, and microservice-to-microservice communication.&lt;/li&gt;
&lt;li&gt;NodePort is handy for quick testing and development work.&lt;/li&gt;
&lt;li&gt;LoadBalancer is what you need for production apps that face the internet.&lt;/li&gt;
&lt;li&gt;These Service types actually build on each other. A LoadBalancer creates a NodePort, which creates a ClusterIP underneath.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Choosing one&lt;/h3&gt;
&lt;p&gt;Start from who needs to reach the workload. Nothing outside the cluster? ClusterIP. A teammate needs to poke at it this afternoon? NodePort. Real users on the internet? LoadBalancer, or an ingress controller sitting behind one.&lt;/p&gt;
&lt;h3&gt;Moving between types&lt;/h3&gt;
&lt;p&gt;Changing type is an edit to one field, and because the types nest, going from ClusterIP to LoadBalancer keeps the same in-cluster DNS name working. Going the other way removes the public IP, so anything pointing at it needs to move first.&lt;/p&gt;
&lt;h2&gt;Why the choice matters&lt;/h2&gt;
&lt;h3&gt;Security&lt;/h3&gt;
&lt;p&gt;ClusterIP keeps internal traffic internal, and that is the whole security argument. Every time you promote a Service to NodePort or LoadBalancer, you&amp;#39;re adding a door, so the question worth asking is whether that workload needed one.&lt;/p&gt;
&lt;h3&gt;Performance and scaling&lt;/h3&gt;
&lt;p&gt;A LoadBalancer distributes traffic and drops unhealthy backends. NodePort sends everything to whichever node the client picked, and if that node is busy, that&amp;#39;s the client&amp;#39;s problem.&lt;/p&gt;
&lt;h3&gt;Running it day to day&lt;/h3&gt;
&lt;p&gt;A cloud load balancer gives you metrics and health checks you&amp;#39;d otherwise build. NodePort gives you a port number to remember and nothing else.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your Kubernetes service strategy?&lt;/h2&gt;
&lt;h3&gt;Community approaches&lt;/h3&gt;
&lt;p&gt;How are you exposing services in your Kubernetes clusters? Got any tips for managing external access?&lt;/p&gt;
&lt;h3&gt;Beyond Services&lt;/h3&gt;
&lt;p&gt;Most teams past a few public endpoints move to an ingress controller or a service mesh, and I&amp;#39;d like to hear where you drew that line and whether the mesh was worth its operational cost.&lt;/p&gt;
</content:encoded><category>DevOps &amp; Kubernetes</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0005-kubernetes-services-clusterip-nodeport-loadbalancer/hero.png" length="0" type="image/jpeg"/></item><item><title>Managing Terraform at Scale with Terragrunt</title><link>https://devtips.mkabumattar.com/post/terraform-terragrunt-wrappers/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/terraform-terragrunt-wrappers/</guid><description>How Terragrunt wraps Terraform to remove duplicated backend and provider config across dev, staging, and production: defining shared settings once, overriding per environment, and letting Terragrunt handle state and module dependencies.</description><pubDate>Sun, 21 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The problem with Terraform at scale&lt;/h2&gt;
&lt;h3&gt;Duplicated code across environments&lt;/h3&gt;
&lt;p&gt;If you&amp;#39;re managing infrastructure with Terraform across several environments or projects, you&amp;#39;ve probably hit the point where every new environment is a copy of the last one. That&amp;#39;s what wrappers like Terragrunt exist for: they keep the shared parts in one file.&lt;/p&gt;
&lt;h3&gt;Maintenance overhead&lt;/h3&gt;
&lt;p&gt;With plain Terraform, a change to the backend block is a change in every environment directory. Miss one and that environment quietly runs on the old settings until someone notices the state file in the wrong bucket.&lt;/p&gt;
&lt;h2&gt;Why Terraform gets messy&lt;/h2&gt;
&lt;h3&gt;Repetitive configuration&lt;/h3&gt;
&lt;p&gt;Plain Terraform is fine for one environment. Once you&amp;#39;re running dev, staging and production, you&amp;#39;re duplicating backend configuration, provider settings and variable files across directories. Every update means the same edit three times, and the third one is the one you forget.&lt;/p&gt;
&lt;h3&gt;Environment-specific boilerplate&lt;/h3&gt;
&lt;p&gt;Each environment needs almost the same configuration with two values changed. That &amp;quot;almost&amp;quot; is where copy-paste errors live, because a diff of two 40-line files is not something you read carefully at 5 PM.&lt;/p&gt;
&lt;h3&gt;State management&lt;/h3&gt;
&lt;p&gt;Every environment needs its own state file, its own lock table and its own key prefix. Setting that up by hand for each one is both tedious and the sort of thing that goes wrong silently.&lt;/p&gt;
&lt;h2&gt;The fix: the Terragrunt wrapper&lt;/h2&gt;
&lt;h3&gt;How Terragrunt works&lt;/h3&gt;
&lt;p&gt;Terragrunt is a thin wrapper around Terraform that fills in the parts Terraform leaves to you. You define your backend config, provider settings and common variables once, and each environment inherits them. Your Terraform modules stay generic, and Terragrunt supplies the environment-specific values.&lt;/p&gt;
&lt;h3&gt;Configuration inheritance&lt;/h3&gt;
&lt;p&gt;The root &lt;code&gt;terragrunt.hcl&lt;/code&gt; holds the shared settings. Each child config points at it with an &lt;code&gt;include&lt;/code&gt; block, so the backend and provider definitions exist in exactly one file.&lt;/p&gt;
&lt;h3&gt;Environment-specific overrides&lt;/h3&gt;
&lt;p&gt;A child config adds only what differs: instance sizes, CIDR ranges, replica counts. The module never learns which environment it is running in, which is what makes it safe to reuse.&lt;/p&gt;
&lt;h3&gt;What it looks like in practice&lt;/h3&gt;
&lt;p&gt;Instead of duplicating the backend config in every environment, you define it once in the root &lt;code&gt;terragrunt.hcl&lt;/code&gt; and let Terragrunt generate the per-environment state key from the directory path.&lt;/p&gt;
&lt;h2&gt;What Terragrunt gives you&lt;/h2&gt;
&lt;h3&gt;DRY infrastructure code&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Use Terragrunt to remove duplicate code across environments.&lt;/li&gt;
&lt;li&gt;Define backend and provider configs once, reuse everywhere.&lt;/li&gt;
&lt;li&gt;Keep your Terraform modules generic and environment-agnostic.&lt;/li&gt;
&lt;li&gt;Let Terragrunt handle state management and dependencies between modules.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;State management you don&amp;#39;t write&lt;/h3&gt;
&lt;p&gt;Terragrunt derives each state key from the directory structure and creates the backend if it doesn&amp;#39;t exist, so a new environment is a new folder rather than a checklist.&lt;/p&gt;
&lt;h3&gt;Consistent configuration&lt;/h3&gt;
&lt;p&gt;Every environment starts from the same base, and the differences are the handful of values you wrote down on purpose.&lt;/p&gt;
&lt;h2&gt;Why Terragrunt matters for teams&lt;/h2&gt;
&lt;h3&gt;Scaling past a few environments&lt;/h3&gt;
&lt;p&gt;Terragrunt keeps a Terraform repo readable as it grows. Less time copying files, more time on the infrastructure itself. It does add a tool and a config language to learn, and that cost is real, though it&amp;#39;s paid once rather than per environment.&lt;/p&gt;
&lt;h3&gt;Productivity&lt;/h3&gt;
&lt;p&gt;An update lands in one file and applies everywhere, so the &amp;quot;did we update staging too?&amp;quot; conversation stops happening.&lt;/p&gt;
&lt;h3&gt;Less to hold in your head&lt;/h3&gt;
&lt;p&gt;There&amp;#39;s less code between a new hire and their first change, and most of what&amp;#39;s left is the part that actually describes your infrastructure.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your Terraform strategy?&lt;/h2&gt;
&lt;h3&gt;Community approaches&lt;/h3&gt;
&lt;p&gt;Do you use Terragrunt or another wrapper for Terraform? How do you keep your infrastructure code clean across environments?&lt;/p&gt;
&lt;h3&gt;Alternative tools&lt;/h3&gt;
&lt;p&gt;Terragrunt, Terraspace and hand-rolled wrapper scripts all solve this, and the shell script is genuinely the right call for some teams. I&amp;#39;d like to know which one you kept.&lt;/p&gt;
</content:encoded><category>Cloud &amp; Infrastructure Automation</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0004-terraform-terragrunt-wrappers/hero.png" length="0" type="image/jpeg"/></item><item><title>HashiCorp Pulls the Plug on CDKTF</title><link>https://devtips.mkabumattar.com/post/cdktf-deprecation-hashicorp-terraform/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/cdktf-deprecation-hashicorp-terraform/</guid><description>HashiCorp just deprecated CDKTF as of December 10, 2025. If you built your infrastructure in TypeScript, Python, or Go to avoid HCL, your options are HCL with OpenTofu or a move to Pulumi, and vendor lock-in just bit again.
</description><pubDate>Mon, 15 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;CDKTF is officially deprecated&lt;/h2&gt;
&lt;h3&gt;The deprecation announcement&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Well, it finally happened.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;HashiCorp (now owned by IBM) officially deprecated the Cloud Development Kit for Terraform (CDKTF) on December 10, 2025. The repository is archived. No more features. No more fixes. If you chose CDKTF to write infrastructure code in TypeScript, Python, or Go instead of HCL, you&amp;#39;re now being told to go back to the very thing you tried to avoid.&lt;/p&gt;
&lt;h3&gt;Impact on existing users&lt;/h3&gt;
&lt;p&gt;Your existing stacks keep working, because CDKTF only generates Terraform configuration and Terraform still runs it. What you lose is bindings for new providers, bug fixes, and anyone to report a security issue to after the archive date.&lt;/p&gt;
&lt;h2&gt;Why HashiCorp killed CDKTF&lt;/h2&gt;
&lt;h3&gt;The business reason&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Why did this happen?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;According to HashiCorp, CDKTF &amp;quot;did not find product-market fit at scale.&amp;quot; Translation: not enough enterprise customers using it to justify the investment. This is what happens when tools are owned by a single vendor focused on enterprise priorities over community needs.&lt;/p&gt;
&lt;h3&gt;What that says about priorities&lt;/h3&gt;
&lt;p&gt;CDKTF was never what enterprises were paying for. The paid tiers around Terraform are, and CDKTF meant maintaining a second toolchain that sold nothing on its own. Under IBM that arithmetic got less forgiving, not more.&lt;/p&gt;
&lt;h2&gt;Migration options after the deprecation&lt;/h2&gt;
&lt;h3&gt;Your choices&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What are your options?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You&amp;#39;ve got two paths forward:&lt;/p&gt;
&lt;h3&gt;Option 1: Go back to HCL (with OpenTofu)&lt;/h3&gt;
&lt;p&gt;HashiCorp suggests using &lt;code&gt;cdktf synth --hcl&lt;/code&gt; to convert your code to raw HCL. But you don&amp;#39;t have to run that HCL on HashiCorp&amp;#39;s Terraform. &lt;a href=&quot;https://opentofu.org/&quot;&gt;OpenTofu&lt;/a&gt; is the truly open-source, Linux Foundation-backed alternative that won&amp;#39;t change licenses on you or sunset tools you depend on.&lt;/p&gt;
&lt;h3&gt;Converting existing code&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;cdktf synth --hcl&lt;/code&gt; emits HCL rather than JSON, so you get files a reviewer can read. Budget time to clean the output up. Generated resource names are long, and every loop and conditional you wrote in TypeScript arrives fully expanded.&lt;/p&gt;
&lt;h3&gt;OpenTofu as the alternative&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://opentofu.org/&quot;&gt;OpenTofu&lt;/a&gt; is a Linux Foundation fork of Terraform from before the licence change, so the state format, the provider protocol and most of the CLI are the same. In practice the move is a binary swap and a CI change rather than a rewrite.&lt;/p&gt;
&lt;h3&gt;Option 2: Switch to Pulumi&lt;/h3&gt;
&lt;p&gt;If you picked CDKTF because you wanted real programming languages with loops, variables, and proper abstractions, &lt;a href=&quot;https://pulumi.com/&quot;&gt;Pulumi&lt;/a&gt; is your best bet. Unlike CDKTF (which was always a translation layer), Pulumi is native infrastructure-as-software. You keep the power of TypeScript/Python/Go without the deprecation risk.&lt;/p&gt;
&lt;h3&gt;What Pulumi does differently&lt;/h3&gt;
&lt;p&gt;CDKTF generated Terraform configuration and handed it to Terraform. &lt;a href=&quot;https://pulumi.com/&quot;&gt;Pulumi&lt;/a&gt; talks to the providers itself, which mostly shows up in errors: a failure points at the line you wrote instead of at generated output you have to map back.&lt;/p&gt;
&lt;h3&gt;You keep the language&lt;/h3&gt;
&lt;p&gt;Loops, functions, real types and your existing unit tests all survive the move. What changes is the state backend, the CLI your pipeline runs, and the fact that you are now paying attention to a different company&amp;#39;s roadmap.&lt;/p&gt;
&lt;h2&gt;The vendor lock-in lesson&lt;/h2&gt;
&lt;h3&gt;Vendor risk&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The bigger lesson here?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;CDKTF is the small version of this. The Terraform licence change in 2023 was the large one. Both came out of one company deciding what happens to code other people had already shipped to production. Today it&amp;#39;s CDKTF. Tomorrow it could be another tool you depend on.&lt;/p&gt;
&lt;h3&gt;Long-term strategy&lt;/h3&gt;
&lt;p&gt;The test I now apply is whether we could keep deploying if the vendor walked away tomorrow. An open state format counts. A provider protocol someone else has already implemented counts. A fork that exists and has commits in it counts. A tool only one company ships does not.&lt;/p&gt;
&lt;h2&gt;Plan your migration now&lt;/h2&gt;
&lt;h3&gt;Start now&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Your move:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you&amp;#39;re using CDKTF in production, start planning your migration now. Whether you go with OpenTofu for stability or Pulumi for programming power, don&amp;#39;t wait until support runs out completely.&lt;/p&gt;
&lt;h3&gt;Migration timeline&lt;/h3&gt;
&lt;p&gt;Nothing breaks on a specific date, which sounds relaxed right up until a provider you use ships a change and nobody is left to regenerate the bindings. Treat it as a quarter of work spread across sprints, not a sprint.&lt;/p&gt;
&lt;h3&gt;Risk assessment&lt;/h3&gt;
&lt;p&gt;Sort your stacks by how often you change them. The ones you touch weekly hurt first, because those are the ones that will want a newer provider. A stack nobody has edited in a year can sit on archived CDKTF for a while, and being honest about that is what makes the rest of the plan fit.&lt;/p&gt;
&lt;h2&gt;Community discussion&lt;/h2&gt;
&lt;h3&gt;Share your experience&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What do you think?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Have you been using CDKTF? What&amp;#39;s your migration plan? I would like to hear whether &lt;code&gt;synth --hcl&lt;/code&gt; gave you output you were actually willing to keep, because that is the part I am least sure about.&lt;/p&gt;
&lt;h3&gt;Lessons learned&lt;/h3&gt;
&lt;p&gt;The thing I keep coming back to is that CDKTF was not a bad tool. It was a decent tool with no revenue attached to it, and that turns out to be the risk worth pricing when you pick anything from a single vendor.&lt;/p&gt;
</content:encoded><category>Cloud &amp; Infrastructure Automation</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0006-cdktf-deprecation-hashicorp-terraform/hero.png" length="0" type="image/jpeg"/></item><item><title>Tracing Microservices with OpenTelemetry</title><link>https://devtips.mkabumattar.com/post/tracing-microservices-opentelemetry/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/tracing-microservices-opentelemetry/</guid><description>How OpenTelemetry traces a request across distributed services: instrumenting your code, running a collector, and visualizing the resulting spans in Jaeger or Zipkin to find bottlenecks and errors.</description><pubDate>Mon, 23 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why monitor your microservices?&lt;/h2&gt;
&lt;h3&gt;The complexity of distributed systems&lt;/h3&gt;
&lt;p&gt;If you&amp;#39;re juggling multiple services, it&amp;#39;s hard to track how they work together. OpenTelemetry lets you follow one request end to end and see where it went wrong, which is closer to a map than to a dashboard.&lt;/p&gt;
&lt;h3&gt;What poor observability costs you&lt;/h3&gt;
&lt;p&gt;Without tracing, a microservice architecture is a black box with good uptime graphs. Every incident starts with the same twenty minutes of asking which service is at fault.&lt;/p&gt;
&lt;h2&gt;The challenge of microservices observability&lt;/h2&gt;
&lt;h3&gt;Debugging distributed systems&lt;/h3&gt;
&lt;p&gt;With microservices, one slow or broken part drags the rest down with it. Without a clear view of what&amp;#39;s happening, you&amp;#39;re guessing at which service to look at, and you&amp;#39;re guessing while the pager is going off.&lt;/p&gt;
&lt;h3&gt;The cascade effect&lt;/h3&gt;
&lt;p&gt;One failing service triggers timeouts in its callers, which trigger retries, which push load onto services that were fine a minute ago. By the time you look, five things are red and none of them is the cause.&lt;/p&gt;
&lt;h3&gt;Where logs and metrics stop helping&lt;/h3&gt;
&lt;p&gt;Metrics tell you latency went up. Logs tell you what one service did. Neither one connects the log line in your API to the log line in the database wrapper three hops later, and that connection is the thing you actually need.&lt;/p&gt;
&lt;h2&gt;The fix: OpenTelemetry distributed tracing&lt;/h2&gt;
&lt;h3&gt;How distributed tracing works&lt;/h3&gt;
&lt;p&gt;OpenTelemetry is a free, open source toolkit that tracks requests as they move through your services. Each service records a span, the spans carry a shared trace ID, and the collector stitches them back into one timeline.&lt;/p&gt;
&lt;h3&gt;Instrumenting your services&lt;/h3&gt;
&lt;p&gt;Add the OpenTelemetry libraries to your code, then run a collector to receive the data and forward it to something like Jaeger or Zipkin. Auto-instrumentation for HTTP clients and database drivers gets you most of the picture before you write a single manual span.&lt;/p&gt;
&lt;h3&gt;Reading the traces&lt;/h3&gt;
&lt;p&gt;What you get is a waterfall: one bar per span, nested by caller. The slow hop is the wide bar, and the failed hop is the red one. That is usually the whole investigation.&lt;/p&gt;
&lt;h2&gt;Implementation steps&lt;/h2&gt;
&lt;h3&gt;Adding the libraries&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Add OpenTelemetry libraries to your services.&lt;/li&gt;
&lt;li&gt;Set up a collector to feed data to a visualization tool.&lt;/li&gt;
&lt;li&gt;Check traces regularly to spot and fix issues fast.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Configuring the collector&lt;/h3&gt;
&lt;p&gt;Point your services at one collector rather than at the backend directly. It handles batching, retries and sampling, and it means switching from Jaeger to a hosted platform later is a collector config change instead of a redeploy of every service.&lt;/p&gt;
&lt;h3&gt;Keeping up with it&lt;/h3&gt;
&lt;p&gt;Look at traces when nothing is broken, not only during incidents. Knowing what a healthy trace looks like is what makes an unhealthy one obvious, and it&amp;#39;s how you notice the retry loop that has been quietly doubling your database load.&lt;/p&gt;
&lt;h2&gt;What tracing gives you&lt;/h2&gt;
&lt;h3&gt;Faster resolution&lt;/h3&gt;
&lt;p&gt;You stop reading five log streams and start reading one timeline. The question changes from &amp;quot;which service is slow&amp;quot; to &amp;quot;why is this span slow&amp;quot;, and that second question has an answer.&lt;/p&gt;
&lt;h3&gt;Fewer cascading failures&lt;/h3&gt;
&lt;p&gt;Catching the slow dependency early keeps it from turning into timeouts, retries and a system-wide incident.&lt;/p&gt;
&lt;h3&gt;A real picture of your dependencies&lt;/h3&gt;
&lt;p&gt;Traces show the calls your architecture diagram forgot. Every team I&amp;#39;ve watched turn tracing on has found at least one call nobody meant to make.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your monitoring strategy?&lt;/h2&gt;
&lt;h3&gt;Community approaches&lt;/h3&gt;
&lt;p&gt;How do you keep tabs on your microservices? Got any favorite tools to share?&lt;/p&gt;
&lt;h3&gt;Tool comparisons&lt;/h3&gt;
&lt;p&gt;Jaeger, Zipkin and DataDog all pull this off, with different amounts of running your own storage. I&amp;#39;d like to hear which trade-off you took and whether you&amp;#39;d take it again.&lt;/p&gt;
</content:encoded><category>Observability &amp; Monitoring</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0003-tracing-microservices-opentelemetry/hero.png" length="0" type="image/jpeg"/></item><item><title>Organizing Terraform with Modules</title><link>https://devtips.mkabumattar.com/post/organizing-terraform-modules/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/organizing-terraform-modules/</guid><description>How to split Terraform code into reusable modules for networking, databases, and other common components, stored in a shared repository so multiple projects and environments can pull from the same source instead of copy-pasted configuration.</description><pubDate>Mon, 16 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why organize your Terraform code?&lt;/h2&gt;
&lt;h3&gt;Where the complexity comes from&lt;/h3&gt;
&lt;p&gt;If you&amp;#39;re using Terraform to build out your infrastructure, you know how quickly things get complicated. Every new environment, every new account, every new team wants a slightly different version of the same VPC. That&amp;#39;s where modules come in. Think of them as the thing that keeps your code reusable instead of copied.&lt;/p&gt;
&lt;h3&gt;What structure buys you&lt;/h3&gt;
&lt;p&gt;A module gives one VPC, one RDS instance or one IAM setup a single definition that every environment calls. When the definition changes, you change it once and bump a version tag instead of hunting through five directories.&lt;/p&gt;
&lt;h2&gt;The problem with messy Terraform code&lt;/h2&gt;
&lt;h3&gt;Duplicated code&lt;/h3&gt;
&lt;p&gt;As your infrastructure gets bigger, your Terraform files turn into a real tangle. You copy and paste code, you end up with files nobody wants to open, and you lose track of which copy is current. Updates become a headache, and your whole team slows down with you.&lt;/p&gt;
&lt;h3&gt;Maintenance pain&lt;/h3&gt;
&lt;p&gt;Large, monolithic Terraform files are hard to read, hard to test and hard to change. A tweak to a security group in one area can break an unrelated part of your infrastructure, and &lt;code&gt;terraform plan&lt;/code&gt; is the first place you find out.&lt;/p&gt;
&lt;h3&gt;Team collaboration problems&lt;/h3&gt;
&lt;p&gt;When several people work on the same large files, merge conflicts become frequent, and resolving a conflict in HCL that describes live infrastructure is exactly the kind of merge you don&amp;#39;t want to get wrong.&lt;/p&gt;
&lt;h2&gt;The fix: Terraform modules&lt;/h2&gt;
&lt;h3&gt;How a module is structured&lt;/h3&gt;
&lt;p&gt;Imagine modules as ready-to-go blueprints for parts of your infrastructure. You could create a module for a standard network setup or a database configuration. Then you reuse that blueprint across different projects or environments. Store these modules in a shared place, like a Git repository, so your team can call them up with a few lines of code whenever needed.&lt;/p&gt;
&lt;h3&gt;Reusing a module&lt;/h3&gt;
&lt;p&gt;Once a module exists, every project and environment calls the same source. Each caller passes its own variables, so dev and prod share the definition without sharing the sizing.&lt;/p&gt;
&lt;h3&gt;Version control and sharing&lt;/h3&gt;
&lt;p&gt;Pin the module source to a tag rather than a branch. A shared repository means everyone on the team can call the module with a few lines of code, and pinning means a change to &lt;code&gt;main&lt;/code&gt; doesn&amp;#39;t reach production until someone chooses it.&lt;/p&gt;
&lt;h2&gt;Module concepts&lt;/h2&gt;
&lt;h3&gt;Breaking infrastructure into modules&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Split your Terraform code into modules for the pieces you build more than once.&lt;/li&gt;
&lt;li&gt;Put those modules in a shared repository so everyone on the team can reach them.&lt;/li&gt;
&lt;li&gt;Use input variables to customize how a module behaves for different situations.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Input variables&lt;/h3&gt;
&lt;p&gt;Input variables are the only knobs a caller gets. Expose instance sizes, CIDR ranges and counts. Keep naming conventions and tagging inside the module, so callers can&amp;#39;t drift from them by accident.&lt;/p&gt;
&lt;h3&gt;Module dependencies&lt;/h3&gt;
&lt;p&gt;Modules can reference each other&amp;#39;s outputs, and that&amp;#39;s how a network module feeds subnet IDs to a database module. It&amp;#39;s also how you get an ordering problem, so keep the dependency chain shallow enough to explain out loud.&lt;/p&gt;
&lt;h2&gt;What modules give you&lt;/h2&gt;
&lt;h3&gt;Code quality&lt;/h3&gt;
&lt;p&gt;Modules stop you writing the same configuration over and over. Fewer copies means fewer mistakes, and it means a review of one module covers every place that module is used.&lt;/p&gt;
&lt;h3&gt;Faster development&lt;/h3&gt;
&lt;p&gt;Reusable modules mean less time on boilerplate. Standing up a new environment turns into filling in variables rather than writing HCL from scratch.&lt;/p&gt;
&lt;h3&gt;Team productivity&lt;/h3&gt;
&lt;p&gt;Everyone works from the same definitions, so a discussion about the network is a discussion about one module instead of five copies that have quietly diverged.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your approach?&lt;/h2&gt;
&lt;h3&gt;Community insights&lt;/h3&gt;
&lt;p&gt;How do you like to keep your Terraform projects organized? Any tips or tricks you&amp;#39;ve picked up along the way?&lt;/p&gt;
&lt;h3&gt;Sharing what works&lt;/h3&gt;
&lt;p&gt;Whether you&amp;#39;re using the Terraform Registry, Git submodules or a private registry, the trade-offs are different, and hearing how you landed on yours is useful to the rest of us.&lt;/p&gt;
</content:encoded><category>Cloud &amp; Infrastructure Automation</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0002-organizing-terraform-modules/hero.png" length="0" type="image/jpeg"/></item><item><title>Securing CI/CD with IAM Roles</title><link>https://devtips.mkabumattar.com/post/securing-cicd-with-iam-roles/</link><guid isPermaLink="true">https://devtips.mkabumattar.com/post/securing-cicd-with-iam-roles/</guid><description>How to scope IAM roles per environment (dev, staging, production) in a CI/CD pipeline so each stage only gets the permissions it needs, cutting the blast radius if credentials leak or a build step misbehaves.</description><pubDate>Mon, 09 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Why secure your CI/CD pipeline?&lt;/h2&gt;
&lt;h3&gt;Why pipeline security matters&lt;/h3&gt;
&lt;p&gt;Your pipeline holds credentials for every environment you deploy to, which makes it one of the most valuable targets you own. A separate IAM role per environment, each carrying only the permissions that environment needs, is the cheapest way to keep that target small.&lt;/p&gt;
&lt;h3&gt;Common security risks&lt;/h3&gt;
&lt;p&gt;Lots of CI/CD setups hand their tools far more access than they need. One long-lived key with broad permissions, shared by every job, because that was the fastest thing to wire up on a Friday.&lt;/p&gt;
&lt;h2&gt;The security problem&lt;/h2&gt;
&lt;h3&gt;Over-privileged access&lt;/h3&gt;
&lt;p&gt;The usual shape is a single set of credentials with something close to admin. It works, so nobody revisits it. Then a test job with production write access runs a script someone edited in a hurry, and the blast radius is your whole account.&lt;/p&gt;
&lt;h3&gt;What it costs when it goes wrong&lt;/h3&gt;
&lt;p&gt;Compromised or misconfigured credentials let an attacker reach production systems, read sensitive data or push their own code through your deployment path. The outage is bad. The part where you cannot say which resources were touched is worse.&lt;/p&gt;
&lt;h2&gt;The fix: environment-specific IAM roles&lt;/h2&gt;
&lt;h3&gt;Separating environments&lt;/h3&gt;
&lt;p&gt;Set up separate IAM roles for each stage: dev, staging and production. Give each role only the permissions it needs for its job. Your build tool might need to read a code repo, but it has no business touching production data. AWS IAM and GitHub Actions both make this straightforward to wire up.&lt;/p&gt;
&lt;h3&gt;Applying least privilege&lt;/h3&gt;
&lt;p&gt;Each environment gets its own IAM role with the minimum permissions it needs. Dev roles build and test. Staging roles deploy to test infrastructure. Production roles get the smallest set that still lets a deploy finish, and nothing else.&lt;/p&gt;
&lt;h3&gt;Tools and platforms&lt;/h3&gt;
&lt;p&gt;AWS IAM and GitHub Actions make this easy to set up. GitLab CI, Azure DevOps and Jenkins support the same role-based pattern, so the approach travels if you change platforms.&lt;/p&gt;
&lt;h2&gt;Implementation steps&lt;/h2&gt;
&lt;h3&gt;Creating environment-specific roles&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Create IAM roles for each environment in your pipeline.&lt;/li&gt;
&lt;li&gt;Only give the exact permissions needed for each task.&lt;/li&gt;
&lt;li&gt;Check roles regularly to keep access tight.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Mapping permissions&lt;/h3&gt;
&lt;p&gt;Write down what each pipeline stage actually does, then grant exactly that: repo read for builds, artifact bucket write for deployments, infrastructure permissions for provisioning. If you can&amp;#39;t explain why a permission is there, take it away and see what breaks in dev.&lt;/p&gt;
&lt;h3&gt;Audits and updates&lt;/h3&gt;
&lt;p&gt;Permissions accumulate. Someone adds one to unblock a deploy at 6 PM and it stays for two years, so put a recurring review on the calendar and use your provider&amp;#39;s access-analyzer output to find the ones nothing has used.&lt;/p&gt;
&lt;h2&gt;What least privilege gives you&lt;/h2&gt;
&lt;h3&gt;A smaller blast radius&lt;/h3&gt;
&lt;p&gt;Scoped roles limit what a compromised job can reach. A leaked dev credential gets an attacker a dev environment, and that is the whole point.&lt;/p&gt;
&lt;h3&gt;Problems surface in dev&lt;/h3&gt;
&lt;p&gt;With roles separated, a job that tries to touch something it shouldn&amp;#39;t fails in dev with a permission error rather than succeeding in production.&lt;/p&gt;
&lt;h3&gt;Shorter audits&lt;/h3&gt;
&lt;p&gt;Compliance work gets easier because the access pattern is already written down as policy. You point at the role definitions instead of reconstructing who could do what.&lt;/p&gt;
&lt;h2&gt;What&amp;#39;s your approach?&lt;/h2&gt;
&lt;h3&gt;Community discussion&lt;/h3&gt;
&lt;p&gt;How do you keep your pipelines locked down? Got any tips to share?&lt;/p&gt;
&lt;h3&gt;Share your experience&lt;/h3&gt;
&lt;p&gt;Whether you&amp;#39;re on AWS, GitHub Actions or something else, I&amp;#39;m curious where you drew the line between safe and workable, because too strict and people start bypassing the pipeline entirely.&lt;/p&gt;
</content:encoded><category>DevOps &amp; DevSecOps</category><author>Mohammad Abu Mattar</author><enclosure url="https://devtips.mkabumattar.com/assets/devtips/0001-securing-cicd-with-iam-roles/hero.png" length="0" type="image/jpeg"/></item></channel></rss>