<?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: David Tio</title>
    <description>The latest articles on DEV Community by David Tio (@davidtio).</description>
    <link>https://dev.to/davidtio</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%2F3804844%2F9b6ee07a-d847-4296-af23-d07335a2a638.jpg</url>
      <title>DEV Community: David Tio</title>
      <link>https://dev.to/davidtio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/davidtio"/>
    <language>en</language>
    <item>
      <title>Customizing Docker Images: Write Your First Dockerfile (2026)</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Mon, 25 May 2026 10:04:18 +0000</pubDate>
      <link>https://dev.to/davidtio/customizing-docker-images-write-your-first-dockerfile-2026-5ga7</link>
      <guid>https://dev.to/davidtio/customizing-docker-images-write-your-first-dockerfile-2026-5ga7</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Pre-built images are convenient until they break. A Dockerfile turns your app into a portable, reproducible artifact you can fix, rebuild, and own.&lt;/p&gt;




&lt;h3&gt;
  
  
  🤔 Why This Matters
&lt;/h3&gt;

&lt;p&gt;In episode 10, we fixed the startup race. Ghost now waits for MySQL to be genuinely ready before starting. The stack is stable.&lt;/p&gt;

&lt;p&gt;But after &lt;code&gt;docker compose down --volumes&lt;/code&gt;, you rebuild from scratch: new theme, default config, all manual setup repeated. The image pulled from Docker Hub does not remember your changes.&lt;/p&gt;

&lt;p&gt;That is not a Ghost problem. That is what it looks like when you do not own the image.&lt;/p&gt;

&lt;p&gt;A Dockerfile is how you own it. You start from a base, install what you need, bake in configuration, and define exactly what runs when the container starts. The result is an artifact that rebuilds cleanly and consistently every time.&lt;/p&gt;

&lt;p&gt;This episode covers the fundamentals: &lt;code&gt;FROM&lt;/code&gt;, &lt;code&gt;WORKDIR&lt;/code&gt;, &lt;code&gt;COPY&lt;/code&gt;, &lt;code&gt;RUN&lt;/code&gt;, &lt;code&gt;EXPOSE&lt;/code&gt;, and &lt;code&gt;CMD&lt;/code&gt;. The example is a Flask app — small enough to understand, realistic enough to matter.&lt;/p&gt;




&lt;h3&gt;
  
  
  ✅ Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ep 1-10 completed.&lt;/strong&gt; You are comfortable with Compose files, multi-service stacks, and health checks.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;Create a working directory for this episode:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/noteboard
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/noteboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will create three files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;noteboard/
├── app.py
├── requirements.txt
└── Dockerfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  ✍️ The App
&lt;/h3&gt;

&lt;p&gt;Create a minimal Flask app:&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="nv"&gt;$ &lt;/span&gt;vi app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="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="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;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h1&amp;gt;Noteboard&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Hello.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the requirements file — just Flask for now:&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="nv"&gt;$ &lt;/span&gt;vi requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flask==3.1.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🐳 First Dockerfile: Flask Dev Server
&lt;/h3&gt;

