<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://mradelvand.github.io/docker/feed.xml" rel="self" type="application/atom+xml" /><link href="https://mradelvand.github.io/docker/" rel="alternate" type="text/html" /><updated>2026-05-19T12:20:45+00:00</updated><id>https://mradelvand.github.io/docker/feed.xml</id><title type="html">The Cloud from the South</title><subtitle>To my family and especially to my Dad. To my city that was brought into being by oil, almost killed by oil. you will remain with me forever. sharing my learning&apos;s, so you might find it useful. If you have any questions feel free to ask.</subtitle><author><name>REZA</name></author><entry><title type="html">Docker &amp;amp; Containerization Series — Part 1: From Local Container to Cloud Automation</title><link href="https://mradelvand.github.io/docker/docker/devops%20&%20automation/github/2026/05/15/lab01.html" rel="alternate" type="text/html" title="Docker &amp;amp; Containerization Series — Part 1: From Local Container to Cloud Automation" /><published>2026-05-15T00:00:00+00:00</published><updated>2026-05-15T00:00:00+00:00</updated><id>https://mradelvand.github.io/docker/docker/devops%20&amp;%20automation/github/2026/05/15/lab01</id><content type="html" xml:base="https://mradelvand.github.io/docker/docker/devops%20&amp;%20automation/github/2026/05/15/lab01.html"><![CDATA[<blockquote>
  <p><strong>Series</strong>: Docker → Kubernetes → DevSecOps<br />
<strong>Level</strong>: Complete Beginner — every click and every command is shown<br />
<strong>Repo</strong>: <a href="https://github.com/mradelvand/reza-plan-docker">reza-plan-docker</a><br />
<strong>Stack</strong>: React 19 + Vite · Express 5 · Node 20 Alpine · GitHub Codespaces · GitHub Actions</p>
</blockquote>

<hr />

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#1-the-big-picture--what-were-building">The Big Picture — What We’re Building</a></li>
  <li><a href="#2-what-is-docker">What is Docker?</a></li>
  <li><a href="#3-install-docker-on-your-machine">Install Docker on Your Machine</a></li>
  <li><a href="#4-clone-the-project">Clone the Project</a></li>
  <li><a href="#5-understand-the-project-files">Understand the Project Files</a></li>
  <li><a href="#6-step-1--create-the-dockerignore-file">Step 1 — Create the <code>.dockerignore</code> File</a></li>
  <li><a href="#7-step-2--create-the-dockerfile">Step 2 — Create the <code>Dockerfile</code></a></li>
  <li><a href="#8-step-3--build-the-docker-image">Step 3 — Build the Docker Image</a></li>
  <li><a href="#9-step-4--run-the-container-manually">Step 4 — Run the Container Manually</a></li>
  <li><a href="#10-step-5--test-the-running-app">Step 5 — Test the Running App</a></li>
  <li><a href="#11-step-6--debug-when-things-go-wrong">Step 6 — Debug When Things Go Wrong</a></li>
  <li><a href="#12-step-7--create-the-docker-composeyml-file">Step 7 — Create the <code>docker-compose.yml</code> File</a></li>
  <li><a href="#13-step-8--push-your-image-to-a-registry">Step 8 — Push Your Image to a Registry</a></li>
  <li><a href="#14-understanding-multi-stage-builds">Understanding Multi-Stage Builds</a></li>
  <li><a href="#15-step-9--run-in-the-cloud-with-github-codespaces">Step 9 — Run in the Cloud with GitHub Codespaces</a></li>
  <li><a href="#16-step-10--automate-with-github-actions">Step 10 — Automate with GitHub Actions</a></li>
  <li><a href="#17-docker-best-practices-checklist">Docker Best Practices Checklist</a></li>
  <li><a href="#18-lessons-learned">Lessons Learned</a></li>
  <li><a href="#19-whats-next">What’s Next</a></li>
</ol>

<hr />

<h2 id="1-the-big-picture--what-were-building">1. The Big Picture — What We’re Building</h2>

<p>Most Docker tutorials stop at “the container runs on my laptop.” This post goes further. By the end you will have done all of this:</p>

<ol>
  <li>The <code>reza-plan</code> app (React + Express tracking app) packaged into a Docker container</li>
  <li>That same container running <strong>in the cloud for free</strong> on GitHub Codespaces</li>
  <li>A GitHub Actions workflow that <strong>automatically starts and stops</strong> the Codespace every day</li>
</ol>

<p>Here is the full picture of what we are building:</p>

<pre><code>┌──────────────────────────────────────────────────────────────────────────┐
│                          FULL SYSTEM MAP                                 │
│                                                                          │
│  GitHub Actions (runs on a schedule — like a cron job)                   │
│       │                                                                  │
│       │  starts at 08:17 UTC every weekday morning                       │
│       │  stops  at 13:17 UTC every weekday afternoon                     │
│       ▼                                                                  │
│  GitHub Codespace (free Linux cloud machine from GitHub)                 │
│  ┌───────────────────────────────────────────────────┐                   │
│  │  Docker Container                                 │                   │
│  │  ┌─────────────────────────────────────────────┐ │                   │
│  │  │  node:20-alpine (tiny Linux + Node.js)      │ │                   │
│  │  │  Express server listening on port 3000      │ │                   │
│  │  │  React app served as pre-built HTML/JS/CSS  │ │                   │
│  │  │  /app/data ←──── volume (your data on host) │ │                   │
│  │  └─────────────────────────────────────────────┘ │                   │
│  └───────────────────────────────────────────────────┘                   │
│       │                                                                  │
│       │  port 3000 forwarded → public HTTPS URL                          │
│       ▼                                                                  │
│  https://your-codespace-name-3000.app.github.dev                         │
│                                                                          │
│  Telegram bot notifies you when the codespace starts or stops            │
└──────────────────────────────────────────────────────────────────────────┘
</code></pre>

<p>This is real DevOps — not just a toy example. Everything here is what you would do professionally. Let’s build it step by step.</p>

<hr />

<h2 id="2-what-is-docker">2. What is Docker?</h2>

<p>Before touching any file, understand the two core ideas.</p>

<h3 id="image--the-blueprint">Image = the blueprint</h3>

<p>A Docker <strong>image</strong> is a read-only package that contains everything your app needs to run: the operating system layer, your code, all libraries and dependencies, and the command to start it. Think of it like a ZIP file that you can hand to any computer in the world and it will run identically.</p>

<h3 id="container--the-running-instance">Container = the running instance</h3>

<p>A Docker <strong>container</strong> is what you get when you actually run an image. One image can produce many containers — all identical, all isolated from each other and from your host machine.</p>

<pre><code>┌───────────────────────────────────────────────────────────────────┐
│  Think of it like a recipe and a meal                             │
│                                                                   │
│  Image   = the recipe (read-only, reusable, shareable)            │
│  Container = the meal (running, can be stopped, can be deleted)   │
│                                                                   │
│  You can cook the same recipe on any stove in the world           │
│  and the meal will taste the same                                 │
│                                                                   │
│  Docker:                                                          │
│  You can run the same image on your laptop, on GitHub,            │
│  on AWS — and it behaves identically every time                   │
└───────────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="why-does-this-matter-for-devops">Why does this matter for DevOps?</h3>

<p>Without Docker: “It works on my machine” — and breaks everywhere else because dependencies, OS versions, and configs differ.</p>

<p>With Docker: the image <em>is</em> the environment. Same image everywhere = same behavior everywhere. This is the foundation of modern CI/CD, Kubernetes, and cloud deployments.</p>

<hr />

<h2 id="3-install-docker-on-your-machine">3. Install Docker on Your Machine</h2>

<h3 id="ubuntu-or-debian-linux">Ubuntu or Debian Linux</h3>

<p>Open a terminal and run these commands <strong>one block at a time</strong>. Wait for each to finish before running the next.</p>

<p><strong>Block 1 — Remove old Docker versions if any exist:</strong></p>
<pre><code class="language-bash">sudo apt-get remove docker docker-engine docker.io containerd runc
</code></pre>
<blockquote>
  <p>It is fine if this says “unable to locate package” — that just means nothing old was installed.</p>
</blockquote>

<p><strong>Block 2 — Install the tools Docker needs:</strong></p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release
</code></pre>

<p><strong>Block 3 — Add Docker’s official security key:</strong></p>
<pre><code class="language-bash">sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
</code></pre>

<p><strong>Block 4 — Add the Docker software repository:</strong></p>
<pre><code class="language-bash">echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release &amp;&amp; echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
</code></pre>

<p><strong>Block 5 — Install Docker:</strong></p>
<pre><code class="language-bash">sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
</code></pre>

<p><strong>Block 6 — Verify it installed correctly:</strong></p>
<pre><code class="language-bash">docker --version
docker compose version
</code></pre>

<p>You should see output like:</p>
<pre><code>Docker version 26.1.0, build abc123
Docker Compose version v2.27.0
</code></pre>

<p><strong>Block 7 — Run Docker without <code>sudo</code> every time (optional but recommended):</strong></p>
<pre><code class="language-bash">sudo usermod -aG docker $USER
newgrp docker
</code></pre>

<blockquote>
  <p><strong>🔐 Security Note:</strong> Adding yourself to the <code>docker</code> group gives you the ability to run containers without typing <code>sudo</code>. Be aware this is effectively root-level power on the host machine. On a shared server, never do this for untrusted users.</p>
</blockquote>

<p><strong>Test that everything works:</strong></p>
<pre><code class="language-bash">docker run hello-world
</code></pre>
<p>You should see a message saying “Hello from Docker!” — your installation is working.</p>

