<?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>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>