&lt;p&gt;Write your first Dockerfile:&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="nv"&gt;$ &lt;/span&gt;vi Dockerfile
&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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-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;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; app.py .&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;CMD&lt;/span&gt;&lt;span class="s"&gt; ["flask", "run", "--host=0.0.0.0"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what each instruction does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instruction&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FROM python:3.12-slim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Start from the official Python 3.12 slim image — smaller footprint, versioned base&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WORKDIR /app&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set the working directory inside the container. All following instructions run here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COPY requirements.txt .&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copy the requirements file from your host into &lt;code&gt;/app&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RUN pip install ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Install dependencies at build time. This layer is cached until &lt;code&gt;requirements.txt&lt;/code&gt; changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COPY app.py .&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copy the app source code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPOSE 5000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Document that this container listens on port 5000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CMD [...]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The command that runs when the container starts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;EXPOSE&lt;/code&gt; does not publish the port. It is documentation — a signal to whoever runs the image that port 5000 is where the app listens. You still need &lt;code&gt;-p&lt;/code&gt; at runtime.&lt;/p&gt;

&lt;p&gt;Build and run:&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="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; noteboard &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&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; noteboard noteboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it:&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="nv"&gt;$ &lt;/span&gt;curl http://localhost:5000
&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;&amp;lt;h1&amp;gt;Noteboard&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Hello.&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. Now check the logs:&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="nv"&gt;$ &lt;/span&gt;docker logs noteboard
&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; * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://172.17.0.2:5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning is not a style note. Flask's built-in server is single-threaded. It handles one request at a time. Under concurrent load it queues and drops connections. It is not designed to serve real traffic.&lt;/p&gt;

&lt;p&gt;Stop the container before moving on:&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="nv"&gt;$ &lt;/span&gt;docker stop noteboard &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;rm &lt;/span&gt;noteboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🔧 Fix It: Switch to Gunicorn
&lt;/h3&gt;

&lt;p&gt;Gunicorn is a production-grade WSGI server. It runs multiple worker processes, handles concurrent requests, and does not print warnings about being unsuitable for deployment.&lt;/p&gt;

&lt;p&gt;Add it to &lt;code&gt;requirements.txt&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="nv"&gt;$ &lt;/span&gt;vi requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flask==3.1.3
gunicorn==22.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the &lt;code&gt;CMD&lt;/code&gt; in your 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;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-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;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; app.py .&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;CMD&lt;/span&gt;&lt;span class="s"&gt; ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rebuild and run:&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="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; noteboard &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&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; noteboard noteboard
&lt;span class="nv"&gt;$ &lt;/span&gt;docker logs noteboard
&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;[2026-05-25 08:00:00 +0000] [1] [INFO] Starting gunicorn 22.0.0
[2026-05-25 08:00:00 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000 (1)
[2026-05-25 08:00:00 +0000] [7] [INFO] Booting worker with pid: 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No warning. One line change in the Dockerfile — that is what owning the image gives you.&lt;/p&gt;

&lt;p&gt;One thing to notice about the ordering: &lt;code&gt;requirements.txt&lt;/code&gt; is copied and installed before &lt;code&gt;app.py&lt;/code&gt;. This is deliberate. Docker caches each layer. When you change &lt;code&gt;app.py&lt;/code&gt;, Docker reuses the cached pip install layer and only rebuilds from &lt;code&gt;COPY app.py&lt;/code&gt; onward. Reverse the order and every code change triggers a full reinstall.&lt;/p&gt;




&lt;h3&gt;
  
  
  🔨 Verify the Build
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker images noteboard
&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;IMAGE              ID             DISK USAGE   CONTENT SIZE
noteboard:latest   a1b2c3d4e5f6        196MB         48.6MB
&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;$ &lt;/span&gt;curl http://localhost:5000
&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;&amp;lt;h1&amp;gt;Noteboard&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Hello.&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🔁 The Rebuild Workflow
&lt;/h3&gt;

&lt;p&gt;Edit &lt;code&gt;app.py&lt;/code&gt; to change the response:&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="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;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h1&amp;gt;Noteboard&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Version 2.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop the running container, rebuild, and redeploy:&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="nv"&gt;$ &lt;/span&gt;docker stop noteboard &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;rm &lt;/span&gt;noteboard
&lt;span class="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; noteboard &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&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; noteboard noteboard
&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://localhost:5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the second build, Docker reuses the cached install layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; =&amp;gt; CACHED [4/5] RUN pip install --no-cache-dir -r requirements.txt
 =&amp;gt; [5/5] COPY app.py .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the changed layers rebuild. This is why layer ordering matters.&lt;/p&gt;




&lt;h3&gt;
  
  
  🏷️ Tagging Your Builds
&lt;/h3&gt;

&lt;p&gt;Every image you have built so far has been tagged &lt;code&gt;latest&lt;/code&gt;. That is the default when you do not specify one. But &lt;code&gt;latest&lt;/code&gt; is just a label — it has no special meaning. It does not mean newest, it does not update automatically. It is whatever you last tagged with it.&lt;/p&gt;

&lt;p&gt;As your app evolves, version tags give you something &lt;code&gt;latest&lt;/code&gt; cannot: the ability to know exactly what is running and to go back to a previous version if something breaks.&lt;/p&gt;

&lt;p&gt;Tag your current build as &lt;code&gt;0.1&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="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; noteboard:0.1 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now edit &lt;code&gt;app.py&lt;/code&gt; to mark the next release:&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="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;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h1&amp;gt;Noteboard&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Version 1.0 — stable.&amp;lt;/p&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build it as &lt;code&gt;1.0&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="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; noteboard:1.0 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List both:&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="nv"&gt;$ &lt;/span&gt;docker images noteboard
&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;IMAGE              ID             DISK USAGE   CONTENT SIZE
noteboard:1.0      b2f3a4c5d6e7        196MB         48.6MB
noteboard:0.1      a1b2c3d4e5f6        196MB         48.6MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both images exist on your host. You can run either by name:&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="nv"&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; noteboard noteboard:1.0
&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://localhost:5000
&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;&amp;lt;h1&amp;gt;Noteboard&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Version 1.0 — stable.&amp;lt;/p&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;1.0&lt;/code&gt; breaks something, switching back is one flag change:&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="nv"&gt;$ &lt;/span&gt;docker stop noteboard &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;rm &lt;/span&gt;noteboard
&lt;span class="nv"&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; noteboard noteboard:0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop the container before moving on:&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="nv"&gt;$ &lt;/span&gt;docker stop noteboard &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;rm &lt;/span&gt;noteboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  ✏️ Renaming an Image
&lt;/h3&gt;

&lt;p&gt;Docker does not have a rename command. You rename an image by tagging it with the new name and removing the old tag.&lt;/p&gt;

&lt;p&gt;Say you want to rename &lt;code&gt;noteboard&lt;/code&gt; to &lt;code&gt;myapp&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="nv"&gt;$ &lt;/span&gt;docker tag noteboard:1.0 myapp:1.0
&lt;span class="nv"&gt;$ &lt;/span&gt;docker rmi noteboard:1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker tag&lt;/code&gt; creates a new name pointing at the same image layers — nothing is copied or rebuilt. &lt;code&gt;docker rmi&lt;/code&gt; removes the old name. The underlying image data stays on disk as long as at least one tag points to it.&lt;/p&gt;

&lt;p&gt;You can also use this to promote a tested version to &lt;code&gt;latest&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="nv"&gt;$ &lt;/span&gt;docker tag noteboard:1.0 noteboard:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;noteboard:latest&lt;/code&gt; and &lt;code&gt;noteboard:1.0&lt;/code&gt; both point to the same image. Pulling or running &lt;code&gt;noteboard&lt;/code&gt; without a tag will use it.&lt;/p&gt;




&lt;h3&gt;
  
  
  🗄️ Baking Init Scripts into Database Images
&lt;/h3&gt;

&lt;p&gt;The Flask example showed how to control what your app runs. Official database images go further — they provide a hook specifically for initialization.&lt;/p&gt;

&lt;p&gt;Both MariaDB and Postgres run any &lt;code&gt;.sql&lt;/code&gt; or &lt;code&gt;.sh&lt;/code&gt; files placed in &lt;code&gt;/docker-entrypoint-initdb.d/&lt;/code&gt; on first startup. Drop your schema there and the database initializes itself. No manual connection, no migration script, no extra setup step.&lt;/p&gt;

&lt;p&gt;Create a working directory:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/mariadb-custom
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/mariadb-custom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the SQL file:&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="nv"&gt;$ &lt;/span&gt;vi init.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&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;Write the Dockerfile:&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="nv"&gt;$ &lt;/span&gt;vi Dockerfile
&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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; mariadb:11&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; init.sql /docker-entrypoint-initdb.d/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build and run:&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="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; mariadb-custom &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MARIADB_ROOT_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MARIADB_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;appdb &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; mariadb-custom &lt;span class="se"&gt;\&lt;/span&gt;
    mariadb-custom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait a few seconds for initialization, then verify the table was created:&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="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; mariadb-custom mariadb &lt;span class="nt"&gt;-uroot&lt;/span&gt; &lt;span class="nt"&gt;-pdocker&lt;/span&gt; appdb &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SHOW TABLES;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------+
| Tables_in_appdb  |
+------------------+
| notes            |
+------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cleanup:&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="nv"&gt;$ &lt;/span&gt;docker stop mariadb-custom &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;rm &lt;/span&gt;mariadb-custom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table exists because the init script ran at first startup. Tear it down and bring it back up — the schema is part of the image, not a step you repeat.&lt;/p&gt;




&lt;h3&gt;
  
  
  🧪 Exercise 1: Auto-Initialize a Postgres Schema
&lt;/h3&gt;

&lt;p&gt;Postgres supports the same &lt;code&gt;/docker-entrypoint-initdb.d/&lt;/code&gt; hook as MariaDB. Apply the same pattern using a Postgres image.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the project folder:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/pgcustom
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/pgcustom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create the SQL file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;vi init.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;NOW&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;ol&gt;
&lt;li&gt;Write the Dockerfile:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;vi Dockerfile
&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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; postgres:16&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; init.sql /docker-entrypoint-initdb.d/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Build and run:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; pgcustom &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;appdb &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; pgcustom &lt;span class="se"&gt;\&lt;/span&gt;
    pgcustom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Wait a few seconds, then verify the table was created:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; pgcustom psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; appdb &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="s2"&gt;t"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        List of relations
 Schema | Name  | Type  |  Owner
--------+-------+-------+----------
 public | notes | table | postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Cleanup:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker stop pgcustom &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;rm &lt;/span&gt;pgcustom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema was created at first startup — no manual &lt;code&gt;psql&lt;/code&gt; session, no migration script, just a &lt;code&gt;COPY&lt;/code&gt; instruction in the Dockerfile.&lt;/p&gt;




&lt;h3&gt;
  
  
  🧪 Exercise 2: Bake a Custom Theme into a Ghost Image
&lt;/h3&gt;

&lt;p&gt;In episode 10, you ran Ghost with health checks. But after &lt;code&gt;docker compose down --volumes&lt;/code&gt;, Ghost starts fresh — database wiped, any configuration you applied through the UI gone.&lt;/p&gt;

&lt;p&gt;In this exercise, you will modify Ghost's default theme directly in the image. The change is visible the moment Ghost starts — no admin registration, no theme activation, no re-uploading after teardown.&lt;/p&gt;

&lt;p&gt;Ghost ships with a theme called &lt;code&gt;source&lt;/code&gt; that is active by default. Its templates live at &lt;code&gt;/var/lib/ghost/current/content/themes/source/&lt;/code&gt;. Replacing &lt;code&gt;default.hbs&lt;/code&gt; in your Dockerfile replaces it in the image layer — Ghost loads your version on every startup.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a fresh project folder:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/ghost-custom
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/ghost-custom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;config.production.json&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;vi config.production.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:2368"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"::"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2368&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"database"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"client"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mysql"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"connection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"db"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghostpass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"database"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ghost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3306&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"transport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SMTP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1025&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Extract the original &lt;code&gt;default.hbs&lt;/code&gt; from the Ghost image:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; ghost:5-alpine &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;cat&lt;/span&gt; /var/lib/ghost/current/content/themes/source/default.hbs &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; default.hbs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Edit it to add a banner. Find the &lt;code&gt;&amp;lt;div class="gh-viewport"&amp;gt;&lt;/code&gt; line and add the banner immediately after it:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;vi default.hbs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; &amp;lt;div class="gh-viewport"&amp;gt;
&lt;span class="gi"&gt;+
+    &amp;lt;div style="background:#0f766e;color:white;text-align:center;padding:0.75rem;font-size:0.9rem;font-family:sans-serif;"&amp;gt;
+        Running on a custom Ghost image — theme baked in at build time.
+    &amp;lt;/div&amp;gt;
+
&lt;/span&gt;     {{&amp;gt; "components/navigation" navigationLayout=@custom.navigation_layout}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Write the Dockerfile:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;vi Dockerfile
&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="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; ghost:5-alpine&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; config.production.json /var/lib/ghost/config.production.json&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; default.hbs /var/lib/ghost/current/content/themes/source/default.hbs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Build the image:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; ghost-custom &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create the Compose file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;vi docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rootpass&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghostpass&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mysqladmin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-ughost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-pghostpass&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--silent"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;

  &lt;span class="na"&gt;mail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;axllent/mailpit:latest&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;8025:8025"&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost-custom&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;2368:2368"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;mail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_started&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Bring it up:
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Visit &lt;code&gt;http://localhost:2368&lt;/code&gt;. The teal banner appears at the top.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Now tear it down and bring it back:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--volumes&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;http://localhost:2368&lt;/code&gt; again. The banner is still there.&lt;/p&gt;

&lt;p&gt;The database was wiped. The theme modification was not — it is part of the image.&lt;/p&gt;




&lt;h3&gt;
  
  
  🏁 What You Built
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Why It Matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FROM python:3.12-slim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Starts from a clean, versioned base instead of inheriting unknown state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WORKDIR&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sets a predictable working directory — no scattered files across the container filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layer ordering&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;requirements.txt&lt;/code&gt; before &lt;code&gt;app.py&lt;/code&gt; — expensive installs are cached; only changed code rebuilds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gunicorn instead of dev server&lt;/td&gt;
&lt;td&gt;Removes the dev server warning and makes the app production-capable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/docker-entrypoint-initdb.d/&lt;/code&gt; hook&lt;/td&gt;
&lt;td&gt;Schema baked into database images — first startup initializes without manual intervention, works on both MariaDB and Postgres&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version tags (&lt;code&gt;0.1&lt;/code&gt;, &lt;code&gt;1.0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Each build is addressable — you know what is running and can roll back without rebuilding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modified source theme baked into Ghost image&lt;/td&gt;
&lt;td&gt;Change is visible immediately at startup — no registration, no theme activation, survives &lt;code&gt;down --volumes&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;strong&gt;Coming up:&lt;/strong&gt; You built two images this episode. Both work. Neither is reachable without a port number in the URL. How many of your users are typing &lt;code&gt;:2368&lt;/code&gt;? Next episode, we fix that.&lt;/p&gt;




</description>
      <category>docker</category>
      <category>dockerfile</category>
      <category>python</category>
      <category>flask</category>
    </item>
    <item>
      <title>Building a Blog Platform with Docker #5: Add a Dockerfile + Deploy to Clouderized</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Sun, 24 May 2026 15:14:21 +0000</pubDate>
      <link>https://dev.to/davidtio/building-a-blog-platform-with-docker-5-add-a-dockerfile-deploy-to-clouderized-3549</link>
      <guid>https://dev.to/davidtio/building-a-blog-platform-with-docker-5-add-a-dockerfile-deploy-to-clouderized-3549</guid>
      <description>&lt;h1&gt;
  
  
  🐳 Building a Blog Platform with Docker #5: Add a Dockerfile + Deploy to Clouderized
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Write a Dockerfile, build an image, and deploy to Clouderized with one &lt;code&gt;git push&lt;/code&gt; so your Flask blog goes live with automatic HTTPS and zero server babysitting.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔧 Before We Start: URL Control for Blogger Migration
&lt;/h2&gt;

&lt;p&gt;Before touching Docker, there's one fix from Episode 4 worth making now.&lt;/p&gt;

&lt;p&gt;Right now our platform builds URLs from the filename: &lt;code&gt;hey-markdown.md&lt;/code&gt; becomes &lt;code&gt;/2026/04/hey-markdown.html&lt;/code&gt;. That works for new posts, but not for migrated Blogger URLs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Blogger URL&lt;/th&gt;
&lt;th&gt;Filename-based URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/2026/03/docker-rootless-on-ubuntu-2026-guide.html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/2026/03/docker-rootless-ubuntu.html&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We add &lt;code&gt;canonical_url&lt;/code&gt; to frontmatter and use it for two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The actual URL this platform serves the post at&lt;/strong&gt; — so migrated posts keep their Blogger paths&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;&lt;/strong&gt; — tells Google this page is the same content as the old Blogger URL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Update &lt;code&gt;content/posts/hey-markdown.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hey&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Markdown"&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-25&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;first&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;written&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Markdown&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;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;more&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;writing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HTML&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hand."&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="nv"&gt;meta&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;blog&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://blog.dtio.app/2026/04/old-markdown.html"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mismatch is intentional: filename and served path can differ.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a URL Helper to &lt;code&gt;app.py&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;app.py&lt;/code&gt;. Add this import at the top:&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;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add a &lt;code&gt;get_post_path()&lt;/code&gt; function below &lt;code&gt;parse_post()&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_post_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Determine the URL path for a post from its canonical_url.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;meta&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;canonical_url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;canonical_url&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;
    &lt;span class="c1"&gt;# Fallback: auto-generate from date + slug
&lt;/span&gt;    &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meta&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;slug&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;unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;meta&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;date&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.html&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.html&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helper extracts the URL path from &lt;code&gt;canonical_url&lt;/code&gt;, with a date+slug fallback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update &lt;code&gt;get_all_posts()&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;In the existing &lt;code&gt;get_all_posts()&lt;/code&gt; function, add the path field:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_all_posts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;posts_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;posts_dir&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;filepath&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;posts_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;slug&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&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;date&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="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each post now has a &lt;code&gt;path&lt;/code&gt; field — the exact URL it should be served at.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update the Route
&lt;/h3&gt;

&lt;p&gt;Find the existing route 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="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;/&amp;lt;int:year&amp;gt;/&amp;lt;int:month&amp;gt;/&amp;lt;slug&amp;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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;filepath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&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;post.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;post&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace it with this dynamic lookup:&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="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;/&amp;lt;int:year&amp;gt;/&amp;lt;int:month&amp;gt;/&amp;lt;path:slug&amp;gt;.html&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;target_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.html&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;all_posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_all_posts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;matching_post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;all_posts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;target_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;matching_post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;post_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;matching_post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;post_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;matching_post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;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;post.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;post&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;post_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This route matches &lt;code&gt;.html&lt;/code&gt; URLs, finds the matching &lt;code&gt;post['path']&lt;/code&gt;, and loads the correct markdown file.&lt;/p&gt;

&lt;h3&gt;
  
  
  Update the Homepage Links
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;templates/index.html&lt;/code&gt;, replace the manually constructed URL with the post's path:&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="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ post.path }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for the "Read more" link:&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="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ post.path }}"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-400 text-sm hover:text-teal-300 font-medium transition-colors duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Read more →
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Add the Canonical Link Tag
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;templates/post.html&lt;/code&gt;, add the canonical link tag inside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, after the meta description:&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="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"{{ post.description }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
{% if post.canonical_url %}
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ post.canonical_url }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
{% endif %}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{% if %}&lt;/code&gt; guard keeps the tag optional for posts without &lt;code&gt;canonical_url&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Updated Frontmatter Schema
&lt;/h3&gt;

&lt;p&gt;This is now the full frontmatter schema going forward:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; tag, &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; on post page, post list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL generation, sorting, display&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt;, excerpt on homepage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tag pills on post page, eventually tag pages in Ep12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;canonical_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Homepage links + &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; for Blogger migration (optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When migrating posts, set &lt;code&gt;canonical_url&lt;/code&gt; to match the original Blogger URL.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Step 1: Verify Everything Works Locally
&lt;/h2&gt;

&lt;p&gt;Before touching Docker, make sure the platform still runs correctly after the URL changes.&lt;/p&gt;

&lt;p&gt;Activate your venv and run the app:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
&lt;span class="nv"&gt;$ &lt;/span&gt;python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;a href="http://localhost:8000" rel="noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt; and check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Homepage&lt;/strong&gt; — posts listed, links point to the correct paths&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post page&lt;/strong&gt; — renders correctly, &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; is present in the HTML source (view source and search for &lt;code&gt;canonical&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag pills&lt;/strong&gt; — display correctly on post pages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once everything works locally, we're ready to containerise it.&lt;/p&gt;




&lt;h2&gt;
  
  
  📦 Step 2: Add a .dockerignore
&lt;/h2&gt;

&lt;p&gt;Before writing the Dockerfile, tell Docker what to leave out of the image. Create &lt;code&gt;.dockerignore&lt;/code&gt; in the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;venv&lt;/span&gt;/
&lt;span class="err"&gt;__&lt;/span&gt;&lt;span class="n"&gt;pycache__&lt;/span&gt;/
*.&lt;span class="n"&gt;pyc&lt;/span&gt;
.&lt;span class="n"&gt;git&lt;/span&gt;/
.&lt;span class="n"&gt;gitignore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the image lean and avoids shipping local environment files.&lt;/p&gt;




&lt;h2&gt;
  
  
  📄 Step 3: Write the Dockerfile
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;Dockerfile&lt;/code&gt; in the project root:&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-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;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;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; ["python", "app.py"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this order matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;copy &lt;code&gt;requirements.txt&lt;/code&gt; first so dependency install can be cached&lt;/li&gt;
&lt;li&gt;copy app code after that so normal code edits rebuild quickly&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔨 Step 4: Build the Image
&lt;/h2&gt;

&lt;p&gt;Make sure you're in the project root (where the &lt;code&gt;Dockerfile&lt;/code&gt; is):&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="nv"&gt;$ &lt;/span&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; tioblog &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When it finishes, verify the image exists:&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="nv"&gt;$ &lt;/span&gt;docker images tioblog
IMAGE            ID             DISK USAGE   CONTENT SIZE   EXTRA
tioblog:latest   05dbc52b9852        231MB         55.6MB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🚀 Step 5: Run It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8000:8000 tioblog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;a href="http://localhost:8000" rel="noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt;. The blog loads — same as before, but now running inside a container.&lt;/p&gt;




&lt;h2&gt;
  
  
  📝 Step 6: Add .gitignore
&lt;/h2&gt;

&lt;p&gt;Before pushing to Clouderized, make sure you're not committing things that don't belong in the repo. Create &lt;code&gt;.gitignore&lt;/code&gt; in the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;venv&lt;/span&gt;/
&lt;span class="err"&gt;__&lt;/span&gt;&lt;span class="n"&gt;pycache__&lt;/span&gt;/
*.&lt;span class="n"&gt;pyc&lt;/span&gt;
.&lt;span class="n"&gt;env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps your virtual environment, cache files, and any local config out of version control.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌐 Step 7: Deploy to Clouderized
&lt;/h2&gt;

&lt;p&gt;Clouderized is the deployment target in this series because it is optimized for simple container app shipping: push to Git, auto-build, auto-deploy, HTTPS on by default.&lt;/p&gt;

&lt;p&gt;For this blog, deployment is:&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="nv"&gt;$ &lt;/span&gt;git init
&lt;span class="nv"&gt;$ &lt;/span&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial commit for tioblog"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git branch &lt;span class="nt"&gt;-M&lt;/span&gt; main
&lt;span class="nv"&gt;$ &lt;/span&gt;git remote add origin https://git.clouderized.com/davidtio/tioblog.git
&lt;span class="nv"&gt;$ &lt;/span&gt;git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;https://git.clouderized.com/davidtio/tioblog.git&lt;/code&gt; uses &lt;code&gt;davidtio&lt;/code&gt; as the Clouderized username and &lt;code&gt;tioblog&lt;/code&gt; as the project name.&lt;br&gt;
For your app, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://git.clouderized.com/&amp;lt;username&amp;gt;/&amp;lt;project&amp;gt;.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a minute or two, your blog is live at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://davidtio-tioblog.clouderized.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same Dockerfile, now running publicly with HTTPS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add Your Custom Domain
&lt;/h3&gt;

&lt;p&gt;Clouderized also supports custom domains. For this blog, production runs at &lt;code&gt;blog.dtio.app&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;code&gt;https://dash.clouderized.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Open the &lt;code&gt;tioblog&lt;/code&gt; app card&lt;/li&gt;
&lt;li&gt;Click &lt;code&gt;Domains&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add your custom domain (for example &lt;code&gt;blog.dtio.app&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Copy the &lt;code&gt;cfargotunnel&lt;/code&gt; target shown by Clouderized&lt;/li&gt;
&lt;li&gt;Create a &lt;code&gt;CNAME&lt;/code&gt; record for your domain and point it to the &lt;code&gt;cfargotunnel&lt;/code&gt; target&lt;/li&gt;
&lt;/ol&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%2Fcrtoxhkm32x81o559hs4.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%2Fcrtoxhkm32x81o559hs4.png" alt="Clouderized Domains view for tioblog custom domain setup"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After DNS propagates, your app is served on your own domain while Clouderized continues handling routing and HTTPS.&lt;/p&gt;

&lt;p&gt;Why this matters for small creator platforms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you keep one deployment unit (your Docker image)&lt;/li&gt;
&lt;li&gt;you avoid hand-maintained reverse proxy and certificate setup&lt;/li&gt;
&lt;li&gt;you can ship content and app changes with the same Git workflow&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🧪 Step 8: Verify It's Live
&lt;/h2&gt;

&lt;p&gt;Open your browser and check the live URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://davidtio-tioblog.clouderized.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the homepage loads, posts are listed, and a &lt;code&gt;.html&lt;/code&gt; post URL works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://davidtio-tioblog.clouderized.com/2026/04/hey-markdown.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches Blogger-style URLs for smoother migration.&lt;/p&gt;

&lt;p&gt;Also verify HTTPS is active and the cert is valid. On Clouderized this is automatic, so you can focus on content and product work instead of infra chores.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ What You've Built
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;tiohub-blog/
├── Dockerfile         ← new
├── .dockerignore      ← new
├── app.py             (get_post_path, post['path'] in get_all_posts, route adds post['path'])
├── requirements.txt
├── static/
│   └── js/
│       ├── tailwind.config.js
│       └── code-blocks.js
├── templates/
│   ├── index.html     (links use {{ post.path }} instead of manual URLs)
&lt;/span&gt;&lt;span class="gp"&gt;│   └── post.html      (&amp;lt;link rel="canonical"&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;added&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;└── content/
    └── posts/
        ├── hey-markdown.md  (canonical_url added to frontmatter)
        └── second-post.md
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;canonical_url&lt;/code&gt; and &lt;code&gt;post.path&lt;/code&gt; now drive stable post URLs&lt;/li&gt;
&lt;li&gt;dynamic route resolves URL path to the right markdown file&lt;/li&gt;
&lt;li&gt;homepage links use &lt;code&gt;{{ post.path }}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;optional canonical tag added to &lt;code&gt;post.html&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Dockerfile&lt;/code&gt;, &lt;code&gt;.dockerignore&lt;/code&gt;, and &lt;code&gt;.gitignore&lt;/code&gt; added&lt;/li&gt;
&lt;li&gt;app runs in container with &lt;code&gt;docker build&lt;/code&gt; + &lt;code&gt;docker run&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;deployment pushed to Clouderized for public HTTPS hosting&lt;/li&gt;
&lt;li&gt;custom domain can be mapped in &lt;code&gt;Domains&lt;/code&gt; with DNS pointed to the provided &lt;code&gt;cfargotunnel&lt;/code&gt; target&lt;/li&gt;
&lt;li&gt;deployment flow is Git-native, which keeps publish operations repeatable for future series posts&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🚀 Coming Up
&lt;/h2&gt;

&lt;p&gt;One problem: every time you edit a post, you have to rebuild the image to see the change. That defeats the purpose of a content-driven blog.&lt;/p&gt;

&lt;p&gt;Next episode: bind mounts. We'll mount the &lt;code&gt;content/&lt;/code&gt; folder from your machine directly into the container — edit a post, refresh the page, see the change instantly. No rebuild needed.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt; Share it with your network or drop a comment below.&lt;/p&gt;




&lt;h2&gt;
  
  
  SEO Metadata
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Title:&lt;/strong&gt; Building a Blog Platform with Docker #5: Add a Dockerfile + Deploy to Clouderized&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta Description:&lt;/strong&gt; Write a Dockerfile for your Flask blog, build the image, and deploy it to clouderized.com — push to git.clouderized.com/davidtio/tioblog, auto-build, auto-route, and auto-HTTPS at davidtio-tioblog.clouderized.com.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>python</category>
      <category>flask</category>
      <category>blogplatform</category>
    </item>
    <item>
      <title>Desktop Access for KVM on Podman: VNC First, SPICE Next</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Wed, 20 May 2026 15:08:59 +0000</pubDate>
      <link>https://dev.to/davidtio/desktop-access-for-kvm-on-podman-vnc-first-spice-next-3829</link>
      <guid>https://dev.to/davidtio/desktop-access-for-kvm-on-podman-vnc-first-spice-next-3829</guid>
      <description>&lt;h1&gt;
  
  
  Desktop Access for KVM on Podman: VNC First, SPICE Next
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Your VM works over SSH, but some tasks need a real desktop. In this post, we add a QEMU VNC console first, then show the same VM through SPICE and &lt;code&gt;remote-viewer&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤔 Why This Matters
&lt;/h2&gt;

&lt;p&gt;In Post #5, we made the VM reachable over SSH. In Post #6, we fixed the tiny SLES cloud image so it finally had room to install real packages.&lt;/p&gt;

&lt;p&gt;That gives us a working server-style VM — but it's still terminal-only.&lt;/p&gt;

&lt;p&gt;That's fine for many tasks, until you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GUI installers&lt;/li&gt;
&lt;li&gt;visual tools&lt;/li&gt;
&lt;li&gt;browser-based checks inside the guest&lt;/li&gt;
&lt;li&gt;desktop workflows for testing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is the step from "I can SSH into it" to "I can actually see and operate the desktop."&lt;/p&gt;

&lt;p&gt;The important distinction is that we're not starting with a guest-native remote desktop service yet. We first want a VM access path that proves the display layer works, even before SLES-specific RDP configuration enters the picture.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏗️ What We Are Building
&lt;/h2&gt;

&lt;p&gt;We're not rebuilding everything from scratch. We keep the same resized VM disk from Post #6, the same SSH management path from Post #5, and add desktop access in two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;QEMU VNC console&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VM console path&lt;/li&gt;
&lt;li&gt;useful when the guest stops at GRUB, installer screens, first boot, or recovery&lt;/li&gt;
&lt;li&gt;does not depend on a working guest-native remote desktop service&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;GNOME inside the guest&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;full desktop environment&lt;/li&gt;
&lt;li&gt;visible through the VM console&lt;/li&gt;
&lt;li&gt;Firefox and Cockpit for a useful first desktop/admin workflow&lt;/li&gt;
&lt;li&gt;prepares the VM for guest-native remote desktop in the next post&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;SPICE from QEMU&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;alternate VM console path&lt;/li&gt;
&lt;li&gt;works with &lt;code&gt;remote-viewer&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;VNC is first because QEMU can expose the VM console directly. Once GNOME is installed, the same console shows the graphical desktop.&lt;/p&gt;

&lt;p&gt;SPICE is the alternate path we add next so the same VM can be opened with &lt;code&gt;remote-viewer&lt;/code&gt; too.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Posts #1 to #6 completed&lt;/li&gt;
&lt;li&gt;Existing VM disk in &lt;code&gt;~/vm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;qemu:base&lt;/code&gt; image available&lt;/li&gt;
&lt;li&gt;Podman network &lt;code&gt;mynet&lt;/code&gt; available&lt;/li&gt;
&lt;li&gt;Host port &lt;code&gt;5900&lt;/code&gt; free&lt;/li&gt;
&lt;li&gt;SLES 16 full ISO available in &lt;code&gt;~/vm&lt;/code&gt; for non-subscribed installs&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/data&lt;/code&gt; disk from Post #6 available as &lt;code&gt;~/vm/extra-disk.qcow2&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples below assume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VM disk: &lt;code&gt;~/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SLES ISO: &lt;code&gt;~/vm/SLES-16.0-Full-x86_64-GM.install.iso&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Data disk: &lt;code&gt;~/vm/extra-disk.qcow2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Guest user: &lt;code&gt;sysadmin&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are using another distro image, keep the same flow and swap only the package commands for that distro.&lt;/p&gt;




&lt;h2&gt;
  
  
  🖥️ Step 1: Boot the VM with a QEMU VNC Console
&lt;/h2&gt;

&lt;p&gt;First, boot the VM with the QEMU VNC console exposed on host port &lt;code&gt;5900&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The QEMU console matters before the guest desktop is ready. If the VM stops at GRUB or needs boot interaction, SSH will not help yet. You need to see the VM console first.&lt;/p&gt;

&lt;p&gt;One important carry-over from Post #6: we continue with the same VM state, including the &lt;code&gt;/data&lt;/code&gt; disk.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;extra-disk.qcow2&lt;/code&gt; is missing but &lt;code&gt;/etc/fstab&lt;/code&gt; still expects it, the VM may sit at a boot wait for a missing UUID and SSH will never become usable.&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;cd&lt;/span&gt; ~/vm
&lt;span class="c"&gt;# Download your SLES 16 full ISO manually from SUSE Customer Center if needed.&lt;/span&gt;
podman network exists mynet &lt;span class="o"&gt;||&lt;/span&gt; podman network create mynet

podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; kvm-gui &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mynet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 2222:22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5900:5900 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
  qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
  qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-m&lt;/span&gt; 2048 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/extra-disk.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Full-x86_64-GM.install.iso,media&lt;span class="o"&gt;=&lt;/span&gt;cdrom,readonly&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-boot&lt;/span&gt; &lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;c &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-netdev&lt;/span&gt; user,id&lt;span class="o"&gt;=&lt;/span&gt;net0,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::22-:22 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net-pci,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-vga&lt;/span&gt; virtio &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-display&lt;/span&gt; &lt;span class="nv"&gt;vnc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0:0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After you run this, the terminal may look quiet — that's expected. QEMU isn't opening a local GTK window or printing the graphical boot console to your shell. The VM console is being served over VNC on &lt;code&gt;5900&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Quick notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-p 5900:5900&lt;/code&gt; exposes the QEMU VM console to your host&lt;/li&gt;
&lt;li&gt;keep &lt;code&gt;-p 2222:22&lt;/code&gt; for SSH control&lt;/li&gt;
&lt;li&gt;ISO is attached as a virtual CD-ROM so the guest can mount it as &lt;code&gt;/dev/sr0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;extra-disk.qcow2&lt;/code&gt; keeps the &lt;code&gt;/data&lt;/code&gt; mount from Post #6 available during boot&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-boot order=c&lt;/code&gt; keeps the resized cloud disk as the boot target even with the ISO attached&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-vga virtio&lt;/code&gt; gives the guest a proper virtual display adapter for the graphical desktop&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-display vnc=0.0.0.0:0&lt;/code&gt; exposes QEMU's console on port &lt;code&gt;5900&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now connect a VNC client to the QEMU console right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;localhost:5900
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this console to watch boot progress and confirm the VM gets past GRUB and reaches the guest OS. After the guest is up, SSH should work again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 sysadmin@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🎨 Step 2: Install GNOME 48
&lt;/h2&gt;

&lt;p&gt;Once the VM is up, SSH in if you're not already using the QEMU console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 sysadmin@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We'll install GNOME, the default desktop environment on SLES 16.&lt;/p&gt;

&lt;p&gt;There are two practical install paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;subscribed system: install directly from online repos&lt;/li&gt;
&lt;li&gt;non-subscribed system: mount the SLES 16 ISO and use it as a local repo&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Option A: Subscribed SLES (online repos)
&lt;/h3&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;zypper refresh
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper patterns | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; gnome
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; pattern gnome
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; MozillaFirefox cockpit
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start cockpit.socket
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;cockpit.socket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option B: Non-Subscribed SLES (ISO repo)
&lt;/h3&gt;

&lt;p&gt;Mount the attached SLES 16 ISO inside the guest and register it as a local repository:&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 mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/sles16-iso
lsblk
&lt;span class="nb"&gt;sudo &lt;/span&gt;mount /dev/sr0 /mnt/sles16-iso
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper ar &lt;span class="nt"&gt;-f&lt;/span&gt; file:///mnt/sles16-iso sles16-iso
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper refresh
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper patterns | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; gnome
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; pattern gnome
&lt;span class="nb"&gt;sudo &lt;/span&gt;zypper &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; MozillaFirefox cockpit
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start cockpit.socket
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;cockpit.socket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firefox gives the desktop something useful to open immediately.&lt;br&gt;
Cockpit gives you a browser-based admin interface at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://localhost:9090
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From inside the VM desktop, open Firefox and browse to that address.&lt;br&gt;
Cockpit also gives you a decent terminal, so you don't need to add a separate desktop terminal package for this lab.&lt;/p&gt;

&lt;p&gt;Set graphical mode as the default boot target:&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;systemctl set-default graphical.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reboot so SLES starts into the graphical target:&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;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🔌 Step 3: Connect to the Desktop Console
&lt;/h2&gt;

&lt;p&gt;From your host, keep your VNC client connected to the QEMU console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;localhost:5900
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the reboot, the same QEMU VNC console should show the SLES graphical login screen. This is still not guest-native RDP — it's the VM console showing the guest desktop.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Step 4: Where SPICE Fits
&lt;/h2&gt;

&lt;p&gt;QEMU VNC proves the VM console and desktop path work. SPICE is the alternate client path — same VM, different protocol.&lt;/p&gt;

&lt;p&gt;Use the same VM, but switch the display side of the QEMU command to SPICE:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; kvm-spice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mynet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 2222:22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 5930:5930 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
  qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
  qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-m&lt;/span&gt; 2048 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/extra-disk.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Full-x86_64-GM.install.iso,media&lt;span class="o"&gt;=&lt;/span&gt;cdrom,readonly&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-boot&lt;/span&gt; &lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;c &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-netdev&lt;/span&gt; user,id&lt;span class="o"&gt;=&lt;/span&gt;net0,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::22-:22 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net-pci,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-spice&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5930,addr&lt;span class="o"&gt;=&lt;/span&gt;0.0.0.0,disable-ticketing&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-vga &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-keyboard-pci &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-mouse-pci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then connect with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;remote-viewer spice://localhost:5930
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, you have two access paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSH: command-line management on &lt;code&gt;2222&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;QEMU VNC console or SPICE console: boot, recovery, and desktop console&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  💡 Why Not Start with RDP?
&lt;/h2&gt;

&lt;p&gt;SLES 16 has a GNOME Remote Desktop path that uses RDP. That's probably the more familiar protocol for many users, and it's worth covering.&lt;/p&gt;

&lt;p&gt;But it's also a guest OS feature. You configure it inside SLES with GNOME, GDM, TLS credentials, and &lt;code&gt;grdctl&lt;/code&gt; — that's a different layer from the VM console.&lt;/p&gt;

&lt;p&gt;VNC is the generic first step because it works across guest operating systems. SPICE is another QEMU/KVM console protocol and a valid alternate client path.&lt;/p&gt;

&lt;p&gt;So the order is deliberate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;QEMU VNC proves generic console and desktop visibility.&lt;/li&gt;
&lt;li&gt;SPICE gives you another way to open the same console from &lt;code&gt;remote-viewer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;GNOME Remote Desktop over RDP becomes the SLES-native day-to-day access path once the guest is healthy.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  🎉 Wrap Up
&lt;/h2&gt;

&lt;p&gt;At this point, your KVM-on-Podman VM is not only reachable and resized — it's also visible. VNC gives you the generic desktop baseline, and SPICE is an alternate console path for &lt;code&gt;remote-viewer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the next post, we'll switch from console access to guest-native access and use the SLES 16 path: GNOME Remote Desktop over RDP.&lt;/p&gt;

</description>
      <category>kvm</category>
      <category>podman</category>
      <category>vnc</category>
      <category>spice</category>
    </item>
    <item>
      <title>Docker Compose: depends_on and Health Checks That Actually Protect Startup (2026)</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Mon, 11 May 2026 07:00:41 +0000</pubDate>
      <link>https://dev.to/davidtio/docker-compose-dependson-and-health-checks-that-actually-protect-startup-2026-1bc1</link>
      <guid>https://dev.to/davidtio/docker-compose-dependson-and-health-checks-that-actually-protect-startup-2026-1bc1</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; &lt;code&gt;restart: unless-stopped&lt;/code&gt; brings containers back, but it cannot make startup safe. This episode fixes startup races with &lt;code&gt;healthcheck&lt;/code&gt; and &lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  🤔 Why This Matters
&lt;/h3&gt;

&lt;p&gt;In episode 9, we hit a painful failure pattern.&lt;/p&gt;

&lt;p&gt;The app started before Postgres was actually ready. Migration failed once, then the app kept restarting into a broken state. &lt;code&gt;docker compose ps&lt;/code&gt; looked fine, users still saw failures.&lt;/p&gt;

&lt;p&gt;That is the key difference between these two ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restart policy&lt;/strong&gt; answers: what to do after a container exits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health check&lt;/strong&gt; answers: is this service actually ready to serve traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want reliable startup, you need both concepts. In this post we focus on readiness.&lt;/p&gt;




&lt;h3&gt;
  
  
  ✅ Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ep 1-9 completed.&lt;/strong&gt; You are comfortable with Compose files, multi-service stacks, and restart policies.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  🧨 The Startup Race
&lt;/h3&gt;

&lt;p&gt;Create and use a dedicated project folder so container names stay predictable in this post:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/appstack
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/appstack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this minimal stack:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea.dtio.app/davidtio/noteboard:latest&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="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;appstate:/app/state&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;appstate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring it up:&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="nv"&gt;$ &lt;/span&gt;docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typical early logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app-1  | First run, setting up...
app-1  | Migration failed: connection to server at "db" (...) Connection refused
app-1  | Already installed, skipping migrations
app-1  | Serving on :5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now check the app:&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="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://localhost:5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A very common result here is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl: (52) Empty reply from server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the exact failure pattern we care about in this episode. The container is up, but the app is not actually ready.&lt;/p&gt;




&lt;h3&gt;
  
  
  ❌ Why Plain &lt;code&gt;depends_on&lt;/code&gt; Is Not Enough
&lt;/h3&gt;

&lt;p&gt;A common fix attempt is:&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;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only guarantees start order, not readiness.&lt;/p&gt;

&lt;p&gt;Compose starts &lt;code&gt;db&lt;/code&gt; first, but Postgres still needs time to initialize and accept queries. Your app can still race and fail.&lt;/p&gt;




&lt;h3&gt;
  
  
  ✅ Add a Real Database Health Check
&lt;/h3&gt;

&lt;p&gt;Update the &lt;code&gt;db&lt;/code&gt; service with &lt;code&gt;pg_isready&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;test&lt;/code&gt;: command used to check readiness&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;interval&lt;/code&gt;: how often to check&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timeout&lt;/code&gt;: max time for one check&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retries&lt;/code&gt;: failures before unhealthy&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;start_period&lt;/code&gt;: grace period during startup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check status:&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="nv"&gt;$ &lt;/span&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;db&lt;/code&gt; move from starting to healthy.&lt;/p&gt;




&lt;h3&gt;
  
  
  ✅ Gate App Startup on Health, Not Order
&lt;/h3&gt;

&lt;p&gt;Now update &lt;code&gt;app&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea.dtio.app/davidtio/noteboard:latest&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="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;appstate:/app/state&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the important part of the configuration. This configuration will ensure that &lt;code&gt;app&lt;/code&gt; will not start until Compose marks &lt;code&gt;db&lt;/code&gt; healthy.&lt;/p&gt;

&lt;p&gt;After updating the Compose file, always remove containers and volumes first:&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="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--volumes&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then test:&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="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://localhost:5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you should get a valid HTTP response instead of empty reply or transaction errors.&lt;/p&gt;




&lt;h3&gt;
  
  
  📦 Full Compose File (Fixed)
&lt;/h3&gt;

&lt;p&gt;The full compose file will be as follow:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
      &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea.dtio.app/davidtio/noteboard:latest&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="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;appstate:/app/state&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;appstate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🔍 What To Watch During Startup
&lt;/h3&gt;

&lt;p&gt;Useful commands while testing:&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="nv"&gt;$ &lt;/span&gt;docker compose ps
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; db app
&lt;span class="nv"&gt;$ &lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; json appstack-db-1 | jq &lt;span class="s1"&gt;'.[]|.State.Health'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are looking for this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;db&lt;/code&gt; container starts.&lt;/li&gt;
&lt;li&gt;health check runs and turns &lt;code&gt;healthy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;app&lt;/code&gt; starts only after &lt;code&gt;db&lt;/code&gt; is healthy.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  ⚠ Important Caveat
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;depends_on: condition: service_healthy&lt;/code&gt; controls startup order and readiness at startup time.&lt;/p&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; restart dependent services automatically later if &lt;code&gt;db&lt;/code&gt; becomes unhealthy at runtime.&lt;/p&gt;

&lt;p&gt;You still need app-level retry logic, graceful error handling, and observability for runtime incidents.&lt;/p&gt;




&lt;h3&gt;
  
  
  🧪 Exercise: Start Ghost, Sign Up, and Switch to Journal
&lt;/h3&gt;

&lt;p&gt;In this exercise, you will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;start Ghost with MySQL&lt;/li&gt;
&lt;li&gt;apply startup-readiness fix with &lt;code&gt;healthcheck&lt;/code&gt; + &lt;code&gt;depends_on&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;sign up in Ghost Admin with any email (captured by Mailpit)&lt;/li&gt;
&lt;li&gt;switch to the built-in Journal theme&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Exercise Walkthrough
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;Start with this Compose file (plain &lt;code&gt;depends_on&lt;/code&gt; first):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:8&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rootpass&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghostpass&lt;/span&gt;

  &lt;span class="na"&gt;mail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;axllent/mailpit:latest&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;8025:8025"&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost:5-alpine&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;2368:2368"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;database__client&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
      &lt;span class="na"&gt;database__connection__host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;database__connection__user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost&lt;/span&gt;
      &lt;span class="na"&gt;database__connection__password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghostpass&lt;/span&gt;
      &lt;span class="na"&gt;database__connection__database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghost&lt;/span&gt;
      &lt;span class="na"&gt;database__connection__port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3306"&lt;/span&gt;
      &lt;span class="na"&gt;mail__transport&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SMTP&lt;/span&gt;
      &lt;span class="na"&gt;mail__options__host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mail&lt;/span&gt;
      &lt;span class="na"&gt;mail__options__port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1025"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;First run:
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Check behavior:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose logs &lt;span class="nt"&gt;--no-color&lt;/span&gt; db app | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 80
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://localhost:2368
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this first run, you should see this failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl: (7) Failed to connect to localhost port 2368 after 0 ms: Couldn't connect to server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typical broken-state signal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ghost starts, then fails database connection&lt;/li&gt;
&lt;li&gt;MySQL is still initializing&lt;/li&gt;
&lt;li&gt;app goes offline even though containers were started&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real output example (trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app-1  | [INFO] Ghost server started in 0.228s
app-1  | [ERROR] connect ECONNREFUSED 172.20.0.2:3306
app-1  | Error: connect ECONNREFUSED 172.20.0.2:3306
app-1  | [WARN] Ghost is shutting down
db-1   | [Entrypoint]: Initializing database files
db-1   | [Entrypoint]: Creating database ghost
db-1   | [Entrypoint]: MySQL init process done. Ready for start up.
db-1   | mysqld: ready for connections. ... port: 3306
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Apply startup-readiness fix:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&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;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mysqladmin&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ping&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-h&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-ughost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-pghostpass&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--silent"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12&lt;/span&gt;
    &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;

&lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
  &lt;span class="na"&gt;mail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_started&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;After updating the Compose file, reset fully (including volumes), then run:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--volumes&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose ps
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; http://localhost:2368
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Open Ghost and Mailpit:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Ghost site: &lt;code&gt;http://localhost:2368&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ghost Admin: &lt;code&gt;http://localhost:2368/ghost&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Mailpit inbox: &lt;code&gt;http://localhost:8025&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Create your Ghost admin user with any email address.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The email does not need to be real for this lab. Ghost email goes to Mailpit, so use the inbox at &lt;code&gt;http://localhost:8025&lt;/code&gt; for any verification link.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Ghost Admin, switch to the built-in Journal theme:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;Settings -&amp;gt; Design -&amp;gt; Change theme -&amp;gt; Journal -&amp;gt; Activate&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Exercise complete.&lt;/p&gt;

&lt;p&gt;Ghost is now running cleanly, Mailpit is handling local signup flow, and the Journal theme is live.&lt;/p&gt;

&lt;p&gt;Most importantly, you removed the startup race by gating app startup on real database readiness.&lt;/p&gt;

&lt;p&gt;Next episode, we level this up: a simpler, cleaner, more repeatable Ghost deployment you can rebuild with confidence.&lt;/p&gt;




&lt;h3&gt;
  
  
  🏁 What You Built
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mysql&lt;/code&gt; health check&lt;/td&gt;
&lt;td&gt;Confirms the DB is truly ready, not only started&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;service_healthy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Delays Ghost startup until MySQL readiness is real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailpit in local stack&lt;/td&gt;
&lt;td&gt;Captures signup/verification emails without external SMTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reset-and-rerun workflow&lt;/td&gt;
&lt;td&gt;Reproduces and validates the startup race fix cleanly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;strong&gt;Coming up:&lt;/strong&gt; Startup is stable now. Next, we simplify the Ghost deployment so setup is faster, cleaner, and easier to repeat.&lt;/p&gt;




</description>
      <category>docker</category>
      <category>dockercompose</category>
      <category>healthcheck</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building a Blog Platform with Docker #4: Dynamic Routes and a Real Post List</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Sun, 10 May 2026 07:02:51 +0000</pubDate>
      <link>https://dev.to/davidtio/building-a-blog-platform-with-docker-4-dynamic-routes-and-a-real-post-list-46mp</link>
      <guid>https://dev.to/davidtio/building-a-blog-platform-with-docker-4-dynamic-routes-and-a-real-post-list-46mp</guid>
      <description>&lt;h1&gt;
  
  
  🗂️ Building a Blog Platform with Docker #4: Dynamic Routes and a Real Post List
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Replace the hardcoded route and static post list with a dynamic scanner that finds every &lt;code&gt;.md&lt;/code&gt; file in your content folder — drop a file in, it appears on the homepage.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤔 Why This Matters
&lt;/h2&gt;

&lt;p&gt;After Episode 3 you can render a Markdown post. One post. At a hardcoded URL.&lt;/p&gt;

&lt;p&gt;That's proof the plumbing works — but it's not a blog platform. To have a real blog, two things need to change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The homepage&lt;/strong&gt; should list all posts, sorted by date, pulled from actual files — not copied in by hand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The routes&lt;/strong&gt; should be dynamic — any post at any date and slug should just work, without adding a new &lt;code&gt;@app.route&lt;/code&gt; every time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While we're at it, there's a third thing worth doing now: SEO. The moment you have multiple real posts and real URLs, Google can find them. A &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; tag is the minimum you need. And since we already have a &lt;code&gt;description&lt;/code&gt; field in the post frontmatter (we're adding it today), it costs nothing to wire it up.&lt;/p&gt;

&lt;p&gt;By the end of this episode, you'll be able to drop any &lt;code&gt;.md&lt;/code&gt; file into &lt;code&gt;content/posts/&lt;/code&gt;, refresh the homepage, and see it listed. No code changes required.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Starting Point
&lt;/h2&gt;

&lt;p&gt;From Episode 3, your structure looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tiohub-blog/
├── app.py
├── requirements.txt
├── static/
│   └── js/
│       ├── code-blocks.js
│       └── tailwind.config.js
├── templates/
│   ├── index.html
│   └── post.html
└── content/
    └── posts/
        └── hey-markdown.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're missing anything, go back to Episode 3 first.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✏️ Step 1: Update the Frontmatter Schema
&lt;/h2&gt;

&lt;p&gt;We're adding one field to the frontmatter: &lt;code&gt;description&lt;/code&gt;. It does two things at once — it becomes the &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; tag for SEO, and it becomes the excerpt blurb on the homepage post list.&lt;/p&gt;

&lt;p&gt;Update &lt;code&gt;content/posts/hey-markdown.md&lt;/code&gt;. Find the existing frontmatter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hey&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Markdown"&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-25&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="nv"&gt;meta&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;blog&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the &lt;code&gt;description&lt;/code&gt; field between &lt;code&gt;date&lt;/code&gt; and &lt;code&gt;tags&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hey&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Markdown"&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-25&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;first&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;written&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Markdown&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;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;more&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;writing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;HTML&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hand."&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="nv"&gt;meta&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;blog&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the full frontmatter schema going forward. Every post should have all four fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; tag, &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; on post page, post list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;URL generation, sorting, display&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt;, excerpt on homepage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Tag pills on post page, eventually tag pages in Ep12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🔍 Step 2: Add the Post Scanner
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;app.py&lt;/code&gt;. Add a &lt;code&gt;get_all_posts()&lt;/code&gt; function below &lt;code&gt;parse_post()&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_all_posts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;posts_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;posts_dir&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;filepath&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;posts_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;slug&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&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;date&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="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lists every &lt;code&gt;.md&lt;/code&gt; file in &lt;code&gt;content/posts/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Parses each one with the existing &lt;code&gt;parse_post()&lt;/code&gt; function&lt;/li&gt;
&lt;li&gt;Adds a &lt;code&gt;slug&lt;/code&gt; field (the filename without &lt;code&gt;.md&lt;/code&gt;) — this is what goes in the URL&lt;/li&gt;
&lt;li&gt;Sorts all posts by date, newest first&lt;/li&gt;
&lt;li&gt;Returns the full list&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔗 Step 3: Replace the Hardcoded Route with a Dynamic One
&lt;/h2&gt;

&lt;p&gt;Remove the hardcoded &lt;code&gt;hey_markdown_post()&lt;/code&gt; route entirely. Replace it with this:&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="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;/&amp;lt;int:year&amp;gt;/&amp;lt;int:month&amp;gt;/&amp;lt;slug&amp;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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;filepath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&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;post.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;post&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the top of &lt;code&gt;app.py&lt;/code&gt;, find the existing Flask import:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;abort&lt;/code&gt; to it:&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;abort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How the URL is constructed:&lt;/strong&gt; The date in the frontmatter has a &lt;code&gt;year&lt;/code&gt; and &lt;code&gt;month&lt;/code&gt; attribute once parsed by PyYAML (it parses &lt;code&gt;2026-04-25&lt;/code&gt; as a Python &lt;code&gt;date&lt;/code&gt; object). We'll use those in the next step to build the correct link from the homepage. The route parameters &lt;code&gt;year&lt;/code&gt; and &lt;code&gt;month&lt;/code&gt; aren't used to look up the file — the &lt;code&gt;slug&lt;/code&gt; is enough, since slugs are unique. They're there to keep the URL format consistent with the Blogger pattern we established in Episode 3.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏠 Step 4: Update the Homepage
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Update the index route in &lt;code&gt;app.py&lt;/code&gt;.&lt;/strong&gt; Find the existing route:&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="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;index&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace it with:&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="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;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_all_posts&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;posts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Replace the hardcoded post list in &lt;code&gt;templates/index.html&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Find the hardcoded &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; block inside &lt;code&gt;&amp;lt;div class="flex flex-col"&amp;gt;&lt;/code&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="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"border-l-2 border-slate-800 hover:border-brand-500 pl-6 py-5 transition-all duration-300 group cursor-pointer"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-600 text-xs mb-2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;29 Mar 2026 &lt;span class="ni"&gt;&amp;amp;middot;&lt;/span&gt; Blog Platform&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h3&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-100 font-semibold text-lg mb-2 group-hover:text-brand-500 transition-colors duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Building a Blog Platform #1: Flask Setup&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 text-sm leading-relaxed"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Get a basic Flask app running with separate CSS — no Docker yet, just Python and a stylesheet.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace it with this loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;{% for post in posts %}
&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"border-b border-slate-800 pb-10 last:border-0 last:pb-0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center gap-3 mb-3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;time&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 text-sm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.date }}&lt;span class="nt"&gt;&amp;lt;/time&amp;gt;&lt;/span&gt;
        {% for tag in post.tags %}
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-xs bg-teal-900/50 text-teal-300 px-2 py-0.5 rounded-full"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ tag }}&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        {% endfor %}
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h2&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"font-serif text-2xl text-white mb-3 leading-snug"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/{{ post.date.year }}/{{ '%02d' | format(post.date.month) }}/{{ post.slug }}"&lt;/span&gt;
           &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"hover:text-teal-400 transition-colors duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            {{ post.title }}
        &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-400 leading-relaxed mb-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.description }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/{{ post.date.year }}/{{ '%02d' | format(post.date.month) }}/{{ post.slug }}"&lt;/span&gt;
       &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-400 text-sm hover:text-teal-300 font-medium transition-colors duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        Read more →
    &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
{% endfor %}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each post in the list now shows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The date and tag pills in a row&lt;/li&gt;
&lt;li&gt;The post title as a link&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;description&lt;/code&gt; as the excerpt&lt;/li&gt;
&lt;li&gt;A "Read more →" link&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The URL is built from the date and slug: &lt;code&gt;/2026/04/hey-markdown&lt;/code&gt;. No hardcoding anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔎 Step 5: Add Meta Description to the Post Page
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;templates/post.html&lt;/code&gt;. In the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; section, add the meta description tag after &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&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="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"{{ post.description }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. When Google indexes the page, it reads this tag and uses it as the snippet under the link in search results. The content comes directly from the &lt;code&gt;description&lt;/code&gt; field in your frontmatter — the same text you already wrote for the homepage excerpt.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏷️ Step 6: Add Tag Pills to the Post Page
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;templates/post.html&lt;/code&gt;. Inside &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt;, find the date and title block:&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="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 text-sm mb-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.date }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"font-serif text-4xl text-white leading-tight mb-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace it with this — same date and title, with the tag pills inserted between them:&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="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 text-sm mb-3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.date }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-wrap gap-2 mb-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    {% for tag in post.tags %}
    &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-xs bg-teal-900/50 text-teal-300 px-2.5 py-1 rounded-full"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ tag }}&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    {% endfor %}
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"font-serif text-4xl text-white leading-tight mb-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tags are non-clickable for now — they're labels, not links. They become proper links with their own pages in Episode 12, once the platform has enough posts and structure to make tag filtering useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧪 Step 7: Test It
&lt;/h2&gt;

&lt;p&gt;Add a second post so you can verify the list actually works. Create &lt;code&gt;content/posts/second-post.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Second&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post"&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-26&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Proving&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;scanner&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;works&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;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;appeared&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;without&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;touching&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app.py."&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="nv"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

Dropped this file into &lt;span class="sb"&gt;`content/posts/`&lt;/span&gt;. Restarted Flask. It appeared.

No new routes. No hardcoded HTML. Just a file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the app:&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="nv"&gt;$ &lt;/span&gt;python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Homepage&lt;/strong&gt; — both posts listed, newest first, with excerpts and tags&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/2026/04/hey-markdown&lt;/code&gt;&lt;/strong&gt; — first post renders correctly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/2026/04/second-post&lt;/code&gt;&lt;/strong&gt; — second post renders correctly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;View source&lt;/strong&gt; on either post — confirm &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; is present in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop another &lt;code&gt;.md&lt;/code&gt; file&lt;/strong&gt; into &lt;code&gt;content/posts/&lt;/code&gt; and refresh — it should appear immediately&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ✅ What You've Built
&lt;/h2&gt;

&lt;p&gt;Your file structure now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tiohub-blog/
├── app.py
├── requirements.txt
├── static/
│   └── js/
│       ├── tailwind.config.js
│       └── code-blocks.js
├── templates/
│   ├── index.html
│   └── post.html
└── content/
    └── posts/
        ├── hey-markdown.md
        └── second-post.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What changed:&lt;/p&gt;

&lt;p&gt;✅ &lt;code&gt;get_all_posts()&lt;/code&gt; — scans the posts folder and returns all posts sorted by date&lt;br&gt;
✅ Dynamic &lt;code&gt;/&amp;lt;int:year&amp;gt;/&amp;lt;int:month&amp;gt;/&amp;lt;slug&amp;gt;&lt;/code&gt; route — works for any post, no code changes needed&lt;br&gt;
✅ Homepage driven by real files — no more hardcoded HTML&lt;br&gt;
✅ &lt;code&gt;description&lt;/code&gt; field wired up as SEO meta tag and homepage excerpt&lt;br&gt;
✅ Tag pills on post pages&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Coming Up
&lt;/h2&gt;

&lt;p&gt;Next episode: Docker. The app runs fine locally, but "works on my machine" isn't a deployment strategy. We'll write a &lt;code&gt;Dockerfile&lt;/code&gt;, build an image, and run the blog in a container — the same way it'll run in production.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt; Share it with your network or drop a comment below.&lt;/p&gt;




&lt;h2&gt;
  
  
  SEO Metadata
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Title:&lt;/strong&gt; Building a Blog Platform with Docker #4: Dynamic Routes and a Real Post List&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta Description:&lt;/strong&gt; Replace hardcoded Flask routes with a dynamic post scanner. Every .md file in your content folder becomes a post automatically — plus SEO meta description and tag pills.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word Count:&lt;/strong&gt; ~1,100&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>flask</category>
      <category>blogplatform</category>
      <category>seo</category>
    </item>
    <item>
      <title>Resize a Tiny SLES 16 Cloud Image with qemu:base on Ubuntu 26.04</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Wed, 06 May 2026 01:01:03 +0000</pubDate>
      <link>https://dev.to/davidtio/resize-a-tiny-sles-16-cloud-image-with-qemubase-on-ubuntu-2604-h9i</link>
      <guid>https://dev.to/davidtio/resize-a-tiny-sles-16-cloud-image-with-qemubase-on-ubuntu-2604-h9i</guid>
      <description>&lt;h1&gt;
  
  
  Resize a Tiny SLES 16 Cloud Image with qemu:base on Ubuntu 26.04
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; The SLES 16 minimal cloud image boots fast, but it left me with about 3 MB free. That is not enough room to do real work, so we resize it on our brand new &lt;code&gt;qemu:base&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤔 Why This Matters
&lt;/h2&gt;

&lt;p&gt;In Post #4, SLES gave us the most interesting cloud-image result: fast boot, enterprise Linux, and almost no free space.&lt;/p&gt;

&lt;p&gt;The VM works. It boots quickly. Cloud-init configures the user. In Post #5, we made it reachable over the network.&lt;/p&gt;

&lt;p&gt;Then you log in and see this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-Th&lt;/span&gt;
Filesystem     Type      Size  Used Avail Use% Mounted on
/dev/vda3      xfs       742M  739M  3.0M 100% /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 3 megabytes free. I had barely done anything with this VM yet.&lt;/p&gt;

&lt;p&gt;That is painful by today's standard. Installing one package can fill that up. A normal &lt;code&gt;zypper update&lt;/code&gt; is already too much. The VM is technically running, but there is no breathing room.&lt;/p&gt;

&lt;p&gt;Ubuntu cloud images are usually generous enough to be usable without resizing. SLES 16 minimal is not, and that makes it the perfect example for learning how cloud image resizing actually works.&lt;/p&gt;

&lt;p&gt;To resize it properly, our &lt;code&gt;qemu:base&lt;/code&gt; toolbox needs the right disk utility. &lt;code&gt;qemu-img&lt;/code&gt; handles the virtual disk size from outside the guest.&lt;/p&gt;

&lt;p&gt;Ubuntu 26.04 LTS just dropped, so this is a good chance to see how it holds up as the new base for our QEMU toolbox. We will rebuild &lt;code&gt;qemu:base&lt;/code&gt; on Ubuntu 26.04, keep &lt;code&gt;qemu-img&lt;/code&gt; available there, then use that container to fix the SLES disk.&lt;/p&gt;

&lt;p&gt;In this post we will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rebuild &lt;code&gt;qemu:base&lt;/code&gt; on Ubuntu 26.04.&lt;/li&gt;
&lt;li&gt;Use the refreshed toolbox to grow the SLES qcow2 file.&lt;/li&gt;
&lt;li&gt;Expand the root partition inside the VM.&lt;/li&gt;
&lt;li&gt;Grow the filesystem.&lt;/li&gt;
&lt;li&gt;Verify that the VM finally has room to work.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📋 Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Podman&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/vm&lt;/code&gt; directory with cloud images&lt;/li&gt;
&lt;li&gt;A SLES cloud image in &lt;code&gt;~/vm&lt;/code&gt;, already seeded with &lt;code&gt;cloud-init&lt;/code&gt; from Post #4&lt;/li&gt;
&lt;li&gt;SSH access to the VM from Post #5&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The examples use this file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your image has a different name, swap the path in the commands below. If this is a fresh SLES cloud image, boot it once with the &lt;code&gt;cloud-init-sles.iso&lt;/code&gt; from Post #4 before following this post. The resize steps assume the VM already has the &lt;code&gt;sysadmin&lt;/code&gt; user and SSH access configured.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧰 Step 1: Rebuild qemu:base on Ubuntu 26.04
&lt;/h2&gt;

&lt;p&gt;Our earlier &lt;code&gt;qemu:base&lt;/code&gt; image was only meant to get QEMU running. For this episode, we will refresh it on Ubuntu 26.04 and include the disk tools we need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM ubuntu:26.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update &amp;amp;&amp;amp; \
    apt-get install -y --no-install-recommends \
        qemu-system-x86 \
        qemu-utils \
        iproute2 \
        bash \
        ca-certificates \
    &amp;amp;&amp;amp; apt-get clean \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

COPY run-vm.sh /run-vm.sh
RUN chmod +x /run-vm.sh

WORKDIR /vms
CMD ["/bin/bash"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/vm
&lt;span class="nv"&gt;$ &lt;/span&gt;podman build &lt;span class="nt"&gt;-t&lt;/span&gt; qemu:base &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the tools:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; qemu:base qemu-img &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Ubuntu version here is about the toolbox. It does not mean the guest VM is Ubuntu. We are using an Ubuntu-based container to manage and boot a SLES cloud image.&lt;/p&gt;




&lt;h2&gt;
  
  
  📊 Step 2: Check the Problem
&lt;/h2&gt;

&lt;p&gt;Create the same Podman network we used in Post #5:&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="nv"&gt;$ &lt;/span&gt;podman network create mynet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then boot the already-seeded SLES VM with SSH forwarding:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; sles-resize &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mynet &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-p&lt;/span&gt; 2222:22 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-netdev&lt;/span&gt; user,id&lt;span class="o"&gt;=&lt;/span&gt;net0,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::22-:22 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net-pci,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In another terminal, SSH into the VM through the forwarded host port:&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="nv"&gt;$ &lt;/span&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 sysadmin@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you are in, check the root filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-Th&lt;/span&gt; /
Filesystem     Type      Size  Used Avail Use% Mounted on
/dev/vda3      xfs       742M  739M  3.0M 100% /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check the block devices:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; lsblk
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
vda    254:0    0  1.3G  0 disk
├─vda1 254:1    0    2M  0 part
├─vda2 254:2    0  512M  0 part /boot/efi
└─vda3 254:3    0  806M  0 part /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The disk is small, and the root partition is smaller. We need to grow both.&lt;/p&gt;

&lt;p&gt;Shut the VM down cleanly before touching the disk image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;poweroff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🔧 Step 3: Back Up the Image
&lt;/h2&gt;

&lt;p&gt;Before resizing any disk image, make a copy:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; ~/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
     ~/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2.bak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is cheap insurance. If you mistype a partition number later, you can go back.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔧 Step 4: Grow the qcow2 File
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;qemu-img resize&lt;/code&gt; from the &lt;code&gt;qemu:base&lt;/code&gt; container. This grows the virtual disk container, but it does not grow the partition or filesystem inside it yet.&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="nv"&gt;$ &lt;/span&gt;podman 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; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-img info /vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2
image: /vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2
file format: qcow2
virtual size: 1.29 GiB &lt;span class="o"&gt;(&lt;/span&gt;1385168896 bytes&lt;span class="o"&gt;)&lt;/span&gt;
disk size: 339 MiB
cluster_size: 65536
Format specific information:
    compat: 1.1
    compression &lt;span class="nb"&gt;type&lt;/span&gt;: zlib
    lazy refcounts: &lt;span class="nb"&gt;false
    &lt;/span&gt;refcount bits: 16
    corrupt: &lt;span class="nb"&gt;false
    &lt;/span&gt;extended l2: &lt;span class="nb"&gt;false
&lt;/span&gt;Child node &lt;span class="s1"&gt;'/file'&lt;/span&gt;:
    filename: /vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2
    protocol &lt;span class="nb"&gt;type&lt;/span&gt;: file
    file length: 339 MiB &lt;span class="o"&gt;(&lt;/span&gt;355467264 bytes&lt;span class="o"&gt;)&lt;/span&gt;
    disk size: 339 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guest sees a 1.29 GiB virtual disk, but the qcow2 file only uses 339 MiB on the host. That is normal. qcow2 can be compressed and thin-provisioned, so host disk usage and guest-visible disk size are not the same thing.&lt;/p&gt;

&lt;p&gt;Add 10 GB:&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="nv"&gt;$ &lt;/span&gt;podman 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; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-img resize /vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2 +10G
Image resized.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it:&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="nv"&gt;$ &lt;/span&gt;podman 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; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-img info /vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2
image: /vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2
file format: qcow2
virtual size: 11.3 GiB &lt;span class="o"&gt;(&lt;/span&gt;12122587136 bytes&lt;span class="o"&gt;)&lt;/span&gt;
disk size: 339 MiB
cluster_size: 65536
Format specific information:
    compat: 1.1
    compression &lt;span class="nb"&gt;type&lt;/span&gt;: zlib
    lazy refcounts: &lt;span class="nb"&gt;false
    &lt;/span&gt;refcount bits: 16
    corrupt: &lt;span class="nb"&gt;false
    &lt;/span&gt;extended l2: &lt;span class="nb"&gt;false
&lt;/span&gt;Child node &lt;span class="s1"&gt;'/file'&lt;/span&gt;:
    filename: /vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2
    protocol &lt;span class="nb"&gt;type&lt;/span&gt;: file
    file length: 339 MiB &lt;span class="o"&gt;(&lt;/span&gt;355467776 bytes&lt;span class="o"&gt;)&lt;/span&gt;
    disk size: 339 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The virtual size is now bigger, but the host disk usage is still 339 MiB. That is the thin-provisioning part of qcow2: the guest can see the larger disk immediately, but the host file grows only as data is written.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔧 Step 5: Verify the Guest Expanded
&lt;/h2&gt;

&lt;p&gt;Boot the VM again with the same SSH forwarding:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; sles-resize &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mynet &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-p&lt;/span&gt; 2222:22 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-netdev&lt;/span&gt; user,id&lt;span class="o"&gt;=&lt;/span&gt;net0,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::22-:22 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net-pci,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reconnect over SSH:&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="nv"&gt;$ &lt;/span&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 sysadmin@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the VM, check both the block devices and the mounted filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; lsblk
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sr0     11:0    1 1024M  0 rom
vda    253:0    0 11.3G  0 disk
├─vda1 253:1    0    2M  0 part
├─vda2 253:2    0  512M  0 part /boot/efi
└─vda3 253:3    0 10.8G  0 part /

sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-Th&lt;/span&gt; /
Filesystem     Type  Size  Used Avail Use% Mounted on
/dev/vda3      xfs    11G  948M  9.8G   9% /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the clean success case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lsblk&lt;/code&gt; shows the disk at 11.3G.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lsblk&lt;/code&gt; shows the root partition at 10.8G.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;df -Th /&lt;/code&gt; shows the mounted XFS filesystem at 11G with almost 10G available.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this SLES 16 cloud image, cloud-init grew the root partition and filesystem automatically on boot after the qcow2 virtual size changed. The important number is not &lt;code&gt;Used&lt;/code&gt;. The image was already effectively full before resizing. The win is &lt;code&gt;Avail&lt;/code&gt;: from about 3M to 9.8G.&lt;/p&gt;

&lt;p&gt;From about 3 MB free to almost 10 GB available. That is enough room to update the system, install tools, and use the VM like a real machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔧 If the Guest Does Not Auto-Grow
&lt;/h2&gt;

&lt;p&gt;Most cloud images include grow tools because cloud platforms resize disks all the time. If your image does not auto-grow, check which layer is still small.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;lsblk&lt;/code&gt; still shows &lt;code&gt;/dev/vda3&lt;/code&gt; at 806M, grow partition 3 manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;growpart /dev/vda 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;df -Th /&lt;/code&gt; still shows the old 742M filesystem, grow it manually. For XFS, grow the mounted filesystem by mount point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;xfs_growfs /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For ext4, use the block device instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;resize2fs /dev/vda3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; lsblk
sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-Th&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;growpart&lt;/code&gt; is missing and the root filesystem is already this full, the safer option is to go back to your backup image or add a second disk instead of fighting package installation inside the cramped guest.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔧 Add a Second Disk Instead
&lt;/h2&gt;

&lt;p&gt;Sometimes you do not want to resize the root filesystem. A separate data disk is simpler and safer for databases, logs, or application data.&lt;/p&gt;

&lt;p&gt;Power off the VM first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;poweroff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a new qcow2 disk:&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="nv"&gt;$ &lt;/span&gt;podman 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; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-img create &lt;span class="nt"&gt;-f&lt;/span&gt; qcow2 /vm/extra-disk.qcow2 20G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attach it when starting the VM:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; sles-resize &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mynet &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-p&lt;/span&gt; 2222:22 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/extra-disk.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-netdev&lt;/span&gt; user,id&lt;span class="o"&gt;=&lt;/span&gt;net0,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::22-:22 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net-pci,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reconnect over SSH:&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="nv"&gt;$ &lt;/span&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 sysadmin@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the VM, format it and create the mount point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;mkfs.xfs /dev/vdb
sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it persistent by UUID instead of &lt;code&gt;/dev/vdb&lt;/code&gt;. Device names can change if you attach disks in a different order later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;blkid /dev/vdb
/dev/vdb: &lt;span class="nv"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"11111111-2222-3333-4444-555555555555"&lt;/span&gt; &lt;span class="nv"&gt;BLOCK_SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"512"&lt;/span&gt; &lt;span class="nv"&gt;TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"xfs"&lt;/span&gt;

sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "UUID=11111111-2222-3333-4444-555555555555 /data xfs defaults 0 0" &amp;gt;&amp;gt; /etc/fstab'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mount everything from &lt;code&gt;/etc/fstab&lt;/code&gt;, then verify &lt;code&gt;/data&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;mount &lt;span class="nt"&gt;-a&lt;/span&gt;
sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-Th&lt;/span&gt; /data
Filesystem     Type  Size  Used Avail Use% Mounted on
/dev/vdb       xfs    20G  424M   20G   3% /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this when you need more storage but do not need the root filesystem itself to be bigger.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧹 Cleanup
&lt;/h2&gt;

&lt;p&gt;If you created a backup and no longer need it:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; ~/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2.bak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you created the extra disk and want to remove it, clean it up from inside the VM first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;umount /data
sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'\| /data xfs |d'&lt;/span&gt; /etc/fstab
sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;poweroff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then remove the disk image from the host:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; ~/vm/extra-disk.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  ✅ What You've Built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ Rebuilt &lt;code&gt;qemu:base&lt;/code&gt; on Ubuntu 26.04&lt;/li&gt;
&lt;li&gt;✅ Resized a qcow2 cloud image with &lt;code&gt;qemu-img&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;✅ Verified cloud-init expanded the root partition automatically&lt;/li&gt;
&lt;li&gt;✅ Verified the XFS root filesystem grew automatically&lt;/li&gt;
&lt;li&gt;✅ Used a second disk as an alternative&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔜 What's Next?
&lt;/h2&gt;

&lt;p&gt;The VM now has room to breathe. We rebuilt the toolbox, expanded the SLES cloud image, and confirmed the guest can actually use the space.&lt;/p&gt;

&lt;p&gt;There is still more to explore with KVM on Podman.&lt;/p&gt;

&lt;p&gt;See you in the next episode.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://linkedin.com/in/davidtio" rel="noopener noreferrer"&gt;Share with your network&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions?&lt;/strong&gt; Drop a comment below&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>podman</category>
      <category>ubuntu2604</category>
      <category>resize</category>
    </item>
    <item>
      <title>Docker Compose: Scale Out and Stay Resilient (2026)</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Mon, 27 Apr 2026 08:54:02 +0000</pubDate>
      <link>https://dev.to/davidtio/docker-compose-scale-out-and-stay-resilient-2026-4api</link>
      <guid>https://dev.to/davidtio/docker-compose-scale-out-and-stay-resilient-2026-4api</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Scale worker containers with one command and add restart policies so crashed services recover on their own. Then hit the one problem restart policies can't solve.&lt;/p&gt;




&lt;h3&gt;
  
  
  🤔 Why This Matters
&lt;/h3&gt;

&lt;p&gt;In the last post, you put nginx in front of a Python web app. One backend, one load balancer, and it works fine.&lt;/p&gt;

&lt;p&gt;Then traffic picks up and that single backend starts choking. You need another one. So you add a second container to your compose file, update nginx.conf to include it, and restart everything. It works. Traffic grows and you add a third. You edit the compose file, add another upstream entry in nginx.conf, and restart. Same drill.&lt;/p&gt;

&lt;p&gt;Here's where things get interesting. One of those backends crashes. Maybe a memory leak, maybe a bad request triggers a segfault. It goes down, but nginx doesn't know about it. It keeps routing requests to the dead container and users start seeing errors.&lt;/p&gt;

&lt;p&gt;We'll work through both of these problems in this post.&lt;/p&gt;




&lt;h3&gt;
  
  
  ✅ Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ep 1-8 completed.&lt;/strong&gt; You know Compose basics like multi-service files, &lt;code&gt;.env&lt;/code&gt; files, shared networks, and the &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;ps&lt;/code&gt;/&lt;code&gt;logs&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; workflow.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  📦 Scaling Web Apps Behind nginx
&lt;/h3&gt;

&lt;p&gt;Back in the last post, you built a load balancer with nginx in front of a Python web app. If you still have that &lt;code&gt;loadbalance&lt;/code&gt; directory, great. If not, grab the files from the exercise at the end of post 08. You need &lt;code&gt;app.py&lt;/code&gt;, &lt;code&gt;nginx.conf&lt;/code&gt;, and &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's where we left off:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:latest&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;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;

  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python /app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app.py:/app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One backend. nginx in front. &lt;code&gt;nginx.conf&lt;/code&gt; has one upstream entry: &lt;code&gt;server web:5000;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now traffic picks up. One backend isn't enough. You need a second one.&lt;/p&gt;

&lt;p&gt;The obvious approach: copy the &lt;code&gt;web&lt;/code&gt; service, rename the original to &lt;code&gt;web1&lt;/code&gt;, add a &lt;code&gt;web2&lt;/code&gt;, and update nginx.conf.&lt;/p&gt;

&lt;p&gt;Update &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:latest&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;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;

  &lt;span class="na"&gt;web1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python /app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app.py:/app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;

  &lt;span class="na"&gt;web2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python /app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app.py:/app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update &lt;code&gt;nginx.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;web1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;web2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive_timeout&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/favicon.ico&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keepalive_timeout 0&lt;/code&gt; disables HTTP keep-alive. The &lt;code&gt;favicon.ico&lt;/code&gt; block returns an empty response directly from nginx — without it, every browser page load fires two requests (&lt;code&gt;/&lt;/code&gt; and &lt;code&gt;/favicon.ico&lt;/code&gt;) and they interleave on the round-robin, so your page refresh always lands on the same backend. Without it, browsers reuse the same connection and you always hit the same backend. With it, each request gets a fresh connection and nginx round-robins properly.&lt;/p&gt;

&lt;p&gt;Two services. Two upstream entries. Tear down the old stack and start fresh:&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="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;--remove-orphans&lt;/code&gt; removes any containers that are no longer defined in your compose file. Without it, the old &lt;code&gt;web&lt;/code&gt; container stays behind and blocks network cleanup.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[+] up 3/3
 ✔ Container loadbalance-nginx-1   Created
 ✔ Container loadbalance-web1-1    Created
 ✔ Container loadbalance-web2-1    Created
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt; and refresh. The hostname changes between requests. nginx is round-robining between &lt;code&gt;web1&lt;/code&gt; and &lt;code&gt;web2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check the containers:&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="nv"&gt;$ &lt;/span&gt;docker compose ps &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME                       IMAGE         COMMAND   STATUS
loadbalance-nginx-1        nginx:latest  "nginx…"  Up 5 min
loadbalance-web1-1         python:slim   "python…" Up 5 min
loadbalance-web2-1         python:slim   "python…" Up 5 min
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three containers. nginx distributes requests between two backends. It works.&lt;/p&gt;

&lt;p&gt;Now let's break one:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.Pid}}'&lt;/span&gt; loadbalance-web2-1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kills the container process at the OS level, outside Docker's control. Docker sees it as a crash. Let's watch what happens.&lt;/p&gt;

&lt;p&gt;Check:&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="nv"&gt;$ &lt;/span&gt;docker compose ps &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME                       IMAGE         COMMAND   STATUS
loadbalance-nginx-1        nginx:latest  "nginx…"  Up 6 min
loadbalance-web1-1         python:slim   "python…" Up 6 min
loadbalance-web2-1         python:slim   "python…" Exited (137) 10s ago
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;web2 exited with code 137. The kernel killed the process with SIGKILL. Not a clean exit. Just dead.&lt;/p&gt;

&lt;p&gt;Two containers left running. nginx and web1.&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt; and refresh. The page still loads. nginx tries to send a request to web2, gets a connection refused, and falls back to web1. You might see one slow refresh, then everything works.&lt;/p&gt;

&lt;p&gt;This is the real-world pattern: containers die. Crashes. Memory leaks. Bad requests. nginx keeps serving traffic with whatever backends are alive. But we had to bring web2 back manually. Nobody was watching. Nobody got paged. It just stayed dead until someone noticed the output from &lt;code&gt;docker compose ps -a&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the second problem we'll fix in this post. But look at the compose file first. &lt;code&gt;web1&lt;/code&gt; and &lt;code&gt;web2&lt;/code&gt; are identical. Same image, same command, same volumes. The only difference is the name. And nginx.conf lists each one by name. Want a third instance? Add &lt;code&gt;web3&lt;/code&gt; to the compose file. Add &lt;code&gt;server web3:5000;&lt;/code&gt; to nginx.conf. Want ten? Edit ten lines.&lt;/p&gt;

&lt;p&gt;There's a better way.&lt;/p&gt;

&lt;p&gt;Replace &lt;code&gt;web1&lt;/code&gt; and &lt;code&gt;web2&lt;/code&gt; with a single &lt;code&gt;web&lt;/code&gt; service. Add &lt;code&gt;depends_on&lt;/code&gt; so nginx doesn't start before &lt;code&gt;web&lt;/code&gt; exists:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:latest&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;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;

  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python /app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app.py:/app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update &lt;code&gt;nginx.conf&lt;/code&gt; to reference just &lt;code&gt;web&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive_timeout&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/favicon.ico&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When nginx loads this config, it resolves &lt;code&gt;web&lt;/code&gt; through Docker DNS and builds its upstream pool from what it sees at that moment. &lt;code&gt;depends_on&lt;/code&gt; helps startup order, but it does not guarantee nginx picks up every replica on first boot. If nginx starts too early, reload it after the stack is up.&lt;/p&gt;

&lt;p&gt;One service name. No hardcoded numbers.&lt;/p&gt;

&lt;p&gt;Tear down the old stack and start fresh with two instances:&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="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;nginx nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[+] up 3/3
 ✔ Container loadbalance-nginx-1   Created
 ✔ Container loadbalance-web-1     Created
 ✔ Container loadbalance-web-2     Created
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt; and refresh. The hostname should alternate between the two containers on every request.&lt;/p&gt;

&lt;p&gt;Now kill one:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.Pid}}'&lt;/span&gt; loadbalance-web-2&lt;span class="si"&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 shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose ps &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME                       IMAGE         COMMAND   STATUS
loadbalance-nginx-1        nginx:latest  "nginx…"  Up 2 min
loadbalance-web-1          python:slim   "python…" Up 2 min
loadbalance-web-2          python:slim   "python…" Exited (137) 3s ago
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;web-2 is dead. Keep refreshing — the page still loads. nginx detected the failure and routes everything to web-1.&lt;/p&gt;

&lt;p&gt;Now bring it back. You don't need to know which container died or restart it by name. Just tell compose how many you want:&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="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[+] up 2/2
 ✔ Container loadbalance-web-1     Running
 ✔ Container loadbalance-web-2     Started
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;web-2 is back. Round-robin resumes. With the manual web1/web2 approach you'd have had to run &lt;code&gt;docker compose up web2&lt;/code&gt; and know the exact name. With &lt;code&gt;--scale&lt;/code&gt; you just declare the count.&lt;/p&gt;

&lt;p&gt;Scale to five with the same command:&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="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[+] up 5/5
 ✔ Container loadbalance-nginx-1   Running
 ✔ Container loadbalance-web-1     Running
 ✔ Container loadbalance-web-2     Running
 ✔ Container loadbalance-web-3     Started
 ✔ Container loadbalance-web-4     Started
 ✔ Container loadbalance-web-5     Started
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No nginx config changes. But nginx resolved &lt;code&gt;web&lt;/code&gt; at startup when only two containers existed — it doesn't know about the three new ones yet. Reload nginx to pick them up:&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="nv"&gt;$ &lt;/span&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;nginx nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now all five are in the pool. That's the power of &lt;code&gt;--scale&lt;/code&gt; — the config stays the same, you just change the count and reload.&lt;/p&gt;

&lt;p&gt;But there's a gap in this recovery story. Bringing web-2 back required you to notice it was dead and run the command yourself. Nobody was watching.&lt;/p&gt;

&lt;p&gt;Tear it down:&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="nv"&gt;$ &lt;/span&gt;docker compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🔄 Restart Policies
&lt;/h3&gt;

&lt;p&gt;web-2 stayed dead until you ran the command. What if you didn't notice for an hour?&lt;/p&gt;

&lt;p&gt;Back in &lt;a href="https://dev.to/posts/docker-manage-environment"&gt;post 04&lt;/a&gt; we covered restart policies — one flag that tells Docker to bring a container back whenever it exits. The same thing works in Compose. One line, and you never have to babysit a crashed container again.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;restart: unless-stopped&lt;/code&gt; to the &lt;code&gt;web&lt;/code&gt; service:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:latest&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;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;

  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python /app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app.py:/app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with two again:&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="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now kill web-2:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.Pid}}'&lt;/span&gt; loadbalance-web-2&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&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="nv"&gt;$ &lt;/span&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME                  IMAGE          SERVICE  STATUS
loadbalance-nginx-1   nginx:latest   nginx    Up 2 minutes
loadbalance-web-1     python:slim    web      Up 2 minutes
loadbalance-web-2     python:slim    web      Up 1 second
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Already back. &lt;code&gt;web-2&lt;/code&gt; restarted so fast it barely registered as dead. You didn't touch anything — Docker saw the process die and brought it straight back up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;unless-stopped&lt;/code&gt; restarts on crashes and host reboots, but stays down when you run &lt;code&gt;docker compose stop&lt;/code&gt; intentionally.&lt;/p&gt;




&lt;h3&gt;
  
  
  🧪 Exercise 1: Scale Your Worker Stack from Episode 8
&lt;/h3&gt;

&lt;p&gt;Done with the loadbalance stack. Tear it down:&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="nv"&gt;$ &lt;/span&gt;docker compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In episode 8 you built a producer/worker/redis stack. One worker, pulling jobs from a queue. Submit enough jobs and the queue grows faster than the worker drains it. One worker isn't enough.&lt;/p&gt;

&lt;p&gt;Grab your &lt;code&gt;prodwork&lt;/code&gt; directory from episode 8. Your starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prodwork/
├── producer.py
├── worker.py
└── docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Your job:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add &lt;code&gt;restart: unless-stopped&lt;/code&gt; to the &lt;code&gt;worker&lt;/code&gt; service.&lt;/strong&gt; The producer and redis services stay as-is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Scale to three workers:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;worker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;docker compose ps&lt;/code&gt;. You should have one redis, one producer, and three workers.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Watch the logs, then submit jobs:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nv"&gt;$ &lt;/span&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the log stream open, go to &lt;code&gt;http://localhost:5000&lt;/code&gt; and submit a few jobs. You'll see different workers picking them up in real time. The queue drains faster with three workers pulling from it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Kill one worker mid-queue:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.Pid}}'&lt;/span&gt; prodwork-worker-2&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep watching the logs. &lt;code&gt;worker-2&lt;/code&gt; drops out, the other two keep draining. Once &lt;code&gt;worker-2&lt;/code&gt; restarts, it rejoins and starts pulling jobs again — all visible in the log stream.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test that manual stop is respected.&lt;/strong&gt; Stop the stack:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="nv"&gt;$ &lt;/span&gt;docker compose stop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All containers exit and stay stopped. Start it back up and the workers are ready again. No jobs were processed in the meantime, but nothing was lost either. Redis persisted the queue.&lt;/p&gt;

&lt;p&gt;The key thing to notice: you never changed &lt;code&gt;worker.py&lt;/code&gt;. Multiple workers connect to the same Redis queue and &lt;code&gt;brpop&lt;/code&gt; handles the coordination. Each job goes to exactly one worker.&lt;/p&gt;




&lt;h3&gt;
  
  
  🧪 Exercise 2: The App That Dies on Boot
&lt;/h3&gt;

&lt;p&gt;Your worker stack is resilient. But it only works because Redis starts fast and &lt;code&gt;unless-stopped&lt;/code&gt; keeps retrying until it connects. Now try the same pattern with an app that connects to Postgres. Postgres takes longer to initialise, and this time the app has a flaw that makes restart policies actively dangerous.&lt;/p&gt;

&lt;p&gt;Create a new directory:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; appstack &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;appstack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We'll use &lt;code&gt;noteboard&lt;/code&gt;. It's a simple note board app. POST a name and message, GET shows all notes. On first start it runs a migration to create the schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/strong&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;

  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea.dtio.app/davidtio/noteboard:latest&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="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;appstate:/app/state&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;appstate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it and watch the logs:&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="nv"&gt;$ &lt;/span&gt;docker compose up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;app-1  | First run, setting up...
app-1  | Migration failed: connection to server at "db" (172.20.0.2), port 5432 failed: Connection refused
app-1  |
app-1  | Already installed, skipping migrations
app-1  | Database not ready: connection to server at "db" (172.20.0.2), port 5432 failed: Connection refused
app-1  |
app-1  | Already installed, skipping migrations
app-1  | Serving on :5000
app-1  | Exception occurred during processing of request from ('172.20.0.1', 41410)
app-1  | psycopg2.errors.InFailedSqlTransaction: current transaction is aborted,
app-1  | commands ignored until end of transaction block
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:5000&lt;/code&gt;. The browser returns nothing — no error page, just an empty response.&lt;/p&gt;

&lt;p&gt;On the very first boot, the app wrote the state file and tried to run the migration — but Postgres wasn't ready. The migration failed mid-transaction, leaving the connection in a broken state. The app exited. &lt;code&gt;unless-stopped&lt;/code&gt; brought it back. Every restart after that saw the state file and skipped the migration entirely. Postgres eventually came up, the app reached &lt;code&gt;Serving on :5000&lt;/code&gt;, and started accepting requests. But the &lt;code&gt;notes&lt;/code&gt; table was never created, and psycopg2 refuses to run any query on a connection with a prior failed transaction. Every request crashes the handler silently.&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="nv"&gt;$ &lt;/span&gt;docker compose ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME             IMAGE                                      STATUS
appstack-db-1    postgres:16                                Up 2 min
appstack-app-1   gitea.dtio.app/davidtio/noteboard:latest   Up 2 min
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both containers up. Nothing in &lt;code&gt;docker compose ps&lt;/code&gt; tells you anything is wrong.&lt;/p&gt;

&lt;p&gt;Tear down and clear the volume:&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="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--volumes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The damage was done in the first few seconds. The state file was written, the migration never ran, and every restart since then has skipped it without question. Restart policies didn't cause this — they just kept the broken app alive long enough that you might not notice until a user reports it.&lt;/p&gt;

&lt;p&gt;Restart policies can't solve this. They only decide what happens after a container exits. What you actually need is a way to tell Docker: don't start the app until the database is ready to accept connections.&lt;/p&gt;

&lt;p&gt;That's the next post.&lt;/p&gt;




&lt;h3&gt;
  
  
  🏁 What You've Built
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale web=5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spins up five web containers behind one nginx. No config changes needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale worker=3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spins up three workers pulling from the same Redis queue. Each job goes to exactly one worker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compose DNS + nginx reload&lt;/td&gt;
&lt;td&gt;Docker can resolve a service name to multiple scaled instances. Reload nginx after scaling or late starts to refresh the upstream pool.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-restarts crashed containers without manual intervention. See &lt;a href="https://dev.to/posts/docker-manage-environment"&gt;post 04&lt;/a&gt; for the full policy breakdown.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;👉 &lt;strong&gt;Coming up:&lt;/strong&gt; The noteboard died on boot because Postgres wasn't ready, and restart policies made it worse. There has to be a better way to bring a stack up. That's what we tackle next.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt; 🙌&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://linkedin.com/in/davidtio" rel="noopener noreferrer"&gt;Share with your network&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter:&lt;/strong&gt; &lt;a href="https://x.com/intent/tweet?text=Just%20learned%20Docker%20Compose%20scaling%20and%20resilience!&amp;amp;url=https://blog.dtio.app/2026/04/docker-compose-scale-out-stay-resilient.html" rel="noopener noreferrer"&gt;Tweet about it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions?&lt;/strong&gt; Drop a comment below or reach out on LinkedIn&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>dockercompose</category>
      <category>scaling</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building a Blog Platform with Docker #3: Markdown Rendering</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Sun, 26 Apr 2026 08:59:28 +0000</pubDate>
      <link>https://dev.to/davidtio/building-a-blog-platform-with-docker-3-markdown-rendering-17ma</link>
      <guid>https://dev.to/davidtio/building-a-blog-platform-with-docker-3-markdown-rendering-17ma</guid>
      <description>&lt;h1&gt;
  
  
  Building a Blog Platform with Docker #3: Markdown Rendering
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Stop writing HTML for your posts. Parse &lt;code&gt;.md&lt;/code&gt; files with YAML frontmatter and render them in Flask.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Last time you built a blog that looks like a blog. Teal nav, dark background, editorial post list. Nice.&lt;/p&gt;

&lt;p&gt;But the post in that list? It's hardcoded HTML. Every time you want a new post, you have to edit a template. That's not a blog platform, that's a website.&lt;/p&gt;

&lt;p&gt;A real blog platform lets you write a file, drop it in a folder, and have it appear. That's what Markdown gives you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Markdown?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You write in plain text: no &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; tags, no &lt;code&gt;&amp;amp;amp;&lt;/code&gt;, no forgetting to close a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It's readable as-is, even before rendering&lt;/li&gt;
&lt;li&gt;Every major writing tool supports it&lt;/li&gt;
&lt;li&gt;Easy to version control in git&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of this post, you'll be able to drop a &lt;code&gt;.md&lt;/code&gt; file into a folder and have Flask render it as a proper HTML page.&lt;/p&gt;




&lt;h2&gt;
  
  
  Starting Point
&lt;/h2&gt;

&lt;p&gt;You should have this from last time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tiohub-blog/
├── app.py
├── static/
│   └── js/
│       └── tailwind.config.js
└── templates/
    └── index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't have this, go back to Episode 2 first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Install the Packages
&lt;/h2&gt;

&lt;p&gt;You need two libraries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Markdown&lt;/code&gt;: converts Markdown syntax to HTML&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PyYAML&lt;/code&gt;: parses the YAML frontmatter at the top of each post&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make sure your venv is active first:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install both:&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="nv"&gt;$ &lt;/span&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;Markdown PyYAML
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save them to &lt;code&gt;requirements.txt&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="nv"&gt;$ &lt;/span&gt;pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;pip freeze&lt;/code&gt;?&lt;/strong&gt; It saves every installed package with its exact version. Anyone cloning your repo can run &lt;code&gt;pip install -r requirements.txt&lt;/code&gt; and get the exact same environment. We'll need this when we add Docker later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Create Your First Post
&lt;/h2&gt;

&lt;p&gt;Create the content folder:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; content/posts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;content/posts/hey-markdown.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hey&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Markdown"&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-04-25&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="nv"&gt;meta&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;blog&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

This is my first post written in Markdown.

No more HTML by hand. No more forgetting to close a &lt;span class="sb"&gt;`&amp;lt;div&amp;gt;`&lt;/span&gt;. Just write, save, done.

Here's what Markdown looks like in practice:
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Bold**&lt;/span&gt; with double asterisks
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="ge"&gt;*Italic*&lt;/span&gt; with single asterisks
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`Code`&lt;/span&gt; with backticks

And a code block:

&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="err"&gt;``&lt;/span&gt;

bash
$ source venv/bin/activate
&lt;span class="gh"&gt;# apt update&lt;/span&gt;
&lt;span class="err"&gt;\&lt;/span&gt;

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

&lt;/div&gt;

&lt;p&gt;```&lt;br&gt;
&lt;br&gt;
python&lt;br&gt;
print("First Markdown post, and it actually works")&lt;br&gt;
\&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
That's it. Clean to write, clean to read.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The section between the &lt;code&gt;---&lt;/code&gt; markers at the top is called &lt;strong&gt;frontmatter&lt;/strong&gt;. It's YAML, a simple key-value format. The &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, and &lt;code&gt;tags&lt;/code&gt; fields are metadata about the post. The Markdown content starts after the second &lt;code&gt;---&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Write the Parse Function
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;app.py&lt;/code&gt;. Add the imports at the top:&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;import&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now add a &lt;code&gt;parse_post()&lt;/code&gt; function below the imports:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;

    &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="o"&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;fenced_code&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;tables&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the &lt;code&gt;.md&lt;/code&gt; file&lt;/li&gt;
&lt;li&gt;Checks if it starts with &lt;code&gt;---&lt;/code&gt; (frontmatter present)&lt;/li&gt;
&lt;li&gt;Splits on &lt;code&gt;---&lt;/code&gt; to separate the YAML from the Markdown&lt;/li&gt;
&lt;li&gt;Parses the YAML into a Python dict (&lt;code&gt;meta&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Converts the Markdown body to HTML and stores it as &lt;code&gt;meta['content']&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Returns the whole thing: metadata and rendered HTML together&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;fenced_code&lt;/code&gt; and &lt;code&gt;tables&lt;/code&gt;?&lt;/strong&gt; The base &lt;code&gt;markdown&lt;/code&gt; library is minimal. The &lt;code&gt;fenced_code&lt;/code&gt; extension enables code blocks with triple backticks. The &lt;code&gt;tables&lt;/code&gt; extension adds table support. Both are included with the library, no extra install needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Add a Route
&lt;/h2&gt;

&lt;p&gt;Add a new route to &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="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;/2026/04/hey-markdown&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;hey_markdown_post&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts/hey-markdown.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;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;post.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;post&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this URL format?&lt;/strong&gt; This matches Blogger's URL pattern: &lt;code&gt;/&amp;lt;year&amp;gt;/&amp;lt;month&amp;gt;/&amp;lt;slug&amp;gt;&lt;/code&gt;. My current blog is on Blogger, and I'm planning to migrate it to this platform. If the URLs match from the start, existing posts won't need 301 redirects when I move them over.&lt;/p&gt;

&lt;p&gt;In Episode 4, this becomes a dynamic route, &lt;code&gt;/&amp;lt;int:year&amp;gt;/&amp;lt;int:month&amp;gt;/&amp;lt;slug&amp;gt;&lt;/code&gt;, that looks up any post by its date and slug. For now, we're hardcoding it to prove the parsing works.&lt;/p&gt;

&lt;p&gt;Your full &lt;code&gt;app.py&lt;/code&gt; should now look like this:&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;import&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&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="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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;

    &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="o"&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;fenced_code&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;tables&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;meta&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;index&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="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;/2026/04/hey-markdown&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;hey_markdown_post&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts/hey-markdown.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;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;post.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;post&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;post&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;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: Create the Post Template
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;templates/post.html&lt;/code&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&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&amp;amp;family=DM+Sans:wght@400;500;700&amp;amp;display=swap"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.tailwindcss.com?plugins=typography"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"{{ url_for('static', filename='js/tailwind.config.js') }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&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;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-slate-950 text-gray-100 font-sans min-h-screen flex flex-col"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- Navbar --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-brand-700 border-b border-brand-600"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ url_for('index') }}"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"font-serif text-xl text-white"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                David Tio's Blog
            &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center space-x-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-100 hover:text-white font-medium text-sm transition duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-100 hover:text-white font-medium text-sm transition duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Series&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-100 hover:text-white font-medium text-sm transition duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;About&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- Post --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"max-w-3xl mx-auto px-6 py-16 flex-1 w-full"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 text-sm mb-4"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.date }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;h1&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"font-serif text-4xl text-white leading-tight mb-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ post.title }}&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"border-t border-slate-800 mb-10"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"prose prose-invert max-w-none
                    prose-headings:font-serif prose-headings:text-white
                    prose-p:text-gray-300 prose-p:leading-relaxed
                    prose-a:text-brand-500 prose-a:no-underline hover:prose-a:underline
                    prose-code:text-brand-500 prose-code:bg-slate-800 prose-code:px-1 prose-code:rounded
                    prose-pre:p-0 prose-pre:bg-transparent prose-pre:border-0
                    prose-strong:text-white
                    prose-li:text-gray-300"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            {{ post.content | safe }}
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- Footer --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;footer&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bg-brand-700 border-t border-brand-600"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"max-w-6xl mx-auto px-6 py-6 flex items-center justify-between"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-100 text-sm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;copy;&lt;/span&gt; 2026 David Tio.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"flex space-x-6"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-100 hover:text-white text-sm transition-colors duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;LinkedIn&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-100 hover:text-white text-sm transition-colors duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Twitter&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"#"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-teal-100 hover:text-white text-sm transition-colors duration-200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;GitHub&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/footer&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;A few things to note:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;{{ post.content | safe }}&lt;/code&gt;&lt;/strong&gt;: The &lt;code&gt;| safe&lt;/code&gt; filter tells Flask's template engine not to escape the HTML. Without it, you'd see raw &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; tags on the page instead of rendered HTML. This is safe here because &lt;em&gt;you&lt;/em&gt; control the content, it's your own Markdown files, not user input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;prose&lt;/code&gt; classes&lt;/strong&gt;: These are Tailwind Typography utility classes that style HTML content you don't control directly. Without them, the rendered Markdown (&lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;, etc.) would inherit no styles at all from Tailwind, because Tailwind resets all browser defaults by design. The &lt;code&gt;prose&lt;/code&gt; classes restore readable typography for body content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;prose-pre:p-0 prose-pre:bg-transparent prose-pre:border-0&lt;/code&gt;&lt;/strong&gt;: These override the default &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; styling that &lt;code&gt;prose&lt;/code&gt; applies. In the next step we'll add a JavaScript file that wraps each code block in a styled container with a language label and a Copy button. Those wrapper elements provide the background, padding, and border, so we strip the defaults here to avoid double-styling. If you're not planning to add the code-block wrapper yet, you can swap these for &lt;code&gt;prose-pre:bg-slate-800 prose-pre:border prose-pre:border-slate-700&lt;/code&gt; for now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait, do I need to install anything for &lt;code&gt;prose&lt;/code&gt;?&lt;/strong&gt; No package install is needed for this tutorial because the Tailwind Play CDN can enable first-party plugins through the script URL. The &lt;code&gt;?plugins=typography&lt;/code&gt; part is what makes the &lt;code&gt;prose&lt;/code&gt; classes available.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Format the Date
&lt;/h2&gt;

&lt;p&gt;Right now, &lt;code&gt;{{ post.date }}&lt;/code&gt; outputs exactly what's in your YAML: &lt;code&gt;2026-04-25&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I generally use YYYY-MM-DD in my coding files. It's great for sorting and it keeps everything in chronological order. But it doesn't read like a blog post date. Let's format it into something easier for readers.&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;app.py&lt;/code&gt;. Add the &lt;code&gt;datetime&lt;/code&gt; import at the top:&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;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now add a &lt;code&gt;format_date()&lt;/code&gt; function below &lt;code&gt;parse_post()&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;format_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Convert YYYY-MM-DD to &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;01 January 2026&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; format.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;date_value&lt;/span&gt; &lt;span class="o"&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;strptime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;date_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%d %B %Y&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accepts either a string (&lt;code&gt;"2026-04-25"&lt;/code&gt;) or a &lt;code&gt;date&lt;/code&gt; object (PyYAML sometimes parses dates automatically)&lt;/li&gt;
&lt;li&gt;Converts string input to a proper date object&lt;/li&gt;
&lt;li&gt;Returns it formatted as &lt;code&gt;25 April 2026&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now update &lt;code&gt;app.py&lt;/code&gt; to apply the formatting when returning the post:&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="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;/2026/04/hey-markdown&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;hey_markdown_post&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content/posts/hey-markdown.md&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;format_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&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;date&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="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;post.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;post&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The date in your template will now read &lt;code&gt;25 April 2026&lt;/code&gt; instead of &lt;code&gt;2026-04-25&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Better Code Blocks
&lt;/h2&gt;

&lt;p&gt;At this point the page works. Markdown renders, the layout matches the rest of the site. But the code blocks are plain, just a dark box with no label and no way to copy the content.&lt;/p&gt;

&lt;p&gt;Since we're building a platform for technical content, this matters. Readers want to copy commands and snippets without selecting text manually.&lt;/p&gt;

&lt;p&gt;We'll fix this with a small JavaScript file. Create it in &lt;code&gt;static/js/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;static/js/code-blocks.js&lt;/code&gt;:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pre&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/language-&lt;/span&gt;&lt;span class="se"&gt;([\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;copyText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;shellLanguages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zsh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;console&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shellLanguages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;$#&lt;/span&gt;&lt;span class="se"&gt;]\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trimEnd&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rounded-lg border border-slate-700 overflow-hidden my-6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex items-center justify-between bg-slate-800 px-4 py-2 border-b border-slate-700&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;span&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-xs text-gray-400 font-mono&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Copy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-xs text-gray-400 hover:text-white bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded transition-colors duration-200&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;copyText&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Copied!&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Copy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bg-slate-900 p-4 overflow-x-auto m-0 text-sm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pre&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;What this does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finds every &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; block on the page after it loads&lt;/li&gt;
&lt;li&gt;Reads the language from the &lt;code&gt;class="language-python"&lt;/code&gt; attribute added by the &lt;code&gt;fenced_code&lt;/code&gt; extension&lt;/li&gt;
&lt;li&gt;Wraps the block in a styled container with a header bar showing the language label and a Copy button&lt;/li&gt;
&lt;li&gt;Uses &lt;code&gt;navigator.clipboard.writeText()&lt;/code&gt; to copy the code text&lt;/li&gt;
&lt;li&gt;Strips leading &lt;code&gt;$&lt;/code&gt; and &lt;code&gt;#&lt;/code&gt; prompts from shell snippets before copying, so readers can paste commands directly&lt;/li&gt;
&lt;li&gt;Changes the button to "Copied!" for 2 seconds as feedback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now update &lt;code&gt;post.html&lt;/code&gt; with two changes. First, override the &lt;code&gt;prose-pre&lt;/code&gt; defaults so they don't conflict with the custom wrapper the script builds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;prose-pre:p-0 prose-pre:bg-transparent prose-pre:border-0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, add the script tag just before &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&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="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"{{ url_for('static', filename='js/code-blocks.js') }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script tag goes at the bottom, after the page content has loaded, so that &lt;code&gt;querySelectorAll('pre')&lt;/code&gt; finds the rendered blocks.&lt;/p&gt;

&lt;p&gt;Your &lt;code&gt;prose-pre&lt;/code&gt; classes from Step 5 already have this covered, so you don't need to change anything in the &lt;code&gt;&amp;lt;div class="prose ..."&amp;gt;&lt;/code&gt; block.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Test It
&lt;/h2&gt;

&lt;p&gt;Run the app:&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="nv"&gt;$ &lt;/span&gt;python app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;a href="http://localhost:8000/2026/04/hey-markdown" rel="noopener noreferrer"&gt;http://localhost:8000/2026/04/hey-markdown&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It should look like this:&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%2Fyrvcqu0tlurvvibqeakb.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%2Fyrvcqu0tlurvvibqeakb.png" alt="Rendered Markdown blog post with styled code blocks and Copy buttons" width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try the Copy button on the bash block. It should leave out the &lt;code&gt;$&lt;/code&gt; and &lt;code&gt;#&lt;/code&gt; prompts, so the copied text is ready to paste into your terminal.&lt;/p&gt;

&lt;p&gt;The homepage at &lt;a href="http://localhost:8000" rel="noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt; still shows the hardcoded post list from Episode 2. That's fine for now, we'll wire everything together in Episode 4.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You've Built
&lt;/h2&gt;

&lt;p&gt;Your file structure is now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tiohub-blog/
├── app.py
├── requirements.txt
├── static/
│   └── js/
│       ├── tailwind.config.js
│       └── code-blocks.js
├── templates/
│   ├── index.html
│   └── post.html
└── content/
    └── posts/
        └── hey-markdown.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You've added:&lt;br&gt;
✅ &lt;code&gt;Markdown&lt;/code&gt; and &lt;code&gt;PyYAML&lt;/code&gt; installed and saved to &lt;code&gt;requirements.txt&lt;/code&gt;&lt;br&gt;
✅ &lt;code&gt;parse_post()&lt;/code&gt;: reads a &lt;code&gt;.md&lt;/code&gt; file, splits frontmatter from content, returns both&lt;br&gt;
✅ &lt;code&gt;format_date()&lt;/code&gt;: converts &lt;code&gt;2026-04-25&lt;/code&gt; to &lt;code&gt;25 April 2026&lt;/code&gt; for display&lt;br&gt;
✅ &lt;code&gt;/&amp;lt;year&amp;gt;/&amp;lt;month&amp;gt;/&amp;lt;slug&amp;gt;&lt;/code&gt; URL format matching Blogger's pattern&lt;br&gt;
✅ &lt;code&gt;post.html&lt;/code&gt; template with matching nav, footer, and Tailwind Typography styles&lt;br&gt;
✅ Code blocks styled as boxes with a language label and one-click copy button&lt;/p&gt;




&lt;h2&gt;
  
  
  Coming Up
&lt;/h2&gt;

&lt;p&gt;Next time: multiple posts. We'll scan the &lt;code&gt;content/posts/&lt;/code&gt; folder for all &lt;code&gt;.md&lt;/code&gt; files, sort them by date, and list them on the homepage. We'll also make the route dynamic, &lt;code&gt;/&amp;lt;int:year&amp;gt;/&amp;lt;int:month&amp;gt;/&amp;lt;slug&amp;gt;&lt;/code&gt;, so any post can be reached by its date and filename.&lt;/p&gt;

&lt;p&gt;The hardcoded post in &lt;code&gt;index.html&lt;/code&gt; goes away. The hardcoded route in &lt;code&gt;app.py&lt;/code&gt; goes away too.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt; Share it with your network or drop a comment below.&lt;/p&gt;




&lt;h2&gt;
  
  
  SEO Metadata
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Title:&lt;/strong&gt; Building a Blog Platform with Docker #3: Markdown Rendering (2026)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta Description:&lt;/strong&gt; Stop writing HTML for every blog post. Parse Markdown files with YAML frontmatter in Flask using the Markdown and PyYAML libraries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target Keywords:&lt;/strong&gt; flask markdown rendering, pyyaml frontmatter flask, python markdown blog, flask blog markdown tutorial 2026&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Word Count:&lt;/strong&gt; ~1,100&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>flask</category>
      <category>markdown</category>
      <category>blogplatform</category>
    </item>
    <item>
      <title>KVM on Podman Networking</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Thu, 23 Apr 2026 01:16:24 +0000</pubDate>
      <link>https://dev.to/davidtio/kvm-on-podman-networking-32k6</link>
      <guid>https://dev.to/davidtio/kvm-on-podman-networking-32k6</guid>
      <description>&lt;h1&gt;
  
  
  KVM on Podman Networking
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Two networking modes for KVM in Podman — container-native (private) and bridge (full network). Each serves different use cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤔 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Your VMs need to talk to something — containers, the host, or the outside world. But networking isn't one-size-fits-all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container-native&lt;/strong&gt; is simple, private, and requires no host setup. Your VM lives inside the container's network namespace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bridge networking&lt;/strong&gt; gives you full VM behavior — visible to host, other VMs, and the network. But it needs bridge, dnsmasq, and TAP devices on the host.&lt;/p&gt;

&lt;p&gt;This post shows both approaches.&lt;/p&gt;




&lt;h2&gt;
  
  
  🐳 Section 1: Container-Native Networking
&lt;/h2&gt;

&lt;p&gt;The VM lives inside the container's network namespace. It's private — only visible to containers on your Podman network.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Use This
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Databases that shouldn't be exposed&lt;/li&gt;
&lt;li&gt;Testing environments&lt;/li&gt;
&lt;li&gt;Quick experiments without host setup&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&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%2F4s5tqvt3mshpl9l983uw.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%2F4s5tqvt3mshpl9l983uw.png" alt="Container-Native Architecture" width="529" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qemu:base&lt;/code&gt; image from Post #1&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/vm&lt;/code&gt; directory with cloud images&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1: Create a Podman Network
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman network create mynet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Run the VM with Slirp
&lt;/h3&gt;

&lt;p&gt;Run the VM with slirp networking and port forwarding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; qemu-container &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mynet &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-p&lt;/span&gt; 2222:22 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-p&lt;/span&gt; 6379:6379 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/noble-server-cloudimg-amd64.img,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-netdev&lt;/span&gt; user,id&lt;span class="o"&gt;=&lt;/span&gt;net0,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::22-:22,hostfwd&lt;span class="o"&gt;=&lt;/span&gt;tcp::6379-:6379 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-device&lt;/span&gt; virtio-net-pci,netdev&lt;span class="o"&gt;=&lt;/span&gt;net0 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mynet&lt;/code&gt; — Podman network (10.89.0.0/24)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-p 2222:22 -p 6379:6379&lt;/code&gt; — expose to host&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hostfwd=tcp::22-:22&lt;/code&gt; — forward VM port 22 to container (all interfaces)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hostfwd=tcp::6379-:6379&lt;/code&gt; — forward VM port 6379 to container (all interfaces)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-netdev user&lt;/code&gt; — slirp user-mode networking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The VM boots and gets an IP from slirp's internal DHCP (usually 10.0.2.15).&lt;/p&gt;

&lt;h3&gt;
  
  
  🐧 Step 3: Install Redis in the VM
&lt;/h3&gt;

&lt;p&gt;Wait for boot and cloud-init (~20 seconds). Login at the console and install Redis:&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;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; redis-server
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/bind 127.0.0.1/bind 0.0.0.0/'&lt;/span&gt; /etc/redis/redis.conf
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/protected-mode yes/protected-mode no/'&lt;/span&gt; /etc/redis/redis.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; redis-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧪 Step 4: Test — Host to VM
&lt;/h3&gt;

&lt;p&gt;From your host, test the Redis connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redis-cli localhost 6379
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or SSH to the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 sysadmin@localhost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧪 Step 5: Test — Container to VM
&lt;/h3&gt;

&lt;p&gt;From a new terminal, test with an app-container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mynet &lt;span class="se"&gt;\&lt;/span&gt;
    docker.io/redis:latest &lt;span class="se"&gt;\&lt;/span&gt;
    redis-cli &lt;span class="nt"&gt;-h&lt;/span&gt; qemu-container ping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Podman DNS resolves &lt;code&gt;qemu-container&lt;/code&gt; to 10.89.0.2 — no need to remember IPs.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌉 Section 2: Bridge Networking
&lt;/h2&gt;

&lt;p&gt;The VM acts like it's on a real network. It's visible to the host, other VMs, and the network.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to Use This
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;VMs that need full network access&lt;/li&gt;
&lt;li&gt;Hosting services accessible from the network&lt;/li&gt;
&lt;li&gt;Multi-VM environments&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&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%2Fn0uuhxr85efh0ajmbye3.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%2Fn0uuhxr85efh0ajmbye3.png" alt="Bridge Architecture" width="795" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qemu:base&lt;/code&gt; image from Post #1&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/vm&lt;/code&gt; directory with cloud images&lt;/li&gt;
&lt;li&gt;Root access on your host&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 1: Host Setup
&lt;/h3&gt;

&lt;p&gt;Create the bridge and TAPs on your host:&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;ip &lt;span class="nb"&gt;link &lt;/span&gt;add kvmbr0 &lt;span class="nb"&gt;type &lt;/span&gt;bridge
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.168.100.1/24 dev kvmbr0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;kvmbr0 up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure dnsmasq:&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 tee&lt;/span&gt; /etc/dnsmasq.d/kvmbr0.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
interface=kvmbr0
bind-interfaces
dhcp-range=192.168.100.200,192.168.100.250,12h
dhcp-option=3,192.168.100.1
dhcp-option=6,8.8.8.8
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart dnsmasq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable IP forwarding:&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 tee&lt;/span&gt; /etc/sysctl.d/99-kvmbr0.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.ipv4.ip_forward=1
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create TAP devices:&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;ip tuntap add tap0 mode tap
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;tap0 master kvmbr0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;tap0 up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Build the QEMU Image
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/kvmnet
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/kvmnet

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; run-vm.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
DISK=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="sh"&gt;
CPUS=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
MEMORY=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;1024&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
TAP=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;tap0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;

qemu-system-x86_64 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
    -enable-kvm -cpu host &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
    -m &lt;/span&gt;&lt;span class="nv"&gt;$MEMORY&lt;/span&gt;&lt;span class="sh"&gt; -smp &lt;/span&gt;&lt;span class="nv"&gt;$CPUS&lt;/span&gt;&lt;span class="sh"&gt; -nographic &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
    -drive file=&lt;/span&gt;&lt;span class="nv"&gt;$DISK&lt;/span&gt;&lt;span class="sh"&gt;,format=qcow2,if=virtio &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
    -netdev tap,id=net0,ifname=&lt;/span&gt;&lt;span class="nv"&gt;$TAP&lt;/span&gt;&lt;span class="sh"&gt;,script=no,downscript=no &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
    -device virtio-net-pci,netdev=net0
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x run-vm.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Containerfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FROM alpine:latest

RUN apk add --no-cache \
        qemu-system-x86_64 \
        qemu-img \
        iproute2 \
        bash

COPY run-vm.sh /run-vm.sh
RUN chmod +x /run-vm.sh

WORKDIR /vms
CMD ["/bin/bash"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman build &lt;span class="nt"&gt;-t&lt;/span&gt; qemu:base &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Run a VM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; vm1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/net/tun:/dev/net/tun &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    /run-vm.sh /vm/noble-server-cloudimg-amd64.img
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for the VM to boot and cloud-init (~20 seconds). Check the IP in the VM console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr show ens3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note this IP — you'll use it for the tests below.&lt;/p&gt;

&lt;h3&gt;
  
  
  🧪 Step 4: Test — Host to VM
&lt;/h3&gt;

&lt;p&gt;From your host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;redis-cli &amp;lt;your-vm-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🧪 Step 6: Test — Container to VM
&lt;/h3&gt;

&lt;p&gt;Launch an app-container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; app-container &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;container:vm1 &lt;span class="se"&gt;\&lt;/span&gt;
    docker.io/redis:latest &lt;span class="se"&gt;\&lt;/span&gt;
    redis-cli &lt;span class="nt"&gt;-h&lt;/span&gt; &amp;lt;your-vm-ip&amp;gt; ping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Container-Native&lt;/th&gt;
&lt;th&gt;Bridge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host setup&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Bridge, dnsmasq, TAPs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM visible to host&lt;/td&gt;
&lt;td&gt;Via -p ports&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container-to-VM&lt;/td&gt;
&lt;td&gt;Via Podman DNS&lt;/td&gt;
&lt;td&gt;Via IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM network&lt;/td&gt;
&lt;td&gt;Private (slirp)&lt;/td&gt;
&lt;td&gt;Real (bridge)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use case&lt;/td&gt;
&lt;td&gt;Private/testing&lt;/td&gt;
&lt;td&gt;Full networking&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🧹 Cleanup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Container-Native
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; qemu-container
podman network &lt;span class="nb"&gt;rm &lt;/span&gt;mynet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bridge
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; vm1 app-container

&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;delete tap0
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;delete kvmbr0
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; /etc/dnsmasq.d/kvmbr0.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart dnsmasq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What You've Built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ Container-native networking with slirp&lt;/li&gt;
&lt;li&gt;✅ Port forwarding (hostfwd) from VM to container&lt;/li&gt;
&lt;li&gt;✅ Podman DNS for container-to-VM communication&lt;/li&gt;
&lt;li&gt;✅ Host access via -p ports&lt;/li&gt;
&lt;li&gt;✅ Bridge networking with host bridge&lt;/li&gt;
&lt;li&gt;✅ TAP devices for VM networking&lt;/li&gt;
&lt;li&gt;✅ Container-to-VM via shared network&lt;/li&gt;
&lt;li&gt;✅ Host-to-VM via bridge&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;Your VMs and containers can now talk to each other. I am actually not sure what's next but this is definitely not the end .&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://linkedin.com/in/davidtio" rel="noopener noreferrer"&gt;Share with your network&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter:&lt;/strong&gt; &lt;a href="https://x.com/intent/tweet?text=KVM%20on%20Podman%20networking!&amp;amp;url=https://blog.dtio.app/2026/04/kvm-on-podman-networking.html" rel="noopener noreferrer"&gt;Tweet about it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions?&lt;/strong&gt; Drop a comment below&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>podman</category>
      <category>networking</category>
      <category>bridge</category>
    </item>
    <item>
      <title>Docker Compose Explained: Multi-Container Stacks (2026)</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Mon, 20 Apr 2026 01:05:42 +0000</pubDate>
      <link>https://dev.to/davidtio/docker-compose-explained-multi-container-stacks-2026-109h</link>
      <guid>https://dev.to/davidtio/docker-compose-explained-multi-container-stacks-2026-109h</guid>
      <description>&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Connect CloudBeaver and PostgreSQL in one compose file. Then scale up to a full four-service Nextcloud stack with shared networks.&lt;/p&gt;




&lt;h3&gt;
  
  
  🤔 Why This Matters
&lt;/h3&gt;

&lt;p&gt;In the &lt;a href="https://blog.dtio.app/2026/04/docker-compose-explained-one-file-one-container.html" rel="noopener noreferrer"&gt;last post&lt;/a&gt;, you created two compose projects: PostgreSQL in one directory, CloudBeaver in another. Each has its own compose file, its own network, its own lifecycle. They can't talk to each other.&lt;/p&gt;

&lt;p&gt;That's the problem this post solves. We'll put them in one file, on one network, and CloudBeaver can finally reach PostgreSQL. No custom network commands. No &lt;code&gt;--network&lt;/code&gt; flags. Just one &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once you've got two services talking, we'll scale up to four. Here's the full Nextcloud stack with MariaDB, Redis, PHP-FPM, and nginx all in one file.&lt;/p&gt;

&lt;p&gt;By the end of this post, you'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CloudBeaver + PostgreSQL connected in one compose file&lt;/li&gt;
&lt;li&gt;A four-service Nextcloud stack on a shared network&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  ✅ Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ep 1-7 completed.&lt;/strong&gt; You know Compose basics like single service per file, &lt;code&gt;.env&lt;/code&gt; files, and the &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;ps&lt;/code&gt;/&lt;code&gt;logs&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; workflow.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  📦 The Problem: Two Compose Files, Two Networks
&lt;/h3&gt;

&lt;p&gt;Last time you ended up with PostgreSQL in one directory and CloudBeaver in another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/
├── dtstack-pg/
│   ├── docker-compose.yml
│   └── .env
└── dtstack-cb/
    ├── docker-compose.yml
    └── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each project gets its own network. PostgreSQL is on &lt;code&gt;dtstack-pg_default&lt;/code&gt;, CloudBeaver is on &lt;code&gt;dtstack-cb_default&lt;/code&gt;. They can't reach each other. You can't connect CloudBeaver to the database.&lt;/p&gt;

&lt;p&gt;That's what multi-service compose fixes. One file, one network, both services talking.&lt;/p&gt;




&lt;h3&gt;
  
  
  🔧 Step 1: CloudBeaver + PostgreSQL in One File
&lt;/h3&gt;

&lt;p&gt;Create a single directory for your stack:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; cloudstack &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;cloudstack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dtstack-pg&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PG_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${PG_DATABASE}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dtstack&lt;/span&gt;

  &lt;span class="na"&gt;cloudbeaver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dtstack-cb&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dbeaver/cloudbeaver:latest&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;8978:8978"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cbdata:/opt/cloudbeaver/workspace&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dtstack&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cbdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dtstack&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two services. One &lt;code&gt;networks:&lt;/code&gt; block. Both on the same &lt;code&gt;dtstack&lt;/code&gt; network.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;.env&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
PG_PASSWORD=docker
PG_DATABASE=testdb
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No &lt;code&gt;ports&lt;/code&gt; on PostgreSQL.&lt;/strong&gt; CloudBeaver reaches it on the internal network, so there's no need to expose port 5432 to the host. Only CloudBeaver needs a port mapping since it's the one you access from your browser.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Each service lists &lt;code&gt;networks: - dtstack&lt;/code&gt;.&lt;/strong&gt; This explicitly connects them to the shared bridge network. Compose would create a default network and connect them automatically, but declaring it explicitly makes the intent clear.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  🚀 Start the Stack
&lt;/h3&gt;



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

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[+] up 32/32
 ✔ Image postgres:17                Pulled
 ✔ Image dbeaver/cloudbeaver:latest Pulled
 ✔ Network cloudstack_dtstack       Created
 ✔ Volume cloudstack_pgdata         Created
 ✔ Volume cloudstack_cbdata         Created
 ✔ Container dtstack-cb             Started
 ✔ Container dtstack-pg             Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Seven things done (two images pulled, network, two volumes, two containers). Everything connected.&lt;/p&gt;

&lt;p&gt;Verify both services are running:&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="nv"&gt;$ &lt;/span&gt;docker compose ps
NAME         IMAGE                        COMMAND                  SERVICE      CREATED      STATUS      PORTS
dtstack-cb   dbeaver/cloudbeaver:latest   &lt;span class="s2"&gt;"./launch-product.sh"&lt;/span&gt;    cloudbeaver  5 min ago    Up 5 min    0.0.0.0:8978-&amp;gt;8978/tcp
dtstack-pg   postgres:17                  &lt;span class="s2"&gt;"docker-entrypoint.s…"&lt;/span&gt;   postgres     5 min ago    Up 5 min    5432/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only CloudBeaver has a port mapping. PostgreSQL is on the &lt;code&gt;dtstack&lt;/code&gt; network but invisible to the host. That's exactly what we want.&lt;/p&gt;




&lt;h3&gt;
  
  
  🔍 Verify Connectivity
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;http://localhost:8978&lt;/code&gt; in your browser. CloudBeaver loads.&lt;/p&gt;

&lt;p&gt;Now add PostgreSQL as a connection in CloudBeaver:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Host:&lt;/strong&gt; &lt;code&gt;postgres&lt;/code&gt; (the service name, not an IP address)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port:&lt;/strong&gt; &lt;code&gt;5432&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; &lt;code&gt;testdb&lt;/code&gt; (from your &lt;code&gt;.env&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Username:&lt;/strong&gt; &lt;code&gt;postgres&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password:&lt;/strong&gt; &lt;code&gt;docker&lt;/code&gt; (from your &lt;code&gt;.env&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Connect. It works. CloudBeaver reaches PostgreSQL by service name. No IP addresses. No &lt;code&gt;docker network connect&lt;/code&gt;. It just works.&lt;/p&gt;




&lt;h3&gt;
  
  
  🔍 Inspect the Network
&lt;/h3&gt;

&lt;p&gt;See what Compose created:&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="nv"&gt;$ &lt;/span&gt;docker network inspect cloudstack_dtstack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;Containers&lt;/code&gt; section. Both services are listed with their IP addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Containers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"abc123..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dtstack-pg"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"IPv4Address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"172.19.0.2/16"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"def456..."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dtstack-cb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"IPv4Address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"172.19.0.3/16"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two containers. One network. Use the service name (&lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;cloudbeaver&lt;/code&gt;) when connecting services to each other — not the container name.&lt;/p&gt;




&lt;h3&gt;
  
  
  🛑 Tear It Down
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker compose down
&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;[+] down 3/3
 ✔ Container dtstack-cb       Removed
 ✔ Container dtstack-pg       Removed
 ✔ Network cloudstack_dtstack Removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two containers gone. Network gone. Volumes survive.&lt;/p&gt;

&lt;p&gt;Volumes are preserved by default. Check:&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="nv"&gt;$ &lt;/span&gt;docker volume &lt;span class="nb"&gt;ls&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;cloudstack
&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;local  cloudstack_pgdata
local  cloudstack_cbdata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the stack again and your database is still there:&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="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To remove everything including volumes:&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="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--volumes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[+] down 5/5
 ✔ Container dtstack-pg       Removed
 ✔ Container dtstack-cb       Removed
 ✔ Volume cloudstack_cbdata   Removed
 ✔ Volume cloudstack_pgdata   Removed
 ✔ Network cloudstack_dtstack Removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  📦 Step 2: Scale Up to a Four-Service Nextcloud Stack
&lt;/h3&gt;

&lt;p&gt;Now let's go bigger. Four services in one file, all talking to each other.&lt;/p&gt;

&lt;p&gt;Create a single directory for your Nextcloud stack:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; nextcloud &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;nextcloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nc-db&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mariadb:11&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_ROOT_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MYSQL_ROOT_PASSWORD}&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_DATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MYSQL_DATABASE}&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MYSQL_USER}&lt;/span&gt;
      &lt;span class="na"&gt;MYSQL_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${MYSQL_PASSWORD}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dbdata:/var/lib/mysql&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nextcloud&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nc-redis&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:8.6&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redisdata:/data&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nextcloud&lt;/span&gt;

  &lt;span class="na"&gt;php&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nc-php&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nextcloud:fpm&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./html:/var/www/html&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nextcloud&lt;/span&gt;

  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nc-nginx&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:latest&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;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./html:/var/www/html&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nextcloud&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dbdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redisdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nextcloud&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four services. One &lt;code&gt;networks:&lt;/code&gt; block. All of them on the same &lt;code&gt;nextcloud&lt;/code&gt; network.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;.env&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
MYSQL_ROOT_PASSWORD=nextcloud
MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud
MYSQL_PASSWORD=nextcloud
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;nginx.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.php&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.php?&lt;/span&gt;&lt;span class="nv"&gt;$query_string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="nf"&gt;php&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$document_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to notice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Volumes are shared.&lt;/strong&gt; &lt;code&gt;dbdata&lt;/code&gt; and &lt;code&gt;redisdata&lt;/code&gt; are defined once at the bottom and used by the services that need them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PHP-FPM and nginx both mount &lt;code&gt;./html&lt;/code&gt;.&lt;/strong&gt; Both services mount the same host directory at the same container path: &lt;code&gt;/var/www/html&lt;/code&gt;. When Nextcloud writes an uploaded file to &lt;code&gt;/var/www/html/data/user1/photo.jpg&lt;/code&gt; inside the PHP-FPM container, nginx can immediately serve it from the same path. No copying, no syncing, just one shared directory.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Nginx needs a config to talk to PHP-FPM.&lt;/strong&gt; The &lt;code&gt;nginx.conf&lt;/code&gt; file tells nginx: when you see a &lt;code&gt;.php&lt;/code&gt; request, don't serve the raw file. Forward it to the &lt;code&gt;php&lt;/code&gt; service on port 9000 via FastCGI. Without this, your browser would download &lt;code&gt;index.php&lt;/code&gt; instead of running it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  🚀 Start the Nextcloud Stack
&lt;/h3&gt;



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

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[+] up 19/19
 ✔ Image nextcloud:fpm         Pulled
 ✔ Image nginx:latest          Pulled
 ✔ Image mariadb:11            Pulled
 ✔ Image redis:8.6             Pulled
 ✔ Network nextcloud_nextcloud Created
 ✔ Volume nextcloud_dbdata     Created
 ✔ Volume nextcloud_redisdata  Created
 ✔ Container nc-nginx          Started
 ✔ Container nc-db             Started
 ✔ Container nc-redis          Started
 ✔ Container nc-php            Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Eleven things done (four images pulled, network, two volumes, four containers). Everything connected.&lt;/p&gt;

&lt;p&gt;Verify all services are running:&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="nv"&gt;$ &lt;/span&gt;docker compose ps
NAME         IMAGE            COMMAND                  SERVICE   CREATED      STATUS      PORTS
nc-db        mariadb:11       &lt;span class="s2"&gt;"docker-entrypoint.s…"&lt;/span&gt;   db        5 min ago    Up 5 min    3306/tcp
nc-redis     redis:8.6        &lt;span class="s2"&gt;"docker-entrypoint.s…"&lt;/span&gt;   redis     5 min ago    Up 5 min    6379/tcp
nc-php       nextcloud:fpm    &lt;span class="s2"&gt;"docker-entrypoint.s…"&lt;/span&gt;   php       5 min ago    Up 5 min    9000/tcp
nc-nginx     nginx:latest     &lt;span class="s2"&gt;"/docker-entrypoint.…"&lt;/span&gt;   nginx     5 min ago    Up 5 min    0.0.0.0:8080-&amp;gt;80/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;nc-nginx&lt;/code&gt; has a port mapping. The other three services are on the &lt;code&gt;nextcloud&lt;/code&gt; network but invisible to the host. That's exactly what we want.&lt;/p&gt;




&lt;h3&gt;
  
  
  🖥️ Use Nextcloud
&lt;/h3&gt;

&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt;. The Nextcloud setup page loads.&lt;/p&gt;

&lt;p&gt;Create an admin account, then fill in the database section:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database type:&lt;/strong&gt; MariaDB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database user:&lt;/strong&gt; &lt;code&gt;nextcloud&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database password:&lt;/strong&gt; &lt;code&gt;nextcloud&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database name:&lt;/strong&gt; &lt;code&gt;nextcloud&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database host:&lt;/strong&gt; &lt;code&gt;nc-db&lt;/code&gt;
&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%2Flzbpcfho063qtsyvhpxc.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%2Flzbpcfho063qtsyvhpxc.png" alt="Nextcloud setup page showing database configuration with nc-db host and MariaDB credentials" width="702" height="1630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hit &lt;strong&gt;Finish setup&lt;/strong&gt;. Nextcloud initializes, connects to MariaDB, and drops you into the dashboard.&lt;/p&gt;

&lt;p&gt;Upload a file. Create a folder. It works. All four services are talking to each other through that single compose file.&lt;/p&gt;

&lt;p&gt;Check the &lt;code&gt;html/&lt;/code&gt; directory on the host. The &lt;code&gt;nextcloud:fpm&lt;/code&gt; image populated it on first start:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;html/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see Nextcloud's file structure like &lt;code&gt;index.php&lt;/code&gt;, &lt;code&gt;core/&lt;/code&gt;, &lt;code&gt;apps/&lt;/code&gt;, &lt;code&gt;config/&lt;/code&gt;, and more. Nginx is serving from this same directory, so your static files and PHP requests all come from the same source.&lt;/p&gt;




&lt;h3&gt;
  
  
  🛑 Tear Down the Nextcloud Stack
&lt;/h3&gt;

&lt;p&gt;To stop and remove containers, volumes, and the network:&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="nv"&gt;$ &lt;/span&gt;docker compose down &lt;span class="nt"&gt;--volumes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--volumes&lt;/code&gt; removes named volumes (&lt;code&gt;dbdata&lt;/code&gt;, &lt;code&gt;redisdata&lt;/code&gt;) but not bind-mounted directories. The &lt;code&gt;html/&lt;/code&gt; directory on your host stays untouched. Remove it manually if you want a clean slate:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; html/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start again and you'll get a fresh Nextcloud setup.&lt;/p&gt;




&lt;h3&gt;
  
  
  🧪 Exercise 1: Producer, Queue, and Worker
&lt;/h3&gt;

&lt;p&gt;In a real production system, you often have long-running tasks that shouldn't block a web request. The solution is a job queue: the web server adds a job, a separate worker picks it up and processes it.&lt;/p&gt;

&lt;p&gt;Create a directory and save these two scripts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;producer.py&lt;/code&gt;&lt;/strong&gt; (adds jobs to Redis):&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;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&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;redis&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;6379&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_responses&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&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;do_GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;llen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&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;text/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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;h2&amp;gt;Job Queue&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; jobs in queue&amp;lt;/p&amp;gt;&amp;lt;form method=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;post&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;lt;input name=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;job&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; placeholder=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Enter job name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&amp;lt;button&amp;gt;Submit&amp;lt;/button&amp;gt;&amp;lt;/form&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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;Content-Length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_qs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lpush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Location&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;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&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;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&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="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;worker.py&lt;/code&gt;&lt;/strong&gt; (processes jobs from Redis):&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;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&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;redis&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;6379&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_responses&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Worker ready. Waiting for jobs...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;brpop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Processing &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="si"&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="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Completed &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Your job:&lt;/strong&gt; Write the compose file to connect all three services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hints:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three services: redis, producer, worker&lt;/li&gt;
&lt;li&gt;All three need to be on the same network&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;python:slim&lt;/code&gt; for both producer and worker&lt;/li&gt;
&lt;li&gt;Mount &lt;code&gt;producer.py&lt;/code&gt; and &lt;code&gt;worker.py&lt;/code&gt; into their respective containers&lt;/li&gt;
&lt;li&gt;Producer needs port 5000 exposed&lt;/li&gt;
&lt;li&gt;Worker uses &lt;code&gt;brpop&lt;/code&gt; which blocks until a job is available&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  📦 Exercise 1 Solution
&lt;/h3&gt;

&lt;p&gt;Create a directory and save all three files inside it:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; prodwork &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;prodwork
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;/strong&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:latest&lt;/span&gt;

  &lt;span class="na"&gt;producer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sh -c "pip install redis &amp;amp;&amp;amp; python -u /app/producer.py"&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="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./producer.py:/app/producer.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;

  &lt;span class="na"&gt;worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sh -c "pip install redis &amp;amp;&amp;amp; python -u /app/worker.py"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./worker.py:/app/worker.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-u&lt;/code&gt; flag forces unbuffered output. Without it, Python buffers &lt;code&gt;print()&lt;/code&gt; when there's no terminal, and you won't see worker logs in real time.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;networks:&lt;/code&gt; block in the compose file. Compose creates a default network named &lt;code&gt;prodwork_default&lt;/code&gt; and connects all three services automatically. You can verify:&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="nv"&gt;$ &lt;/span&gt;docker network &lt;span class="nb"&gt;ls&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;prodwork
80e4fc2182b5   prodwork_default   bridge    &lt;span class="nb"&gt;local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start:&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="nv"&gt;$ &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the worker and start submitting jobs:&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="nv"&gt;$ &lt;/span&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; worker
worker  | Worker ready. Waiting &lt;span class="k"&gt;for &lt;/span&gt;jobs...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:5000&lt;/code&gt; in your browser, submit a job, and every 30-60 seconds you'll see the worker process it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;worker  | Processing "Generate monthly report"...
worker  | Completed Generate monthly report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The web server never blocked. The job queue handled the delay.&lt;/p&gt;




&lt;h3&gt;
  
  
  🧪 Exercise 2: Build a Load Balanced App
&lt;/h3&gt;

&lt;p&gt;Here's a Python app that shows its hostname and a random background color. We'll put nginx in front of it. Writing the compose file is your job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;app.py&lt;/code&gt;&lt;/strong&gt; (stdlib, no external dependencies):&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;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;colors&lt;/span&gt; &lt;span class="o"&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;#e74c3c&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;#c0392b&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;#8e44ad&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;#2c3e50&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;#2980b9&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;#16a085&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;#27ae60&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;#d35400&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;#f39c12&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;#2d3436&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&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;do_GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/favicon.ico&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;204&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&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;text/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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gethostname&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gethostbyname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;uptime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;html&amp;gt;&amp;lt;body style=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;background:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;;font-family:monospace;text-align:center;padding-top:10%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;
        &amp;lt;h1 style=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;font-size:4em;color:white&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/h1&amp;gt;
        &amp;lt;p style=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;font-size:1.8em;color:white&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/p&amp;gt;
        &amp;lt;p style=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;font-size:1.4em;color:white&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;Requests: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; | Uptime: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uptime&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&amp;lt;/p&amp;gt;
        &amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&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="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;nginx.conf&lt;/code&gt;&lt;/strong&gt; (load balance across upstream instances):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;web&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Hints for the compose file:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two services: nginx and web (python:slim)&lt;/li&gt;
&lt;li&gt;Nginx needs port 8080 mapped to 80&lt;/li&gt;
&lt;li&gt;Nginx mounts &lt;code&gt;nginx.conf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Web mounts &lt;code&gt;app.py&lt;/code&gt;, runs on port 5000 internally (no host port needed)&lt;/li&gt;
&lt;li&gt;Both on the same network (or just let Compose create the default)&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  📦 Exercise 2 Solution
&lt;/h3&gt;

&lt;p&gt;Create a directory and save all three files inside it (&lt;code&gt;docker-compose.yml&lt;/code&gt;, &lt;code&gt;app.py&lt;/code&gt;, &lt;code&gt;nginx.conf&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; loadbalance &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;loadbalance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;/strong&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:latest&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;8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf&lt;/span&gt;

  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:slim&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python /app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./app.py:/app/app.py&lt;/span&gt;
    &lt;span class="na"&gt;working_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





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

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt;. You'll see a random color with the container hostname. Refresh. Same result for now.&lt;/p&gt;

&lt;p&gt;Next post we'll put this under pressure.&lt;/p&gt;




&lt;h3&gt;
  
  
  🏁 What You've Built
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One file, two services&lt;/td&gt;
&lt;td&gt;CloudBeaver + PostgreSQL on the same network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One file, four services&lt;/td&gt;
&lt;td&gt;MariaDB, Redis, PHP-FPM, nginx, all in one place&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared volume mounts&lt;/td&gt;
&lt;td&gt;PHP-FPM and nginx mount &lt;code&gt;./html&lt;/code&gt; at the same &lt;code&gt;/var/www/html&lt;/code&gt; path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx + FastCGI&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nginx.conf&lt;/code&gt; proxies PHP requests to PHP-FPM on port 9000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No unnecessary ports&lt;/td&gt;
&lt;td&gt;Only the web-facing service is exposed, database and cache stay internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; for secrets&lt;/td&gt;
&lt;td&gt;Passwords live in a file, not in YAML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis job queue&lt;/td&gt;
&lt;td&gt;Producer, worker, and queue. Three services, one compose file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load balanced app&lt;/td&gt;
&lt;td&gt;nginx + Python web service, ready for scaling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;blockquote&gt;
&lt;p&gt;👉 &lt;strong&gt;Coming up:&lt;/strong&gt; You've got services talking to each other. But what happens when the worker crashes, or the queue suddenly has a hundred jobs? How do you build a stack that holds up?&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt; 🙌&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://linkedin.com/in/davidtio" rel="noopener noreferrer"&gt;Share with your network&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter:&lt;/strong&gt; &lt;a href="https://x.com/intent/tweet?text=Just%20learned%20multi-container%20Docker%20Compose!&amp;amp;url=https://blog.dtio.app/2026/04/docker-compose-multi-container-stacks.html" rel="noopener noreferrer"&gt;Tweet about it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions?&lt;/strong&gt; Drop a comment below or reach out on LinkedIn&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>compose</category>
      <category>linux</category>
      <category>devops</category>
    </item>
    <item>
      <title>Run Your First Podman Container: Images, Lifecycle, and Flags (2026)</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Sun, 19 Apr 2026 05:30:43 +0000</pubDate>
      <link>https://dev.to/davidtio/run-your-first-podman-container-images-lifecycle-and-flags-2026-563k</link>
      <guid>https://dev.to/davidtio/run-your-first-podman-container-images-lifecycle-and-flags-2026-563k</guid>
      <description>&lt;h2&gt;
  
  
  🐳 Run Your First Podman Container: Images, Lifecycle, and Flags (2026)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Pull images, run containers, manage their lifecycle, and understand the flags that make Podman different from Docker.&lt;/p&gt;




&lt;h3&gt;
  
  
  🤔 Why This Matters
&lt;/h3&gt;

&lt;p&gt;In Ep 1, you installed Podman, set up shared storage, and verified rootless operation. Now it's time to actually &lt;strong&gt;do something&lt;/strong&gt; with it.&lt;/p&gt;

&lt;p&gt;This post walks you through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finding and pulling images from container registries&lt;/li&gt;
&lt;li&gt;Running your first container with the right flags&lt;/li&gt;
&lt;li&gt;Managing container lifecycles (start, stop, restart, remove)&lt;/li&gt;
&lt;li&gt;The key differences between &lt;code&gt;podman run&lt;/code&gt; and &lt;code&gt;docker run&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end, you'll be comfortable managing containers with Podman. You'll also understand why &lt;code&gt;podman run&lt;/code&gt; is a drop-in replacement for &lt;code&gt;docker run&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  ✅ Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Podman installed&lt;/strong&gt;, rootless on SLES 16 (see Ep 1)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared storage configured&lt;/strong&gt; (multi-user UID/GID setup)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 minutes&lt;/strong&gt; to run your first workload&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  🔍 Finding Images
&lt;/h3&gt;

&lt;p&gt;Podman uses container registries to pull images. On SLES, the default registry is &lt;code&gt;registry.suse.com&lt;/code&gt;. It's SUSE's own registry with images built and maintained for SLES:&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="nv"&gt;$ &lt;/span&gt;podman search nginx
NAME                                                                              DESCRIPTION
registry.suse.com/caasp/v4/nginx-ingress-controller                               
registry.suse.com/caasp/v4.5/ingress-nginx-controller                             
registry.suse.com/caasp/v5/ingress-nginx-controller                               
registry.suse.com/cap/nginx-buildpack                                             
registry.suse.com/cap/stratos-metrics-nginx                                       
registry.suse.com/cap/suse-nginx-buildpack                                        
registry.suse.com/rancher/library-nginx                                           
registry.suse.com/suse/nginx                                                      
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start here. If the image you need is in the SUSE registry, use it. It's tested on SLES, receives security updates, and works seamlessly with your system.&lt;/p&gt;

&lt;p&gt;But the SUSE registry doesn't have everything. If you can't find an image there, fall back to Docker Hub (&lt;code&gt;docker.io&lt;/code&gt;) or Quay (&lt;code&gt;quay.io&lt;/code&gt;), which is where most of the world's container images live. To search Docker Hub, prefix with the registry name:&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="nv"&gt;$ &lt;/span&gt;podman search docker.io/library/nginx
NAME                                              DESCRIPTION
docker.io/library/nginx                           Official build of Nginx.
docker.io/nginx/nginx-ingress                     NGINX and NGINX Plus Ingress Controllers...
docker.io/nginx/nginx-prometheus-exporter         NGINX Prometheus Exporter &lt;span class="k"&gt;for &lt;/span&gt;NGINX...
docker.io/nginx/unit                              This repository is retired, use the Docker...
docker.io/nginx/nginx-ingress-operator            NGINX Ingress Operator &lt;span class="k"&gt;for &lt;/span&gt;NGINX and NGINX...
docker.io/bitnami/nginx                           Bitnami Secure Image &lt;span class="k"&gt;for &lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for the &lt;strong&gt;official&lt;/strong&gt; images (from &lt;code&gt;docker.io/library/&lt;/code&gt;). They're maintained by the software's creators and receive regular security updates.&lt;/p&gt;




&lt;h3&gt;
  
  
  🐳 Running Your First Container
&lt;/h3&gt;

&lt;p&gt;Let's run nginx from the SUSE registry:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dtnginx suse/nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me break down each flag:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Detach&lt;/strong&gt;: runs the container in the background so you get your terminal back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--rm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Auto-remove&lt;/strong&gt;: automatically deletes the container once it stops&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--name dtnginx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Name it &lt;code&gt;dtnginx&lt;/code&gt; instead of a random ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;suse/nginx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The image to run. Podman on SLES searches &lt;code&gt;registry.suse.com&lt;/code&gt; by default, so the short name works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If this is the first time running this image, Podman downloads it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Resolving "suse/nginx" using unqualified-search registries (/etc/containers/registries.conf)
Trying to pull registry.suse.com/suse/nginx:latest...
Getting image source signatures
Checking if image destination supports signatures
Copying blob 5a22de652f81 done   |
Copying blob 36e296237f0c done   |
Copying config 885ea61474 done   |
Writing manifest to image destination
Storing signatures
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  📊 Checking Container Status
&lt;/h3&gt;

&lt;p&gt;Verify it's running:&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="nv"&gt;$ &lt;/span&gt;podman ps
CONTAINER ID  IMAGE                                COMMAND               CREATED        STATUS         PORTS       NAMES
431e72ff8902  registry.suse.com/suse/nginx:latest  nginx &lt;span class="nt"&gt;-g&lt;/span&gt; daemon o...  10 seconds ago Up 10 seconds  80/tcp      dtnginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;podman ps&lt;/code&gt; and &lt;code&gt;docker ps&lt;/code&gt; produce identical output. If you've used Docker, the commands feel the same.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To see all containers (including stopped ones):&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="nv"&gt;$ &lt;/span&gt;podman ps &lt;span class="nt"&gt;-a&lt;/span&gt;
CONTAINER ID  IMAGE                                COMMAND               CREATED         STATUS                     PORTS       NAMES
431e72ff8902  registry.suse.com/suse/nginx:latest  nginx &lt;span class="nt"&gt;-g&lt;/span&gt; daemon o...  2 minutes ago   Up 2 minutes               80/tcp      dtnginx
2ea01225897d  quay.io/podman/hello:latest          /usr/local/bin/po...  3 minutes ago   Exited &lt;span class="o"&gt;(&lt;/span&gt;0&lt;span class="o"&gt;)&lt;/span&gt; 3 minutes ago               hopeful_heyrovsky
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dtnginx&lt;/code&gt; is still running in the background. The &lt;code&gt;hello&lt;/code&gt; container from Ep1 is sitting in "Exited" state. Because it was run without &lt;code&gt;--rm&lt;/code&gt;, it stayed around after it finished.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip: forgot to name your container?&lt;/strong&gt; Podman assigns random names like &lt;code&gt;hopeful_heyrovsky&lt;/code&gt; or &lt;code&gt;agitated_villani&lt;/code&gt;. You can rename it anytime:&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;podman rename hopeful_heyrovsky dthello
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Now &lt;code&gt;podman ps -a&lt;/code&gt; shows &lt;code&gt;dthello&lt;/code&gt; instead. Much easier to manage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To clean up stopped containers, use &lt;code&gt;podman rm&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="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;rm &lt;/span&gt;dthello
dthello
&lt;span class="nv"&gt;$ &lt;/span&gt;podman ps &lt;span class="nt"&gt;-a&lt;/span&gt;
CONTAINER ID  IMAGE  COMMAND  CREATED  STATUS  PORTS  NAMES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  💻 Accessing the Container Shell
&lt;/h3&gt;

&lt;p&gt;Open 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;&lt;span class="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; dtnginx /bin/bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-i&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeps stdin open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-t&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allocates a pseudo-terminal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You're now inside the container. Check the nginx default page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash-5.2# curl localhost
&amp;lt;&lt;span class="o"&gt;!&lt;/span&gt;DOCTYPE html&amp;gt;
&amp;lt;html&amp;gt;
&amp;lt;&lt;span class="nb"&gt;head&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&amp;lt;title&amp;gt;Welcome to nginx!&amp;lt;/title&amp;gt;
&amp;lt;style&amp;gt;
    body &lt;span class="o"&gt;{&lt;/span&gt;
        width: 35em&lt;span class="p"&gt;;&lt;/span&gt;
        margin: 0 auto&lt;span class="p"&gt;;&lt;/span&gt;
        font-family: Tahoma, Verdana, Arial, sans-serif&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&amp;lt;h1&amp;gt;Welcome to nginx!&amp;lt;/h1&amp;gt;
&amp;lt;p&amp;gt;If you see this page, the container with the nginx web server is 
successfully installed and working.&amp;lt;/p&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit the shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash-5.2# &lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🛑 Stopping and Cleaning Up
&lt;/h3&gt;

&lt;p&gt;Stop the container:&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="nv"&gt;$ &lt;/span&gt;podman stop dtnginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since we used &lt;code&gt;--rm&lt;/code&gt;, it's automatically removed. Confirm:&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="nv"&gt;$ &lt;/span&gt;podman ps &lt;span class="nt"&gt;-a&lt;/span&gt;
CONTAINER ID  IMAGE  COMMAND  CREATED  STATUS  PORTS  NAMES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No sign of &lt;code&gt;dtnginx&lt;/code&gt;. The &lt;code&gt;--rm&lt;/code&gt; flag took care of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you forgot &lt;code&gt;--rm&lt;/code&gt;&lt;/strong&gt;, the container stays around in "Exited" state. Clean it up:&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="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;rm &lt;/span&gt;dtnginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or remove all stopped containers at once:&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="nv"&gt;$ &lt;/span&gt;podman container prune
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;p&gt;Many container images are configured through environment variables instead of editing config files. This is how you run databases, web apps, and services without ever touching a config file inside the container.&lt;/p&gt;

&lt;p&gt;Use the &lt;code&gt;-e&lt;/code&gt; flag to pass variables at runtime. Let's run MariaDB:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dtmariadb &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MARIADB_ROOT_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;podman &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;MARIADB_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;testdb &lt;span class="se"&gt;\&lt;/span&gt;
    suse/mariadb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This starts MariaDB with the root password set to &lt;code&gt;podman&lt;/code&gt; and automatically creates a database called &lt;code&gt;testdb&lt;/code&gt;. Without &lt;code&gt;MARIADB_ROOT_PASSWORD&lt;/code&gt;, the container refuses to start.&lt;/p&gt;

&lt;p&gt;You can verify the variables inside a 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;&lt;span class="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;exec &lt;/span&gt;dtmariadb &lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;MARIADB
&lt;span class="nv"&gt;MARIADB_ROOT_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;podman
&lt;span class="nv"&gt;MARIADB_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;testdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To connect to the database from inside the container:&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="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; dtmariadb mariadb &lt;span class="nt"&gt;-u&lt;/span&gt; root &lt;span class="nt"&gt;-ppodman&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SHOW DATABASES;"&lt;/span&gt;
Database
information_schema
mysql
performance_schema
testdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each image documents its supported environment variables on its Docker Hub page. Scroll down to the "Environment Variables" section and you'll find everything you need. Things like &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;, &lt;code&gt;MYSQL_ROOT_PASSWORD&lt;/code&gt;, &lt;code&gt;REDIS_PASSWORD&lt;/code&gt;, and dozens more. That's always the first place I check before running a new database image.&lt;/p&gt;

&lt;p&gt;Stop the container when done:&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="nv"&gt;$ &lt;/span&gt;podman stop dtmariadb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🔄 Key Differences: Podman vs Docker
&lt;/h3&gt;

&lt;p&gt;You'll notice the commands are nearly identical. But here's what's different under the hood:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Docker&lt;/th&gt;
&lt;th&gt;Podman&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Daemon&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Required (&lt;code&gt;dockerd&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No daemon needed. Containers run as direct child processes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rootless&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Requires extra setup&lt;/td&gt;
&lt;td&gt;Rootless by default, no extra setup needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Systemd integration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Native support via &lt;code&gt;podman generate systemd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pod support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Via Compose&lt;/td&gt;
&lt;td&gt;Native support via &lt;code&gt;podman pod create&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fork/exec model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single daemon manages all&lt;/td&gt;
&lt;td&gt;Each container is independent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In practice: &lt;code&gt;alias docker=podman&lt;/code&gt; works for 95% of daily commands.&lt;/p&gt;




&lt;h3&gt;
  
  
  🏋️ Exercise
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Part 1: Run Redis and Store Data
&lt;/h4&gt;

&lt;p&gt;Redis isn't in the SUSE registry (the available image is 8 years old), so we fall back to 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;&lt;span class="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dtredis docker.io/library/redis
&lt;span class="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; dtredis redis-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SET mykey &lt;span class="s2"&gt;"Hello from Podman!"&lt;/span&gt;
&lt;span class="go"&gt;OK
&lt;/span&gt;&lt;span class="gp"&gt;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;GET mykey
&lt;span class="go"&gt;"Hello from Podman!"
&lt;/span&gt;&lt;span class="gp"&gt;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Part 2: Run PostgreSQL and Add Data
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dtpg &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;podman &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;testdb &lt;span class="se"&gt;\&lt;/span&gt;
    suse/postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait ~10 seconds for PostgreSQL to initialize, then create a table and insert some data:&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="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; dtpg psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; testdb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;testdb&lt;/span&gt;&lt;span class="o"&gt;=#&lt;/span&gt; &lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt;
&lt;span class="n"&gt;testdb&lt;/span&gt;&lt;span class="o"&gt;=#&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Alice'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alice@example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;testdb&lt;/span&gt;&lt;span class="o"&gt;=#&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="n"&gt;name&lt;/span&gt;  &lt;span class="o"&gt;|&lt;/span&gt;       &lt;span class="n"&gt;email&lt;/span&gt;        
&lt;span class="c1"&gt;-------+--------------------&lt;/span&gt;
 &lt;span class="n"&gt;Alice&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;alice&lt;/span&gt;&lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;testdb&lt;/span&gt;&lt;span class="o"&gt;=#&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice is in the database. Everything looks good.&lt;/p&gt;

&lt;h4&gt;
  
  
  Part 3: Remove Both and Lose Everything
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;podman stop dtredis dtpg
&lt;span class="nv"&gt;$ &lt;/span&gt;podman ps &lt;span class="nt"&gt;-a&lt;/span&gt;
CONTAINER ID  IMAGE  COMMAND  CREATED  STATUS  PORTS  NAMES
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are gone (thanks to &lt;code&gt;--rm&lt;/code&gt;). Now start fresh containers:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dtredis docker.io/library/redis
&lt;span class="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; dtredis redis-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;127.0.0.1:6379&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;GET mykey
&lt;span class="go"&gt;(nil)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis data is gone. Now check PostgreSQL:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dtpg &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;podman &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;testdb &lt;span class="se"&gt;\&lt;/span&gt;
    suse/postgres
&lt;span class="nv"&gt;$ &lt;/span&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; dtpg psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; testdb &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT * FROM users;"&lt;/span&gt;
ERROR:  relation &lt;span class="s2"&gt;"users"&lt;/span&gt; does not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alice is gone. The table is gone. The database is a fresh empty shell.&lt;/p&gt;

&lt;p&gt;Containers are ephemeral by design. When they go, everything inside them goes with them.&lt;/p&gt;

&lt;p&gt;Next chapter: we rescue Alice. Persistent storage that survives container death.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;👉 &lt;strong&gt;Coming up:&lt;/strong&gt; You've got nginx running and MariaDB configured. But here's a problem. Stop the container, remove it, start a new one, and your data is gone. The database you just created? Disappeared.&lt;/p&gt;

&lt;p&gt;Next time: we fix that. Persistent storage that survives container death. Your data lives on even when containers come and go.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt; 🙌&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://linkedin.com/in/davidtio" rel="noopener noreferrer"&gt;Share with your network&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter:&lt;/strong&gt; &lt;a href="https://x.com/intent/tweet?text=Just%20ran%20my%20first%20Podman%20container!" rel="noopener noreferrer"&gt;Tweet about it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions?&lt;/strong&gt; Drop a comment below or reach out on LinkedIn&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>podman</category>
      <category>containers</category>
      <category>linux</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Boot in Seconds: Cloud Images + cloud-init in Podman</title>
      <dc:creator>David Tio</dc:creator>
      <pubDate>Wed, 15 Apr 2026 02:58:08 +0000</pubDate>
      <link>https://dev.to/davidtio/boot-in-seconds-cloud-images-cloud-init-in-podman-2ki0</link>
      <guid>https://dev.to/davidtio/boot-in-seconds-cloud-images-cloud-init-in-podman-2ki0</guid>
      <description>&lt;h1&gt;
  
  
  Boot in Seconds: Cloud Images + cloud-init
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Quick one-liner:&lt;/strong&gt; Skip the installer entirely — download a pre-built cloud image, seed it with &lt;code&gt;cloud-init&lt;/code&gt;, and boot a fully configured VM in seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  💡 Why This Matters
&lt;/h2&gt;

&lt;p&gt;Post #3 proved persistence works, but that interactive Alpine install took time. Download ISO, boot, run installer, answer prompts, wait, configure. Repeat that for every new VM and you'll spend more time installing than actually using them.&lt;/p&gt;

&lt;p&gt;Cloud images solve this. They're pre-built disk images with an OS already installed. Ubuntu, Fedora, Debian, Rocky — they all publish ready-to-boot images. You download one, tell &lt;code&gt;cloud-init&lt;/code&gt; your SSH key and username, and boot. No installer, no prompts, no waiting.&lt;/p&gt;

&lt;p&gt;This post swaps the manual install for a cloud image. You'll download an Ubuntu cloud image, create a &lt;code&gt;cloud-init&lt;/code&gt; seed, and boot straight into a configured VM.&lt;/p&gt;




&lt;h2&gt;
  
  
  📋 Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qemu:base&lt;/code&gt; image from Post #1&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/vm&lt;/code&gt; directory from Post #3&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;genisoimage&lt;/code&gt; installed on your host (provides the &lt;code&gt;mkisofs&lt;/code&gt; command)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📥 Step 1: Download a Cloud Image
&lt;/h2&gt;

&lt;p&gt;Ubuntu publishes cloud images for every release. Grab the latest LTS (24.04 Noble Numbat):&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/vm
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; noble-server-cloudimg-amd64.img
&lt;span class="nt"&gt;-rw-rw-r--&lt;/span&gt; 1 user user 650M Apr  1 12:00 noble-server-cloudimg-amd64.img
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 650 MB file is a complete Ubuntu 24.04 installation, ready to boot. No install needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  🗺️ Where to Find Cloud Images
&lt;/h3&gt;

&lt;p&gt;Most major Linux distributions publish cloud images. Here's where to get them:&lt;/p&gt;

&lt;h4&gt;
  
  
  Open Source Distributions
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Distribution&lt;/th&gt;
&lt;th&gt;Cloud Image Repository&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ubuntu&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://cloud-images.ubuntu.com/" rel="noopener noreferrer"&gt;https://cloud-images.ubuntu.com/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Debian&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://cloud.debian.org/images/" rel="noopener noreferrer"&gt;https://cloud.debian.org/images/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fedora&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://download.fedoraproject.org/pub/fedora/linux/releases/" rel="noopener noreferrer"&gt;https://download.fedoraproject.org/pub/fedora/linux/releases/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CentOS Stream&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://cloud.centos.org/centos/" rel="noopener noreferrer"&gt;https://cloud.centos.org/centos/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rocky Linux&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://download.rockylinux.org/pub/rocky/9/images/x86_64/" rel="noopener noreferrer"&gt;https://download.rockylinux.org/pub/rocky/9/images/x86_64/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AlmaLinux&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/" rel="noopener noreferrer"&gt;https://repo.almalinux.org/almalinux/9/cloud/x86_64/images/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;openSUSE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://download.opensuse.org/tumbleweed/appliances/" rel="noopener noreferrer"&gt;https://download.opensuse.org/tumbleweed/appliances/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Arch Linux&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://geo.mirror.pkgbuild.com/images/" rel="noopener noreferrer"&gt;https://geo.mirror.pkgbuild.com/images/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  Enterprise Distributions
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Distribution&lt;/th&gt;
&lt;th&gt;Cloud Image Repository&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RHEL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://access.redhat.com/downloads/content/rhel" rel="noopener noreferrer"&gt;https://access.redhat.com/downloads/content/rhel&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Subscription (free developer tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SLES&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://download.suse.com/" rel="noopener noreferrer"&gt;https://download.suse.com/&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Account required (free trial available)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Oracle Linux&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://yum.oracle.com/oracle-linux-templates.html" rel="noopener noreferrer"&gt;https://yum.oracle.com/oracle-linux-templates.html&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Amazon Linux&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.aws.amazon.com/linux/al2023/ug/outside-ec2-download.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/linux/al2023/ug/outside-ec2-download.html&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Format tips:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Look for &lt;code&gt;qcow2&lt;/code&gt; format — it's thin-provisioned and works best with QEMU/KVM&lt;/li&gt;
&lt;li&gt;Some sites offer raw images (&lt;code&gt;.img&lt;/code&gt;) — these work too but take more disk space&lt;/li&gt;
&lt;li&gt;Avoid VMDK or VDI formats — those are for VMware and VirtualBox&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Alpine Linux offers cloud images but uses dynamic URLs based on provider, architecture, and firmware options. Visit &lt;a href="https://alpinelinux.org/cloud/" rel="noopener noreferrer"&gt;https://alpinelinux.org/cloud/&lt;/a&gt; to generate the correct download link.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚙️ Step 2: Create a cloud-init Seed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cloud-init&lt;/code&gt; is the standard for first-boot VM configuration. It runs on the first boot, reads a small YAML file, and sets up users, SSH keys, hostnames, packages — whatever you tell it to.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔑 Get Your SSH Public Key
&lt;/h3&gt;

&lt;p&gt;Cloud images don't have passwords by default — they use SSH key authentication. You'll need a key pair to log in.&lt;/p&gt;

&lt;p&gt;Check if you already have one:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.ssh/id_ed25519.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see a file, skip ahead — you're set. If not, generate one. &lt;strong&gt;ed25519&lt;/strong&gt; is the modern standard: faster, smaller, and more secure than RSA:&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="nv"&gt;$ &lt;/span&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"your-email@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Press Enter to accept the default location (&lt;code&gt;~/.ssh/id_ed25519&lt;/code&gt;). Optionally set a passphrase for extra security.&lt;/p&gt;

&lt;p&gt;Then grab your public key:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...your-key-here... your-email@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the entire line — you'll paste it into the &lt;code&gt;user-data&lt;/code&gt; file below.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔒 Creating a Hashed Password
&lt;/h3&gt;

&lt;p&gt;Cloud images accept passwords in two formats: plain text (risky) or hashed (secure). We'll use a SHA-512 hash. Use &lt;code&gt;read -s&lt;/code&gt; to enter your password without it appearing in shell history, then pipe it to &lt;code&gt;openssl&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Password: "&lt;/span&gt; PW &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; openssl passwd &lt;span class="nt"&gt;-6&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;unset &lt;/span&gt;PW
&lt;span class="nv"&gt;$6$randomsalt$hashedpasswordstring&lt;/span&gt;...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-6&lt;/code&gt; flag means SHA-512. The output starts with &lt;code&gt;$6$&lt;/code&gt; — that's the identifier. Copy the entire string.&lt;/p&gt;

&lt;h3&gt;
  
  
  📝 Build the user-data File
&lt;/h3&gt;

&lt;p&gt;Create the &lt;code&gt;user-data&lt;/code&gt; file for Ubuntu:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; noble
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; noble/user-data &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#cloud-config
preserve_hostname: false
hostname: kvmpodman
users:
  - name: sysadmin
    groups: sudo
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...your-key-here...
    lock_passwd: false
    passwd: '&lt;/span&gt;&lt;span class="nv"&gt;$6$randomsalt$your&lt;/span&gt;&lt;span class="sh"&gt;-hashed-password...'
runcmd:
  - echo "cloud-init completed" &amp;gt; /home/sysadmin/.cloud-init-done
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the SSH key with your own (&lt;code&gt;cat ~/.ssh/id_ed25519.pub&lt;/code&gt;) and the &lt;code&gt;passwd&lt;/code&gt; hash with the one you generated above.&lt;/p&gt;

&lt;p&gt;This seed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sets the hostname to &lt;code&gt;kvmpodman&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Creates a user named &lt;code&gt;sysadmin&lt;/code&gt; with sudo access&lt;/li&gt;
&lt;li&gt;Enables password login (for console testing)&lt;/li&gt;
&lt;li&gt;Leaves a marker file when cloud-init finishes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  📄 Create meta-data
&lt;/h3&gt;

&lt;p&gt;Create an empty &lt;code&gt;meta-data&lt;/code&gt; file (required, but can be empty):&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;touch &lt;/span&gt;noble/meta-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  💿 Step 3: Build the cloud-init ISO
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cloud-init&lt;/code&gt; reads its config from a CD-ROM attached to the VM. Use &lt;code&gt;mkisofs&lt;/code&gt; to create a small ISO containing your seed files. Run this on your host, from &lt;code&gt;~/vm&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="nv"&gt;$ &lt;/span&gt;mkisofs &lt;span class="nt"&gt;-J&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-input-charset&lt;/span&gt; utf-8 &lt;span class="nt"&gt;-V&lt;/span&gt; cidata &lt;span class="nt"&gt;-o&lt;/span&gt; cloud-init-noble.iso noble/user-data noble/meta-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total translation table size: 0
Total rockridge attributes bytes: 331
Total directory bytes: 0
Path table size(bytes): 10
Max brk space used 0
182 extents written (0 MB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-J&lt;/code&gt; flag enables Joliet extensions, &lt;code&gt;-R&lt;/code&gt; adds Rock Ridge (Unix file permissions — this fixes the warning), and &lt;code&gt;-V cidata&lt;/code&gt; sets the volume label to &lt;code&gt;cidata&lt;/code&gt; — which is what &lt;code&gt;cloud-init&lt;/code&gt; scans for on boot.&lt;/p&gt;

&lt;p&gt;That small ISO contains your entire first-boot configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Step 4: Boot the Cloud Image
&lt;/h2&gt;

&lt;p&gt;Attach both the cloud image disk and the cloud-init ISO. The cloud image boots as normal, &lt;code&gt;cloud-init&lt;/code&gt; detects the CD-ROM, and applies your seed on first boot:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/noble-server-cloudimg-amd64.img,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-cdrom&lt;/span&gt; /vm/cloud-init-noble.iso &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-boot&lt;/span&gt; c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VM boots. Wait about 15-30 seconds for &lt;code&gt;cloud-init&lt;/code&gt; to run. You'll see output like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;cloud-init 25.3-0ubuntu1~24.04.1 running 'modules:config' at Sat, 12 Apr 2026 12:34:56 +0000
cloud-init 25.3-0ubuntu1~24.04.1 running 'modules:final' at Sat, 12 Apr 2026 12:34:58 +0000
cloud-init 25.3-0ubuntu1~24.04.1 finished at Sat, 12 Apr 2026 12:35:02 +0000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you see &lt;code&gt;finished&lt;/code&gt;, the VM is ready. Log in with the username and password you set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;kvmpodman login: sysadmin
Password:
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then shut down cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;poweroff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container will exit automatically when the VM powers off, and the disk image persists.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Step 5: Verify cloud-init Ran
&lt;/h2&gt;

&lt;p&gt;Boot again, this time without the cloud-init ISO (it's only needed on first boot):&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/noble-server-cloudimg-amd64.img,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log in with &lt;code&gt;sysadmin&lt;/code&gt; and the password you set. Then verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;su -
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; password &lt;span class="k"&gt;for &lt;/span&gt;sysadmin: 
root@kvmpodman:~#
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the disk layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;root@kvmpodman:~# lsblk
NAME    MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sda       8:0    0  3.5G  0 disk 
├─sda1    8:1    0  2.5G  0 part /
├─sda14   8:14   0    4M  0 part 
├─sda15   8:15   0  106M  0 part /boot/efi
└─sda16 259:0    0  913M  0 part /boot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cloud image is thin-provisioned — the disk is 3.5 GB total, with a 2.5 GB root partition, 913 MB for &lt;code&gt;/boot&lt;/code&gt;, and 106 MB for EFI. The 4 MB &lt;code&gt;sda14&lt;/code&gt; is a BIOS boot partition (GRUB uses it on non-EFI boots). No swap is configured by default.&lt;/p&gt;

&lt;p&gt;Memory-wise, this VM was given 1 GB (&lt;code&gt;-m 1024&lt;/code&gt;), and Ubuntu reports about 961 MB available after kernel reservations.&lt;/p&gt;

&lt;p&gt;Confirm &lt;code&gt;cloud-init&lt;/code&gt; ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;root@kvmpodman:~# cloud-init status
status: &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then power off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;root@kvmpodman:~# poweroff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container exits automatically when the VM shuts down, and the disk image persists.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧪 Step 6: Mini Shootout — SLES 16 and Amazon Linux 2023
&lt;/h2&gt;

&lt;p&gt;Cloud images skip the installer entirely, and &lt;code&gt;cloud-init&lt;/code&gt; automates the rest of the setup. A working VM boots in seconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Time to Working VM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manual install (Post #3)&lt;/td&gt;
&lt;td&gt;5-10 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud image + cloud-init (IDE/SATA)&lt;/td&gt;
&lt;td&gt;~25s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud image + cloud-init (VirtIO)&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ubuntu is the easy path. With a working VM in about 10 seconds, we have plenty of time to spin up different distributions and see how they compare. Let's try two I find interesting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SLES 16&lt;/strong&gt; — enterprise Linux that rarely gets covered in tutorials. Most blog posts stop at RHEL or CentOS. SLES is what shops with a SUSE subscription actually run, and it's almost nowhere to be found in container or KVM content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon Linux 2023&lt;/strong&gt; — I know it exists, but I've never used it outside an EC2 instance. It's built exclusively for AWS, so booting it as a native KVM VM on my own hardware is something I've been curious about.&lt;/p&gt;

&lt;p&gt;We'll download both, build cloud-init seeds for each, and boot with VirtIO.&lt;/p&gt;

&lt;h3&gt;
  
  
  📥 Download the Images
&lt;/h3&gt;

&lt;p&gt;Head to the links in the table above and grab the qcow2 images for your distro:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SLES 16&lt;/strong&gt;: &lt;a href="https://download.suse.com/" rel="noopener noreferrer"&gt;SUSE Download Center&lt;/a&gt; — requires a free SUSE account. Look for &lt;code&gt;SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amazon Linux 2023&lt;/strong&gt;: &lt;a href="https://docs.aws.amazon.com/linux/al2023/ug/outside-ec2-download.html" rel="noopener noreferrer"&gt;AWS Documentation&lt;/a&gt; — free, no account needed. Look for &lt;code&gt;al2023-kvm-2023.10.20260325.0-kernel-6.1-x86_64.xfs.gpt.qcow2&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop both files into &lt;code&gt;~/vm/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⚙️ Build cloud-init Seeds
&lt;/h3&gt;

&lt;p&gt;Create a directory for each distro:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; sles16 al2023
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SLES 16&lt;/strong&gt; — uses &lt;code&gt;wheel&lt;/code&gt; group for sudo. SLES comments out &lt;code&gt;%wheel&lt;/code&gt; in &lt;code&gt;/etc/sudoers&lt;/code&gt; by default, so we need to uncomment it:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sles16/user-data &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#cloud-config
preserve_hostname: false
hostname: kvmpodman
users:
  - name: sysadmin
    groups: wheel
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...your-key-here...
    lock_passwd: false
    passwd: '&lt;/span&gt;&lt;span class="nv"&gt;$6$randomsalt$your&lt;/span&gt;&lt;span class="sh"&gt;-hashed-password...'
runcmd:
  - sed -i 's/^#&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*%wheel&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*ALL=(ALL)&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*ALL&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sh"&gt;*&lt;/span&gt;&lt;span class="nv"&gt;$/&lt;/span&gt;&lt;span class="sh"&gt;%wheel ALL=(ALL) ALL/' /etc/sudoers
  - echo "cloud-init completed" &amp;gt; /home/sysadmin/.cloud-init-done
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;touch &lt;/span&gt;sles16/meta-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Amazon Linux 2023&lt;/strong&gt; — also uses &lt;code&gt;wheel&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; al2023/user-data &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#cloud-config
preserve_hostname: false
hostname: kvmpodman
users:
  - name: sysadmin
    groups: wheel
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...your-key-here...
    lock_passwd: false
    passwd: '&lt;/span&gt;&lt;span class="nv"&gt;$6$randomsalt$your&lt;/span&gt;&lt;span class="sh"&gt;-hashed-password...'
runcmd:
  - echo "cloud-init completed" &amp;gt; /home/sysadmin/.cloud-init-done
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;touch &lt;/span&gt;al2023/meta-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SLES seed uses &lt;code&gt;wheel&lt;/code&gt; and uncommented &lt;code&gt;%wheel&lt;/code&gt; in &lt;code&gt;/etc/sudoers&lt;/code&gt;. Amazon Linux also uses &lt;code&gt;wheel&lt;/code&gt; but doesn't need the sed hack. The &lt;code&gt;runcmd&lt;/code&gt; just leaves a marker file.&lt;/p&gt;

&lt;p&gt;Build the ISOs on your host, from &lt;code&gt;~/vm&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="nv"&gt;$ &lt;/span&gt;mkisofs &lt;span class="nt"&gt;-J&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-input-charset&lt;/span&gt; utf-8 &lt;span class="nt"&gt;-V&lt;/span&gt; cidata &lt;span class="nt"&gt;-o&lt;/span&gt; cloud-init-sles.iso sles16/user-data sles16/meta-data
&lt;span class="nv"&gt;$ &lt;/span&gt;mkisofs &lt;span class="nt"&gt;-J&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nt"&gt;-input-charset&lt;/span&gt; utf-8 &lt;span class="nt"&gt;-V&lt;/span&gt; cidata &lt;span class="nt"&gt;-o&lt;/span&gt; cloud-init-al2023.iso al2023/user-data al2023/meta-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🚀 Boot
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-cdrom&lt;/span&gt; /vm/cloud-init-sles.iso &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-boot&lt;/span&gt; c
&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;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/al2023-kvm-2023.10.20260325.0-kernel-6.1-x86_64.xfs.gpt.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-cdrom&lt;/span&gt; /vm/cloud-init-al2023.iso &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-boot&lt;/span&gt; c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first boot with the cloud-init ISO configures the VM. After that, drop the &lt;code&gt;-cdrom&lt;/code&gt; flag — it's only needed once:&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="nv"&gt;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/SLES-16.0-Minimal-VM.x86_64-Cloud-GM.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio
&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;$ &lt;/span&gt;podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--device&lt;/span&gt; /dev/kvm &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-v&lt;/span&gt; ~/vm:/vm:z &lt;span class="se"&gt;\&lt;/span&gt;
    qemu:base &lt;span class="se"&gt;\&lt;/span&gt;
    qemu-system-x86_64 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-enable-kvm&lt;/span&gt; &lt;span class="nt"&gt;-cpu&lt;/span&gt; host &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-nographic&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-m&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-drive&lt;/span&gt; &lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/vm/al2023-kvm-2023.10.20260325.0-kernel-6.1-x86_64.xfs.gpt.qcow2,format&lt;span class="o"&gt;=&lt;/span&gt;qcow2,if&lt;span class="o"&gt;=&lt;/span&gt;virtio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📊 Results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Distribution&lt;/th&gt;
&lt;th&gt;Disk Size&lt;/th&gt;
&lt;th&gt;Boot Time&lt;/th&gt;
&lt;th&gt;Root Usage&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu 24.04&lt;/td&gt;
&lt;td&gt;3.5 GB&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;Lightweight server, ext4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLES 16&lt;/td&gt;
&lt;td&gt;1.3 GB&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;Minimal install, xfs, very tight on disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Linux 2023&lt;/td&gt;
&lt;td&gt;25 GB&lt;/td&gt;
&lt;td&gt;~20s&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;td&gt;Cloud-optimized, xfs, plenty of room&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  🦎 SLES 16 — What's Different
&lt;/h3&gt;

&lt;p&gt;SLES boots fine with its own cloud-init seed. The &lt;code&gt;wheel&lt;/code&gt; group works for sudo. Boot time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;real    0m9.855s
user    0m0.085s
sys     0m0.055s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 10 seconds — same ballpark as Ubuntu. QEMU defaults to the right boot device when there's only one disk, so &lt;code&gt;-boot c&lt;/code&gt; isn't needed for subsequent boots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; cloud-init &lt;span class="nt"&gt;--version&lt;/span&gt;
/usr/bin/cloud-init 25.1.3-160000.1.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The disk layout tells a different story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; lsblk
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sr0     11:0    1  364K  0 rom  
vda    253:0    0  1.3G  0 disk 
├─vda1 253:1    0    2M  0 part 
├─vda2 253:2    0  512M  0 part /boot/efi
└─vda3 253:3    0  806M  0 part /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three partitions instead of four — no separate &lt;code&gt;/boot&lt;/code&gt;, just EFI and root. The 2 MB &lt;code&gt;vda1&lt;/code&gt; is the BIOS boot partition. The root filesystem is xfs, not ext4.&lt;/p&gt;

&lt;p&gt;And the disk is already 97% full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysadmin@kvmpodman:~&amp;gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-Th&lt;/span&gt;
Filesystem     Type      Size  Used Avail Use% Mounted on
/dev/vda3      xfs       742M  716M   27M  97% /
/dev/vda2      vfat      512M  3.9M  508M   1% /boot/efi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;27 MB of free space on root is tight. Installing a single package with &lt;code&gt;zypper&lt;/code&gt; will likely fill the remaining space. Ubuntu gave you 688 MB free on a 3.5 GB image. SLES is a true minimal image — but that means almost no headroom.&lt;/p&gt;

&lt;p&gt;Memory-wise, both Ubuntu and SLES report about 960 MB from the 1 GB allocation — no surprise there.&lt;/p&gt;

&lt;h3&gt;
  
  
  ☁️ Amazon Linux 2023 — What's Different
&lt;/h3&gt;

&lt;p&gt;Amazon Linux takes a completely different approach. It ships a 25 GB image with only 7% 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="o"&gt;[&lt;/span&gt;sysadmin@localhost ~]&lt;span class="nv"&gt;$ &lt;/span&gt;lsblk
NAME     MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sr0       11:0    1  364K  0 rom  
vda      252:0    0   25G  0 disk 
├─vda1   252:1    0   25G  0 part /
├─vda127 259:0    0    1M  0 part 
└─vda128 259:1    0   10M  0 part /boot/efi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three partitions — a single massive 25 GB root partition, a 1 MB BIOS boot partition, and a tiny 10 MB EFI partition. No separate &lt;code&gt;/boot&lt;/code&gt;. The filesystem is xfs, same as SLES.&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="o"&gt;[&lt;/span&gt;sysadmin@localhost ~]&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-Th&lt;/span&gt;
Filesystem     Type      Size  Used Avail Use% Mounted on
/dev/vda1      xfs        25G  1.7G   24G   7% /
/dev/vda128    vfat       10M  1.3M  8.7M  13% /boot/efi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;24 GB of free space out of the box. This image is built for production use, not minimal testing. You can install packages, run services, and never worry about disk space.&lt;/p&gt;

&lt;p&gt;Memory usage is similar — 964 MB total, 317 MB 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="o"&gt;[&lt;/span&gt;sysadmin@localhost ~]&lt;span class="nv"&gt;$ &lt;/span&gt;free &lt;span class="nt"&gt;-m&lt;/span&gt;
               total        used        free      shared  buff/cache   available
Mem:             964         317         433           2         213         508
Swap:              0           0           0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloud-init version:&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="o"&gt;[&lt;/span&gt;sysadmin@localhost ~]&lt;span class="nv"&gt;$ &lt;/span&gt;cloud-init &lt;span class="nt"&gt;--version&lt;/span&gt;
/usr/bin/cloud-init 22.2.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One oddity: the hostname shows as &lt;code&gt;localhost&lt;/code&gt; instead of &lt;code&gt;kvmpodman&lt;/code&gt;. Amazon Linux ships with cloud-init 22.2.2, which is significantly older than Ubuntu's 25.3 or SLES's 25.1. The &lt;code&gt;preserve_hostname: false&lt;/code&gt; directive may not be honored properly in this older version, or Amazon's own hostname logic overrides it during boot. Fix it manually:&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="o"&gt;[&lt;/span&gt;sysadmin@localhost ~]&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;hostnamectl set-hostname kvmpodman
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boot time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;real    0m19.998s
user    0m0.061s
sys     0m0.089s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 20 seconds — the slowest of the three. Ubuntu and SLES both hit ~10 seconds. Amazon's extra startup time likely comes from its larger disk image, older cloud-init version, and the init services it brings up by default. Still, 20 seconds is nothing for a full production-grade Linux install.&lt;/p&gt;

&lt;p&gt;The real kicker: this is an image designed exclusively for AWS EC2, and it boots natively as a KVM VM inside a Podman container. No AWS APIs, no special tooling — just qcow2 and VirtIO. It works.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔍 Distro Comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Ubuntu 24.04&lt;/th&gt;
&lt;th&gt;SLES 16&lt;/th&gt;
&lt;th&gt;Amazon Linux 2023&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Disk size&lt;/td&gt;
&lt;td&gt;3.5 GB&lt;/td&gt;
&lt;td&gt;1.3 GB&lt;/td&gt;
&lt;td&gt;25 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root usage&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;97%&lt;/td&gt;
&lt;td&gt;7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem&lt;/td&gt;
&lt;td&gt;ext4&lt;/td&gt;
&lt;td&gt;xfs&lt;/td&gt;
&lt;td&gt;xfs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boot time&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;td&gt;~20s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cloud-init&lt;/td&gt;
&lt;td&gt;25.3&lt;/td&gt;
&lt;td&gt;25.1&lt;/td&gt;
&lt;td&gt;22.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partitions&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swap&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ⚠️ Distro-Specific Gotchas
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;SLES:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uses &lt;code&gt;zypper&lt;/code&gt; instead of &lt;code&gt;apt&lt;/code&gt;/&lt;code&gt;dnf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Root filesystem is xfs, not ext4&lt;/li&gt;
&lt;li&gt;Disk is extremely tight (97% used out of the box) — not ideal for installing additional packages&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;wheel&lt;/code&gt; group works for sudo access&lt;/li&gt;
&lt;li&gt;Older images may have cloud-init issues — grab the latest from the SUSE download center&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Amazon Linux 2023:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uses &lt;code&gt;dnf&lt;/code&gt; like RHEL/CentOS&lt;/li&gt;
&lt;li&gt;Comes with cloud-init pre-installed (it's designed for AWS)&lt;/li&gt;
&lt;li&gt;May try to reach AWS metadata services on boot — harmless but adds a few seconds&lt;/li&gt;
&lt;li&gt;Default shell for root is &lt;code&gt;bash&lt;/code&gt;, but the &lt;code&gt;ec2-user&lt;/code&gt; default varies&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ✅ What You've Built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ Ubuntu cloud image downloaded and ready to boot&lt;/li&gt;
&lt;li&gt;✅ &lt;code&gt;cloud-init&lt;/code&gt; seed with user, SSH key, and hashed password&lt;/li&gt;
&lt;li&gt;✅ VM boots in under 10 seconds with VirtIO, fully configured&lt;/li&gt;
&lt;li&gt;✅ No manual installer, no interactive prompts&lt;/li&gt;
&lt;li&gt;✅ SLES 16 and Amazon Linux 2023 running side by side&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔜 What's Next?
&lt;/h2&gt;

&lt;p&gt;You can boot into the VM, but you're stuck in the console. Typing commands in the QEMU terminal is clunky — no copy/paste, no multiple windows, no real terminal features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post #5:&lt;/strong&gt; We'll set up SSH networking so you can connect from your host terminal, use your favorite SSH client, and treat this VM like a real remote server.&lt;/p&gt;




&lt;p&gt;This guide is &lt;strong&gt;Part 4&lt;/strong&gt; of the &lt;strong&gt;KVM Virtual Machines on Podman&lt;/strong&gt; series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="//KVM-01-CONTAINER.md"&gt;Build a KVM-Ready Container Image from Scratch&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="//KVM-02-KVM-ACCELERATION.md"&gt;KVM Acceleration in a Rootless Podman Container&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="//KVM-03-PERSISTENT-DISK.md"&gt;Persistent VMs in Podman: Install Alpine to a qcow2 Disk Image&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Coming up in Part 5:&lt;/strong&gt; SSH Into Your Podman VM — Container Networking for KVM&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Found this helpful?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://linkedin.com/in/davidtio" rel="noopener noreferrer"&gt;Share with your network&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter:&lt;/strong&gt; &lt;a href="https://x.com/intent/tweet?text=Boot%20KVM%20VMs%20in%20seconds%20with%20cloud%20images!&amp;amp;url=https://blog.dtio.app/2026/04/boot-in-seconds-cloud-images-cloud-init.html" rel="noopener noreferrer"&gt;Tweet about it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Questions?&lt;/strong&gt; Drop a comment below or reach out on LinkedIn&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kvm</category>
      <category>podman</category>
      <category>cloudinit</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