<hr />

<h3 id="macos-or-windows">macOS or Windows</h3>

<ol>
  <li>Go to <a href="https://www.docker.com/products/docker-desktop">https://www.docker.com/products/docker-desktop</a></li>
  <li>Click <strong>Download Docker Desktop</strong> for your operating system</li>
  <li>Open the downloaded file and follow the installer</li>
  <li>Once installed, open Docker Desktop — wait for the whale icon in your taskbar to show “Docker is running”</li>
  <li>Open a terminal (Terminal on Mac, PowerShell on Windows) and verify:
    <pre><code class="language-bash">docker --version
docker compose version
</code></pre>
  </li>
</ol>

<hr />

<h2 id="4-clone-the-project">4. Clone the Project</h2>

<p>Open a terminal on your machine and run:</p>

<pre><code class="language-bash"># Clone the repo
git clone https://github.com/mradelvand/reza-plan-docker.git

# Move into the project folder
cd reza-plan-docker

# Confirm you are in the right place
ls
</code></pre>

<p>You should see these files listed:</p>
<pre><code>Dockerfile  docker-compose.yml  package.json  server.js  src/  public/  ...
</code></pre>

<p>All commands from this point forward are run <strong>inside the <code>reza-plan-docker</code> folder</strong> unless stated otherwise.</p>

<hr />

<h2 id="5-understand-the-project-files">5. Understand the Project Files</h2>

<p>Before creating any Docker files, understand what you are working with.</p>

<pre><code>reza-plan-docker/
│
├── src/                  ← React source code (what gets compiled)
│   ├── App.jsx           ← sidebar, navigation, all panels
│   ├── AgentPanel.jsx    ← daily log form, AI feedback, stats
│   ├── data.js           ← course sections, weekly schedules
│   ├── storage.js        ← functions that call the Express API
│   ├── index.css         ← all styles
│   └── main.jsx          ← React entry point
│
├── public/               ← static files (favicon, icons)
├── data/                 ← WHERE YOUR PROGRESS DATA LIVES
│                           (progress.json is created here at runtime)
│
├── server.js             ← Express backend server
│                           serves the API AND the compiled React app
│
├── package.json          ← project dependencies and scripts
├── vite.config.js        ← Vite dev server settings
│
│   ── YOU WILL CREATE THESE ──
├── .dockerignore         ← tells Docker what files to ignore
├── Dockerfile            ← recipe to build the image
└── docker-compose.yml    ← easier way to run the container
</code></pre>

<p><strong>The most important thing to understand before we continue:</strong></p>

<p>In <strong>development</strong> (without Docker), you run <code>npm start</code>. This starts two servers at the same time:</p>
<ul>
  <li>Vite dev server on port <code>5173</code> — serves the React frontend with hot-reload</li>
  <li>Express server on port <code>3001</code> — serves the API</li>
</ul>

<p>In <strong>production</strong> (inside Docker), there is no Vite, no hot-reload. Instead:</p>
<ul>
  <li>Express runs on port <code>3000</code> and does <strong>both jobs</strong> — serves the API <strong>and</strong> serves the pre-compiled React app as static files from the <code>dist/</code> folder</li>
</ul>

<p>This is why we need a build step in the Dockerfile — to compile the React source code into static files that Express can serve.</p>

<hr />

<h2 id="6-step-1--create-the-dockerignore-file">6. Step 1 — Create the <code>.dockerignore</code> File</h2>

<h3 id="what-is-dockerignore">What is <code>.dockerignore</code>?</h3>

<p>When you tell Docker to build an image, it first collects all the files in your project folder and sends them to the Docker engine. This is called the <strong>build context</strong>. The <code>.dockerignore</code> file tells Docker which files to <strong>leave out</strong> of that collection.</p>

<p>Without <code>.dockerignore</code>, Docker would try to send your entire project — including <code>node_modules</code> (which can be 200MB+) and <code>data/progress.json</code> (which contains your personal data). This is slow and potentially dangerous.</p>

<h3 id="where-to-create-it">Where to create it</h3>

<p>The <code>.dockerignore</code> file must be in the <strong>root of your project folder</strong> — the same folder that contains your <code>Dockerfile</code> and <code>package.json</code>.</p>

<pre><code>reza-plan-docker/          ← you are here
├── .dockerignore          ← create it RIGHT HERE, at this level
├── package.json
├── server.js
└── src/
</code></pre>

<h3 id="how-to-create-it">How to create it</h3>

<p><strong>Option A — using the terminal:</strong></p>
<pre><code class="language-bash"># Make sure you are in the project root
pwd
# Should show: /path/to/reza-plan-docker

# Create the file
cat &gt; .dockerignore &lt;&lt; 'EOF'
node_modules
dist
data
.git
*.md
SETUP.md
HOW_TO_MODIFY.md
EOF
</code></pre>

<p><strong>Option B — using VS Code:</strong></p>
<ol>
  <li>Open VS Code in the project folder: <code>code .</code></li>
  <li>In the file explorer on the left, click the <strong>New File</strong> icon (the page with a plus sign)</li>
  <li>Type exactly: <code>.dockerignore</code> (with the dot at the start) and press Enter</li>
  <li>Paste this content into the file:</li>
</ol>

<pre><code>node_modules
dist
data
.git
*.md
SETUP.md
HOW_TO_MODIFY.md
</code></pre>

<ol>
  <li>Save with <code>Ctrl+S</code> (Windows/Linux) or <code>Cmd+S</code> (Mac)</li>
</ol>

<h3 id="verify-it-was-created">Verify it was created</h3>

<pre><code class="language-bash">ls -la | grep dockerignore
# Should show: .dockerignore
</code></pre>

<h3 id="why-each-line-is-there">Why each line is there</h3>

<table>
  <thead>
    <tr>
      <th>Line</th>
      <th>What it excludes</th>
      <th>Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>node_modules</code></td>
      <td>All installed npm packages</td>
      <td>200MB+. Docker will install them cleanly inside the image instead</td>
    </tr>
    <tr>
      <td><code>dist</code></td>
      <td>Previous compiled React output</td>
      <td>We compile fresh inside Docker</td>
    </tr>
    <tr>
      <td><code>data</code></td>
      <td>Your <code>progress.json</code> data file</td>
      <td>This is personal data — mounted as a volume at runtime, not baked into the image</td>
    </tr>
    <tr>
      <td><code>.git</code></td>
      <td>Git history and metadata</td>
      <td>No purpose inside a production image</td>
    </tr>
    <tr>
      <td><code>*.md</code></td>
      <td>All markdown files</td>
      <td>Documentation — not needed at runtime</td>
    </tr>
    <tr>
      <td><code>SETUP.md</code>, <code>HOW_TO_MODIFY.md</code></td>
      <td>Specific docs</td>
      <td>Explicitly excluded for clarity</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>🔐 Security Note:</strong> The <code>data</code> directory being excluded is critical. If you ever create a <code>.env</code> file in this project (for API keys, passwords, etc.), add <code>.env</code> to <code>.dockerignore</code> immediately. A leaked secret baked into a Docker image pushed to a public registry is a serious incident. Always ask yourself before building: “is there anything in my project folder I would not want public?”</p>
</blockquote>

<hr />

<h2 id="7-step-2--create-the-dockerfile">7. Step 2 — Create the <code>Dockerfile</code></h2>

<h3 id="what-is-a-dockerfile">What is a Dockerfile?</h3>

<p>A <code>Dockerfile</code> is a text file that contains step-by-step instructions for building your Docker image. Docker reads it top to bottom, like a recipe, and each instruction creates a new layer in the image.</p>

<h3 id="where-to-create-it-1">Where to create it</h3>

<p>The <code>Dockerfile</code> also goes in the <strong>root of your project folder</strong>, next to <code>package.json</code>:</p>

<pre><code>reza-plan-docker/          ← you are here
├── .dockerignore          ← just created this
├── Dockerfile             ← create it RIGHT HERE
├── package.json
├── server.js
└── src/
</code></pre>

<h3 id="how-to-create-it-1">How to create it</h3>

<p><strong>Option A — using the terminal:</strong></p>
<pre><code class="language-bash"># Make sure you are in the project root
pwd
# Should show: /path/to/reza-plan-docker

# Create the Dockerfile (note: no file extension)
touch Dockerfile
</code></pre>
<p>Then open it with your text editor and paste the content below.</p>

<p><strong>Option B — using VS Code:</strong></p>
<ol>
  <li>In the file explorer, click the <strong>New File</strong> icon</li>
  <li>Type exactly: <code>Dockerfile</code> (capital D, no extension) and press Enter</li>
  <li>Paste the full content below</li>
  <li>Save with <code>Ctrl+S</code> or <code>Cmd+S</code></li>
</ol>

<h3 id="the-complete-dockerfile">The complete Dockerfile</h3>

<pre><code class="language-dockerfile"># =============================================================================
# Stage 1 — BUILD
# This stage installs everything and compiles the React app.
# It will be thrown away after building — nothing from here
# except the compiled output reaches the final image.
# =============================================================================
FROM node:20-alpine AS builder

# Set the working directory inside this stage's container.
# All commands from here run inside /app.
WORKDIR /app

# --- Layer caching trick ---
# Copy ONLY the package files first, before the source code.
# Docker caches each layer. If package.json hasn't changed,
# Docker skips the npm install step on the next build.
# This saves minutes on every build after the first.
COPY package*.json ./

# Install ALL dependencies (dev + production).
# We need dev tools like Vite to compile the React app.
RUN npm install

# Now copy the rest of the source code.
# This is copied AFTER npm install so editing a React file
# does not invalidate the npm install cache.
COPY . .

