<?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: Yaa Kesewaa Yeboah </title>
    <description>The latest articles on DEV Community by Yaa Kesewaa Yeboah  (@yaak).</description>
    <link>https://dev.to/yaak</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%2F3783204%2Fc0b90de3-08db-4f06-90fc-92a90c362f2c.png</url>
      <title>DEV Community: Yaa Kesewaa Yeboah </title>
      <link>https://dev.to/yaak</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yaak"/>
    <language>en</language>
    <item>
      <title>I Made My Docker Container Progressively More Secure</title>
      <dc:creator>Yaa Kesewaa Yeboah </dc:creator>
      <pubDate>Sat, 16 May 2026 11:46:11 +0000</pubDate>
      <link>https://dev.to/yaak/i-made-my-docker-container-progressively-more-secure-2ob4</link>
      <guid>https://dev.to/yaak/i-made-my-docker-container-progressively-more-secure-2ob4</guid>
      <description>&lt;p&gt;I recently completed a structured Docker security lab that walks through five progressively more secure versions of the same minimal Node.js application. Rather than just reading about Docker security best practices, I wanted to observe each vulnerability and its fix directly in the terminal.&lt;/p&gt;

&lt;p&gt;This is what I found.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;The application is deliberately simple — a Node.js HTTP server that reports one thing: the user ID of the process running it. That single output makes security differences immediately visible.&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;uid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getuid&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;username&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root -- DANGER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`non-root (uid: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;uid&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;uid&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, the process is root. When it is anything else, it is a restricted user. Every scenario change shows up in that number.&lt;/p&gt;

&lt;p&gt;I also created a &lt;code&gt;.env&lt;/code&gt; file containing fake credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DATABASE_PASSWORD=super_secret_password_123
API_KEY=sk-live-abc123xyz789
STRIPE_SECRET=rk_live_do_not_share_this
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This simulates what exists in almost every real project. You will see exactly what happens to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 1 — The Insecure Default
&lt;/h2&gt;

&lt;p&gt;The starting Dockerfile is what you might write if you are new to Docker and following a basic tutorial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["npm", "start"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Layer Caching Is Real and Measurable
&lt;/h3&gt;

&lt;p&gt;Before worrying about security, I observed something important about build performance. The first build took noticeably longer — every layer ran from scratch with no cache available.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvbfb1y6xgpmm8h1sqsag.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvbfb1y6xgpmm8h1sqsag.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;shows the first cold build running each layer sequentially with real timing output&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second build, with nothing changed, was almost instant. Every layer showed &lt;code&gt;CACHED&lt;/code&gt;. Docker had stored each layer separately and reused all of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9hlbnaxdclyeufk0n58x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9hlbnaxdclyeufk0n58x.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows all layers returning CACHED and the dramatically shorter build time&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then I added a single comment to &lt;code&gt;app.js&lt;/code&gt; and rebuilt. The &lt;code&gt;COPY . .&lt;/code&gt; layer was invalidated, and so was every layer above it — including &lt;code&gt;RUN npm install&lt;/code&gt;. My dependencies reinstalled even though &lt;code&gt;package.json&lt;/code&gt; had not changed at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bsayxizm1c42q3al3t9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bsayxizm1c42q3al3t9.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows exactly which layer lost the cache hit and every layer above it re-executing&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This matters because the order of instructions in your Dockerfile directly controls how efficient your builds are. The correct pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# CORRECT — npm install only re-runs when package.json changes&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# WRONG — any code change forces a full reinstall&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Running as Root
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:3000
&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;App is running
User ID : 0
Running as : root -- DANGER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9uq7p9m57kznszd9lk2y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9uq7p9m57kznszd9lk2y.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows the terminal with uid 0 printed, confirming the process is running as root&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fija9o6x2fknjwhebuftq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fija9o6x2fknjwhebuftq.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows the curl output with "root -- DANGER" in the response body&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;uid 0&lt;/code&gt; is root. The application process and everything it can touch inside the container has unrestricted access.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Secret Leak
&lt;/h3&gt;

&lt;p&gt;This was the most striking observation. I opened a shell inside the running container:&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; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; sh
&lt;span class="nb"&gt;cat&lt;/span&gt; /app/.env
&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;DATABASE_PASSWORD=super_secret_password_123
API_KEY=sk-live-abc123xyz789
STRIPE_SECRET=rk_live_do_not_share_this
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyrt0abizikftxpqo3mq5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyrt0abizikftxpqo3mq5.png" alt=" " width="800" height="239"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows all three credentials printed inside the running container with no authentication required&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;COPY . .&lt;/code&gt; had silently copied the &lt;code&gt;.env&lt;/code&gt; file into the image. Anyone who can pull that image from a registry can read those credentials. The container does not even need to be running — &lt;code&gt;docker run --rm insecure-app cat /app/.env&lt;/code&gt; returns the same output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three problems, one basic Dockerfile:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Application runs as root&lt;/li&gt;
&lt;li&gt;Credentials are baked into the image&lt;/li&gt;
&lt;li&gt;Layer caching breaks on every code change&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Scenario 2 — Running as a Non-Root User
&lt;/h2&gt;

&lt;p&gt;One addition to the Dockerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;groupadd &lt;span class="nt"&gt;-r&lt;/span&gt; appuser &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; appuser appuser
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; appuser:appuser /app
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; appuser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;USER&lt;/code&gt; instruction switches the active user. Every subsequent instruction in the Dockerfile — and the process that starts when the container runs — uses this identity instead of root.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App is running
User ID : 999
Running as : non-root (uid: 999)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flcsfl95za3n4t9n4b9y1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flcsfl95za3n4t9n4b9y1.png" alt=" " width="800" height="145"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows curl output with uid 999, confirming the app is no longer running as root&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To make this concrete, I opened a shell inside the running container and tried several things as &lt;code&gt;appuser&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/passwd     &lt;span class="c"&gt;# Permission denied&lt;/span&gt;
apt-get &lt;span class="nb"&gt;install &lt;/span&gt;curl          &lt;span class="c"&gt;# Permission denied (lock file)&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; /bin/backdoor           &lt;span class="c"&gt;# Permission denied&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0qn8wpdym274qp1pkm8z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0qn8wpdym274qp1pkm8z.png" alt=" " width="800" height="192"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows all three write attempts denied with "Permission denied" errors&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An attacker who exploits the application now inherits &lt;code&gt;appuser&lt;/code&gt;'s restrictions — not root's unlimited access. This is called blast radius reduction. You cannot always prevent exploitation; you can ensure the damage is contained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this does not fix:&lt;/strong&gt; &lt;code&gt;appuser&lt;/code&gt; can still read files it has permission to access. The &lt;code&gt;.env&lt;/code&gt; file is still inside the image. Running &lt;code&gt;cat /app/.env&lt;/code&gt; inside the non-root container still works because &lt;code&gt;appuser&lt;/code&gt; owns those files. The secrets problem needs a separate fix.&lt;/p&gt;


&lt;h2&gt;
  
  
  Scenario 3 — Protecting Secrets with .dockerignore
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.dockerignore&lt;/code&gt; is a plain text file placed alongside your Dockerfile. It lists patterns that Docker excludes from the build context before any &lt;code&gt;COPY&lt;/code&gt; instruction runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.env
.env.*
*.pem
*.key
*.cert
.git
node_modules
Dockerfile*
README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rebuilding with this file in place:&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;--rm&lt;/span&gt; secure-copy-app find /app &lt;span class="nt"&gt;-type&lt;/span&gt; f
&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;/app/app.js
/app/package.json
/app/package-lock.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy68enx5wkxsf3j9cfvgy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy68enx5wkxsf3j9cfvgy.png" alt=" " width="800" height="148"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows find output listing only app.js, package.json, and package-lock.json with no .env present&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; file is gone. I confirmed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; secure-copy-app &lt;span class="nb"&gt;cat&lt;/span&gt; /app/.env
&lt;span class="c"&gt;# cat: /app/.env: No such file or directory&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5uu612cupveem8ck0mzt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5uu612cupveem8ck0mzt.png" alt=" " width="800" height="122"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows the "No such file or directory" error when attempting to read .env&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I then created a &lt;code&gt;test.pem&lt;/code&gt; file in my project directory and rebuilt. The &lt;code&gt;.pem&lt;/code&gt; pattern matched it automatically — no additional configuration needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F02xddamnachgqu6lj95v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F02xddamnachgqu6lj95v.png" alt=" " width="800" height="272"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows test.pem absent from the find output after rebuild, proving the *.pem pattern worked&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The critical thing to understand: &lt;code&gt;.dockerignore&lt;/code&gt; runs before any &lt;code&gt;COPY&lt;/code&gt; instruction executes. Files excluded this way are never sent to the Docker daemon at all. They cannot appear in any layer, not even temporarily. This is different from deleting a file inside the container — deletion still creates a layer, and forensic tools can recover data from earlier layers in the image history.&lt;/p&gt;


&lt;h2&gt;
  
  
  Scenario 4 — Multi-Stage Builds
&lt;/h2&gt;

&lt;p&gt;After three scenarios, the image still contained compilers, &lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt;, and hundreds of other programs the application does not need to serve HTTP traffic. A multi-stage build fixes this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: builder — uses the full Node.js image&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /build&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; app.js .&lt;/span&gt;

&lt;span class="c"&gt;# Stage 2: runtime — uses a minimal image&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:20-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;groupadd &lt;span class="nt"&gt;-r&lt;/span&gt; appuser &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; appuser appuser
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/app.js ./app.js&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /build/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; appuser:appuser /app
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; appuser&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "app.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;COPY --from=builder&lt;/code&gt; transfers only what I explicitly listed. Everything else — &lt;code&gt;npm&lt;/code&gt;, the package cache, compilers — is gone permanently.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Size Difference
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;insecure-app&lt;/td&gt;
&lt;td&gt;~1.10 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nonroot-app&lt;/td&gt;
&lt;td&gt;~1.10 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;multistage-app&lt;/td&gt;
&lt;td&gt;~240 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmoxsc1z5oyzyd5baq1oq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmoxsc1z5oyzyd5baq1oq.png" alt=" " width="800" height="148"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows docker images output with all three images listed side by side, making the 870 MB difference visible&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Adding a non-root user does not change image size — it changes who runs the application. The multi-stage build removed roughly 870 MB, an 78% reduction.&lt;/p&gt;

&lt;p&gt;Inside the multi-stage container, none of these existed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nt"&gt;--version&lt;/span&gt;    &lt;span class="c"&gt;# sh: npm: not found&lt;/span&gt;
curl &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# sh: curl: not found&lt;/span&gt;
git &lt;span class="nt"&gt;--version&lt;/span&gt;    &lt;span class="c"&gt;# sh: git: not found&lt;/span&gt;
apt-get          &lt;span class="c"&gt;# sh: apt-get: not found&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbqdx2tq3k9mav7ar3o6g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbqdx2tq3k9mav7ar3o6g.png" alt=" " width="800" height="261"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows each tool command returning "not found" inside the multi-stage container&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An attacker who achieves code execution inside this container cannot use these tools to download malware, compile attack utilities, or interact with external systems. The tools are not there.&lt;/p&gt;

&lt;p&gt;The 870 MB difference is not just storage. It is attack surface that no longer exists.&lt;/p&gt;


&lt;h2&gt;
  
  
  Scenario 5 — Runtime Hardening
&lt;/h2&gt;

&lt;p&gt;The Dockerfile controls what is inside the image. Runtime flags control what a running container is permitted to do at the operating system level.&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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--read-only&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tmpfs&lt;/span&gt; /tmp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"128m"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0.5"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cap-drop&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;--security-opt&lt;/span&gt; no-new-privileges:true &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  multistage-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Read-Only Filesystem
&lt;/h3&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; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"echo test &amp;gt; /app/hacked.txt"&lt;/span&gt;
&lt;span class="c"&gt;# sh: /app/hacked.txt: Read-only file system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftp52gwabbvqh12suf08i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftp52gwabbvqh12suf08i.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows the kernel-level "Read-only file system" error rejecting the write attempt&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The kernel rejected the write. An attacker cannot persist any file, install any tool, or modify any binary. &lt;code&gt;--tmpfs /tmp&lt;/code&gt; provides a small in-memory scratch area if the application needs to write temporary files at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource Limits
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'"Memory"|"NanoCpus"'&lt;/span&gt;
&lt;span class="c"&gt;# "Memory": 134217728,    (exactly 128 MB)&lt;/span&gt;
&lt;span class="c"&gt;# "NanoCpus": 500000000,  (exactly 0.5 CPU cores)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbaf6i41grj6ko30vzx7f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbaf6i41grj6ko30vzx7f.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows the Memory and NanoCpus values returned by docker inspect confirming the limits are registered at the kernel level&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These limits are enforced by the Linux kernel's cgroup subsystem — not by application code. A compromised container cannot exhaust host memory or CPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linux Capabilities
&lt;/h3&gt;

&lt;p&gt;Linux divides root privilege into roughly 40 distinct units called capabilities. By default, a container receives about 15 of them. &lt;code&gt;--cap-drop=ALL&lt;/code&gt; removes every one. The application still served HTTP traffic correctly after dropping all capabilities — it needed none of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuji1rv2kv31j3h5z5hdh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuji1rv2kv31j3h5z5hdh.png" alt=" " width="800" height="422"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;&lt;em&gt;shows a capability-dependent command failing inside the container while curl &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt; still succeeds, proving the app works without any capabilities&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--security-opt no-new-privileges:true&lt;/code&gt; prevents any process inside the container from gaining more privileges than it started with, even if a setuid binary is present on the filesystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Picture
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Root?&lt;/th&gt;
&lt;th&gt;Secrets in Image?&lt;/th&gt;
&lt;th&gt;Build Tools?&lt;/th&gt;
&lt;th&gt;Runtime Constraints&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 — Default&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — Non-Root&lt;/td&gt;
&lt;td&gt;&lt;code&gt;USER appuser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — .dockerignore&lt;/td&gt;
&lt;td&gt;Exclude secrets&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — Multi-Stage&lt;/td&gt;
&lt;td&gt;Separate stages&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 — Runtime&lt;/td&gt;
&lt;td&gt;Kernel flags&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;Read-only, capped, no capabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What Surprised Me Most
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;COPY . .&lt;/code&gt; is silent about what it includes.&lt;/strong&gt; There is no warning when it bundles your &lt;code&gt;.env&lt;/code&gt; file. No error. The build succeeds. The credentials are just there, readable by anyone with access to the image. A single &lt;code&gt;.dockerignore&lt;/code&gt; file — two minutes to write — prevents this entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image size and security are directly related.&lt;/strong&gt; I expected the multi-stage build to be a performance optimisation. I did not expect it to be a meaningful security improvement. But removing 870 MB of programs from an image is the same as removing 870 MB of potential attack tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Dockerfile and the runtime flags protect different things.&lt;/strong&gt; An attacker who bypasses the application layer still faces constraints from the operating system if the runtime flags are in place. Neither layer replaces the other.&lt;/p&gt;




&lt;p&gt;If you are working through Docker security for the first time, run the insecure scenario first and look at the output of &lt;code&gt;cat /app/.env&lt;/code&gt; inside the running container before adding &lt;code&gt;.dockerignore&lt;/code&gt;. Seeing the credentials appear in the terminal makes the fix feel much more real than reading about it.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Learned Docker by Running a 3-Container Quiz App</title>
      <dc:creator>Yaa Kesewaa Yeboah </dc:creator>
      <pubDate>Sat, 16 May 2026 09:41:12 +0000</pubDate>
      <link>https://dev.to/yaak/i-learned-docker-by-running-a-3-container-quiz-app-255e</link>
      <guid>https://dev.to/yaak/i-learned-docker-by-running-a-3-container-quiz-app-255e</guid>
      <description>&lt;p&gt;I'll be honest: I had read about Docker containers probably five times before anything clicked. The diagrams made sense in isolation, but I couldn't picture how a real application actually &lt;em&gt;used&lt;/em&gt; any of it. What does a network between containers look like in practice? What does a volume actually do?&lt;/p&gt;

&lt;p&gt;For one of our DevSecOps assignments, we were pointed to Samuel Nartey's DockerQuiz project — a Kahoot-style Docker quiz that is itself a fully containerized 3-container application. The premise was simple and smart: learn Docker by running Docker, not by reading about it.&lt;/p&gt;

&lt;p&gt;This is my honest write-up of building it, breaking it, and what I actually learned along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Project Is
&lt;/h2&gt;

&lt;p&gt;DockerQuiz is a Flask web app that quizzes you on Docker concepts. But the real lesson isn't the quiz — it's the infrastructure underneath it.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;docker compose up&lt;/code&gt;, three containers spin up and connect over a private network:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;quiz-app&lt;/strong&gt; — the Flask application serving the quiz at &lt;code&gt;localhost:5000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mongo&lt;/strong&gt; — a MongoDB instance storing your profiles, scores, and session state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mongo-express&lt;/strong&gt; — a web UI for browsing MongoDB live at &lt;code&gt;localhost:8081&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────────┐
│                     quiz-network  (bridge)                      │
│                                                                 │
│   ┌──────────────┐      ┌───────────────┐    ┌──────────────┐  │
│   │   quiz-app   │─────▶│     mongo     │◀───│mongo-express │  │
│   │ Flask :5000  │      │ MongoDB :27017│    │ Web UI :8081 │  │
│   └──────────────┘      └───────────────┘    └──────────────┘  │
└─────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate containers. One shared network. One command to start everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting It Running
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Prerequisites: Just Docker Desktop
&lt;/h3&gt;

&lt;p&gt;No Python, no MongoDB, no Node.js installation needed. Everything runs inside containers. That alone felt like a small revelation — my machine didn't need to know anything about Flask or MongoDB. Docker handled all of it.&lt;/p&gt;

&lt;p&gt;After cloning the repo and navigating into the project folder, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run took a few minutes — Docker was pulling the official &lt;code&gt;mongo:7.0&lt;/code&gt; and &lt;code&gt;mongo-express:1.0.2&lt;/code&gt; images from Docker Hub. After that, every subsequent run took a matter of seconds.&lt;/p&gt;

&lt;p&gt;Once the terminal showed &lt;code&gt;quiz-app | * Running on http://0.0.0.0:5000&lt;/code&gt;, I opened two tabs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http://localhost:5000&lt;/code&gt; — the quiz&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;http://localhost:8081&lt;/code&gt; — Mongo Express&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzn8y7r4lsuw5b9nh9j5e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzn8y7r4lsuw5b9nh9j5e.png" alt="Three containers starting up via docker compose up --build" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
Three containers starting up via docker compose up --build&lt;/p&gt;


&lt;h2&gt;
  
  
  Playing the Quiz and Watching the Data
&lt;/h2&gt;

&lt;p&gt;I created a profile — name, avatar, role — and started answering questions. What made this different from just reading a tutorial was that I could open Mongo Express on the side and watch my own data appear in real time.&lt;/p&gt;

&lt;p&gt;Every answer I submitted updated a document in the &lt;code&gt;quiz_states&lt;/code&gt; collection. When I finished, a full result document landed in &lt;code&gt;results&lt;/code&gt;. It wasn't abstract anymore — I could &lt;em&gt;see&lt;/em&gt; the Flask container writing to the MongoDB container, and browse it through a third container, all on the same local machine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6eue81x0ehxd7gu9ckjf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6eue81x0ehxd7gu9ckjf.png" alt="The quiz interface at localhost:5000" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The quiz interface at localhost:5000&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57oo3ze17fal11nzawpg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57oo3ze17fal11nzawpg.png" alt="Mongo Express showing the live database at localhost:8081" width="800" height="420"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Mongo Express showing the live database at localhost:8081&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F08e78k14dj5n7s2761i9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F08e78k14dj5n7s2761i9.png" alt="My profile document appearing in the profiles collection" width="800" height="300"&gt;&lt;/a&gt;&lt;br&gt;
_My profile document appearing in the profiles collection&lt;br&gt;
_&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8o2jfs66je1q6n77abhe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8o2jfs66je1q6n77abhe.png" alt="My completed quiz result in the results collection" width="800" height="312"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;My completed quiz result in the results collection&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The moment that landed hardest was seeing this line in &lt;code&gt;app.py&lt;/code&gt;:&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;MONGO_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mongodb://mongo:27017/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mongo&lt;/code&gt; isn't &lt;code&gt;localhost&lt;/code&gt;. It's the &lt;strong&gt;service name&lt;/strong&gt; from &lt;code&gt;docker-compose.yml&lt;/code&gt; — and Docker automatically resolves it to the right container's IP address. That's Docker's built-in DNS. Containers discover each other by name, not by hardcoded addresses. It's the pattern used in virtually every real production deployment, and seeing it in a small project made it finally make sense.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Experiment That Showed Me What &lt;code&gt;depends_on&lt;/code&gt; Actually Does
&lt;/h2&gt;

&lt;p&gt;The README has a section called &lt;em&gt;Experiment and Break Things&lt;/em&gt;. I tried two of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Experiment 1 — Changing the Port Mapping
&lt;/h3&gt;

&lt;p&gt;The first one was clean and immediate. I changed this line in &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="c1"&gt;# Before&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;5000:5000"&lt;/span&gt;

&lt;span class="c1"&gt;# After&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;8000:5000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The format is &lt;code&gt;HOST_PORT:CONTAINER_PORT&lt;/code&gt;. The container still listens on port 5000 internally — nothing inside Docker changes. But now my machine maps its port 8000 to that container port. So &lt;code&gt;localhost:5000&lt;/code&gt; stopped working and &lt;code&gt;localhost:8000&lt;/code&gt; served the app instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fecrklyh6tax76mwxhl0x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fecrklyh6tax76mwxhl0x.png" alt="The quiz app now accessible at port 8000" width="800" height="253"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The quiz app now accessible at port 8000&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcz6f5ur05g7dle19ea9h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcz6f5ur05g7dle19ea9h.png" alt="Confirming the app loads correctly at localhost:8000" width="800" height="309"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Confirming the app loads correctly at localhost:8000&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's a small change, but it made port mapping tangible. The container's internal world is completely separate from what I expose on my machine.&lt;/p&gt;
&lt;h3&gt;
  
  
  Experiment 2 — Commenting Out &lt;code&gt;depends_on&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This one surprised me — and the surprise turned out to be the most educational part.&lt;/p&gt;

&lt;p&gt;I commented out the &lt;code&gt;depends_on&lt;/code&gt; block in &lt;code&gt;docker-compose.yml&lt;/code&gt; so that &lt;code&gt;quiz-app&lt;/code&gt; would no longer wait for &lt;code&gt;mongo&lt;/code&gt; to be ready before starting:&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;quiz-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="c1"&gt;# depends_on:&lt;/span&gt;
    &lt;span class="c1"&gt;#   - mongo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expected chaos. The quiz app starting before the database — surely that would crash everything?&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fti9dszzslgxg1e4xcoaw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fti9dszzslgxg1e4xcoaw.png" alt="Terminal output after commenting out depends_on " width="800" height="628"&gt;&lt;/a&gt;&lt;br&gt;
_Terminal output after commenting out depends_on _&lt;/p&gt;

&lt;p&gt;It... worked. The app came up fine.&lt;/p&gt;

&lt;p&gt;Here's what I learned: &lt;code&gt;depends_on&lt;/code&gt; only controls &lt;strong&gt;start order&lt;/strong&gt;, not readiness. It tells Docker to &lt;em&gt;start&lt;/em&gt; the &lt;code&gt;mongo&lt;/code&gt; container before &lt;code&gt;quiz-app&lt;/code&gt;, but it does not wait for MongoDB to actually be accepting connections. In practice, MongoDB often starts fast enough that it doesn't matter.&lt;/p&gt;

&lt;p&gt;But the deeper reason everything stayed stable is in the app itself. &lt;code&gt;app.py&lt;/code&gt; has a retry loop — it attempts to connect to MongoDB up to 5 times with 2-second gaps between attempts. So even without &lt;code&gt;depends_on&lt;/code&gt;, the app just quietly retried until the database was ready. The resilience was already built in.&lt;/p&gt;

&lt;p&gt;The lesson wasn't "comments &lt;code&gt;depends_on&lt;/code&gt; is safe to remove." It was: &lt;strong&gt;retry logic in your application is what actually handles race conditions, not start order alone&lt;/strong&gt;. &lt;code&gt;depends_on&lt;/code&gt; is a hint, not a guarantee. Real-world applications can't assume the services they depend on will be available the moment they start — and this project demonstrates exactly how to handle that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stopping and Cleaning Up
&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;# Stop containers, keep your data&lt;/span&gt;
docker compose down

&lt;span class="c"&gt;# Stop containers AND wipe the database&lt;/span&gt;
docker compose down &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-v&lt;/code&gt; flag removes the named volume (&lt;code&gt;mongo-data&lt;/code&gt;), which is where MongoDB persists your data between restarts. Without it, your quiz results survive a &lt;code&gt;docker compose down&lt;/code&gt; and come back when you start again. With it, you're starting completely fresh.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyh9huth6fnc4mvsy5d72.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyh9huth6fnc4mvsy5d72.png" alt="Running docker compose down to stop all containers" width="800" height="120"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Running docker compose down to stop all containers&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Walked Away Understanding
&lt;/h2&gt;

&lt;p&gt;Before this project, I could define Docker concepts. After it, I understood them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container networking&lt;/strong&gt; — Containers on the same network talk to each other by service name. &lt;code&gt;mongo&lt;/code&gt; resolves to the MongoDB container because Docker's internal DNS makes it so. No IP addresses, no host machine involvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port mapping&lt;/strong&gt; — The container's internal port and the host port are completely independent. &lt;code&gt;"8000:5000"&lt;/code&gt; means "my machine's 8000 talks to the container's 5000." Changing the host side changes nothing inside the container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Named volumes&lt;/strong&gt; — &lt;code&gt;mongo-data:/data/db&lt;/code&gt; means data written inside the container at &lt;code&gt;/data/db&lt;/code&gt; is actually stored in a Docker-managed volume on the host. That's why your quiz results survive a restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt; vs. actual readiness&lt;/strong&gt; — Start order is not the same as service readiness. Your application needs to handle the case where its dependencies aren't immediately available. Retry logic matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why multiple containers&lt;/strong&gt; — The separation between &lt;code&gt;quiz-app&lt;/code&gt;, &lt;code&gt;mongo&lt;/code&gt;, and &lt;code&gt;mongo-express&lt;/code&gt; isn't over-engineering. It means you can update the Flask app without touching the database. You can scale the app without scaling the database. You can swap MongoDB for something else without rewriting application code. This is the architecture pattern that shows up in real production systems.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;The full project is here: &lt;strong&gt;&lt;a href="https://github.com/samuel-nartey/devops-labs" rel="noopener noreferrer"&gt;github.com/samuel-nartey/devops-labs&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Navigate to &lt;code&gt;Docker &amp;amp; Containers/Running Your First Container/running docker compose&lt;/code&gt;, run &lt;code&gt;docker compose up --build&lt;/code&gt;, and play through it yourself. Then open &lt;code&gt;docker-compose.yml&lt;/code&gt; and start experimenting. The experiments in the README are genuinely worth doing — even when (especially when) they don't produce the failure you expected.&lt;/p&gt;

&lt;p&gt;Docker clicked for me when I stopped reading about it and started running something real. This project is exactly that.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>dockercompose</category>
    </item>
    <item>
      <title>How I Built, Scanned, and Automated a Docker Pipeline</title>
      <dc:creator>Yaa Kesewaa Yeboah </dc:creator>
      <pubDate>Sat, 09 May 2026 15:12:52 +0000</pubDate>
      <link>https://dev.to/yaak/how-i-built-scanned-and-automated-a-docker-pipeline-4dgc</link>
      <guid>https://dev.to/yaak/how-i-built-scanned-and-automated-a-docker-pipeline-4dgc</guid>
      <description>&lt;p&gt;I recently completed a hands-on Docker and DevSecOps lab as part of my learning track, and I wanted to write up the full walkthrough — including the parts where things broke, because that's honestly where the real learning happened.&lt;/p&gt;

&lt;p&gt;By the end of this lab, I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pulled and inspected a public container image from Docker Hub&lt;/li&gt;
&lt;li&gt;Built a Python Flask web app and packaged it into a secure Docker image&lt;/li&gt;
&lt;li&gt;Pushed it to Docker Hub and shared it&lt;/li&gt;
&lt;li&gt;Automated the entire workflow with a GitHub Actions pipeline that includes &lt;strong&gt;Trivy vulnerability scanning as a hard security gate&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article walks you through each phase exactly as I did it, including the three issues I ran into and how I resolved them. If you're replicating this yourself, this should save you some time.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You'll Need
&lt;/h2&gt;

&lt;p&gt;Before you start, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker Desktop&lt;/strong&gt; installed and running&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;GitHub account&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Docker Hub account&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A code editor (VS Code recommended)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You'll also need a &lt;strong&gt;Docker Hub Personal Access Token&lt;/strong&gt; — not your password. Generate one at:&lt;br&gt;
&lt;code&gt;hub.docker.com → Avatar → Account Settings → Security → New Access Token&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Give it Read &amp;amp; Write permissions and copy it straight away. You only see it once.&lt;/p&gt;


&lt;h2&gt;
  
  
  Phase 0 — Pull and Inspect a Public Container
&lt;/h2&gt;

&lt;p&gt;The first phase is simple but important. Before building anything yourself, you act as a consumer — you pull a public image, run it, and observe what happens.&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;# Pull the lightweight Nginx image&lt;/span&gt;
docker pull nginx:alpine

&lt;span class="c"&gt;# Run it in detached mode, mapping host port 8080 to container port 80&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="nt"&gt;--name&lt;/span&gt; my-nginx nginx:alpine

&lt;span class="c"&gt;# Check it's running&lt;/span&gt;
docker ps

&lt;span class="c"&gt;# View the logs&lt;/span&gt;
docker logs my-nginx

&lt;span class="c"&gt;# Clean up&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; my-nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt; in your browser and you'll see the Nginx welcome page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What just happened?&lt;/strong&gt; You downloaded a pre-built filesystem (the image) from Docker Hub, and Docker spun up an isolated process (the container) from it. The &lt;code&gt;-p 8080:80&lt;/code&gt; flag mapped port 80 inside the container to port 8080 on your machine, making it reachable from your browser.&lt;/p&gt;

&lt;p&gt;This pull-and-run flow is the foundation of everything else in the lab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9917a6miihv8f9sc7z2l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9917a6miihv8f9sc7z2l.png" alt="Phase 0 — Pulling the nginx:alpine image,Running the container and confirming with docker ps, " width="800" height="253"&gt;&lt;/a&gt; &lt;em&gt;Pulling the nginx:alpine image from Docker Hub in the terminal&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1 — Build, Push, Pull &amp;amp; Share Your Own Image
&lt;/h2&gt;

&lt;p&gt;Now you become the image creator. This phase has you build a Python Flask web application, write a Dockerfile, push the image to Docker Hub, and share it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Project Structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-project/
├── app.py
├── templates/
│   └── index.html
├── requirements.txt
├── Dockerfile
└── .dockerignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdt2obv59v1yd5cstg6nh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdt2obv59v1yd5cstg6nh.png" alt="Project structure" width="800" height="184"&gt;&lt;/a&gt; &lt;em&gt;Project folder structure in VS Code showing all the required files&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Application
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;app.py&lt;/code&gt;&lt;/strong&gt; — A minimal Flask app with a home page and a &lt;code&gt;/health&lt;/code&gt; endpoint.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render_template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;APP_NODE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APP_NODE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;developer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;home&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;render_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;APP_NODE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health&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;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healthy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;APP_NODE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&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;APP_NODE&lt;/code&gt; variable is read from the environment. This externalises configuration from code — you can inject any identifier at runtime without rebuilding the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;templates/index.html&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&amp;lt;title&amp;gt;&lt;/span&gt;Welcome&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font-family: sans-serif; text-align: center; padding-top: 5rem;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Hello from &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"color: #2b6cb0;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ node }}&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;This container is alive and running!&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;requirements.txt&lt;/code&gt;&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;flask==3.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinning the version ensures reproducible builds.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Dockerfile
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.11-slim&lt;/span&gt;

&lt;span class="k"&gt;LABEL&lt;/span&gt;&lt;span class="s"&gt; maintainer="your-name@example.com"&lt;/span&gt;
&lt;span class="k"&gt;LABEL&lt;/span&gt;&lt;span class="s"&gt; version="1.0.0"&lt;/span&gt;
&lt;span class="k"&gt;LABEL&lt;/span&gt;&lt;span class="s"&gt; description="Simple Flask web app for Docker/GitHub Actions lab"&lt;/span&gt;

&lt;span class="c"&gt;# Create a non-root user — running as root in a container is a security risk&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;groupadd &lt;span class="nt"&gt;-r&lt;/span&gt; appuser &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; appuser appuser

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Copy requirements first to leverage Docker layer caching&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; appuser:appuser /app
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; appuser&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 5000&lt;/span&gt;

&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=3s --start-period=5s --retries=3 \&lt;/span&gt;
    CMD curl -f http://localhost:5000/health || exit 1

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "app.py"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-root user:&lt;/strong&gt; If the application is ever compromised, an attacker running as a non-root user has far less access than one running as root. It's a foundational security hardening step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer caching order:&lt;/strong&gt; Copying &lt;code&gt;requirements.txt&lt;/code&gt; before the rest of the application code means Docker's build cache is preserved for the &lt;code&gt;pip install&lt;/code&gt; layer as long as dependencies haven't changed. If you only modified &lt;code&gt;app.py&lt;/code&gt;, Docker doesn't reinstall packages — it reuses the cached layer. This significantly speeds up builds, especially in CI pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.dockerignore&lt;/code&gt;&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;__pycache__
*.pyc
*.pyo
.git
.env
*.md
Dockerfile
.dockerignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;.dockerignore&lt;/code&gt;, your entire &lt;code&gt;.git&lt;/code&gt; directory and any local &lt;code&gt;.env&lt;/code&gt; files would be copied into the image — inflating its size and potentially exposing secrets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build and Test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; my-web-app &lt;span class="nb"&gt;.&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 5000:5000 &lt;span class="nt"&gt;--name&lt;/span&gt; webapp-test my-web-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;http://localhost:5000&lt;/code&gt; for the home page and &lt;code&gt;http://localhost:5000/health&lt;/code&gt; for the health check.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq226nkhhlglb8ghxk3bt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq226nkhhlglb8ghxk3bt.png" alt="Building the image" width="800" height="420"&gt;&lt;/a&gt; &lt;em&gt;docker build output showing all layers building successfully&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftu30i45v4wwfsilt7yx4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftu30i45v4wwfsilt7yx4.png" alt="Testing the app at localhost:5000" width="800" height="397"&gt;&lt;/a&gt; &lt;em&gt;Flask app home page loading at localhost:5000&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb1jqs8xvln03nmuwurv9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb1jqs8xvln03nmuwurv9.png" alt="Health check returning JSON" width="800" height="181"&gt;&lt;/a&gt; &lt;em&gt;The /health endpoint returning a JSON response in the browser&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Push to Docker Hub
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker login &lt;span class="nt"&gt;-u&lt;/span&gt; your-dockerhub-username
docker tag my-web-app your-dockerhub-username/my-web-app:v1.0.0-yourname
docker push your-dockerhub-username/my-web-app:v1.0.0-yourname
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag format &lt;code&gt;&amp;lt;version&amp;gt;-&amp;lt;yourname&amp;gt;&lt;/code&gt; makes your image personally traceable — useful when you're sharing a registry with other learners.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjbluvxr8hxz70rc1qafw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjbluvxr8hxz70rc1qafw.png" alt="Pushing to Docker Hub" width="800" height="205"&gt;&lt;/a&gt; &lt;em&gt;Pushing the tagged image to Docker Hub in the terminal&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq1mc5y42jkc931vq6p7m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq1mc5y42jkc931vq6p7m.png" alt="Docker Hub repository set to public" width="800" height="393"&gt;&lt;/a&gt; &lt;em&gt;Docker Hub showing the repository set to public&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pull Your Own Image
&lt;/h3&gt;

&lt;p&gt;To simulate being a new user, delete your local copy and pull it from Docker Hub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker rmi your-dockerhub-username/my-web-app:v1.0.0-yourname
docker pull your-dockerhub-username/my-web-app:v1.0.0-yourname
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 5000:5000 &lt;span class="nt"&gt;--name&lt;/span&gt; pulled-app your-dockerhub-username/my-web-app:v1.0.0-yourname
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feep7zhuw3wuf8335m4sa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feep7zhuw3wuf8335m4sa.png" alt="Pulling the image from Docker Hub" width="800" height="86"&gt;&lt;/a&gt;* Pulling the image back from Docker Hub after deleting the local copy*&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmzl1nqxocrht2zxn73d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftmzl1nqxocrht2zxn73d.png" alt="Running the container from the pulled image" width="800" height="211"&gt;&lt;/a&gt; &lt;em&gt;Running a container from the freshly pulled image&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;![Testing the pulled image]](&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4wwzlfwsj8ejpvb3i632.png" rel="noopener noreferrer"&gt;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4wwzlfwsj8ejpvb3i632.png&lt;/a&gt;) &lt;em&gt;Home page working correctly from the pulled image&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh5nafkrjg19stfuef33p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh5nafkrjg19stfuef33p.png" alt="Health check on the pulled image" width="800" height="139"&gt;&lt;/a&gt; &lt;em&gt;Health endpoint responding correctly from the pulled image&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The image should behave identically to your local build — which is the point. Portability is a core Docker guarantee.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2 — Automate with a GitHub Actions DevSecOps Pipeline
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. Phase 2 takes everything manual from Phase 1 and automates it in a CI/CD pipeline that runs on every push to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Builds the Docker image&lt;/li&gt;
&lt;li&gt;Scans it for vulnerabilities with &lt;strong&gt;Trivy&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Only pushes to Docker Hub if the scan passes&lt;/li&gt;
&lt;li&gt;Runs a smoke test to verify the pushed image works&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Set Up GitHub Secrets
&lt;/h3&gt;

&lt;p&gt;Go to your repo → &lt;strong&gt;Settings → Secrets and variables → Actions → New repository secret&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add two secrets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DOCKERHUB_USERNAME&lt;/code&gt; — your Docker Hub username&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DOCKERHUB_TOKEN&lt;/code&gt; — your access token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;These must be two separate secrets.&lt;/strong&gt; More on why this matters in the issues section below.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Workflow File
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;.github/workflows/docker-build-push.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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build, Scan, Push and Verify Docker Image&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&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;DOCKER_HUB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USERNAME }}&lt;/span&gt;
  &lt;span class="na"&gt;IMAGE_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-web-app&lt;/span&gt;
  &lt;span class="na"&gt;TAG_SUFFIX&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yourname&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-scan-push-verify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&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;Checkout repository&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Log in to Docker Hub&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_TOKEN }}&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;Build Docker image (load locally, do not push yet)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;load&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;push&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;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;${{ env.DOCKER_HUB_USER }}/${{ env.IMAGE_NAME }}:v1.0.0-${{ env.TAG_SUFFIX }}&lt;/span&gt;
            &lt;span class="s"&gt;${{ env.DOCKER_HUB_USER }}/${{ env.IMAGE_NAME }}:latest&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;Run Trivy vulnerability scanner&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@v0.20.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;image-ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;env.DOCKER_HUB_USER&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;env.IMAGE_NAME&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}:v1.0.0-${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;env.TAG_SUFFIX&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
          &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;table'&lt;/span&gt;
          &lt;span class="na"&gt;exit-code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
          &lt;span class="na"&gt;ignore-unfixed&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;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HIGH,CRITICAL'&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;Push Docker image to Hub&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;docker push ${{ env.DOCKER_HUB_USER }}/${{ env.IMAGE_NAME }}:v1.0.0-${{ env.TAG_SUFFIX }}&lt;/span&gt;
          &lt;span class="s"&gt;docker push ${{ env.DOCKER_HUB_USER }}/${{ env.IMAGE_NAME }}:latest&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;Pull image and run smoke tests&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;docker pull ${{ env.DOCKER_HUB_USER }}/${{ env.IMAGE_NAME }}:v1.0.0-${{ env.TAG_SUFFIX }}&lt;/span&gt;
          &lt;span class="s"&gt;docker run -d -p 5000:5000 --name verify-container \&lt;/span&gt;
            &lt;span class="s"&gt;${{ env.DOCKER_HUB_USER }}/${{ env.IMAGE_NAME }}:v1.0.0-${{ env.TAG_SUFFIX }}&lt;/span&gt;
          &lt;span class="s"&gt;sleep 5&lt;/span&gt;
          &lt;span class="s"&gt;curl -f http://localhost:5000/health || (echo "Health check failed!" &amp;amp;&amp;amp; exit 1)&lt;/span&gt;
          &lt;span class="s"&gt;curl -s http://localhost:5000/ | grep -i "hello" || (echo "Home page missing greeting!" &amp;amp;&amp;amp; exit 1)&lt;/span&gt;
          &lt;span class="s"&gt;echo "All smoke tests passed."&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;Remove test container&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker rm -f verify-container || &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two conditional flags worth understanding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;if: success()&lt;/code&gt; on the push step — this ensures we never push an image that hasn't passed all previous steps, especially the vulnerability scan.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;if: always()&lt;/code&gt; on the cleanup step — this guarantees the test container is removed even if earlier steps failed, preventing orphaned processes on the runner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Push and Trigger
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add .github/workflows/docker-build-push.yml
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add DevSecOps CI pipeline"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then go to the &lt;strong&gt;Actions&lt;/strong&gt; tab in your repo and watch it run.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Issues I Ran Into
&lt;/h2&gt;

&lt;p&gt;This is the section I actually want people to read. The happy path above is clean, but this is what happened in practice.&lt;/p&gt;




&lt;h3&gt;
  
  
  Issue 1 — Docker Hub Credentials Not Set Up as Separate Secrets
&lt;/h3&gt;

&lt;p&gt;The first pipeline run failed at login. The runner couldn't authenticate with Docker Hub.&lt;/p&gt;

&lt;p&gt;The problem was that I hadn't properly separated the credentials into two distinct GitHub secrets. The &lt;code&gt;docker/login-action&lt;/code&gt; needs &lt;code&gt;username&lt;/code&gt; and &lt;code&gt;password&lt;/code&gt; as completely separate values:&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;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USERNAME }}&lt;/span&gt;
&lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once I added them as two separate secrets — &lt;code&gt;DOCKERHUB_USERNAME&lt;/code&gt; and &lt;code&gt;DOCKERHUB_TOKEN&lt;/code&gt; — the login step worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaway:&lt;/strong&gt; Never combine credentials into a single secret, and never use your Docker Hub password here — use the Personal Access Token.&lt;/p&gt;




&lt;h3&gt;
  
  
  Issue 2 — Trivy Action Version Tag Missing the &lt;code&gt;v&lt;/code&gt; Prefix
&lt;/h3&gt;

&lt;p&gt;The pipeline got past login and build, then failed at the Trivy step with an "Unable to resolve action" error.&lt;/p&gt;

&lt;p&gt;The cause was a missing &lt;code&gt;v&lt;/code&gt; in the action version tag:&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="c1"&gt;# Broken&lt;/span&gt;
&lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@0.20.0&lt;/span&gt;

&lt;span class="c1"&gt;# Correct&lt;/span&gt;
&lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@v0.20.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub Actions resolves action versions against the actual Git tags in the action's repository. The official release tag is &lt;code&gt;v0.20.0&lt;/code&gt; — without the &lt;code&gt;v&lt;/code&gt;, it can't find it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaway:&lt;/strong&gt; Always check the official action repository for the exact tag format. Most actions use &lt;code&gt;v&lt;/code&gt; prefixes — a missing &lt;code&gt;v&lt;/code&gt; is an easy mistake and produces an unhelpful error message.&lt;/p&gt;




&lt;h3&gt;
  
  
  Issue 3 — Trivy Flagging HIGH and CRITICAL Vulnerabilities, Blocking the Push
&lt;/h3&gt;

&lt;p&gt;With the version tag corrected, Trivy finally ran — and immediately failed the pipeline. It found &lt;code&gt;HIGH&lt;/code&gt; and &lt;code&gt;CRITICAL&lt;/code&gt; severity vulnerabilities in transitive dependencies (&lt;code&gt;wheel&lt;/code&gt; and &lt;code&gt;jaraco.context&lt;/code&gt;) coming from the &lt;code&gt;python:3.11-slim&lt;/code&gt; base image.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgbkwaeebnsou0gi1y57z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgbkwaeebnsou0gi1y57z.png" alt="Trivy vulnerability scan output" width="800" height="420"&gt;&lt;/a&gt; &lt;em&gt;Trivy scan output listing HIGH and CRITICAL vulnerabilities that blocked the push&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This was the pipeline working exactly as intended. The &lt;code&gt;exit-code: '1'&lt;/code&gt; setting means any HIGH or CRITICAL finding blocks the push — that's the security gate in action.&lt;/p&gt;

&lt;p&gt;The question was how to fix it. The wrong approach would have been to add patching packages directly to &lt;code&gt;requirements.txt&lt;/code&gt; — those are transitive dependencies that the application itself doesn't actually use, and bloating the requirements file with them is bad practice.&lt;/p&gt;

&lt;p&gt;The correct approach was a &lt;strong&gt;base image upgrade&lt;/strong&gt;. By moving to a more recent patch version of &lt;code&gt;python:3.11-slim&lt;/code&gt;, the image inherits upstream security fixes that the Python Docker team had already applied. The vulnerabilities were resolved without touching the application's dependencies at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fspn8s5sz4ghwaal4ulz0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fspn8s5sz4ghwaal4ulz0.png" alt="Successful pipeline run" width="800" height="396"&gt;&lt;/a&gt; &lt;em&gt;Final successful end-to-end pipeline run with all steps passing&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaway:&lt;/strong&gt; When Trivy flags base image vulnerabilities, upgrade the base image — don't suppress the finding or patch transitive dependencies directly. Keeping base images current is a core DevSecOps practice, and this is exactly the kind of thing an automated pipeline should surface and enforce.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Lab Actually Teaches
&lt;/h2&gt;

&lt;p&gt;Looking back, the manual steps in Phase 1 exist to give you a clear mental model before Phase 2 automates all of it. By the time you write the workflow file, you understand exactly what each step is doing because you've done it yourself.&lt;/p&gt;

&lt;p&gt;The pipeline is also a good introduction to what a real supply chain security gate looks like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The image is built but not pushed&lt;/li&gt;
&lt;li&gt;It's scanned before any push happens&lt;/li&gt;
&lt;li&gt;A vulnerable image is blocked, not just warned about&lt;/li&gt;
&lt;li&gt;The push only happens if everything passes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That ordering matters. It's the difference between security as an afterthought and security baked into the delivery process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Clean Up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-aq&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; 2&amp;gt;/dev/null
docker rmi my-web-app your-dockerhub-username/my-web-app:v1.0.0-yourname nginx:alpine 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/" rel="noopener noreferrer"&gt;Docker Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions" rel="noopener noreferrer"&gt;GitHub Actions Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/aquasecurity/trivy" rel="noopener noreferrer"&gt;Trivy by Aqua Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/samuel-nartey/devops-labs/tree/feature/docker-manual-lab/docker-manual-automated-lab" rel="noopener noreferrer"&gt;Original lab by Samuel Nartey&lt;/a&gt;
-&lt;a href="https://github.com/Yaa-K/docker-manual-automated-lab" rel="noopener noreferrer"&gt;My Github repo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're working through a similar lab and hit any of the same issues, I hope this saved you some debugging time. Feel free to drop a comment if anything needs more explanation.&lt;/p&gt;




</description>
      <category>docker</category>
      <category>devsecops</category>
      <category>github</category>
      <category>devops</category>
    </item>
    <item>
      <title>10 Linux Security Incidents, Reproduced and Fixed</title>
      <dc:creator>Yaa Kesewaa Yeboah </dc:creator>
      <pubDate>Sun, 01 Mar 2026 21:31:44 +0000</pubDate>
      <link>https://dev.to/yaak/10-linux-security-incidents-reproduced-and-fixed-b9h</link>
      <guid>https://dev.to/yaak/10-linux-security-incidents-reproduced-and-fixed-b9h</guid>
      <description>&lt;p&gt;There's a difference between reading about Linux security and actually breaking things on purpose. This assignment made me do the latter; 10 real-world incidents, reproduced and fixed one by one.&lt;/p&gt;

&lt;p&gt;I'll keep this post high-level. If you want the full commands, screenshots, and detailed documentation, it's all on my GitHub: &lt;a href="https://github.com/Yaa-K/devsecops-linux-assignment-2.git" rel="noopener noreferrer"&gt;github.com/Yaa-K/devsecops-linux-assignment&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 1 — Shared Document Deletion Incident
&lt;/h2&gt;

&lt;p&gt;An intern deleted a shared design document. I reproduced it by creating three users, a shared group, and a group-writable directory. As expected, any group member could delete any file inside — regardless of who created it.&lt;/p&gt;

&lt;p&gt;The fix was &lt;code&gt;chattr +i&lt;/code&gt;, which makes a file immutable at the filesystem level. Even root cannot delete it without explicitly removing the attribute first. This is the key difference between &lt;code&gt;chmod&lt;/code&gt; and &lt;code&gt;chattr&lt;/code&gt; — one enforces permissions, the other enforces filesystem-level constraints that sit below permissions entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F26swju18al0lrszne1ch.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F26swju18al0lrszne1ch.png" alt="Deletion blocked by chattr +i" width="800" height="183"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 2 — Log Overwrite Incident
&lt;/h2&gt;

&lt;p&gt;One accidental &lt;code&gt;&amp;gt;&lt;/code&gt; instead of &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; destroyed over 100 lines of application logs instantly.&lt;/p&gt;

&lt;p&gt;I restored the logs, backed them up using &lt;code&gt;cp -p&lt;/code&gt; to preserve timestamps, and applied &lt;code&gt;chattr +a&lt;/code&gt; (append-only) so no user — regardless of permissions — could overwrite the file again.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;cp -p&lt;/code&gt; matters here because timestamps are forensic evidence. Without them, a backup file loses its evidentiary value — you can no longer tell when events actually occurred.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 3 — Permission &amp;amp; Ownership Drift
&lt;/h2&gt;

&lt;p&gt;Copying files without the &lt;code&gt;-p&lt;/code&gt; flag silently resets ownership and timestamps. A file that was &lt;code&gt;dev_user:project_team 600&lt;/code&gt; becomes your user's file after a plain &lt;code&gt;cp&lt;/code&gt;. In production that kind of drift can expose secrets without anyone noticing.&lt;/p&gt;

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

&lt;p&gt;The fix: always use &lt;code&gt;cp -p&lt;/code&gt; for anything going to production.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 4 — Relative Path Deployment Failure
&lt;/h2&gt;

&lt;p&gt;A deployment script used a relative path. It worked perfectly from the project directory and broke immediately when called from anywhere else — which is exactly what CI/CD systems do.&lt;/p&gt;

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

&lt;p&gt;Switching to an absolute path fixed it completely. Relative paths are environment-dependent. Absolute paths are not.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 5 — Monitoring Failure After Log Cleanup
&lt;/h2&gt;

&lt;p&gt;Removing a log file broke a symlink-based monitoring agent. A hard link to the same file survived without any issues.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmbuk5ct3t5qngdf7i9jj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmbuk5ct3t5qngdf7i9jj.png" alt="Hard link works, symlink broken" width="800" height="152"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A symlink is just a pointer to a path — remove the original and the symlink has nothing to point to. A hard link shares the same inode, so the data lives on as long as the link exists. For monitoring tools that need to survive log rotation, hard links are the safer choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 6 — Sensitive Data Exposure Hunt
&lt;/h2&gt;

&lt;p&gt;Scanned an entire directory tree for exposed credentials using &lt;code&gt;grep -r&lt;/code&gt;, multiple expressions with &lt;code&gt;-e&lt;/code&gt;, and a pattern file with &lt;code&gt;-f&lt;/code&gt;. Found passwords, API keys, and tokens sitting in plaintext in both config and log files.&lt;/p&gt;

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

&lt;p&gt;Finding credentials in plaintext means immediate rotation. No exceptions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 7 — Shared Directory Stability Controls
&lt;/h2&gt;

&lt;p&gt;Developers were losing files because teammates were accidentally deleting each other's work. The fix was a single command — applying the sticky bit with &lt;code&gt;chmod +t&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The sticky bit means only the file owner or root can delete a file, even if everyone in the group has write access. It's the same mechanism that keeps &lt;code&gt;/tmp&lt;/code&gt; working safely on every Linux system.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 8 — Special Permission Risk Review
&lt;/h2&gt;

&lt;p&gt;This one covered three special bits — sticky, setgid, and setuid — across a nested production-like directory path.&lt;/p&gt;

&lt;p&gt;SetGID ensures new files inherit the directory's group automatically, so the whole team always has access without needing manual &lt;code&gt;chgrp&lt;/code&gt; after every file creation.&lt;/p&gt;

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

&lt;p&gt;For setuid, the difference between lowercase &lt;code&gt;s&lt;/code&gt; and uppercase &lt;code&gt;S&lt;/code&gt; is worth knowing. Lowercase means it's active. Uppercase means setuid was set without an execute bit — completely meaningless and always a misconfiguration.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Scenario 9 — Process Anomaly Investigation
&lt;/h2&gt;

&lt;p&gt;A rogue infinite loop process was consuming 100% CPU. I identified it with &lt;code&gt;ps&lt;/code&gt; and &lt;code&gt;top&lt;/code&gt;, suspended it with &lt;code&gt;kill -STOP&lt;/code&gt;, resumed it with &lt;code&gt;kill -CONT&lt;/code&gt;, and terminated it gracefully with &lt;code&gt;kill -15&lt;/code&gt;.&lt;/p&gt;

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

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

&lt;p&gt;Always try &lt;code&gt;kill -15&lt;/code&gt; (SIGTERM) first. It lets the process clean up properly. &lt;code&gt;kill -9&lt;/code&gt; forces it dead with no cleanup — in production that can mean corrupted data or stuck locks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 10 — Incident Documentation via Heredoc
&lt;/h2&gt;

&lt;p&gt;Generated a structured incident report for all 10 scenarios using a heredoc directly in the terminal — no text editor needed.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  What I Took Away
&lt;/h2&gt;

&lt;p&gt;A few things that genuinely stuck with me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;chattr&lt;/code&gt; is underused.&lt;/strong&gt; For files that should never be touched, it's a stronger guarantee than &lt;code&gt;chmod&lt;/code&gt; — it sits below the permission layer entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;gt;&lt;/code&gt; vs &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; matters more than people realise.&lt;/strong&gt; In log management this is the difference between preserving forensic history and destroying it. Default to &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uppercase &lt;code&gt;S&lt;/code&gt; on a file is always a misconfiguration.&lt;/strong&gt; Don't ignore it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absolute paths in scripts are not optional in production.&lt;/strong&gt; They work from anywhere. Relative paths don't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard links and symlinks are not interchangeable.&lt;/strong&gt; Symlinks are convenient but fragile. Know when to use each.&lt;/p&gt;

&lt;p&gt;This assignment was genuinely useful. These are not contrived scenarios — they reflect real incidents. If you want to dig into the full commands and evidence for each one, everything is documented on my GitHub: &lt;a href="https://github.com/Yaa-K/parocyber-linux-assignment" rel="noopener noreferrer"&gt;github.com/Yaa-K/parocyber-linux-assignment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Completed as part of the ParoCyber Linux Assignment, facilitated by Samuel Nartey Otuafo at ParoCyber LLC.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devsecops</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>I Thought I Knew Linux. This Lab Proved Me Wrong.</title>
      <dc:creator>Yaa Kesewaa Yeboah </dc:creator>
      <pubDate>Fri, 20 Feb 2026 21:57:19 +0000</pubDate>
      <link>https://dev.to/yaak/i-thought-i-knew-linux-this-lab-proved-me-wrong-2ljp</link>
      <guid>https://dev.to/yaak/i-thought-i-knew-linux-this-lab-proved-me-wrong-2ljp</guid>
      <description>&lt;p&gt;I've been using Linux for a while. Commands like &lt;code&gt;useradd&lt;/code&gt;, &lt;code&gt;chmod&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt; — I knew them. I could navigate the terminal without panicking. So when I started my first assignment in the ParoCyber DevSecOps Bootcamp, I figured it would be straightforward.&lt;/p&gt;

&lt;p&gt;It wasn't. And I mean that in the best way possible.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Assignment Was About
&lt;/h2&gt;

&lt;p&gt;The lab had two scenarios. The first was a password state investigation — find users on a system with no passwords set, understand &lt;em&gt;where&lt;/em&gt; Linux stores that information, and remediate it. The second was a full onboarding simulation: create users, assign them to department groups, configure a CI/CD service account, manage sudo access, and safely offboard a user.&lt;/p&gt;

&lt;p&gt;On paper, it sounds like basic sysadmin work. In practice, it forced me to think like a security engineer — and that changed everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Moment That Reframed Everything
&lt;/h2&gt;

&lt;p&gt;In Scenario 1, the task said: &lt;em&gt;"You are not told where Linux stores password state. Finding it is part of the task."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I knew about &lt;code&gt;/etc/passwd&lt;/code&gt;. Everyone who has touched Linux knows &lt;code&gt;/etc/passwd&lt;/code&gt;. But when I ran &lt;code&gt;cat /etc/passwd&lt;/code&gt;, the password field just showed &lt;code&gt;x&lt;/code&gt;. A placeholder. I had seen that a hundred times and never thought twice about it.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;x&lt;/code&gt; means the actual password data lives somewhere else — &lt;code&gt;/etc/shadow&lt;/code&gt;. And when I inspected it, I saw something that stopped me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;audit_test:!:...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;!&lt;/code&gt; in the second field. One character. That single character is the difference between a secured account and a vulnerability sitting open on your system. I had been using Linux without ever knowing that file existed, let alone what it meant.&lt;/p&gt;

&lt;p&gt;Then I tested whether a normal user could read &lt;code&gt;/etc/shadow&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;cat&lt;/span&gt; /etc/shadow
&lt;span class="c"&gt;# Permission denied&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's when it clicked. The fact that &lt;code&gt;/etc/passwd&lt;/code&gt; is world-readable but &lt;code&gt;/etc/shadow&lt;/code&gt; is root-only isn't an accident — it's a deliberate security design. Two files, one visible to everyone, one locked down. I had been looking at half the picture this whole time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 2: Access Control Is a People Problem Too
&lt;/h2&gt;

&lt;p&gt;The second scenario was about onboarding eight new staff members across four departments — dev, security, QA, and ops. Each team gets its own group. Each group gets access to what it needs and nothing more.&lt;/p&gt;

&lt;p&gt;What struck me here wasn't the commands. It was the &lt;em&gt;reasoning&lt;/em&gt; behind them.&lt;/p&gt;

&lt;p&gt;When I created the &lt;code&gt;ci_runner&lt;/code&gt; service account with a non-login shell:&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;useradd &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /usr/sbin/nologin ci_runner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trainer's question wasn't "did you run the command" — it was "do you know &lt;em&gt;why&lt;/em&gt; this matters?" A service account with an interactive shell is an open door. Automated pipelines don't need to log in. Humans shouldn't be logging in as them either. That shell setting is a security decision, not a configuration detail.&lt;/p&gt;

&lt;p&gt;Same with removing a user at the end. I used:&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;userdel yaa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;userdel -r&lt;/code&gt;. The &lt;code&gt;-r&lt;/code&gt; flag would have deleted the home directory too. But in a real environment, that directory might contain evidence — scripts, logs, files that matter in an investigation. Deleting it immediately could mean destroying forensic data. That's not a Linux lesson. That's a security operations lesson.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Taking Away
&lt;/h2&gt;

&lt;p&gt;I came into this thinking DevSecOps was Linux plus some security tools on top. What I'm leaving with is a different understanding — security isn't a layer you add. It's a lens you apply to every decision, including the ones that look purely technical.&lt;/p&gt;

&lt;p&gt;The commands I ran in this lab weren't new. The &lt;em&gt;questions&lt;/em&gt; behind them were.&lt;/p&gt;

&lt;p&gt;Why does this file have these permissions? Why does this account need this shell? What happens to this data when this user is gone? Those questions are what separate someone who &lt;em&gt;uses&lt;/em&gt; Linux from someone who &lt;em&gt;secures&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;This is assignment one. I'm already thinking differently. Looking forward to what's next.&lt;/p&gt;

</description>
      <category>devsecops</category>
      <category>linux</category>
      <category>cybersecurity</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
