<?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: Edem Khadiev</title>
    <description>The latest articles on DEV Community by Edem Khadiev (@eekhdv).</description>
    <link>https://dev.to/eekhdv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F977898%2F02786638-121e-4049-aa34-8c4963752424.jpeg</url>
      <title>DEV Community: Edem Khadiev</title>
      <link>https://dev.to/eekhdv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eekhdv"/>
    <language>en</language>
    <item>
      <title>Stop Shipping 1GB Python Containers Running as Root (Multi-Stage Builds + uv + Trivy)</title>
      <dc:creator>Edem Khadiev</dc:creator>
      <pubDate>Sun, 08 Nov 2026 10:51:41 +0000</pubDate>
      <link>https://dev.to/eekhdv/stop-shipping-1gb-python-containers-running-as-root-multi-stage-builds-uv-trivy-58a5</link>
      <guid>https://dev.to/eekhdv/stop-shipping-1gb-python-containers-running-as-root-multi-stage-builds-uv-trivy-58a5</guid>
      <description>&lt;p&gt;Look at almost any Python backend moving from a laptop to production and the first Dockerfile looks like 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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12&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;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It builds locally. It passes a basic smoke test. Someone merges it.&lt;br&gt;
And you just created three problems in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bloated images:&lt;/strong&gt; The default &lt;code&gt;python:3.12&lt;/code&gt; tag pulls in hundreds of megabytes of C compilers, headers, and Debian junk. Your tiny API now weighs over 1GB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root execution:&lt;/strong&gt; Docker runs containers as root (UID 0) by default. Catch an RCE in your web framework, and the attacker owns the container namespace as root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Red CI pipelines:&lt;/strong&gt; Run a scanner like &lt;code&gt;Trivy&lt;/code&gt; and your build triggers a flood of HIGH and CRITICAL CVEs sitting in base OS packages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fixing this takes 40 lines of config. Here is the base setup I use for FastAPI services with &lt;code&gt;uv&lt;/code&gt;, multi-stage isolation, and automated Trivy gates.&lt;/p&gt;
&lt;h3&gt;
  
  
  What You Need
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Docker (24.0+) on Linux or macOS&lt;/li&gt;
&lt;li&gt;A Python 3.12 project managed with uv&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Step 1. The Service
&lt;/h3&gt;

&lt;p&gt;Create a minimal network health-check endpoint in &lt;code&gt;main.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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psutil&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;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hardened Telemetry Service&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.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;/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;async&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="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;ok&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;interfaces&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;psutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;net_if_stats&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why &lt;code&gt;psutil&lt;/code&gt;? Because on Linux, if a pre-built wheel isn't matched, &lt;code&gt;psutil&lt;/code&gt; compiles C extensions from source. That is the exact moment developers give up, install &lt;code&gt;build-essential&lt;/code&gt; and &lt;code&gt;gcc&lt;/code&gt; straight into the production image and leave them there forever (Recognized yourself? I did :) ).&lt;/p&gt;

&lt;p&gt;Don't do that. Split the build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2. The Multi-Stage Dockerfile (THE BASE)
&lt;/h3&gt;

&lt;p&gt;Shipping compilers to production is lazy. We split the Dockerfile into two stages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;builder&lt;/code&gt;: Installs dependencies and compiles wheels into an isolated &lt;code&gt;/app/.venv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runtime&lt;/code&gt;: Starts from a clean &lt;code&gt;slim-bookworm&lt;/code&gt; image, patches the OS, drops root privileges and copies only the compiled &lt;code&gt;/app/.venv&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&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="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ghcr.io/astral-sh/uv:python3.12-bookworm-slim&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; /app&lt;/span&gt;

&lt;span class="c"&gt;# Enable bytecode compilation and copy mode for cleaner layer transfers&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; UV_COMPILE_BYTECODE=1 \&lt;/span&gt;
    UV_LINK_MODE=copy

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pyproject.toml uv.lock* ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--no-install-project&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&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;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt;

&lt;span class="c"&gt;# Hardened production runtime&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;python:3.12-slim-bookworm&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;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# 1. Patch base Debian packages to resolve OS-level CVEs&lt;/span&gt;
&lt;span class="c"&gt;# 2. Create a dedicated, non-root system user and group&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get upgrade &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; groupadd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--gid&lt;/span&gt; 10001 appgroup &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 10001 &lt;span class="nt"&gt;--gid&lt;/span&gt; appgroup &lt;span class="nt"&gt;--no-create-home&lt;/span&gt; appuser

&lt;span class="c"&gt;# Copy only the pre-built virtualenv and application entry point&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=appuser:appgroup /app/.venv /app/.venv&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=appuser:appgroup /app/main.py /app/main.py&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH="/app/.venv/bin:$PATH" \&lt;/span&gt;
    PYTHONUNBUFFERED=1

&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; 8000&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  &lt;code&gt;apt-get upgrade -y&lt;/code&gt; is mandatory here
&lt;/h4&gt;

&lt;p&gt;People assume pulling &lt;code&gt;python:3.12-slim-bookworm&lt;/code&gt; gives them a fully patched OS. It doesn't. Official Docker Hub images are rebuilt periodically, not on every single Debian security advisory. Running &lt;code&gt;apt-get update &amp;amp;&amp;amp; apt-get upgrade -y&lt;/code&gt; in the runtime stage pulls the latest patched C libraries right now, and wiping &lt;code&gt;/var/lib/apt/lists/*&lt;/code&gt; in the same layer keeps the image slim.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3. Build &amp;amp; Check UID
&lt;/h3&gt;

&lt;p&gt;Build 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 build &lt;span class="nt"&gt;-t&lt;/span&gt; telemetry-service:hardened &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify you aren't running as root:&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; &lt;span class="nt"&gt;--entrypoint&lt;/span&gt; &lt;span class="nb"&gt;id &lt;/span&gt;telemetry-service:hardened
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10001&lt;span class="o"&gt;(&lt;/span&gt;appuser&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;gid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10001&lt;span class="o"&gt;(&lt;/span&gt;appgroup&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;groups&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10001&lt;span class="o"&gt;(&lt;/span&gt;appgroup&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4. Automate the Paranoia with Trivy
&lt;/h3&gt;

&lt;p&gt;Run Trivy against the image to check both the Debian layer and your &lt;code&gt;uv.lock&lt;/code&gt; packages:&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; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /var/run/docker.sock:/var/run/docker.sock &lt;span class="se"&gt;\&lt;/span&gt;
  aquasec/trivy:latest image &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--severity&lt;/span&gt; HIGH,CRITICAL &lt;span class="se"&gt;\&lt;/span&gt;
  telemetry-service:hardened
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make sure nobody breaks this on the next PR, wire it into &lt;code&gt;.github/workflows/ci.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;Container Security &amp;amp; CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&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-and-scan&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;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;Build Hardened Image&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 build -t telemetry-service:${{ github.sha }} .&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@master&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;telemetry-service:${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;github.sha&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;CRITICAL,HIGH'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>docker</category>
      <category>python</category>
      <category>devops</category>
      <category>security</category>
    </item>
  </channel>
</rss>