# Compile the React app with Vite.
# Output goes to /app/dist as static HTML, CSS, and JS files.
RUN npm run build


# =============================================================================
# Stage 2 — PRODUCTION
# This is the image that actually ships and runs.
# It starts completely fresh — none of Stage 1's files are here
# except what we explicitly copy over.
# =============================================================================
FROM node:20-alpine

WORKDIR /app

# Install ONLY production dependencies.
# No Vite, no ESLint, no dev tools — keeps the image small and safe.
COPY package*.json ./
RUN npm install --omit=dev

# Copy the compiled React app from Stage 1.
# The build tools that produced it are left behind and discarded.
COPY --from=builder /app/dist ./dist

# Copy the Express server — this is what actually runs.
COPY server.js ./

# Create the data directory.
# The real data (progress.json) is stored on the HOST machine
# and mounted into this directory at runtime via a Docker volume.
# This mkdir just ensures the mount point exists in the image.
RUN mkdir -p /app/data

# Document which port the app listens on.
# This does NOT publish the port — that happens at runtime.
EXPOSE 3000

# The command that runs when the container starts.
# Using array format (exec form) is important — it means Node.js
# receives shutdown signals directly so it can save data gracefully.
CMD ["node", "server.js"]
</code></pre>

<h3 id="verify-it-was-created-1">Verify it was created</h3>

<pre><code class="language-bash">ls -la | grep Dockerfile
# Should show: Dockerfile
</code></pre>

<h3 id="how-docker-reads-this-file--the-flow">How Docker reads this file — the flow</h3>

<pre><code>┌──────────────────────────────────────────────────────────────────────┐
│  STAGE 1 (builder) — runs and then is completely thrown away         │
│                                                                      │
│  FROM node:20-alpine AS builder                                      │
│    Start with a minimal Linux + Node.js 20 image                    │
│                    ↓                                                 │
│  WORKDIR /app                                                        │
│    All commands now run inside /app                                  │
│                    ↓                                                 │
│  COPY package*.json ./                                               │
│    Copy package.json + package-lock.json into /app                  │
│                    ↓                                                 │
│  RUN npm install                                                     │
│    Install all packages (including Vite, ESLint, etc.)               │
│                    ↓                                                 │
│  COPY . .                                                            │
│    Copy all source code (src/, public/, vite.config.js, etc.)       │
│                    ↓                                                 │
│  RUN npm run build                                                   │
│    Vite compiles React → /app/dist/index.html + assets/             │
│                                                                      │
│  ── ENTIRE STAGE 1 IS DISCARDED ──────────────────────────────────  │
│                                                                      │
│  STAGE 2 (production) — this is the actual image that ships          │
│                                                                      │
│  FROM node:20-alpine    ← brand new, empty image                    │
│                    ↓                                                 │
│  npm install --omit=dev ← only express, cors, react, react-dom      │
│                    ↓                                                 │
│  COPY --from=builder /app/dist ./dist  ← grab compiled React        │
│                    ↓                                                 │
│  COPY server.js ./  ← add the Express server                        │
│                    ↓                                                 │
│  RUN mkdir -p /app/data  ← create the volume mount point            │
│                    ↓                                                 │
│  CMD ["node", "server.js"]  ← this runs when container starts       │
└──────────────────────────────────────────────────────────────────────┘
</code></pre>

<hr />

<h2 id="8-step-3--build-the-docker-image">8. Step 3 — Build the Docker Image</h2>

<p>Now you have both files created. Let’s build the image.</p>

<h3 id="run-the-build-command">Run the build command</h3>

<p>Make sure you are in the project root (where the <code>Dockerfile</code> is), then run:</p>

<pre><code class="language-bash">docker build -t reza-plan:1.0 .
</code></pre>

<p>Let’s break down this command:</p>

<pre><code>docker build          ← the build command
-t reza-plan:1.0      ← tag (name) the image: name=reza-plan, version=1.0
.                     ← the dot means "use the current folder as context"
                        Docker will look for Dockerfile here
                        and collect files (minus .dockerignore) from here
</code></pre>

<h3 id="what-you-will-see-while-it-builds">What you will see while it builds</h3>

<pre><code>[+] Building 65.3s
 =&gt; [builder 1/6] FROM node:20-alpine              ← downloading base image
 =&gt; [builder 2/6] WORKDIR /app                     ← setting work directory
 =&gt; [builder 3/6] COPY package*.json ./            ← copying package files
 =&gt; [builder 4/6] RUN npm install                  ← installing packages (~40s)
 =&gt; [builder 5/6] COPY . .                         ← copying source code
 =&gt; [builder 6/6] RUN npm run build                ← compiling React
 =&gt; [stage-1 2/6] COPY package*.json ./
 =&gt; [stage-1 3/6] RUN npm install --omit=dev       ← prod-only packages
 =&gt; [stage-1 4/6] COPY --from=builder /app/dist    ← grabbing compiled React
 =&gt; [stage-1 5/6] COPY server.js ./
 =&gt; [stage-1 6/6] RUN mkdir -p /app/data
 =&gt; exporting to image
 =&gt; naming to reza-plan:1.0
</code></pre>

<p>The first build takes a few minutes because it downloads the base image and installs all packages. <strong>Subsequent builds are much faster</strong> because Docker caches the layers.</p>

<h3 id="verify-the-image-was-created">Verify the image was created</h3>

<pre><code class="language-bash">docker images
</code></pre>

<p>You should see something like:</p>
<pre><code>REPOSITORY   TAG    IMAGE ID       CREATED          SIZE
reza-plan    1.0    a3f9bc12d4e1   2 minutes ago    185MB
</code></pre>

<p>The image is about 185MB. If you had not used a multi-stage build, it would be over 600MB.</p>

<h3 id="understanding-tags">Understanding tags</h3>

<pre><code class="language-bash"># You can build the same image with multiple tags
docker build -t reza-plan:1.0 .           # version number
docker build -t reza-plan:latest .        # latest (for local use)
docker build -t reza-plan:dev .           # for development testing

# When you are ready to push to Docker Hub or GHCR:
docker build -t yourusername/reza-plan:1.0 .
</code></pre>

<blockquote>
  <p><strong>🔐 Security Note:</strong> Before shipping any image, scan it for known security vulnerabilities. Install Trivy and run:</p>
  <pre><code class="language-bash"># Install trivy on Linux
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | \
  sh -s -- -b /usr/local/bin

# Scan your image
trivy image reza-plan:1.0
</code></pre>
  <p>The <code>node:20-alpine</code> base image is chosen specifically because Alpine Linux has fewer installed packages, which means fewer potential vulnerabilities compared to a full Ubuntu or Debian base.</p>
</blockquote>

<hr />

<h2 id="9-step-4--run-the-container-manually">9. Step 4 — Run the Container Manually</h2>

<h3 id="create-the-data-directory-first">Create the data directory first</h3>

<p>The app needs a <code>data/</code> folder to store <code>progress.json</code>. Create it on your host machine:</p>

<pre><code class="language-bash">mkdir -p data
</code></pre>

<p>This folder on your machine will be connected to the container via a volume mount. Your data lives here — not inside the container.</p>

<h3 id="run-the-container">Run the container</h3>

<pre><code class="language-bash">docker run -d \
  -p 3000:3000 \
  -v $(pwd)/data:/app/data \
  --name reza-plan \
  reza-plan:1.0
</code></pre>

<p>Breaking down every part of this command:</p>

<pre><code>docker run            ← start a container from an image
-d                    ← detached mode: runs in the background
                        (without this, the terminal would be locked)

-p 3000:3000          ← port mapping
                        left side  (3000) = port on YOUR machine
                        right side (3000) = port inside the container
                        format is always: host_port:container_port

-v $(pwd)/data:/app/data  ← volume mount (connects a folder)
                            $(pwd) = your current directory (auto-filled)
                            left side  = folder on YOUR machine: ./data
                            right side = folder inside container: /app/data
                            they are linked — changes on either side
                            are instantly visible on the other side

--name reza-plan      ← give the container a memorable name
                        without this, Docker invents a random name

reza-plan:1.0         ← which image to run (name:tag from the build step)
</code></pre>

<h3 id="what-the-volume-mount-means">What the volume mount means</h3>

<pre><code>Your machine (host)                 Container
────────────────────                ─────────────────────
./data/progress.json   ←──────────► /app/data/progress.json
                          linked     (same file, two views)

When the app writes progress data → it appears on your machine
When you delete the container    → your data is still safe
When you rebuild the image       → your data is still safe
</code></pre>

<hr />

<h2 id="10-step-5--test-the-running-app">10. Step 5 — Test the Running App</h2>

<h3 id="check-the-container-is-running">Check the container is running</h3>

<pre><code class="language-bash">docker ps
</code></pre>

<p>You should see:</p>
<pre><code>CONTAINER ID   IMAGE          COMMAND            STATUS        PORTS
a3f9bc12d4e1   reza-plan:1.0  "node server.js"   Up 30 sec     0.0.0.0:3000-&gt;3000/tcp
</code></pre>

<p>If <code>docker ps</code> shows nothing at all — the container started and immediately crashed. This is exactly what happened when we first ran this project. Here is what we saw and how we fixed it.</p>

<hr />

<h3 id="-real-bug-we-hit--container-exits-immediately">🐛 Real Bug We Hit — Container Exits Immediately</h3>

<p>When we ran <code>docker ps</code>, the output was empty:</p>

<pre><code>CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
</code></pre>

<p>We ran <code>docker ps -a</code> (which shows stopped containers too):</p>

<pre><code>CONTAINER ID   IMAGE           COMMAND                  CREATED         STATUS                     PORTS   NAMES
41a415636af4   reza-plan:1.0   "docker-entrypoint.s…"   1 minute ago    Exited (1) 1 minute ago             reza-plan
</code></pre>

<p><code>Exited (1)</code> means the process crashed with an error. The next step is always the same: <strong>read the logs</strong>.</p>

<pre><code class="language-bash">docker logs reza-plan
</code></pre>

<p>The output was:</p>

<pre><code>/app/node_modules/path-to-regexp/dist/index.js:108
                    throw new PathError(`Missing parameter name at index ${index}`, str);
                          ^
PathError [TypeError]: Missing parameter name at index 1: *; visit https://git.new/pathToRegexpError for info
    at consumeUntil (/app/node_modules/path-to-regexp/dist/index.js:108:27)
    ...
    at app.&lt;computed&gt; [as get] (/app/node_modules/express/lib/application.js:478:22) {
  originalPath: '*'
}
Node.js v20.20.2
</code></pre>

<h3 id="diagnosing-the-error">Diagnosing the error</h3>

<p>The key phrase is: <code>PathError: Missing parameter name at index 1: *</code> and <code>originalPath: '*'</code></p>

<p>This is an <strong>Express 5 breaking change</strong>. In Express 4, you could write:</p>

<pre><code class="language-javascript">app.get('*', handler)   // works in Express 4
</code></pre>

<p>In Express 5, the wildcard <code>*</code> must be named. The old syntax throws a <code>PathError</code> at startup, which crashes the process immediately — before the server even starts listening.</p>

<p>This project uses Express 5 (<code>"express": "^5.2.1"</code> in <code>package.json</code>). The <code>server.js</code> file has this line near the bottom:</p>

<pre><code class="language-javascript">// ❌ This crashes Express 5
app.get('*', (req, res) =&gt; {
  ...
});
</code></pre>

<h3 id="the-fix--update-serverjs">The fix — update <code>server.js</code></h3>

<p>Open <code>server.js</code> in your editor. Find the catch-all route near the bottom — it looks like this:</p>

<pre><code class="language-javascript">// ── Catch-all: serve React app for any non-API route ─────────────────────────
app.get('*', (req, res) =&gt; {
  const index = path.join(DIST, 'index.html');
  if (fs.existsSync(index)) {
    res.sendFile(index);
  } else {
    res.status(404).send('App not built. Run: npm run build');
  }
});
</code></pre>

<p>Change <code>'*'</code> to <code>'/{*path}'</code>:</p>

<pre><code class="language-javascript">// ── Catch-all: serve React app for any non-API route ─────────────────────────
// Express 5 requires named wildcards — '*' is no longer valid syntax
app.get('/{*path}', (req, res) =&gt; {
  const index = path.join(DIST, 'index.html');
  if (fs.existsSync(index)) {
    res.sendFile(index);
  } else {
    res.status(404).send('App not built. Run: npm run build');
  }
});
</code></pre>

<p>Save the file.</p>

<h3 id="why-this-fix-works">Why this fix works</h3>

<p><code>/{*path}</code> is Express 5’s named wildcard syntax. It means: “match any path, and call that captured segment <code>path</code>.” It does exactly the same job as <code>*</code> did in Express 4 — catch every request that didn’t match an earlier route and serve <code>index.html</code> so React can handle client-side navigation.</p>

<pre><code>Express 4:   app.get('*', handler)         ← unnamed wildcard, now invalid
Express 5:   app.get('/{*path}', handler)  ← named wildcard, correct syntax
</code></pre>

<h3 id="rebuild-and-rerun-after-the-fix">Rebuild and rerun after the fix</h3>

<p>Any time you change source code, you must rebuild the image. The container is running your old image, not your edited file.</p>

<pre><code class="language-bash"># Remove the crashed container
docker rm reza-plan

# Rebuild the image with the fix
docker build -t reza-plan:1.0 .

# Run again
docker run -d \
  -p 3000:3000 \
  -v $(pwd)/data:/app/data \
  --name reza-plan \
  reza-plan:1.0

# Verify it is running now
docker ps
</code></pre>

<p>You should now see:</p>
<pre><code>CONTAINER ID   IMAGE          COMMAND            STATUS        PORTS
a3f9bc12d4e1   reza-plan:1.0  "node server.js"   Up 10 sec     0.0.0.0:3000-&gt;3000/tcp
</code></pre>

<h3 id="what-this-debugging-experience-teaches-you">What this debugging experience teaches you</h3>

<pre><code>┌─────────────────────────────────────────────────────────────────────┐
│  The Docker debugging loop — memorize this:                         │
│                                                                     │
│  1. docker ps          → is it running?                             │
│  2. docker ps -a       → did it crash? (look for Exited status)     │
│  3. docker logs &lt;name&gt; → what went wrong? (always read the logs)    │
│  4. fix the code       → edit the source file                       │
│  5. docker rm &lt;name&gt;   → remove the dead container                  │
│  6. docker build       → rebuild the image with the fix             │
│  7. docker run         → try again                                  │
│                                                                     │
│  The logs almost always contain the answer.                         │
│  Read them before searching the internet.                           │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>

<hr />

<h3 id="check-the-logs-after-the-fix">Check the logs after the fix</h3>

<pre><code class="language-bash">docker logs reza-plan
</code></pre>

<p>You should now see the startup message from <code>server.js</code>:</p>
<pre><code>✓ Reza's Plan running at http://localhost:3000
  Data file : /app/data/progress.json
  Export    : http://localhost:3000/api/export
</code></pre>

<h3 id="open-the-app-in-your-browser">Open the app in your browser</h3>

<p>Go to: <strong>http://localhost:3000</strong></p>

<p>The full Reza Plan app should load — the sidebar, the progress coach panel, all of it.</p>

<h3 id="test-the-api-endpoints-directly">Test the API endpoints directly</h3>

<p>The app has three API endpoints defined in <code>server.js</code>. Test them with <code>curl</code>:</p>

<pre><code class="language-bash"># Get all progress entries (empty array on first run)
curl http://localhost:3000/api/entries

# Add a test entry
curl -X POST http://localhost:3000/api/entries \
  -H "Content-Type: application/json" \
  -d '{
    "date": "2026-05-17",
    "duration": 60,
    "tasks": ["Docker — videos or hands-on lab"],
    "mood": "Good",
    "notes": "Learned Docker multi-stage builds today — and fixed an Express 5 bug"
  }'

# You should see: {"ok":true,"total":1}

# Get the weekly export summary (plain text, for pasting to Claude)
curl http://localhost:3000/api/export
</code></pre>

<h3 id="verify-data-persistence">Verify data persistence</h3>

<p>After adding an entry, check that the file appeared on your host machine:</p>

<pre><code class="language-bash">cat data/progress.json
</code></pre>

<p>You should see your entry stored as JSON. Now test that the data survives container destruction:</p>

<pre><code class="language-bash"># Stop and remove the container
docker stop reza-plan
docker rm reza-plan

# Start a fresh container
docker run -d -p 3000:3000 -v $(pwd)/data:/app/data --name reza-plan reza-plan:1.0

# Check the data is still there
curl http://localhost:3000/api/entries
</code></pre>

<p>Your entry is still there. The container was destroyed and recreated, but the data lived on your machine the whole time.</p>

<hr />

<h2 id="11-step-6--debug-when-things-go-wrong">11. Step 6 — Debug When Things Go Wrong</h2>

<p>These are the debugging steps to run when something is not working.</p>

<h3 id="the-container-is-not-in-docker-ps">The container is not in <code>docker ps</code></h3>

<pre><code class="language-bash"># Look for stopped containers too
docker ps -a

# If you see your container with STATUS "Exited":
docker logs reza-plan
</code></pre>

<p>The logs almost always tell you exactly what went wrong.</p>

<h3 id="common-errors-and-fixes">Common errors and fixes</h3>

<p><strong>Error: “port is already in use”</strong></p>
<pre><code class="language-bash"># Something else is using port 3000
# Either stop the other process, or use a different host port:
docker run -d -p 8080:3000 -v $(pwd)/data:/app/data --name reza-plan reza-plan:1.0
# Then open http://localhost:8080
</code></pre>

<p><strong>Error: “no such file or directory” for <code>dist/</code></strong></p>
<pre><code class="language-bash"># The React build step failed in Stage 1
# Rebuild with no cache to see the full error output
docker build --no-cache -t reza-plan:1.0 .
</code></pre>

<p><strong>App loads but shows “App not built”</strong></p>
<pre><code class="language-bash"># The dist/ folder is empty or missing in the image
# Inspect what is actually inside the container:
docker exec -it reza-plan sh
ls /app/dist    # should show: index.html  assets/
exit
</code></pre>

<h3 id="open-a-shell-inside-the-running-container">Open a shell inside the running container</h3>

<p>This is the most powerful debugging tool. You can explore the container’s filesystem as if you were SSH’d into a server:</p>

<pre><code class="language-bash">docker exec -it reza-plan sh
</code></pre>

<pre><code># Now you are inside the container:
ls /app                        # see what files are there
ls /app/dist                   # check the compiled React output
cat /app/data/progress.json    # see your data
node --version                 # confirm Node.js version
exit                           # leave the container shell
</code></pre>

<h3 id="debug-a-container-that-crashes-on-startup">Debug a container that crashes on startup</h3>

<p>If the container exits immediately before you can exec into it:</p>

<pre><code class="language-bash"># Override the startup command — run a shell instead of node server.js
docker run -it --entrypoint sh reza-plan:1.0

# Now manually try to start the server:
node server.js
# Read the error message
exit
</code></pre>

<h3 id="rebuild-after-making-changes">Rebuild after making changes</h3>

<pre><code class="language-bash"># Rebuild the image (faster than first time — uses cache for unchanged layers)
docker build -t reza-plan:1.1 .

# Remove the old container
docker stop reza-plan
docker rm reza-plan

# Run the new version (your data is safe in ./data)
docker run -d -p 3000:3000 -v $(pwd)/data:/app/data --name reza-plan reza-plan:1.1
</code></pre>

<h3 id="clean-up-unused-docker-objects">Clean up unused Docker objects</h3>

<pre><code class="language-bash">docker container prune    # remove all stopped containers
docker image prune        # remove dangling (unnamed) images
docker system prune -a    # remove everything not currently in use
docker system df          # see how much disk Docker is using
</code></pre>

<hr />

<h2 id="12-step-7--create-the-docker-composeyml-file">12. Step 7 — Create the <code>docker-compose.yml</code> File</h2>

<p>Running <code>docker run -d -p 3000:3000 -v $(pwd)/data:/app/data --name reza-plan reza-plan:1.0</code> every time is tedious and easy to get wrong. Docker Compose lets you save all of that into one file that you commit to Git — anyone who clones the repo just runs <code>docker compose up</code> and everything works.</p>

<h3 id="where-to-create-it-2">Where to create it</h3>

<p>Same location as everything else — the <strong>project root</strong>:</p>

<pre><code>reza-plan-docker/          ← you are here
├── .dockerignore          ← created in Step 1
├── Dockerfile             ← created in Step 2
├── docker-compose.yml     ← create it RIGHT HERE
├── package.json
└── server.js
</code></pre>

<h3 id="how-to-create-it-2">How to create it</h3>

<p><strong>Option A — terminal:</strong></p>
<pre><code class="language-bash">touch docker-compose.yml
</code></pre>
<p>Then open and paste the content below.</p>

<p><strong>Option B — VS Code:</strong></p>
<ol>
  <li>Click the <strong>New File</strong> icon in the explorer</li>
  <li>Type: <code>docker-compose.yml</code> and press Enter</li>
  <li>Paste the content below and save</li>
</ol>

<h3 id="the-complete-docker-composeyml">The complete <code>docker-compose.yml</code></h3>

<pre><code class="language-yaml">services:
  reza-plan:
    build: .
    container_name: reza-plan
    ports:
      - "3000:3000"
    volumes:
      - ./data:/app/data
    restart: unless-stopped
</code></pre>

<h3 id="what-every-line-does">What every line does</h3>

<pre><code class="language-yaml">services:               ← start of the services block
  reza-plan:            ← the name of this service (you choose it)

    build: .            ← build the image from the Dockerfile
                          in the current directory (the dot)
                          use "docker compose up --build" to rebuild

    container_name: reza-plan
                        ← give the container a fixed name
                          without this Docker picks a random name

    ports:
      - "3000:3000"     ← host_port:container_port
                          same as -p 3000:3000 in docker run

    volumes:
      - ./data:/app/data  ← bind mount: links ./data on your machine
                            to /app/data inside the container
                            same as -v $(pwd)/data:/app/data in docker run
                            your progress.json lives here safely

    restart: unless-stopped
                        ← automatically restart if the container crashes
                          or if your computer reboots
                          but NOT if you manually run "docker compose stop"
</code></pre>

<h3 id="run-the-app-with-compose">Run the app with Compose</h3>

<p>From now on, use these commands instead of <code>docker run</code>:</p>

<pre><code class="language-bash"># Start in the background (builds image if not already built)
docker compose up -d

# Force a rebuild before starting (use after changing Dockerfile or code)
docker compose up --build -d

# See what is running
docker compose ps

# Read logs
docker compose logs -f

# Open a shell in the running container
docker compose exec reza-plan sh

# Stop the container (does not delete it)
docker compose stop

# Stop AND remove the container
docker compose down

# Stop, remove container, AND delete volumes (⚠️ this deletes your data)
docker compose down -v
</code></pre>

<blockquote>
  <p><strong>🔐 Security Note — Bind Mount vs Named Volume:</strong><br />
The <code>./data:/app/data</code> mount is called a <strong>bind mount</strong> — it links a specific folder on your machine directly into the container. This is appropriate here because you want easy access to <code>progress.json</code>. The container only sees the <code>data/</code> folder — it cannot access your <code>src/</code>, your <code>.env</code> files, or anything else. For a production database with sensitive data, you would use a <strong>named volume</strong> (<code>mydata:/var/lib/postgresql/data</code>) instead, which Docker manages internally.</p>
</blockquote>

<hr />

<h2 id="13-step-8--push-your-image-to-a-registry">13. Step 8 — Push Your Image to a Registry</h2>

<p>A registry is where Docker images are stored and shared. When you push an image to a registry, anyone with access can pull and run it — including GitHub Codespaces, cloud servers, and teammates.</p>

<h3 id="option-a--docker-hub-easiest">Option A — Docker Hub (easiest)</h3>

<p><strong>Step 1:</strong> Create a free account at <a href="https://hub.docker.com">https://hub.docker.com</a></p>

<p><strong>Step 2:</strong> Log in from your terminal. Use an <strong>Access Token</strong>, not your password:</p>
<ul>
  <li>Go to hub.docker.com → click your profile → <strong>Account Settings</strong></li>
  <li>Click <strong>Security</strong> → <strong>New Access Token</strong></li>
  <li>Give it a name like “my-laptop”, select “Read &amp; Write” permission</li>
  <li>Copy the token that appears (you only see it once)</li>
</ul>

<pre><code class="language-bash">docker login
# Enter your Docker Hub username
# For password: paste the Access Token you just created (not your account password)
</code></pre>

<p><strong>Step 3:</strong> Tag your image with your username:</p>
<pre><code class="language-bash"># Replace "yourusername" with your actual Docker Hub username
docker tag reza-plan:1.0 yourusername/reza-plan:1.0
</code></pre>

<p><strong>Step 4:</strong> Push:</p>
<pre><code class="language-bash">docker push yourusername/reza-plan:1.0
</code></pre>

<p><strong>Step 5:</strong> Verify it uploaded. Go to hub.docker.com → your profile → Repositories. You should see <code>reza-plan</code> listed.</p>

<p>Now anyone can run your app with no Node.js installed:</p>
<pre><code class="language-bash">mkdir -p data
docker run -d -p 3000:3000 -v $(pwd)/data:/app/data yourusername/reza-plan:1.0
</code></pre>

<h3 id="option-b--github-container-registry--ghcr-best-for-github-projects">Option B — GitHub Container Registry / GHCR (best for GitHub projects)</h3>

<p>GHCR links your image directly to your GitHub account and repository.</p>

<p><strong>Step 1:</strong> Create a Personal Access Token (PAT) on GitHub:</p>
<ul>
  <li>Go to github.com → click your profile picture → <strong>Settings</strong></li>
  <li>Scroll down the left sidebar → click <strong>Developer settings</strong></li>
  <li>Click <strong>Personal access tokens</strong> → <strong>Tokens (classic)</strong></li>
  <li>Click <strong>Generate new token (classic)</strong></li>
  <li>Give it a name: <code>ghcr-push</code></li>
  <li>Set expiration: 90 days (or custom)</li>
  <li>Check these scopes: <code>write:packages</code>, <code>read:packages</code>, <code>delete:packages</code></li>
  <li>Click <strong>Generate token</strong> at the bottom</li>
  <li><strong>Copy the token immediately</strong> — you will not see it again</li>
</ul>

<p><strong>Step 2:</strong> Save the token as an environment variable (so you don’t paste it in plain text):</p>
<pre><code class="language-bash">export GITHUB_PAT=your_token_here
</code></pre>

<p><strong>Step 3:</strong> Log in to GHCR:</p>
<pre><code class="language-bash">echo $GITHUB_PAT | docker login ghcr.io -u yourgithubusername --password-stdin
</code></pre>

<p><strong>Step 4:</strong> Tag your image:</p>
<pre><code class="language-bash"># Replace "yourgithubusername" with your GitHub username
docker tag reza-plan:1.0 ghcr.io/yourgithubusername/reza-plan:1.0
</code></pre>

<p><strong>Step 5:</strong> Push:</p>
<pre><code class="language-bash">docker push ghcr.io/yourgithubusername/reza-plan:1.0
</code></pre>

<p><strong>Step 6:</strong> Make the package public (so Codespaces can pull it without authentication):</p>
<ul>
  <li>Go to github.com → your profile → click <strong>Packages</strong></li>
  <li>Click <code>reza-plan</code> → <strong>Package settings</strong></li>
  <li>Scroll to <strong>Danger Zone</strong> → click <strong>Change visibility</strong> → set to <strong>Public</strong></li>
</ul>

<blockquote>
  <p><strong>🔐 Security Note — Tokens, Not Passwords:</strong><br />
Never paste your GitHub password or account password into terminal commands. Always use Personal Access Tokens with the minimum permissions needed for the task. Store tokens in environment variables, not hardcoded in scripts. If you ever accidentally paste a token into a public place (a GitHub issue, a Stack Overflow post, a commit), revoke it immediately in your GitHub/Docker settings.</p>
</blockquote>

<hr />

<h2 id="14-understanding-multi-stage-builds">14. Understanding Multi-Stage Builds</h2>

<p>You have already used a multi-stage build — the Dockerfile has two <code>FROM</code> statements. This section explains why that matters.</p>

<h3 id="what-would-happen-without-stages">What would happen without stages</h3>

<p>If we wrote a simple single-stage Dockerfile:</p>

<pre><code class="language-dockerfile"># ❌ Single-stage — do not do this in production
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install        # installs EVERYTHING including Vite, ESLint, etc.
COPY . .
RUN npm run build      # compiles React
EXPOSE 3000
CMD ["node", "server.js"]
</code></pre>

<p>The final image would contain:</p>
<ul>
  <li>Vite (the build tool) — not needed to run the app</li>
  <li>Rolldown / Babel (bundler and compiler) — not needed to run the app</li>
  <li>ESLint (code linter) — not needed to run the app</li>
  <li>All TypeScript type definitions — not needed to run the app</li>
  <li>The React source code in <code>src/</code> — not needed to run the app (we only need the compiled output in <code>dist/</code>)</li>
</ul>

<p><strong>Result: ~600MB image, with dozens of extra tools that are security risks.</strong></p>

<h3 id="what-the-multi-stage-build-achieves">What the multi-stage build achieves</h3>

<pre><code>Stage 1 (builder)                     Stage 2 (production)
─────────────────────                  ─────────────────────
node:20-alpine                         node:20-alpine (fresh)
+ all 200+ npm packages                + express, cors, react, react-dom
+ Vite, Rolldown, Babel                + /app/dist (compiled output only)
+ ESLint, TypeScript tools             + server.js
+ source code in src/
→ compiles React → /app/dist
[THROWN AWAY]                          [THIS IS WHAT SHIPS]
</code></pre>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Single-stage</th>
      <th>Multi-stage (this repo)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Image size</td>
      <td>~600MB</td>
      <td>~185MB</td>
    </tr>
    <tr>
      <td>Build tools in production</td>
      <td>Yes — security risk</td>
      <td>No</td>
    </tr>
    <tr>
      <td>React source code exposed</td>
      <td>Yes</td>
      <td>No</td>
    </tr>
    <tr>
      <td>Pull speed on slow network</td>
      <td>Slow</td>
      <td>3× faster</td>
    </tr>
  </tbody>
</table>

<p>The key Docker instruction that makes this work is:</p>
<pre><code class="language-dockerfile">COPY --from=builder /app/dist ./dist
</code></pre>

<p>This reaches into Stage 1 (which is named <code>builder</code>) and copies only the compiled output. Everything else in Stage 1 is permanently discarded.</p>

<hr />

<h2 id="15-step-9--run-in-the-cloud-with-github-codespaces">15. Step 9 — Run in the Cloud with GitHub Codespaces</h2>

<p>So far everything has run on your machine. Now let’s move the container to GitHub’s free cloud infrastructure.</p>

<h3 id="why-codespaces-for-docker-learners">Why Codespaces for Docker learners</h3>

<pre><code>┌─────────────────────────────────────────────────────────────────────┐
│  The traditional problem:                                           │
│  To share your app with the world, you need a server.               │
│  Servers cost money and require complex setup.                      │
│                                                                     │
│  GitHub Codespaces solves this for learners and small projects:     │
│                                                                     │
│  ✅ Free tier: 60 hours/month on a 2-core Linux machine             │
│  ✅ Docker is pre-installed — no setup                              │
│  ✅ Built-in port forwarding — your container gets a public URL     │
│  ✅ No firewall config, no SSH keys, no server maintenance          │
│  ✅ Deleted when you are done — zero ongoing cost                   │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="create-the-devcontainer-folder-and-config-file">Create the <code>.devcontainer</code> folder and config file</h3>

<p>The <code>.devcontainer/devcontainer.json</code> file tells GitHub Codespaces how to set up your environment when you open a Codespace.</p>

<p><strong>Step 1:</strong> Create the folder and file. In your project root:</p>

<pre><code class="language-bash">mkdir -p .devcontainer
touch .devcontainer/devcontainer.json
</code></pre>

<p>Your project structure now looks like:</p>
<pre><code>reza-plan-docker/
├── .devcontainer/
│   └── devcontainer.json    ← create this
├── .dockerignore
├── Dockerfile
├── docker-compose.yml
├── package.json
└── server.js
</code></pre>

<p><strong>Step 2:</strong> Open <code>.devcontainer/devcontainer.json</code> and paste this content:</p>

<pre><code class="language-json">{
  "name": "reza-plan",
  "dockerComposeFile": "../docker-compose.yml",
  "service": "reza-plan",
  "workspaceFolder": "/app",
  "forwardPorts": [3000],
  "portsAttributes": {
    "3000": {
      "label": "Reza Plan App",
      "onAutoForward": "openBrowser"
    }
  },
  "postAttachCommand": {
    "keepalive": "while true; do sleep 240; echo 'alive'; done"
  },
  "hostRequirements": {
    "cpus": 2
  }
}
</code></pre>

<p><strong>What each setting does:</strong></p>

<pre><code>"dockerComposeFile": "../docker-compose.yml"
  → use your existing docker-compose.yml to build and run the container
  → the ../ means "go up one folder from .devcontainer/ to find it"

"service": "reza-plan"
  → which service in docker-compose.yml to use
  → matches the name you gave the service in docker-compose.yml

"workspaceFolder": "/app"
  → when the Codespace opens, the terminal starts inside /app

"forwardPorts": [3000]
  → automatically forward port 3000 from the container to the browser

"portsAttributes"
  → when port 3000 is detected, label it and open the browser automatically

"postAttachCommand"
  → runs after the container starts
  → the keepalive loop pings every 4 minutes to prevent the Codespace
    from going to sleep due to inactivity

"hostRequirements": { "cpus": 2 }
  → request a 2-core machine (the free tier supports this)
</code></pre>

<p><strong>Step 3:</strong> Commit and push all these new files to GitHub:</p>

<pre><code class="language-bash">git add .dockerignore Dockerfile docker-compose.yml .devcontainer/
git commit -m "Add Docker setup and devcontainer config"
git push
</code></pre>

<h3 id="start-your-first-codespace">Start your first Codespace</h3>

<p><strong>Step 1:</strong> Go to your GitHub repository in a browser.</p>

<p><strong>Step 2:</strong> Click the green <strong>Code</strong> button (the same one you use to clone).</p>

<p><strong>Step 3:</strong> Click the <strong>Codespaces</strong> tab.</p>

<p><strong>Step 4:</strong> Click <strong>Create codespace on main</strong>.</p>

<p><strong>Step 5:</strong> Wait. The first time takes 2–3 minutes. GitHub is:</p>
<ul>
  <li>Provisioning a Linux machine for you</li>
  <li>Building your Docker container from <code>docker-compose.yml</code></li>
  <li>Starting the container</li>
</ul>

<p><strong>Step 6:</strong> When it finishes, a VS Code editor opens in your browser with a terminal at the bottom. The terminal is running inside your container.</p>

<p><strong>Step 7:</strong> Look at the <strong>Ports</strong> tab at the bottom of VS Code (next to Terminal). You should see port <code>3000</code> listed with a URL like <code>https://your-codespace-name-3000.app.github.dev</code>.</p>

<p><strong>Step 8:</strong> Click that URL. Your app is live on the internet.</p>

<h3 id="make-the-port-public">Make the port public</h3>

<p>By default the port URL requires GitHub login to access. To make it truly public:</p>

<p>In the Ports tab, right-click on port <code>3000</code> → <strong>Port Visibility</strong> → <strong>Public</strong>.</p>

<p>Or using the terminal inside the Codespace:</p>
<pre><code class="language-bash">gh codespace ports visibility 3000:public -c $CODESPACE_NAME
</code></pre>

<blockquote>
  <p><strong>🔐 Security Note:</strong> A public port means anyone with the URL can access your app. The <code>reza-plan</code> app is a personal tracker — consider leaving it private (requires GitHub login) if you are storing real study data you do not want to share publicly.</p>
</blockquote>

<h3 id="stop-the-codespace-when-you-are-done">Stop the Codespace when you are done</h3>

<p>Always stop your Codespace when not using it to stay within the free tier limits.</p>

<p><strong>Option A — from the browser:</strong></p>
<ul>
  <li>Go to github.com/codespaces</li>
  <li>Find your codespace → click the three dots <code>...</code> → <strong>Stop codespace</strong></li>
</ul>

<p><strong>Option B — from the terminal inside the Codespace:</strong></p>
<pre><code class="language-bash">gh codespace stop
</code></pre>

<hr />

<h2 id="16-step-10--automate-with-github-actions">16. Step 10 — Automate with GitHub Actions</h2>

<p>Manually starting and stopping a Codespace every day gets old quickly. GitHub Actions can do this automatically on a schedule. It’s free, runs in the cloud, and sends you a Telegram notification each time.</p>

<h3 id="how-github-actions-works">How GitHub Actions works</h3>

<pre><code>┌─────────────────────────────────────────────────────────────────────┐
│  You write a YAML file with instructions                            │
│  You push it to the .github/workflows/ folder in your repo          │
│  GitHub reads it and runs it automatically on the schedule          │
│                                                                     │
│  It is like cron, but:                                              │
│  - hosted by GitHub (no server needed)                              │
│  - free for public repos and generous for private                   │
│  - has access to GitHub APIs built-in                               │
│  - can send notifications via Telegram, Slack, email, etc.          │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="create-the-workflows-folder-and-files">Create the workflows folder and files</h3>

<p><strong>Step 1:</strong> Create the folder structure:</p>

<pre><code class="language-bash">mkdir -p .github/workflows
</code></pre>

<p>Your project now looks like:</p>
<pre><code>reza-plan-docker/
├── .devcontainer/
│   └── devcontainer.json
├── .github/
│   └── workflows/
│       ├── start-codespace.yml    ← create this
│       └── stop-codespace.yml     ← create this
├── .dockerignore
├── Dockerfile
├── docker-compose.yml
└── ...
</code></pre>

<h3 id="set-up-github-secrets">Set up GitHub Secrets</h3>

<p>The workflow files are stored in your repo and visible to anyone. You cannot put tokens and passwords directly in them. GitHub Secrets stores them encrypted.</p>

<p><strong>Step 1:</strong> Get your Codespace name:</p>
<ul>
  <li>Go to github.com/codespaces</li>
  <li>Look at the name under your repo name — it will be something like <code>expert-bassoon-6vgxv6jv9pv7c49wq</code></li>
  <li>Copy that exact name — character for character</li>
</ul>

<p><strong>Step 2:</strong> Create a Personal Access Token for the workflows:</p>
<ul>
  <li>Go to github.com → Settings → Developer settings → Personal access tokens → Tokens (classic)</li>
  <li>Click <strong>Generate new token (classic)</strong></li>
  <li>Name: <code>codespace-automation</code></li>
  <li>Expiration: 90 days</li>
  <li>Scopes: check <code>codespace</code></li>
  <li>Click <strong>Generate token</strong> → copy immediately</li>
</ul>

<p><strong>Step 3 (optional but recommended) — Set up Telegram notifications:</strong></p>
<ul>
  <li>Open Telegram → search for <code>@BotFather</code></li>
  <li>Send the message: <code>/newbot</code></li>
  <li>Follow the prompts — give your bot a name and username</li>
  <li>BotFather gives you a <strong>TOKEN</strong> — copy it</li>
  <li>Start a conversation with your new bot (send it any message)</li>
  <li>Get your chat ID by going to this URL in a browser (replace <code>YOUR_TOKEN</code>):
<code>https://api.telegram.org/botYOUR_TOKEN/getUpdates</code></li>
  <li>Look in the response for <code>"chat": {"id": 123456789}</code> — that number is your chat ID</li>
</ul>

<p><strong>Step 4:</strong> Add all secrets to GitHub:</p>
<ul>
  <li>Go to your GitHub repository</li>
  <li>Click <strong>Settings</strong> (top menu of the repo, not your account settings)</li>
  <li>In the left sidebar, click <strong>Secrets and variables</strong> → <strong>Actions</strong></li>
  <li>Click <strong>New repository secret</strong> for each one:</li>
</ul>

<table>
  <thead>
    <tr>
      <th>Secret name</th>
      <th>Value to paste</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>GH_TOKEN</code></td>
      <td>The Personal Access Token you created in Step 2</td>
    </tr>
    <tr>
      <td><code>CODESPACE_NAME</code></td>
      <td>The exact codespace name (e.g. <code>expert-bassoon-6vgxv6jv9pv7c49wq</code>)</td>
    </tr>
    <tr>
      <td><code>TELEGRAM_TOKEN</code></td>
      <td>Your Telegram bot token (optional)</td>
    </tr>
    <tr>
      <td><code>TELEGRAM_CHAT_ID</code></td>
      <td>Your Telegram chat ID number (optional)</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>🔐 Security Note:</strong> <code>CODESPACE_NAME</code> must be the full exact name of the codespace — not your repo name, not your username. A wrong value gives a silent 404 error from the API with no clear error message. Always verify by listing your codespaces first:</p>
  <pre><code class="language-bash">gh codespace list
</code></pre>
</blockquote>

<h3 id="create-the-start-workflow">Create the start workflow</h3>

<p>Create the file <code>.github/workflows/start-codespace.yml</code> and paste this content:</p>

<pre><code class="language-yaml">name: Start Codespace

on:
  schedule:
    # Runs at 08:17 UTC every weekday (Mon-Fri)
    # Why 17 and not 0? The top of the hour (8:00) is congested —
    # thousands of scheduled jobs queue at once. Using :17 means
    # your job processes faster with shorter wait times.
    - cron: '17 8 * * 1-5'
  workflow_dispatch:
    # This line makes the workflow also triggerable manually.
    # Go to your repo → Actions → this workflow → Run workflow
    # Always test manually first before relying on the schedule.

jobs:
  start:
    runs-on: ubuntu-latest   # GitHub provides this runner machine for free

    steps:
      - name: Start Codespace via GitHub REST API
        # We use the REST API directly instead of the gh CLI
        # because gh CLI's "start" command broke in newer versions.
        # REST API endpoints are stable and version-independent.
        run: |
          curl -s -X POST \
          -H "Authorization: token $" \
          -H "Accept: application/vnd.github+json" \
          https://api.github.com/user/codespaces/$/start

      - name: Send Telegram notification — success
        # "if: success()" means this step only runs if the previous step worked.
        if: success()
        run: |
          curl -s -X POST \
          https://api.telegram.org/bot$/sendMessage \
          -d chat_id=$ \
          -d text="✅ reza-plan Codespace STARTED at $(date -u '+%H:%M UTC')"

      - name: Send Telegram notification — failure
        # "if: failure()" means this step only runs if something above failed.
        # Exactly ONE of the two notification steps runs per workflow execution.
        if: failure()
        run: |
          curl -s -X POST \
          https://api.telegram.org/bot$/sendMessage \
          -d chat_id=$ \
          -d text="❌ reza-plan START FAILED at $(date -u '+%H:%M UTC') — check GitHub Actions"
</code></pre>

<h3 id="create-the-stop-workflow">Create the stop workflow</h3>

<p>Create the file <code>.github/workflows/stop-codespace.yml</code> and paste this content:</p>

<pre><code class="language-yaml">name: Stop Codespace

on:
  schedule:
    # Runs at 13:17 UTC every weekday (Mon-Fri)
    # 5 hours/day × 22 weekdays = 110 hours/month
    # Free tier limit is 120 core-hours/month — this stays within it.
    - cron: '17 13 * * 1-5'
  workflow_dispatch:

jobs:
  stop:
    runs-on: ubuntu-latest

    steps:
      - name: Stop Codespace via gh CLI
        # gh CLI works fine for stopping — only starting was broken.
        # This is a real-world lesson: use whatever is reliable.
        # You do not have to be consistent if one method does not work.
        run: |
          gh codespace stop --codespace $
        env:
          GH_TOKEN: $

      - name: Send Telegram notification — success
        if: success()
        run: |
          curl -s -X POST \
          https://api.telegram.org/bot$/sendMessage \
          -d chat_id=$ \
          -d text="🔴 reza-plan Codespace STOPPED at $(date -u '+%H:%M UTC')"

      - name: Send Telegram notification — failure
        if: failure()
        run: |
          curl -s -X POST \
          https://api.telegram.org/bot$/sendMessage \
          -d chat_id=$ \
          -d text="❌ reza-plan STOP FAILED at $(date -u '+%H:%M UTC') — check GitHub Actions"
</code></pre>

<h3 id="understanding-the-cron-schedule">Understanding the cron schedule</h3>

<pre><code>cron: '17 8 * * 1-5'
       │  │  │ │ │
       │  │  │ │ └── day of week: 1-5 = Monday to Friday only
       │  │  │ └──── month: * = every month
       │  │  └────── day of month: * = every day
       │  └───────── hour: 8 = 8am UTC
       └──────────── minute: 17 = at :17 past the hour

Result: runs at 8:17 AM UTC, Monday through Friday only
</code></pre>

<p><strong>Free tier math:</strong></p>
<pre><code>5 hours/day (08:17 to 13:17 UTC) × 22 weekdays/month = 110 hours
Free limit: 120 core-hours/month on a 2-core machine
110 hours &lt; 120 hours ✅ — stays within the free tier
</code></pre>

<h3 id="commit-and-push-the-workflows">Commit and push the workflows</h3>

<pre><code class="language-bash">git add .github/
git commit -m "Add GitHub Actions workflows for Codespace start/stop"
git push
</code></pre>

<h3 id="test-the-workflows-manually-before-waiting-for-the-schedule">Test the workflows manually before waiting for the schedule</h3>

<p><strong>Never wait for the cron schedule to discover that a workflow is broken.</strong> Test it manually first:</p>

<ol>
  <li>Go to your GitHub repository</li>
  <li>Click the <strong>Actions</strong> tab in the top navigation</li>
  <li>In the left sidebar, click <strong>Start Codespace</strong></li>
  <li>Click the <strong>Run workflow</strong> button on the right</li>
  <li>Click the green <strong>Run workflow</strong> button in the dropdown</li>
  <li>Watch the run — click on it to see each step’s output</li>
  <li>If it succeeds, check github.com/codespaces to confirm the Codespace started</li>
  <li>If Telegram is configured, you should receive a notification</li>
</ol>

<p>Repeat for the stop workflow.</p>

<h3 id="what-the-api-call-is-actually-doing">What the API call is actually doing</h3>

<pre><code class="language-bash">curl -s -X POST \
-H "Authorization: token GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/user/codespaces/CODESPACE_NAME/start
</code></pre>

<pre><code>curl            → a command-line tool for making web requests (like a browser, but in the terminal)
-s              → silent mode: no progress bar, just the response
-X POST         → HTTP method POST: means "do something" (vs GET which means "read something")
-H              → add a header: extra information sent with the request
Authorization   → proves who you are: "I am the person who owns this token"
Accept          → tells GitHub: "send me the response as JSON format"
URL             → the exact action: start the codespace with THIS name
</code></pre>

<hr />

<h2 id="17-docker-best-practices-checklist">17. Docker Best Practices Checklist</h2>

<p>Here is an audit of the repo we just built. Use this as a reference for any future Docker project.</p>

<h3 id="-what-this-project-does-correctly">✅ What this project does correctly</h3>

<table>
  <thead>
    <tr>
      <th>Practice</th>
      <th>How it is implemented</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Multi-stage build</td>
      <td>Stage 1 compiles React, Stage 2 runs Express — build tools never ship</td>
    </tr>
    <tr>
      <td>Minimal base image</td>
      <td><code>node:20-alpine</code> — Alpine has a tiny footprint and fewer CVEs</td>
    </tr>
    <tr>
      <td>Layer cache order</td>
      <td><code>package*.json</code> is copied before source code — npm install is cached</td>
    </tr>
    <tr>
      <td>Production deps only</td>
      <td><code>npm install --omit=dev</code> in Stage 2 — no Vite or ESLint in production</td>
    </tr>
    <tr>
      <td><code>.dockerignore</code> present</td>
      <td><code>node_modules</code>, <code>dist</code>, <code>data</code>, <code>.git</code> all excluded from build context</td>
    </tr>
    <tr>
      <td>Exec form CMD</td>
      <td><code>CMD ["node", "server.js"]</code> — Node receives signals directly as PID 1</td>
    </tr>
    <tr>
      <td>EXPOSE documents the port</td>
      <td><code>EXPOSE 3000</code> — makes the intent clear to tools and humans</td>
    </tr>
    <tr>
      <td>Data is external</td>
      <td><code>data/</code> is a volume — destroyed containers do not lose your data</td>
    </tr>
    <tr>
      <td>No secrets in the image</td>
      <td>No passwords or tokens baked into any layer</td>
    </tr>
    <tr>
      <td>Correct network binding</td>
      <td><code>app.listen(PORT, '0.0.0.0')</code> — required so Docker port mapping works</td>
    </tr>
  </tbody>
</table>

<h3 id="️-three-improvements-to-make-next">⚠️ Three improvements to make next</h3>

<p><strong>1. Add a non-root USER — most important security improvement</strong></p>

<p>Right now the Express server runs as <code>root</code> inside the container. If a vulnerability in Express or any npm package is exploited, the attacker has root access.</p>

<p>Add these lines to Stage 2 of the Dockerfile, before <code>CMD</code>:</p>

<pre><code class="language-dockerfile"># Create a group and user with no login shell and no home directory
RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup

# Give the new user ownership of the data directory
RUN mkdir -p /app/data &amp;&amp; chown -R appuser:appgroup /app/data

# Switch to the non-root user
USER appuser
</code></pre>

<blockquote>
  <p><strong>🔐 Security Note:</strong> This is CIS Docker Benchmark item 4.1 — the most referenced Docker security requirement in DevSecOps job descriptions. A single <code>USER</code> instruction dramatically limits the blast radius of any attack.</p>
</blockquote>

<p><strong>2. Use <code>npm ci</code> instead of <code>npm install</code> in both stages</strong></p>

<pre><code class="language-dockerfile"># Instead of: RUN npm install
RUN npm ci

# Instead of: RUN npm install --omit=dev  
RUN npm ci --omit=dev
</code></pre>

<p><code>npm ci</code> is stricter — it uses <code>package-lock.json</code> exactly as written, fails if anything does not match, and does a clean install every time. This prevents “it worked yesterday but not today” situations caused by upstream package changes.</p>

<p><strong>3. Add a HEALTHCHECK</strong></p>

<pre><code class="language-dockerfile">HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/entries || exit 1
</code></pre>

<p>Without this, Docker considers the container “healthy” as soon as it starts — even if Express crashed one second later. With a healthcheck, Docker actually pings the app every 30 seconds and marks it unhealthy if it stops responding. Kubernetes and load balancers also use this signal.</p>

<h3 id="the-improved-dockerfile-with-all-three-fixes-applied">The improved Dockerfile with all three fixes applied</h3>

<pre><code class="language-dockerfile"># Stage 1 — BUILD
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2 — PRODUCTION
FROM node:20-alpine

LABEL maintainer="mradelvand"
LABEL org.opencontainers.image.source="https://github.com/mradelvand/reza-plan-docker"

WORKDIR /app

# Create non-root user
RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup

COPY package*.json ./
RUN npm ci --omit=dev

COPY --from=builder /app/dist ./dist
COPY server.js ./

# Create data dir and give ownership to the non-root user
RUN mkdir -p /app/data &amp;&amp; chown -R appuser:appgroup /app/data

# Switch to non-root — everything after this runs as appuser
USER appuser

EXPOSE 3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/entries || exit 1

CMD ["node", "server.js"]
</code></pre>

<hr />

<h2 id="18-lessons-learned">18. Lessons Learned</h2>

<p>These are real problems encountered during this build, documented so you do not have to hit them blind.</p>

<p><strong>Express 5 broke the wildcard route syntax — and the container crashed silently.</strong><br />
<code>app.get('*', handler)</code> is valid in Express 4. In Express 5, <code>path-to-regexp</code> requires named wildcards: <code>app.get('/{*path}', handler)</code>. The container starts, throws a <code>PathError</code> before the server binds to any port, and exits with code 1. <code>docker ps</code> shows nothing. <code>docker ps -a</code> shows <code>Exited (1)</code>. <code>docker logs</code> shows the exact error. The fix is one character change in <code>server.js</code>. The lesson: always run <code>docker logs</code> before anything else when a container exits immediately.</p>

<p><strong>CLI tools break between versions — REST APIs stay stable.</strong><br />
The <code>gh codespace start</code> command broke in a newer version of the GitHub CLI. The REST API endpoint (<code>POST /user/codespaces/{name}/start</code>) never changed. When a CLI tool gives you trouble, call the API directly with curl. APIs are designed to be stable; CLI tools are convenience wrappers that change with each release.</p>

<p><strong>Secrets must be exact — character for character.</strong><br />
<code>CODESPACE_NAME</code> must be the full random name (<code>expert-bassoon-6vgxv6jv9pv7c49wq</code>), not the repo name, not your username. A wrong value returns a silent 404 from the API with no helpful error message. Always verify with <code>gh codespace list</code> before storing the name as a secret.</p>

<p><strong>The cron schedule is never exactly on time.</strong><br />
GitHub Actions scheduled workflows queue behind other jobs. Using the top of the hour (<code>0 8 * * *</code>) means your job competes with thousands of others that all scheduled for <code>8:00</code>. Use odd minutes like <code>17</code> or <code>23</code> — your job runs faster because there is less competition.</p>

<p><strong>Always test with <code>workflow_dispatch</code> before trusting the schedule.</strong><br />
Never write a workflow and then wait until 8 AM tomorrow to find out it is broken. The <code>workflow_dispatch</code> trigger lets you run it manually from the GitHub UI immediately. Fix all errors before the schedule takes over.</p>

<p><strong>Calculate free tier usage before automating.</strong><br />
120 core-hours / month ÷ 2 cores = 60 hours of actual Codespace time. If you run 10 hours/day × 22 weekdays = 220 hours — you hit the limit in 6 days. Always do the math first and design your schedule to stay within the limit.</p>

<hr />

<h2 id="19-whats-next">19. What’s Next</h2>

<p>This is <strong>Part 1</strong>. Here is the full series roadmap:</p>

<table>
  <thead>
    <tr>
      <th>Part</th>
      <th>Topic</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Part 1</strong> ✅</td>
      <td>Docker fundamentals — real app, Codespaces, Actions automation</td>
    </tr>
    <tr>
      <td><strong>Part 2</strong></td>
      <td>Adding a database — Postgres + Redis in Compose, named volumes, healthchecks, <code>depends_on</code></td>
    </tr>
    <tr>
      <td><strong>Part 3</strong></td>
      <td>CI/CD pipeline — GitHub Actions that builds, scans with Trivy, and pushes the image on every commit</td>
    </tr>
    <tr>
      <td><strong>Part 4</strong></td>
      <td>Kubernetes basics — deploying this same app as a Deployment, Service, and PersistentVolumeClaim</td>
    </tr>
    <tr>
      <td><strong>Part 5</strong></td>
      <td>DevSecOps — SBOM generation, OPA policy enforcement, image signing with cosign</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="resources">Resources</h2>

<ul>
  <li><a href="https://docs.docker.com">Docker Official Docs</a></li>
  <li><a href="https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md">Node.js Docker Best Practices</a></li>
  <li><a href="https://docs.github.com/en/codespaces">GitHub Codespaces Docs</a></li>
  <li><a href="https://docs.github.com/en/actions">GitHub Actions Docs</a></li>
  <li><a href="https://github.com/aquasecurity/trivy">Trivy — Image Vulnerability Scanner</a></li>
  <li><a href="https://github.com/hadolint/hadolint">Hadolint — Dockerfile Linter</a></li>
  <li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html">OWASP Docker Security Cheat Sheet</a></li>
  <li><a href="https://www.cisecurity.org/benchmark/docker">CIS Docker Benchmark</a></li>
</ul>

<hr />

<p><em>Written by Reza Adelvand — follow the series on <a href="https://github.com/mradelvand/reza-plan-docker">GitHub</a></em></p>]]></content><author><name>REZA</name></author><category term="docker" /><category term="DevOps &amp; Automation" /><category term="Github" /><category term="Codespace" /><summary type="html"><![CDATA[Series: Docker → Kubernetes → DevSecOps Level: Complete Beginner — every click and every command is shown Repo: reza-plan-docker Stack: React 19 + Vite · Express 5 · Node 20 Alpine · GitHub Codespaces · GitHub Actions]]></summary></entry></feed>