<?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://syssignals.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://syssignals.com/" rel="alternate" type="text/html" /><updated>2026-05-14T18:59:26+00:00</updated><id>https://syssignals.com/feed.xml</id><title type="html">syssignals</title><subtitle>Systems and Signals publishes hands-on, project-based articles on DevOps, cloud infrastructure, containers, and platform engineering. Every article ships working code.</subtitle><author><name>Vishwas Sharma</name></author><entry><title type="html">Day 1: Git Branching Strategies for DevOps Teams — GitFlow vs Trunk-Based Development</title><link href="https://syssignals.com/articles/2026/05/14/day-01-git-branching-strategies/" rel="alternate" type="text/html" title="Day 1: Git Branching Strategies for DevOps Teams — GitFlow vs Trunk-Based Development" /><published>2026-05-14T00:00:00+00:00</published><updated>2026-05-14T00:00:00+00:00</updated><id>https://syssignals.com/articles/2026/05/14/day-01-git-branching-strategies</id><content type="html" xml:base="https://syssignals.com/articles/2026/05/14/day-01-git-branching-strategies/"><![CDATA[<blockquote>
  <p><strong>30 Days of DevOps</strong> — a series by <a href="https://x.com/syssignals">@syssignals</a>
Every article is a working project. Every command is verified. No fluff.</p>
</blockquote>

<h2 id="the-problem-nobody-talks-about">The problem nobody talks about</h2>

<p>Your CI/CD pipeline is fast. Your tests pass. Your Dockerfiles are clean.</p>

<p>But every deployment is still a negotiation. Developers are stepping on each other. Hotfixes break features. The <code class="language-plaintext highlighter-rouge">main</code> branch is a graveyard of half-merged work and nobody quite knows what’s in prod.</p>

<p>The bottleneck isn’t your tools. It’s your branching strategy.</p>

<p>Most teams inherit a branching strategy by accident — someone set it up years ago, nobody questioned it, and now it’s load-bearing. Changing it feels risky, so nobody does.</p>

<p>This article gives you two battle-tested strategies — GitFlow and Trunk-Based Development — with complete setup, real configs, and the decision framework to know which one fits your team. By the end, you’ll have a production-ready repository template with enforced workflow, automated hooks, and PR automation you can use immediately.</p>

<hr />

<h2 id="what-youll-build">What you’ll build</h2>

<p>A fully configured Git repository with:</p>

<ul>
  <li>Branch protection rules enforced via GitHub API</li>
  <li>Husky + lint-staged pre-commit hooks (lint, format, commit message validation)</li>
  <li>Conventional Commits enforcement with commitlint</li>
  <li>PR template with checklist</li>
  <li>GitFlow and Trunk-Based branch models implemented side by side</li>
  <li>A reusable <code class="language-plaintext highlighter-rouge">repo-template/</code> folder you can <code class="language-plaintext highlighter-rouge">git clone</code> into any new project</li>
</ul>

<p><strong>Estimated time:</strong> 45 minutes<br />
<strong>Skill level:</strong> Beginner to intermediate<br />
<strong>All commands tested on:</strong> Ubuntu 22.04, macOS 14 Sonoma, Git 2.43+</p>

<hr />

<h2 id="prerequisites">Prerequisites</h2>

<ul>
  <li>Git 2.30+ installed (<code class="language-plaintext highlighter-rouge">git --version</code>)</li>
  <li>Node.js 18+ installed (<code class="language-plaintext highlighter-rouge">node --version</code>) — needed for Husky and commitlint</li>
  <li>A GitHub account with a personal access token (classic) scoped to <code class="language-plaintext highlighter-rouge">repo</code></li>
  <li><code class="language-plaintext highlighter-rouge">gh</code> CLI installed (optional but recommended — install: https://cli.github.com)</li>
  <li><code class="language-plaintext highlighter-rouge">jq</code> installed (<code class="language-plaintext highlighter-rouge">sudo apt install jq</code> / <code class="language-plaintext highlighter-rouge">brew install jq</code>)</li>
</ul>

<hr />

<h2 id="part-1-understanding-the-two-strategies">Part 1: Understanding the two strategies</h2>

<p>Before touching a terminal, you need to understand what you’re choosing between and why it matters for DevOps.</p>

<h3 id="gitflow">GitFlow</h3>

<p>Introduced by Vincent Driessen in 2010. Designed for teams with scheduled releases and multiple versions in production simultaneously.</p>

<p><strong>Branch structure:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>main          ← production code only. Tagged at every release.
develop       ← integration branch. All features merge here.
feature/*     ← one branch per feature. Branches off develop.
release/*     ← release stabilization. Branches off develop.
hotfix/*      ← emergency fixes. Branches off main.
</code></pre></div></div>

<pre><code class="language-mermaid">%%{init: {'gitGraph': {'rotateCommitLabel': true}}}%%
gitGraph
   commit id: "init" tag: "v0.9.0"
   branch develop
   checkout develop
   commit id: "setup"
   branch feature/auth
   checkout feature/auth
   commit id: "scaffold"
   commit id: "tests"
   checkout develop
   merge feature/auth id: "auth merged"
   branch release/1.0.0
   checkout release/1.0.0
   commit id: "bump v1.0.0"
   checkout main
   merge release/1.0.0 tag: "v1.0.0"
   checkout develop
   merge release/1.0.0
   checkout main
   branch hotfix/1.0.1
   checkout hotfix/1.0.1
   commit id: "null fix"
   checkout main
   merge hotfix/1.0.1 tag: "v1.0.1"
   checkout develop
   merge hotfix/1.0.1
</code></pre>

<p><strong>Lifecycle of a feature:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout develop
git checkout <span class="nt">-b</span> feature/user-authentication
<span class="c"># ... work ...</span>
git checkout develop
git merge <span class="nt">--no-ff</span> feature/user-authentication
git branch <span class="nt">-d</span> feature/user-authentication
</code></pre></div></div>

<p><strong>Lifecycle of a release:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout develop
git checkout <span class="nt">-b</span> release/1.4.0
<span class="c"># ... bug fixes and version bumps only ...</span>
git checkout main
git merge <span class="nt">--no-ff</span> release/1.4.0
git tag <span class="nt">-a</span> v1.4.0 <span class="nt">-m</span> <span class="s2">"Release 1.4.0"</span>
git checkout develop
git merge <span class="nt">--no-ff</span> release/1.4.0
git branch <span class="nt">-d</span> release/1.4.0
</code></pre></div></div>

<p><strong>When GitFlow works:</strong></p>
<ul>
  <li>Mobile apps with App Store review cycles</li>
  <li>SaaS products with enterprise clients on specific versions</li>
  <li>Teams with QA cycles that require a code freeze period</li>
  <li>Regulated industries with formal release gates</li>
</ul>

<p><strong>When GitFlow fails:</strong></p>
<ul>
  <li>Continuous deployment pipelines (release branches block CD)</li>
  <li>Small teams (overhead kills velocity)</li>
  <li>Microservices (coordination across repos becomes impossible)</li>
</ul>

<hr />

<h3 id="trunk-based-development-tbd">Trunk-Based Development (TBD)</h3>

<p>The model used by Google, Meta, Netflix, and most high-performing DevOps teams. All developers commit directly to <code class="language-plaintext highlighter-rouge">main</code> (the “trunk”) or use very short-lived feature branches (&lt; 2 days).</p>

<p><strong>Branch structure:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>main          ← the trunk. Always deployable. CI runs on every commit.
feature/*     ← optional. Max lifetime: 2 days. Merged via PR.
</code></pre></div></div>

<pre><code class="language-mermaid">%%{init: {'gitGraph': {'rotateCommitLabel': true}}}%%
gitGraph
   commit id: "feat: login"
   commit id: "fix: auth"
   branch feature/dashboard
   checkout feature/dashboard
   commit id: "dashboard WIP"
   checkout main
   commit id: "chore: deps"
   merge feature/dashboard id: "flag: off"
   commit id: "feat: search"
   commit id: "perf: cache"
   commit id: "release" tag: "v1.1.0"
</code></pre>

<p><strong>The key practices that make TBD work:</strong></p>

<ol>
  <li><strong>Feature flags</strong> — incomplete features are deployed but hidden behind a flag</li>
  <li><strong>Branch by abstraction</strong> — large refactors use an abstraction layer, not a long-lived branch</li>
  <li><strong>Pair programming</strong> — reduces the need for long review cycles</li>
  <li><strong>Comprehensive CI</strong> — every commit triggers full test suite</li>
  <li><strong>Small commits</strong> — changes that can be reviewed in under 10 minutes</li>
</ol>

<p><strong>When TBD works:</strong></p>
<ul>
  <li>Continuous deployment (deploy multiple times per day)</li>
  <li>Teams with high test coverage (&gt;80%)</li>
  <li>Microservices with independent deployment</li>
  <li>Teams with strong DevOps culture (shared ownership of main)</li>
</ul>

<p><strong>When TBD fails:</strong></p>
<ul>
  <li>Low test coverage (broken main blocks everyone)</li>
  <li>Large distributed teams without feature flag infrastructure</li>
  <li>Compliance requirements that mandate release gates</li>
</ul>

<hr />

<h3 id="which-one-should-you-choose">Which one should you choose?</h3>

<pre><code class="language-mermaid">flowchart TD
    A([Start]) --&gt; B{Multiple versions\nin prod?}
    B --&gt;|Yes| C([GitFlow])
    B --&gt;|No| D{Deploy multiple\ntimes per day?}
    D --&gt;|Yes| E([Trunk-Based Dev])
    D --&gt;|No| F{Test coverage\nabove 80%?}
    F --&gt;|Yes| E
    F --&gt;|No| G{Regulated or\nformal QA gate?}
    G --&gt;|Yes| C
    G --&gt;|No| H([TBD + build\ncoverage first])
    style C fill:#2d5a1b,color:#fff
    style E fill:#1b3a5a,color:#fff
    style H fill:#1b3a5a,color:#fff
</code></pre>

<hr />

<h2 id="part-2-set-up-the-repository">Part 2: Set up the repository</h2>

<h3 id="step-1-initialize-the-repository">Step 1: Initialize the repository</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create project directory</span>
<span class="nb">mkdir </span>devops-git-template <span class="o">&amp;&amp;</span> <span class="nb">cd </span>devops-git-template

<span class="c"># Initialize git</span>
git init
git checkout <span class="nt">-b</span> main

<span class="c"># Create base structure</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> .github/workflows .github/ISSUE_TEMPLATE src tests scripts
<span class="nb">touch </span>README.md .gitignore .env.example
</code></pre></div></div>

<p>Create a solid <code class="language-plaintext highlighter-rouge">.gitignore</code> that covers most DevOps projects:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .gitignore <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
# Environment and secrets
.env
.env.local
.env.*.local
*.pem
*.key
*.p12
*.pfx

# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dist/
build/
.npm

# Python
__pycache__/
*.py[cod]
*</span><span class="nv">$py</span><span class="sh">.class
*.egg-info/
.venv/
venv/
.pytest_cache/

# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example
.terraform.lock.hcl

# Docker
.dockerignore

# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db

# Build artifacts
*.log
*.tmp
*.bak
coverage/
.nyc_output/
</span><span class="no">EOF
</span></code></pre></div></div>

<h3 id="step-2-initialize-nodejs-for-tooling">Step 2: Initialize Node.js for tooling</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm init <span class="nt">-y</span>
</code></pre></div></div>

<h3 id="step-3-install-and-configure-husky">Step 3: Install and configure Husky</h3>

<p>Husky intercepts Git hooks and lets you run scripts before commits and pushes.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Husky and lint-staged</span>
npm <span class="nb">install</span> <span class="nt">--save-dev</span> husky lint-staged

<span class="c"># Initialize Husky (creates .husky/ directory)</span>
npx husky init

<span class="c"># Verify .husky/ was created</span>
<span class="nb">ls</span> <span class="nt">-la</span> .husky/
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>total 16
drwxr-xr-x  3 user user 4096 Jan 15 10:23 .
drwxr-xr-x 12 user user 4096 Jan 15 10:23 ..
-rwxr-xr-x  1 user user   29 Jan 15 10:23 pre-commit
</code></pre></div></div>

<h3 id="step-4-install-and-configure-commitlint">Step 4: Install and configure commitlint</h3>

<p>commitlint enforces the <a href="https://www.conventionalcommits.org/">Conventional Commits</a> specification. This gives you a machine-readable commit history that tools like <code class="language-plaintext highlighter-rouge">semantic-release</code> and changelog generators can parse.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">--save-dev</span> @commitlint/cli @commitlint/config-conventional
</code></pre></div></div>

<p>Create the commitlint config:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> commitlint.config.js <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // Type must be one of the following
    'type-enum': [
      2,
      'always',
      [
        'feat',     // New feature
        'fix',      // Bug fix
        'docs',     // Documentation only
        'style',    // Formatting, no logic change
        'refactor', // Code change that is neither fix nor feature
        'perf',     // Performance improvement
        'test',     // Adding or correcting tests
        'build',    // Build system or dependencies
        'ci',       // CI/CD configuration
        'chore',    // Maintenance tasks
        'revert',   // Reverts a previous commit
        'hotfix',   // Emergency fix (GitFlow specific)
        'release',  // Release commit
      ],
    ],
    // Subject line max length
    'subject-max-length': [2, 'always', 100],
    // Subject must not end with a period
    'subject-full-stop': [2, 'never', '.'],
    // Body must have blank line before it
    'body-leading-blank': [2, 'always'],
    // Footer must have blank line before it
    'footer-leading-blank': [2, 'always'],
  },
};
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Add the commit-msg hook:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .husky/commit-msg <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
#!/bin/sh
npx --no -- commitlint --edit "</span><span class="nv">$1</span><span class="sh">"
</span><span class="no">EOF

</span><span class="nb">chmod</span> +x .husky/commit-msg
</code></pre></div></div>

<h3 id="step-5-install-and-configure-eslint--prettier">Step 5: Install and configure ESLint + Prettier</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">--save-dev</span> eslint prettier eslint-config-prettier

<span class="c"># Create ESLint config</span>
<span class="nb">cat</span> <span class="o">&gt;</span> .eslintrc.js <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
module.exports = {
  env: {
    node: true,
    es2021: true,
  },
  extends: ['eslint:recommended', 'prettier'],
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module',
  },
  rules: {
    'no-console': 'warn',
    'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    'no-var': 'error',
    'prefer-const': 'error',
  },
};
</span><span class="no">EOF

</span><span class="c"># Create Prettier config</span>
<span class="nb">cat</span> <span class="o">&gt;</span> .prettierrc <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
{
  "semi": true,
  "trailingComma": "es5",
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false
}
</span><span class="no">EOF
</span></code></pre></div></div>

<h3 id="step-6-configure-lint-staged">Step 6: Configure lint-staged</h3>

<p>lint-staged runs linters only on staged files — not the entire codebase. This makes pre-commit hooks fast enough that developers won’t disable them.</p>

<p>Add to <code class="language-plaintext highlighter-rouge">package.json</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node <span class="nt">-e</span> <span class="s2">"
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg['lint-staged'] = {
  '*.{js,ts,jsx,tsx}': ['eslint --fix', 'prettier --write'],
  '*.{json,md,yml,yaml}': ['prettier --write'],
  '*.sh': ['shellcheck']
};
pkg.scripts = {
  ...pkg.scripts,
  'prepare': 'husky',
  'lint': 'eslint . --ext .js,.ts',
  'lint:fix': 'eslint . --ext .js,.ts --fix',
  'format': 'prettier --write .',
  'format:check': 'prettier --check .'
};
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
console.log('package.json updated');
"</span>
</code></pre></div></div>

<p>Update the pre-commit hook:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .husky/pre-commit <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
#!/bin/sh
npx lint-staged
</span><span class="no">EOF

</span><span class="nb">chmod</span> +x .husky/pre-commit
</code></pre></div></div>

<p>Here’s what happens on every <code class="language-plaintext highlighter-rouge">git commit</code> with this setup:</p>

<p><strong>On commit:</strong></p>

<pre><code class="language-mermaid">flowchart LR
    A([git commit]) --&gt; B[pre-commit hook]
    B --&gt; C{lint-staged\npasses?}
    C --&gt;|Fail| D([Blocked — fix lint])
    C --&gt;|Pass| E[commit-msg hook]
    E --&gt; F{commitlint\npasses?}
    F --&gt;|Fail| G([Blocked — fix message])
    F --&gt;|Pass| H([Commit created])
</code></pre>

<p><strong>On push:</strong></p>

<pre><code class="language-mermaid">flowchart LR
    A([git push]) --&gt; B[pre-push hook]
    B --&gt; C{Protected\nbranch?}
    C --&gt;|Yes| D([Blocked — open a PR])
    C --&gt;|No| E([Push succeeds])
</code></pre>

<h3 id="step-7-create-the-pr-template">Step 7: Create the PR template</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> .github

<span class="nb">cat</span> <span class="o">&gt;</span> .github/pull_request_template.md <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
## Summary

&lt;!-- What does this PR do? One paragraph max. --&gt;

## Type of change

- [ ] `feat` — New feature
- [ ] `fix` — Bug fix
- [ ] `docs` — Documentation update
- [ ] `refactor` — Code refactor (no functional change)
- [ ] `ci` — CI/CD changes
- [ ] `chore` — Maintenance
- [ ] `hotfix` — Emergency production fix

## Related issues

Closes #&lt;!-- issue number --&gt;

## How to test

&lt;!-- Step-by-step instructions for reviewers to test this change --&gt;

1. 
2. 
3. 

## Checklist

- [ ] My code follows the project's style guidelines
- [ ] I have run `npm run lint` and there are no errors
- [ ] I have added or updated tests
- [ ] I have updated documentation if needed
- [ ] My commits follow the Conventional Commits format
- [ ] I have checked this branch is up to date with the target branch
- [ ] This PR is &lt; 400 lines of change (if not, explain why)

## Screenshots / output (if applicable)

&lt;!-- Paste terminal output or screenshots here --&gt;

## Notes for reviewers

&lt;!-- Anything the reviewer should pay special attention to --&gt;
</span><span class="no">EOF
</span></code></pre></div></div>

<h3 id="step-8-create-github-actions-ci-workflow">Step 8: Create GitHub Actions CI workflow</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .github/workflows/ci.yml <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

concurrency:
  group: </span><span class="nv">$-</span><span class="sh">$
  cancel-in-progress: true

jobs:
  lint:
    name: Lint &amp; Format
    runs-on: ubuntu-22.04
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Check formatting
        run: npm run format:check

  commitlint:
    name: Validate commit messages
    runs-on: ubuntu-22.04
    if: github.event_name == 'pull_request'
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Validate PR commits
        run: |
          npx commitlint </span><span class="se">\</span><span class="sh">
            --from </span><span class="nv">$ </span><span class="se">\</span><span class="sh">
            --to </span><span class="nv">$ </span><span class="se">\</span><span class="sh">
            --verbose

  test:
    name: Tests
    runs-on: ubuntu-22.04
    needs: lint
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test --if-present
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Every PR triggers this pipeline before anyone can merge:</p>

<pre><code class="language-mermaid">flowchart TD
    A([PR opened]) --&gt; B[lint]
    A --&gt; C[commitlint]
    A --&gt; D[test]
    B --&gt; E{All checks\npassed?}
    C --&gt; E
    D --&gt; E
    E --&gt;|No| F([PR blocked])
    E --&gt;|Yes| G([Awaiting review])
    G --&gt; H([Merged to main])
</code></pre>

<hr />

<h2 id="part-3-set-up-gitflow-on-a-real-repository">Part 3: Set up GitFlow on a real repository</h2>

<h3 id="step-1-install-git-flow">Step 1: Install git-flow</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ubuntu / Debian</span>
<span class="nb">sudo </span>apt-get <span class="nb">install </span>git-flow <span class="nt">-y</span>

<span class="c"># macOS</span>
brew <span class="nb">install </span>git-flow-avh

<span class="c"># Verify</span>
git flow version
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1.12.3 (AVH Edition)
</code></pre></div></div>

<h3 id="step-2-initialize-git-flow">Step 2: Initialize git-flow</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Run inside your repo directory</span>
git flow init <span class="nt">-d</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">-d</code> flag accepts all defaults. This creates and configures:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Branch name for production releases: main
Branch name for "next release" development: develop
Feature branches: feature/
Bugfix branches: bugfix/
Release branches: release/
Hotfix branches: hotfix/
Support branches: support/
Version tag prefix: v
</code></pre></div></div>

<p>Verify both branches exist:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git branch <span class="nt">-a</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>* develop
  main
</code></pre></div></div>

<h3 id="step-3-work-through-the-full-gitflow-lifecycle">Step 3: Work through the full GitFlow lifecycle</h3>

<p><strong>Create and finish a feature:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Start a feature</span>
git flow feature start user-authentication

<span class="c"># You are now on branch: feature/user-authentication</span>
<span class="c"># Verify</span>
git branch <span class="nt">--show-current</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>feature/user-authentication
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Simulate some work</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> src/auth
<span class="nb">cat</span> <span class="o">&gt;</span> src/auth/index.js <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
const authenticate = (username, password) =&gt; {
  // Authentication logic would go here
  return { user: username, token: 'jwt-token-placeholder' };
};

module.exports = { authenticate };
</span><span class="no">EOF

</span><span class="c"># Stage and commit with conventional commit format</span>
git add src/auth/index.js
git commit <span class="nt">-m</span> <span class="s2">"feat(auth): add authentication module scaffold"</span>

<span class="c"># Finish the feature — merges to develop with --no-ff</span>
git flow feature finish user-authentication
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Branches 'develop' and 'feature/user-authentication' have diverged.
Updating 9f3e2a1..c4b8d2f
Fast-forward (no commit created; -m option ignored)
 src/auth/index.js | 6 ++++++
 1 file changed, 6 insertions(+)
Deleted branch feature/user-authentication (was c4b8d2f).

Summary of actions:
- The feature branch 'feature/user-authentication' was merged into 'develop'
- Feature branch 'feature/user-authentication' has been locally deleted
- You are now on branch 'develop'
</code></pre></div></div>

<p><strong>Create and finish a release:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Start a release from develop</span>
git flow release start 1.0.0

<span class="c"># Bump version in package.json</span>
npm version 1.0.0 <span class="nt">--no-git-tag-version</span>

git add package.json
git commit <span class="nt">-m</span> <span class="s2">"release: bump version to 1.0.0"</span>

<span class="c"># Finish the release</span>
<span class="c"># This: merges to main, tags v1.0.0, merges back to develop</span>
git flow release finish <span class="s1">'1.0.0'</span>
</code></pre></div></div>

<p>You’ll be prompted for a tag message. Enter: <code class="language-plaintext highlighter-rouge">Release v1.0.0</code></p>

<p>Verify the tag was created:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git tag <span class="nt">-l</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>v1.0.0
</code></pre></div></div>

<p><strong>Simulate a hotfix:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Start hotfix from main</span>
git flow hotfix start 1.0.1

<span class="nb">cat</span> <span class="o">&gt;&gt;</span> src/auth/index.js <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'

// Hotfix: prevent null username crash
const safeAuthenticate = (username, password) =&gt; {
  if (!username || !password) throw new Error('Credentials required');
  return authenticate(username, password);
};

module.exports = { authenticate, safeAuthenticate };
</span><span class="no">EOF

</span>git add src/auth/index.js
git commit <span class="nt">-m</span> <span class="s2">"fix(auth): prevent null username crash in authenticate"</span>

<span class="c"># Finish hotfix — merges to main AND develop</span>
git flow hotfix finish <span class="s1">'1.0.1'</span>
</code></pre></div></div>

<hr />

<h2 id="part-4-set-up-trunk-based-development">Part 4: Set up Trunk-Based Development</h2>

<p>TBD requires discipline more than tooling. The tooling enforces the discipline.</p>

<h3 id="step-1-protect-the-main-branch">Step 1: Protect the main branch</h3>

<p>If you’re using the <code class="language-plaintext highlighter-rouge">gh</code> CLI:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Set your repo name</span>
<span class="nv">REPO</span><span class="o">=</span><span class="s2">"your-github-username/devops-git-template"</span>

gh api <span class="se">\</span>
  <span class="nt">--method</span> PUT <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Accept: application/vnd.github+json"</span> <span class="se">\</span>
  /repos/<span class="k">${</span><span class="nv">REPO</span><span class="k">}</span>/branches/main/protection <span class="se">\</span>
  <span class="nt">--input</span> - <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
{
  "required_status_checks": {
    "strict": true,
    "contexts": ["lint", "test", "commitlint"]
  },
  "enforce_admins": true,
  "required_pull_request_reviews": {
    "required_approving_review_count": 1,
    "dismiss_stale_reviews": true,
    "require_code_owner_reviews": false
  },
  "restrictions": null,
  "allow_force_pushes": false,
  "allow_deletions": false,
  "required_linear_history": true
}
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Expected output:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://api.github.com/repos/your-username/devops-git-template/branches/main/protection"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"required_status_checks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"strict"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="err">...</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>What each rule does:</p>

<table>
  <thead>
    <tr>
      <th>Rule</th>
      <th>What it enforces</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">required_status_checks</code></td>
      <td>CI must pass before merge</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">strict: true</code></td>
      <td>Branch must be up to date with main before merge</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">enforce_admins: true</code></td>
      <td>Rules apply to repo admins too — no exceptions</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">required_approving_review_count: 1</code></td>
      <td>At least 1 human approval required</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">dismiss_stale_reviews: true</code></td>
      <td>New commits invalidate previous approvals</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">allow_force_pushes: false</code></td>
      <td>Nobody can rewrite main history</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">required_linear_history: true</code></td>
      <td>Enforces squash or rebase merges — no merge commits</td>
    </tr>
  </tbody>
</table>

<h3 id="step-2-create-a-codeowners-file">Step 2: Create a CODEOWNERS file</h3>

<p>CODEOWNERS automatically assigns reviewers based on which files are changed.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .github/CODEOWNERS <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
# Global owners — review everything
*                   @your-github-username

# CI/CD changes require DevOps team review
.github/            @your-github-username
*.yml               @your-github-username
Dockerfile*         @your-github-username
terraform/          @your-github-username

# Auth module — security-sensitive
src/auth/           @your-github-username
</span><span class="no">EOF
</span></code></pre></div></div>

<h3 id="step-3-feature-flag-scaffold-for-tbd">Step 3: Feature flag scaffold for TBD</h3>

<p>In Trunk-Based Development, you merge incomplete features to main behind a feature flag. Here’s a minimal implementation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> src/feature-flags.js <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
/**
 * Feature flag system for Trunk-Based Development
 * Flags are read from environment variables.
 * In production, use a service like LaunchDarkly, Flagsmith, or Unleash.
 */

const flags = {
  // Format: FEATURE_FLAG_&lt;NAME&gt;=true|false
  NEW_DASHBOARD: process.env.FEATURE_FLAG_NEW_DASHBOARD === 'true',
  BETA_SEARCH: process.env.FEATURE_FLAG_BETA_SEARCH === 'true',
  USER_AUTHENTICATION_V2: process.env.FEATURE_FLAG_USER_AUTH_V2 === 'true',
};

const isEnabled = (flagName) =&gt; {
  if (!(flagName in flags)) {
    console.warn(`[FeatureFlag] Unknown flag: </span><span class="k">${</span><span class="nv">flagName</span><span class="k">}</span><span class="sh">`);
    return false;
  }
  return flags[flagName];
};

module.exports = { isEnabled, flags };
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Example usage in application code:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">{</span> <span class="nx">isEnabled</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./feature-flags</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">getAuthHandler</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">isEnabled</span><span class="p">(</span><span class="dl">'</span><span class="s1">USER_AUTHENTICATION_V2</span><span class="dl">'</span><span class="p">))</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./auth/v2</span><span class="dl">'</span><span class="p">);</span>  <span class="c1">// New implementation, hidden behind flag</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./auth/v1</span><span class="dl">'</span><span class="p">);</span>    <span class="c1">// Stable implementation</span>
<span class="p">};</span>
</code></pre></div></div>

<h3 id="step-4-add-a-pre-push-hook-to-block-direct-pushes-to-main">Step 4: Add a pre-push hook to block direct pushes to main</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .husky/pre-push <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
#!/bin/sh

CURRENT_BRANCH=</span><span class="si">$(</span>git symbolic-ref HEAD | <span class="nb">sed</span> <span class="nt">-e</span> <span class="s1">'s,.*/\(.*\),\1,'</span><span class="si">)</span><span class="sh">
PROTECTED_BRANCHES="^(main|master|develop)</span><span class="nv">$"</span><span class="sh">

if echo "</span><span class="nv">$CURRENT_BRANCH</span><span class="sh">" | grep -qE "</span><span class="nv">$PROTECTED_BRANCHES</span><span class="sh">"; then
  echo "⛔ Direct push to '</span><span class="nv">$CURRENT_BRANCH</span><span class="sh">' is not allowed."
  echo "   Create a feature branch and open a pull request."
  echo ""
  echo "   git checkout -b feature/your-feature-name"
  exit 1
fi

exit 0
</span><span class="no">EOF

</span><span class="nb">chmod</span> +x .husky/pre-push
</code></pre></div></div>

<p>Test it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Should be blocked if you're on main</span>
git push origin main
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>⛔ Direct push to 'main' is not allowed.
   Create a feature branch and open a pull request.

   git checkout -b feature/your-feature-name
error: failed to push some refs to 'origin'
</code></pre></div></div>

<hr />

<h2 id="part-5-the-reusable-template">Part 5: The reusable template</h2>

<p>Package everything into a template structure others can use:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create the template structure</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> repo-template/.github/workflows
<span class="nb">mkdir</span> <span class="nt">-p</span> repo-template/.husky
<span class="nb">mkdir</span> <span class="nt">-p</span> repo-template/src

<span class="c"># Copy all config files</span>
<span class="nb">cp</span> .gitignore repo-template/
<span class="nb">cp</span> .eslintrc.js repo-template/
<span class="nb">cp</span> .prettierrc repo-template/
<span class="nb">cp </span>commitlint.config.js repo-template/
<span class="nb">cp </span>package.json repo-template/
<span class="nb">cp</span> .github/pull_request_template.md repo-template/.github/
<span class="nb">cp</span> .github/CODEOWNERS repo-template/.github/
<span class="nb">cp</span> .github/workflows/ci.yml repo-template/.github/workflows/
<span class="nb">cp</span> .husky/pre-commit repo-template/.husky/
<span class="nb">cp</span> .husky/pre-push repo-template/.husky/
<span class="nb">cp</span> .husky/commit-msg repo-template/.husky/
<span class="nb">cp </span>src/feature-flags.js repo-template/src/

<span class="c"># Create a setup script so others can bootstrap quickly</span>
<span class="nb">cat</span> <span class="o">&gt;</span> repo-template/setup.sh <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
#!/bin/bash
set -euo pipefail

echo "Setting up repository from syssignals DevOps template..."

# Install dependencies
npm install

# Initialize Husky
npm run prepare

# Verify hooks are executable
chmod +x .husky/pre-commit .husky/pre-push .husky/commit-msg

echo "Setup complete."
echo ""
echo "Next steps:"
echo "  1. Update CODEOWNERS with your GitHub username"
echo "  2. Set branch protection rules on GitHub (see README)"
echo "  3. Run 'npm run lint' to verify ESLint is working"
echo "  4. Make your first commit: git commit -m 'chore: initial repository setup'"
</span><span class="no">EOF

</span><span class="nb">chmod</span> +x repo-template/setup.sh
</code></pre></div></div>

<hr />

<h2 id="how-it-works-under-the-hood">How it works under the hood</h2>

<p><strong>Why Husky intercepts hooks:</strong></p>

<p>Git stores hooks in <code class="language-plaintext highlighter-rouge">.git/hooks/</code> — a directory that is never committed or shared. Husky solves this by adding a <code class="language-plaintext highlighter-rouge">prepare</code> npm script that installs scripts into <code class="language-plaintext highlighter-rouge">.git/hooks/</code> when anyone runs <code class="language-plaintext highlighter-rouge">npm install</code>. The hooks in <code class="language-plaintext highlighter-rouge">.husky/</code> are committed to the repo and become the source of truth. When a developer clones the repo and runs <code class="language-plaintext highlighter-rouge">npm install</code>, Husky automatically installs the hooks.</p>

<p><strong>Why lint-staged and not full linting:</strong></p>

<p>Running ESLint on your entire codebase before every commit is slow. On a large project it can take 30+ seconds. Developers start using <code class="language-plaintext highlighter-rouge">git commit --no-verify</code> to skip it. lint-staged runs linters only on the files you’ve staged with <code class="language-plaintext highlighter-rouge">git add</code> — typically 1–5 files. The hook completes in under 2 seconds, which developers will actually tolerate.</p>

<p><strong>Why conventional commits matter for DevOps:</strong></p>

<p>Conventional commits aren’t just a style guide. Tools like <code class="language-plaintext highlighter-rouge">semantic-release</code> parse commit messages to determine the next version number (feat = minor bump, fix = patch bump, BREAKING CHANGE = major bump), auto-generate changelogs, and publish packages — all automatically in CI. Enforcing the format from day one means you can adopt these tools later without reformatting history.</p>

<p><strong>Why <code class="language-plaintext highlighter-rouge">--no-ff</code> in GitFlow:</strong></p>

<p>The <code class="language-plaintext highlighter-rouge">--no-ff</code> flag (no fast-forward) forces Git to create a merge commit even when the history is linear. Without it, Git can fast-forward the branch pointer and you lose the context that these commits belonged to a feature branch. With <code class="language-plaintext highlighter-rouge">--no-ff</code>, the graph always shows which commits were part of which feature — invaluable for <code class="language-plaintext highlighter-rouge">git bisect</code> and debugging.</p>

<hr />

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

<h3 id="error-1-husky-command-not-found">Error 1: <code class="language-plaintext highlighter-rouge">husky: command not found</code></h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.husky/pre-commit: line 1: husky: command not found
</code></pre></div></div>

<p><strong>Cause:</strong> Husky wasn’t installed or <code class="language-plaintext highlighter-rouge">npm install</code> wasn’t run after cloning.</p>

<p><strong>Fix:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install
</span>npm run prepare
</code></pre></div></div>

<hr />

<h3 id="error-2-commitlint-fails-with-your-commit-message-has-an-error">Error 2: <code class="language-plaintext highlighter-rouge">commitlint</code> fails with <code class="language-plaintext highlighter-rouge">Your commit message has an error</code></h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]
</code></pre></div></div>

<p><strong>Cause:</strong> Commit message doesn’t follow Conventional Commits format.</p>

<p><strong>Fix:</strong> Use the correct format:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Wrong</span>
git commit <span class="nt">-m</span> <span class="s2">"added login feature"</span>

<span class="c"># Correct</span>
git commit <span class="nt">-m</span> <span class="s2">"feat(auth): add login form with JWT support"</span>
</code></pre></div></div>

<hr />

<h3 id="error-3-git-flow-command-not-found-after-install">Error 3: <code class="language-plaintext highlighter-rouge">git flow</code> command not found after install</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git: 'flow' is not a git command
</code></pre></div></div>

<p><strong>Cause:</strong> On some systems <code class="language-plaintext highlighter-rouge">git-flow</code> installs as a separate binary not recognized by git.</p>

<p><strong>Fix:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ubuntu: install the AVH edition explicitly</span>
<span class="nb">sudo </span>apt-get remove git-flow
<span class="nb">sudo </span>apt-get <span class="nb">install </span>git-flow <span class="nt">-y</span>

<span class="c"># macOS: use Homebrew AVH edition</span>
brew uninstall git-flow
brew <span class="nb">install </span>git-flow-avh

<span class="c"># Verify</span>
which git-flow
git flow version
</code></pre></div></div>

<hr />

<h3 id="error-4-branch-protection-api-returns-403">Error 4: Branch protection API returns 403</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
  "message": "Not Found",
  "documentation_url": "..."
}
</code></pre></div></div>

<p><strong>Cause:</strong> Your GitHub token doesn’t have <code class="language-plaintext highlighter-rouge">repo</code> scope, or the repo is private and the token lacks access.</p>

<p><strong>Fix:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verify your token has the right scopes</span>
gh auth status

<span class="c"># Re-authenticate with correct scopes</span>
gh auth login <span class="nt">--scopes</span> repo,workflow
</code></pre></div></div>

<hr />

<h3 id="error-5-lint-staged-fails-with-no-staged-files-match">Error 5: <code class="language-plaintext highlighter-rouge">lint-staged</code> fails with <code class="language-plaintext highlighter-rouge">No staged files match</code></h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
</code></pre></div></div>

<p>This is actually <strong>not</strong> an error — it means lint-staged ran but found no matching files in your staged changes. If you staged a <code class="language-plaintext highlighter-rouge">.sh</code> file and configured <code class="language-plaintext highlighter-rouge">shellcheck</code>, but <code class="language-plaintext highlighter-rouge">shellcheck</code> isn’t installed, you’ll see:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install shellcheck</span>
<span class="nb">sudo </span>apt-get <span class="nb">install </span>shellcheck   <span class="c"># Ubuntu</span>
brew <span class="nb">install </span>shellcheck           <span class="c"># macOS</span>
</code></pre></div></div>

<hr />

<h2 id="whats-next--extend-this-project">What’s next — extend this project</h2>

<p><strong>Make it production-ready:</strong></p>

<ol>
  <li>
    <p>Add <code class="language-plaintext highlighter-rouge">semantic-release</code> to automate version bumps and changelog generation from your conventional commits. Configure it in <code class="language-plaintext highlighter-rouge">.releaserc.json</code> and add a <code class="language-plaintext highlighter-rouge">release</code> job to your CI workflow.</p>
  </li>
  <li>
    <p>Add <code class="language-plaintext highlighter-rouge">danger</code> for automated PR review — it can enforce PR size limits, check that tests were added with features, and post comments when the PR template isn’t filled out.</p>
  </li>
  <li>
    <p>Integrate <code class="language-plaintext highlighter-rouge">git-secrets</code> (AWS) or <code class="language-plaintext highlighter-rouge">gitleaks</code> to scan commits for accidentally committed secrets before they reach GitHub.</p>
  </li>
</ol>

<hr />

<h2 id="series-recap--day-1">Series recap — Day 1</h2>

<p>You now have:</p>

<ul>
  <li>A Git repository with enforced workflow (pre-commit, pre-push, commit-msg hooks)</li>
  <li>Conventional Commits enforced at the hook level and validated in CI</li>
  <li>A PR template that enforces review discipline</li>
  <li>Branch protection rules that make it physically impossible to push broken code to main</li>
  <li>A complete understanding of when to use GitFlow vs Trunk-Based Development</li>
  <li>A reusable <code class="language-plaintext highlighter-rouge">repo-template/</code> folder you can drop into any project</li>
</ul>

<p>The foundation is set. Everything built in the next 29 articles will use this repository structure.</p>

<hr />

<h2 id="day-2-preview">Day 2 preview</h2>

<p><strong>Day 2: Dockerize any application the right way — multi-stage builds and best practices</strong></p>

<p>We’ll take a Node.js application from a 1.2GB naive image to a 45MB hardened production image. Multi-stage builds, non-root users, BuildKit cache mounts, and Docker Scout vulnerability scanning.</p>

<hr />

<p><em>This is Day 1 of the <a href="https://x.com/syssignals">30 Days of DevOps</a> series.</em><br />
<em>Follow <a href="https://x.com/syssignals">@syssignals</a> on X — Day 2 drops tomorrow.</em></p>

<p><em>Found an error? Drop a reply on X and I’ll fix it and credit you.</em></p>]]></content><author><name>Vishwas Sharma</name></author><category term="Git" /><category term="git" /><category term="gitflow" /><category term="trunk-based-development" /><category term="branching" /><category term="husky" /><category term="commitlint" /><category term="ci-cd" /><summary type="html"><![CDATA[Two battle-tested branching strategies with complete setup, real configs, and the decision framework to know which one fits your team.]]></summary></entry><entry><title type="html">Day 2: Dockerize Any Application the Right Way — Multi-Stage Builds &amp;amp; Best Practices</title><link href="https://syssignals.com/articles/2026/05/14/day-02-docker-multi-stage-builds/" rel="alternate" type="text/html" title="Day 2: Dockerize Any Application the Right Way — Multi-Stage Builds &amp;amp; Best Practices" /><published>2026-05-14T00:00:00+00:00</published><updated>2026-05-14T00:00:00+00:00</updated><id>https://syssignals.com/articles/2026/05/14/day-02-docker-multi-stage-builds</id><content type="html" xml:base="https://syssignals.com/articles/2026/05/14/day-02-docker-multi-stage-builds/"><![CDATA[<blockquote>
  <p><strong>30 Days of DevOps</strong> — a series by <a href="https://x.com/syssignals">@syssignals</a>
Every article is a working project. Every command is verified. No fluff.</p>
</blockquote>

<h2 id="the-problem-with-it-works-on-my-machine">The problem with “it works on my machine”</h2>

<p>A new developer joins your team. They clone the repo, follow the README, spend 3 hours debugging a Node version mismatch, give up, and ping you on Slack.</p>

<p>You’ve been there. You’ve been both people in that story.</p>

<p>Docker was supposed to fix this. And it does — but only if you use it correctly. Most teams don’t. They write a Dockerfile that works, ship it, and never look back. That Dockerfile ends up in production running as root, carrying 800 MB of build tools nobody needs, with 47 CVEs sitting quietly in a base image from 2021.</p>

<p>This article fixes that. You’ll take a real Node.js application from a 1.2 GB naive image down to a 45 MB hardened production image. Every decision is explained. Every command is verified. By the end you’ll have a Dockerfile template you can drop into any project and trust.</p>

<hr />

<h2 id="what-youll-build">What you’ll build</h2>

<p>A production-grade Docker setup for a Node.js REST API, including:</p>

<ul>
  <li>A naive Dockerfile (the before — so you understand what you’re fixing)</li>
  <li>A multi-stage Dockerfile that reduces image size by ~96%</li>
  <li>A hardened production image running as a non-root user</li>
  <li>A <code class="language-plaintext highlighter-rouge">.dockerignore</code> that prevents secrets and junk from leaking into images</li>
  <li>Docker Compose for local development with hot reload</li>
  <li>A Docker Scout vulnerability scan comparing naive vs hardened image</li>
  <li>A reusable <code class="language-plaintext highlighter-rouge">docker/</code> folder structure for any project</li>
</ul>

<p><strong>Estimated time:</strong> 60 minutes<br />
<strong>Final image size:</strong> ~45 MB (down from ~1.2 GB)</p>

<p>Here is the complete picture of what this build produces:</p>

<pre><code class="language-mermaid">%%{init: {'theme': 'dark'}}%%
flowchart TD
    SRC(["Your Node.js App\nsrc/  ·  package.json"]):::src

    subgraph BUILD ["Multi-Stage Dockerfile"]
        DEPS["Stage 1 · deps\nnode:20-alpine\nnpm ci --omit=dev"]:::stage
        TEST["Stage 2 · test\nnode:20-alpine\nnpm run test:ci"]:::stage
        FINAL["Stage 3 · production\ndistroless/nodejs20\nUSER nonroot"]:::stage
        DEPS --&gt;|"prod node_modules only"| FINAL
        TEST --&gt;|"CI gate — must pass"| FINAL
    end

    DEV_IMG["myapp:dev\nnodemon · hot reload\ndocker compose up"]:::dev
    PROD_IMG["myapp:production\n47 MB · uid 65532\nzero critical CVEs"]:::prod

    SRC --&gt; BUILD
    DEPS --&gt; DEV_IMG
    FINAL --&gt; PROD_IMG

    classDef src fill:#1a2744,stroke:#58a6ff,color:#e6edf3
    classDef stage fill:#1c2128,stroke:#30363d,color:#8b949e
    classDef dev fill:#1a2744,stroke:#58a6ff,color:#79b8ff
    classDef prod fill:#0d2818,stroke:#3fb950,color:#3fb950
</code></pre>

<hr />

<h2 id="prerequisites">Prerequisites</h2>

<h3 id="operating-system">Operating system</h3>

<table>
  <thead>
    <tr>
      <th>OS</th>
      <th>Status</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ubuntu 22.04 LTS</td>
      <td>Recommended</td>
      <td>All commands tested here</td>
    </tr>
    <tr>
      <td>Ubuntu 20.04 LTS</td>
      <td>Supported</td>
      <td>Works identically</td>
    </tr>
    <tr>
      <td>macOS 13+ (Sonoma/Ventura)</td>
      <td>Supported</td>
      <td>Use Docker Desktop for Mac</td>
    </tr>
    <tr>
      <td>macOS 12 (Monterey)</td>
      <td>Supported</td>
      <td>Use Docker Desktop for Mac</td>
    </tr>
    <tr>
      <td>Windows 11</td>
      <td>Supported</td>
      <td>Requires WSL2 + Docker Desktop</td>
    </tr>
    <tr>
      <td>Windows 10 (21H2+)</td>
      <td>Supported</td>
      <td>Requires WSL2 + Docker Desktop</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Windows users:</strong> All commands must be run inside WSL2 (Ubuntu), not PowerShell or CMD. Docker Desktop routes WSL2 commands through the same daemon. Open Ubuntu from the Start Menu and work from there.</p>
</blockquote>

<hr />

<h3 id="required-software">Required software</h3>

<h4 id="1-docker-engine-240-or-docker-desktop-420">1. Docker Engine 24.0+ or Docker Desktop 4.20+</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check if Docker is already installed</span>
docker <span class="nt">--version</span>
<span class="c"># Expected: Docker version 24.x.x or higher</span>

docker info | <span class="nb">grep</span> <span class="s2">"Server Version"</span>
<span class="c"># Expected: Server Version: 24.x.x</span>
</code></pre></div></div>

<p>Install Docker Engine on Ubuntu (skip if already installed):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Remove old versions</span>
<span class="nb">sudo </span>apt-get remove docker docker-engine docker.io containerd runc 2&gt;/dev/null

<span class="c"># Install dependencies</span>
<span class="nb">sudo </span>apt-get update
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> ca-certificates curl gnupg lsb-release

<span class="c"># Add Docker's official GPG key</span>
<span class="nb">sudo mkdir</span> <span class="nt">-p</span> /etc/apt/keyrings
curl <span class="nt">-fsSL</span> https://download.docker.com/linux/ubuntu/gpg | <span class="se">\</span>
  <span class="nb">sudo </span>gpg <span class="nt">--dearmor</span> <span class="nt">-o</span> /etc/apt/keyrings/docker.gpg

<span class="c"># Add the repository</span>
<span class="nb">echo</span> <span class="se">\</span>
  <span class="s2">"deb [arch=</span><span class="si">$(</span>dpkg <span class="nt">--print-architecture</span><span class="si">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.gpg] </span><span class="se">\</span><span class="s2">
  https://download.docker.com/linux/ubuntu </span><span class="se">\</span><span class="s2">
  </span><span class="si">$(</span>lsb_release <span class="nt">-cs</span><span class="si">)</span><span class="s2"> stable"</span> | <span class="se">\</span>
  <span class="nb">sudo tee</span> /etc/apt/sources.list.d/docker.list <span class="o">&gt;</span> /dev/null

<span class="c"># Install Docker Engine, CLI, containerd, and plugins</span>
<span class="nb">sudo </span>apt-get update
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> docker-ce docker-ce-cli containerd.io <span class="se">\</span>
  docker-buildx-plugin docker-compose-plugin

<span class="c"># Add your user to the docker group (avoids sudo on every command)</span>
<span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker <span class="nv">$USER</span>

<span class="c"># Apply group membership without logout</span>
newgrp docker

<span class="c"># Verify installation</span>
docker run <span class="nt">--rm</span> hello-world
</code></pre></div></div>

<p>Expected final line:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Hello from Docker!
</code></pre></div></div>

<h4 id="2-docker-buildx-for-buildkit-cache-mounts">2. Docker Buildx (for BuildKit cache mounts)</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verify Buildx is available</span>
docker buildx version
<span class="c"># Expected: github.com/docker/buildx v0.12.x linux/amd64</span>

<span class="c"># Enable BuildKit globally (set as default builder)</span>
docker buildx create <span class="nt">--name</span> mybuilder <span class="nt">--use</span>
docker buildx inspect <span class="nt">--bootstrap</span>
</code></pre></div></div>

<p>Expected output (last line):</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[+] Building 4.2s (1/1) FINISHED
</code></pre></div></div>

<h4 id="3-docker-scout-for-vulnerability-scanning">3. Docker Scout (for vulnerability scanning)</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install Docker Scout CLI plugin</span>
curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/docker/scout-cli/main/install.sh | sh

<span class="c"># Verify</span>
docker scout version
<span class="c"># Expected: docker scout version v1.x.x</span>
</code></pre></div></div>

<blockquote>
  <p>If <code class="language-plaintext highlighter-rouge">curl</code> isn’t available: <code class="language-plaintext highlighter-rouge">sudo apt-get install -y curl</code></p>
</blockquote>

<h4 id="4-nodejs-20-lts-for-the-sample-application">4. Node.js 20 LTS (for the sample application)</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install via nvm — avoids permission issues with global packages</span>
curl <span class="nt">-o-</span> https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
<span class="nb">source</span> ~/.bashrc

nvm <span class="nb">install </span>20
nvm use 20
nvm <span class="nb">alias </span>default 20

<span class="c"># Verify</span>
node <span class="nt">--version</span>   <span class="c"># v20.x.x</span>
npm <span class="nt">--version</span>    <span class="c"># 10.x.x</span>
</code></pre></div></div>

<h4 id="5-ide-recommendations">5. IDE recommendations</h4>

<table>
  <thead>
    <tr>
      <th>IDE</th>
      <th>Recommended extensions</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VS Code (recommended)</td>
      <td>Docker (ms-azuretools.vscode-docker), Remote - WSL</td>
    </tr>
    <tr>
      <td>JetBrains IDEs</td>
      <td>Docker plugin (bundled)</td>
    </tr>
    <tr>
      <td>Vim / Neovim</td>
      <td>dockerfile.vim, coc-docker</td>
    </tr>
  </tbody>
</table>

<p>The VS Code Docker extension adds Dockerfile syntax highlighting, image management from the sidebar, and build error detection inline. Not required but saves debugging time.</p>

<hr />

<h3 id="full-environment-check">Full environment check</h3>

<p>Run this before starting. If anything fails, fix it before continuing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">echo</span> <span class="s2">"=== Docker ==="</span> <span class="o">&amp;&amp;</span> docker <span class="nt">--version</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
<span class="nb">echo</span> <span class="s2">"=== Buildx ==="</span> <span class="o">&amp;&amp;</span> docker buildx version <span class="o">&amp;&amp;</span> <span class="se">\</span>
<span class="nb">echo</span> <span class="s2">"=== Compose ==="</span> <span class="o">&amp;&amp;</span> docker compose version <span class="o">&amp;&amp;</span> <span class="se">\</span>
<span class="nb">echo</span> <span class="s2">"=== Node ==="</span> <span class="o">&amp;&amp;</span> node <span class="nt">--version</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
<span class="nb">echo</span> <span class="s2">"=== npm ==="</span> <span class="o">&amp;&amp;</span> npm <span class="nt">--version</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
<span class="nb">echo</span> <span class="s2">"=== Scout ==="</span> <span class="o">&amp;&amp;</span> docker scout version 2&gt;/dev/null | <span class="nb">head</span> <span class="nt">-1</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
<span class="nb">echo</span> <span class="s2">""</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"All checks passed. Ready to build."</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>=== Docker ===
Docker version 24.0.7, build afdd53b
=== Buildx ===
github.com/docker/buildx v0.12.1 linux/amd64
=== Compose ===
Docker Compose version v2.24.5
=== Node ===
v20.11.0
=== npm ===
10.2.4
=== Scout ===
docker scout version v1.4.0

All checks passed. Ready to build.
</code></pre></div></div>

<hr />

<h2 id="part-1-the-sample-application">Part 1: The sample application</h2>

<p>We need a real application — not a toy. This is a Node.js REST API with actual dependencies: Express, input validation, security middleware, and structured logging. Realistic enough that the image size problem is genuine.</p>

<h3 id="step-1-create-the-project-structure">Step 1: Create the project structure</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir </span>docker-best-practices <span class="o">&amp;&amp;</span> <span class="nb">cd </span>docker-best-practices
npm init <span class="nt">-y</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> src/routes src/middleware
</code></pre></div></div>

<h3 id="step-2-install-dependencies">Step 2: Install dependencies</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Production dependencies — go into the final image</span>
npm <span class="nb">install </span>express helmet cors morgan zod dotenv

<span class="c"># Development dependencies — must NEVER go into a production image</span>
npm <span class="nb">install</span> <span class="nt">--save-dev</span> nodemon jest supertest
</code></pre></div></div>

<p>Verify the size impact:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">du</span> <span class="nt">-sh</span> node_modules/
<span class="c"># ~75MB — this entire directory should never reach a production image</span>
</code></pre></div></div>

<h3 id="step-3-create-the-application-source">Step 3: Create the application source</h3>

<p><strong><code class="language-plaintext highlighter-rouge">src/index.js</code></strong> — application entry point:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">'</span><span class="s1">use strict</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">express</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">helmet</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">helmet</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">cors</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">cors</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">morgan</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">morgan</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">healthRouter</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./routes/health</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">usersRouter</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./routes/users</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">errorHandler</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">./middleware/errorHandler</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nx">express</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">PORT</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">PORT</span> <span class="o">||</span> <span class="mi">3000</span><span class="p">;</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">helmet</span><span class="p">());</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">cors</span><span class="p">({</span>
  <span class="na">origin</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">ALLOWED_ORIGINS</span><span class="p">?.</span><span class="nx">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">,</span><span class="dl">'</span><span class="p">)</span> <span class="o">||</span> <span class="p">[</span><span class="dl">'</span><span class="s1">http://localhost:3000</span><span class="dl">'</span><span class="p">],</span>
<span class="p">}));</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">morgan</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">production</span><span class="dl">'</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">combined</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">dev</span><span class="dl">'</span><span class="p">));</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span> <span class="na">limit</span><span class="p">:</span> <span class="dl">'</span><span class="s1">10kb</span><span class="dl">'</span> <span class="p">}));</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="dl">'</span><span class="s1">/health</span><span class="dl">'</span><span class="p">,</span> <span class="nx">healthRouter</span><span class="p">);</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="dl">'</span><span class="s1">/api/users</span><span class="dl">'</span><span class="p">,</span> <span class="nx">usersRouter</span><span class="p">);</span>

<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">((</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Route not found</span><span class="dl">'</span> <span class="p">}));</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">use</span><span class="p">(</span><span class="nx">errorHandler</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">server</span> <span class="o">=</span> <span class="nx">app</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="nx">PORT</span><span class="p">,</span> <span class="dl">'</span><span class="s1">0.0.0.0</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s2">`Server running on port </span><span class="p">${</span><span class="nx">PORT</span><span class="p">}</span><span class="s2"> [</span><span class="p">${</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">development</span><span class="dl">'</span><span class="p">}</span><span class="s2">]`</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">process</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">SIGTERM</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">SIGTERM received — shutting down gracefully</span><span class="dl">'</span><span class="p">);</span>
  <span class="nx">server</span><span class="p">.</span><span class="nx">close</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">process</span><span class="p">.</span><span class="nx">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">));</span>
<span class="p">});</span>

<span class="nx">process</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="dl">'</span><span class="s1">SIGINT</span><span class="dl">'</span><span class="p">,</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">server</span><span class="p">.</span><span class="nx">close</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">process</span><span class="p">.</span><span class="nx">exit</span><span class="p">(</span><span class="mi">0</span><span class="p">));</span>
<span class="p">});</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">app</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">src/routes/health.js</code></strong>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">'</span><span class="s1">use strict</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="p">{</span> <span class="nx">Router</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">router</span> <span class="o">=</span> <span class="nx">Router</span><span class="p">();</span>

<span class="nx">router</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span>
    <span class="na">status</span><span class="p">:</span> <span class="dl">'</span><span class="s1">healthy</span><span class="dl">'</span><span class="p">,</span>
    <span class="na">timestamp</span><span class="p">:</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">().</span><span class="nx">toISOString</span><span class="p">(),</span>
    <span class="na">uptime</span><span class="p">:</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">floor</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">uptime</span><span class="p">()),</span>
    <span class="na">environment</span><span class="p">:</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">development</span><span class="dl">'</span><span class="p">,</span>
  <span class="p">});</span>
<span class="p">});</span>

<span class="nx">router</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/ready</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span> <span class="na">status</span><span class="p">:</span> <span class="dl">'</span><span class="s1">ready</span><span class="dl">'</span> <span class="p">}));</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="na">healthRouter</span><span class="p">:</span> <span class="nx">router</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">src/routes/users.js</code></strong>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">'</span><span class="s1">use strict</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="p">{</span> <span class="nx">Router</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">express</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">z</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">zod</span><span class="dl">'</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">router</span> <span class="o">=</span> <span class="nx">Router</span><span class="p">();</span>

<span class="kd">const</span> <span class="nx">UserSchema</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nx">object</span><span class="p">({</span>
  <span class="na">name</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nx">string</span><span class="p">().</span><span class="nx">min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nx">max</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
  <span class="na">email</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nx">string</span><span class="p">().</span><span class="nx">email</span><span class="p">(),</span>
  <span class="na">role</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">enum</span><span class="p">([</span><span class="dl">'</span><span class="s1">admin</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">user</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">viewer</span><span class="dl">'</span><span class="p">]).</span><span class="k">default</span><span class="p">(</span><span class="dl">'</span><span class="s1">user</span><span class="dl">'</span><span class="p">),</span>
<span class="p">});</span>

<span class="kd">const</span> <span class="nx">db</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Map</span><span class="p">([</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">1</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">1</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Alice</span><span class="dl">'</span><span class="p">,</span> <span class="na">email</span><span class="p">:</span> <span class="dl">'</span><span class="s1">alice@example.com</span><span class="dl">'</span><span class="p">,</span> <span class="na">role</span><span class="p">:</span> <span class="dl">'</span><span class="s1">admin</span><span class="dl">'</span> <span class="p">}],</span>
  <span class="p">[</span><span class="dl">'</span><span class="s1">2</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">2</span><span class="dl">'</span><span class="p">,</span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Bob</span><span class="dl">'</span><span class="p">,</span>   <span class="na">email</span><span class="p">:</span> <span class="dl">'</span><span class="s1">bob@example.com</span><span class="dl">'</span><span class="p">,</span>   <span class="na">role</span><span class="p">:</span> <span class="dl">'</span><span class="s1">user</span><span class="dl">'</span>  <span class="p">}],</span>
<span class="p">]);</span>

<span class="nx">router</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">({</span> <span class="na">users</span><span class="p">:</span> <span class="nb">Array</span><span class="p">.</span><span class="k">from</span><span class="p">(</span><span class="nx">db</span><span class="p">.</span><span class="nx">values</span><span class="p">()),</span> <span class="na">total</span><span class="p">:</span> <span class="nx">db</span><span class="p">.</span><span class="nx">size</span> <span class="p">});</span>
<span class="p">});</span>

<span class="nx">router</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/:id</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">db</span><span class="p">.</span><span class="kd">get</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">id</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">user</span><span class="p">)</span> <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">User not found</span><span class="dl">'</span> <span class="p">});</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">router</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="nx">UserSchema</span><span class="p">.</span><span class="nx">safeParse</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">result</span><span class="p">.</span><span class="nx">success</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Validation failed</span><span class="dl">'</span><span class="p">,</span> <span class="na">details</span><span class="p">:</span> <span class="nx">result</span><span class="p">.</span><span class="nx">error</span><span class="p">.</span><span class="nx">flatten</span><span class="p">()</span> <span class="p">});</span>
  <span class="p">}</span>
  <span class="kd">const</span> <span class="nx">id</span> <span class="o">=</span> <span class="nb">String</span><span class="p">(</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">());</span>
  <span class="kd">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">id</span><span class="p">,</span> <span class="p">...</span><span class="nx">result</span><span class="p">.</span><span class="nx">data</span> <span class="p">};</span>
  <span class="nx">db</span><span class="p">.</span><span class="kd">set</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">user</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="mi">201</span><span class="p">).</span><span class="nx">json</span><span class="p">(</span><span class="nx">user</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="na">usersRouter</span><span class="p">:</span> <span class="nx">router</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">src/middleware/errorHandler.js</code></strong>:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="dl">'</span><span class="s1">use strict</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">errorHandler</span> <span class="o">=</span> <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="nx">err</span><span class="p">.</span><span class="nx">statusCode</span> <span class="o">||</span> <span class="nx">err</span><span class="p">.</span><span class="nx">status</span> <span class="o">||</span> <span class="mi">500</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">message</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">production</span><span class="dl">'</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">An error occurred</span><span class="dl">'</span> <span class="p">:</span> <span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="p">;</span>

  <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="nx">err</span><span class="p">.</span><span class="nx">message</span><span class="p">,</span> <span class="na">path</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span> <span class="na">method</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">method</span> <span class="p">});</span>
  <span class="nx">res</span><span class="p">.</span><span class="nx">status</span><span class="p">(</span><span class="nx">status</span><span class="p">).</span><span class="nx">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="nx">message</span> <span class="p">});</span>
<span class="p">};</span>

<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">errorHandler</span> <span class="p">};</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">.env.example</code></strong> — document required variables (commit this, never <code class="language-plaintext highlighter-rouge">.env</code>):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .env.example <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
NODE_ENV=development
PORT=3000
ALLOWED_ORIGINS=http://localhost:3000
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Update <code class="language-plaintext highlighter-rouge">package.json</code> scripts:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>node <span class="nt">-e</span> <span class="s2">"
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.main = 'src/index.js';
pkg.engines = { node: '&gt;=20.0.0' };
pkg.scripts = {
  start: 'node src/index.js',
  dev: 'nodemon src/index.js',
  test: 'jest --coverage',
  'test:ci': 'jest --ci --forceExit'
};
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
console.log('Updated package.json');
"</span>
</code></pre></div></div>

<p>Verify the app works outside Docker first:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">NODE_ENV</span><span class="o">=</span>development node src/index.js &amp;
<span class="nv">APP_PID</span><span class="o">=</span><span class="nv">$!</span>
<span class="nb">sleep </span>1

curl <span class="nt">-s</span> http://localhost:3000/health | python3 <span class="nt">-m</span> json.tool

<span class="nb">kill</span> <span class="nv">$APP_PID</span> 2&gt;/dev/null
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"healthy"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"timestamp"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2025-01-15T10:23:45.123Z"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"uptime"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
    </span><span class="nl">"environment"</span><span class="p">:</span><span class="w"> </span><span class="s2">"development"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<hr />

<h2 id="part-2-the-naive-dockerfile--the-before-picture">Part 2: The naive Dockerfile — the before picture</h2>

<p>This is the Dockerfile most tutorials show you. It works. It is also dangerous.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> Dockerfile.naive <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
FROM node:20

WORKDIR /app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["node", "src/index.js"]
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Build it and inspect:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker build <span class="nt">-f</span> Dockerfile.naive <span class="nt">-t</span> myapp:naive <span class="nb">.</span>
</code></pre></div></div>

<p>Expected build output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[+] Building 42.1s (8/8) FINISHED
 =&gt; [internal] load build definition from Dockerfile.naive
 =&gt; [1/4] FROM docker.io/library/node:20
 =&gt; [2/4] WORKDIR /app
 =&gt; [3/4] COPY . .
 =&gt; [4/4] RUN npm install
 =&gt; exporting to image
</code></pre></div></div>

<p>Now check what you actually built:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Image size</span>
docker images myapp:naive <span class="nt">--format</span> <span class="s2">"Size: "</span>
<span class="c"># Size: 1.21GB</span>

<span class="c"># Is it running as root?</span>
docker run <span class="nt">--rm</span> myapp:naive <span class="nb">whoami</span>
<span class="c"># root   ← critical security problem</span>

<span class="c"># Are devDependencies included?</span>
docker run <span class="nt">--rm</span> myapp:naive <span class="nb">ls </span>node_modules | <span class="nb">grep </span>nodemon
<span class="c"># nodemon   ← dev tools in production</span>

<span class="c"># How many system tools are exposed?</span>
docker run <span class="nt">--rm</span> myapp:naive which curl bash apt wget
<span class="c"># /usr/bin/curl</span>
<span class="c"># /bin/bash</span>
<span class="c"># /usr/bin/apt</span>
<span class="c"># /usr/bin/wget</span>
<span class="c"># Every one of these is an attacker's tool</span>
</code></pre></div></div>

<p>The naive image has three fundamental problems:</p>

<ol>
  <li><strong>1.21 GB</strong> — the full Debian + Node.js + devDependencies + build tools</li>
  <li><strong>Runs as root</strong> — any container escape gives root on the host</li>
  <li><strong>Full shell and system utilities</strong> — an attacker who gets RCE has a full toolkit</li>
</ol>

<p>Here is exactly what each image contains, and what the production image leaves out:</p>

<pre><code class="language-mermaid">%%{init: {'theme': 'dark'}}%%
flowchart LR
    subgraph NAIVE ["❌  Naive Image · 1.21 GB · runs as root"]
        direction TB
        N1["node:20 Debian base\n~1.0 GB"]:::bad
        N2["devDependencies\nnodemon · jest · supertest"]:::bad
        N3["Shell: bash · curl · wget · apt"]:::bad
        N4["Build tools: gcc · python · make"]:::bad
        N5["Your app source"]:::neutral
        N1 --&gt; N2 --&gt; N3 --&gt; N4 --&gt; N5
    end

    subgraph PROD ["✓  Production Image · 47 MB · uid 65532"]
        direction TB
        P1["distroless/nodejs20-debian12\n~28 MB"]:::good
        P2["prod node_modules only\n~19 MB"]:::good
        P3["Your app source\n&lt; 1 MB"]:::good
        P1 --&gt; P2 --&gt; P3
    end

    classDef bad fill:#2d1215,stroke:#f85149,color:#f85149
    classDef good fill:#0d2818,stroke:#3fb950,color:#3fb950
    classDef neutral fill:#1c2128,stroke:#30363d,color:#e6edf3
</code></pre>

<hr />

<h2 id="part-3-the-production-dockerfile--multi-stage-build">Part 3: The production Dockerfile — multi-stage build</h2>

<p>Multi-stage builds use multiple <code class="language-plaintext highlighter-rouge">FROM</code> statements in one Dockerfile. Each stage builds on the previous. The final image receives only what you explicitly copy — build tools, test frameworks, and dev dependencies never make it in.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> Dockerfile <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
# ─── Stage 1: deps ────────────────────────────────────────────────────────────
# Install production dependencies only.
# This stage is never shipped. Its only output is node_modules.
FROM node:20-alpine AS deps

WORKDIR /app

# Copy lockfiles first — before source code.
# This layer is cached until package.json or package-lock.json changes.
# Changing src/ files won't invalidate this cache layer.
COPY package.json package-lock.json ./

# npm ci: uses lockfile exactly, fails if lockfile is out of sync
# --omit=dev: excludes devDependencies (nodemon, jest, etc.)
RUN npm ci --omit=dev --frozen-lockfile

# ─── Stage 2: test ────────────────────────────────────────────────────────────
# Runs tests with all dependencies (including dev).
# CI can build this target to gate on test failures before shipping prod.
FROM node:20-alpine AS test

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

COPY . .

RUN npm run test:ci --if-present

# ─── Stage 3: production ──────────────────────────────────────────────────────
# The only stage that ships. Built on distroless — no shell, no package manager,
# no system utilities. The absolute minimum to run a Node.js process.
FROM gcr.io/distroless/nodejs20-debian12 AS production

# OCI image labels — document the image for registries and scanners
LABEL org.opencontainers.image.title="myapp"
LABEL org.opencontainers.image.description="Node.js REST API — 30 Days of DevOps"
LABEL org.opencontainers.image.source="https://github.com/syssignals/30-days-devops"
LABEL org.opencontainers.image.licenses="MIT"

WORKDIR /app

# Pull only production node_modules from the deps stage.
# Build tools, compilers, and package caches from that stage don't follow.
COPY --from=deps /app/node_modules ./node_modules

# Copy application source
COPY src/ ./src/
COPY package.json ./

# Distroless images run as nonroot (uid=65532) by default.
# Declaring it explicitly satisfies security scanners and makes intent clear.
USER nonroot

EXPOSE 3000

# Exec form (array) is required in distroless — there is no shell to interpret
# the shell form. It also makes node PID 1, so SIGTERM reaches it directly.
CMD ["/nodejs/bin/node", "src/index.js"]
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Build and measure:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DOCKER_BUILDKIT</span><span class="o">=</span>1 docker build <span class="se">\</span>
  <span class="nt">--target</span> production <span class="se">\</span>
  <span class="nt">--tag</span> myapp:production <span class="se">\</span>
  <span class="nb">.</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[+] Building 18.4s (13/13) FINISHED
 =&gt; [deps 1/3] FROM docker.io/library/node:20-alpine
 =&gt; [deps 2/3] COPY package.json package-lock.json ./
 =&gt; [deps 3/3] RUN npm ci --omit=dev --frozen-lockfile
 =&gt; [production 1/4] COPY --from=deps /app/node_modules
 =&gt; [production 2/4] COPY src/ ./src/
 =&gt; [production 3/4] COPY package.json ./
 =&gt; exporting to image
 =&gt; =&gt; writing image sha256:b7e3d4...
</code></pre></div></div>

<p>Compare sizes:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker images | <span class="nb">grep </span>myapp
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>REPOSITORY   TAG          IMAGE ID       CREATED          SIZE
myapp        production   b7e3d4f8a1c2   10 seconds ago   47.2MB
myapp        naive        a3f8c2d1e4b9   5 minutes ago    1.21GB
</code></pre></div></div>

<p><strong>96% reduction. 1.21 GB → 47 MB.</strong></p>

<p>Verify it runs and is correctly hardened:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Start it</span>
docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--name</span> myapp-test <span class="se">\</span>
  <span class="nt">-p</span> 3000:3000 <span class="se">\</span>
  <span class="nt">-e</span> <span class="nv">NODE_ENV</span><span class="o">=</span>production <span class="se">\</span>
  myapp:production

<span class="nb">sleep </span>2

<span class="c"># Test the health endpoint</span>
curl <span class="nt">-s</span> http://localhost:3000/health | python3 <span class="nt">-m</span> json.tool

<span class="c"># Verify non-root</span>
docker run <span class="nt">--rm</span> myapp:production <span class="nb">id</span>
<span class="c"># uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot)</span>

<span class="c"># Verify no shell available (this should fail — that's the point)</span>
docker <span class="nb">exec </span>myapp-test sh 2&gt;&amp;1
<span class="c"># OCI runtime exec failed: exec failed: unable to start container process:</span>
<span class="c"># exec: "sh": executable file not found in $PATH</span>

<span class="c"># Stop and remove</span>
docker stop myapp-test <span class="o">&amp;&amp;</span> docker <span class="nb">rm </span>myapp-test
</code></pre></div></div>

<hr />

<h2 id="part-4-the-dockerignore-file">Part 4: The <code class="language-plaintext highlighter-rouge">.dockerignore</code> file</h2>

<p>Without <code class="language-plaintext highlighter-rouge">.dockerignore</code>, every <code class="language-plaintext highlighter-rouge">COPY . .</code> sends your entire project — <code class="language-plaintext highlighter-rouge">node_modules</code>, <code class="language-plaintext highlighter-rouge">.env</code>, <code class="language-plaintext highlighter-rouge">.git</code>, IDE configs — to the Docker daemon as build context. This leaks secrets, bloats cache, and slows builds.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> .dockerignore <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
# Dependencies — reinstalled inside the image
node_modules/
npm-debug.log*
yarn-debug.log*

# Secrets — must never reach an image layer
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
.npmrc

# Version control
.git/
.gitignore
.gitattributes

# IDE and OS artefacts
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db

# Test artefacts
coverage/
.nyc_output/
__tests__/
*.test.js
*.spec.js

# Docker config (no need inside the image)
Dockerfile*
docker-compose*.yml
.dockerignore

# Documentation
*.md
docs/

# CI/CD config
.github/
.gitlab-ci.yml
Jenkinsfile
</span><span class="no">EOF
</span></code></pre></div></div>

<p>Measure the build context before vs after:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Build with verbose output to see context size</span>
<span class="nv">DOCKER_BUILDKIT</span><span class="o">=</span>1 docker build <span class="nt">--progress</span><span class="o">=</span>plain <span class="nt">--target</span> production <span class="nt">-t</span> myapp:production <span class="nb">.</span> 2&gt;&amp;1 <span class="se">\</span>
  | <span class="nb">grep</span> <span class="s2">"transferring context"</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#1 transferring context: 38.4kB 0.1s done
</code></pre></div></div>

<p>Without <code class="language-plaintext highlighter-rouge">.dockerignore</code> that number would be ~180 MB (dominated by <code class="language-plaintext highlighter-rouge">node_modules</code>).</p>

<hr />

<h2 id="part-5-docker-compose-for-local-development">Part 5: Docker Compose for local development</h2>

<p>Development needs hot reload, all devDependencies, and live-mounted source. Production needs read-only filesystem, dropped capabilities, and resource limits. These are different Compose files.</p>

<pre><code class="language-mermaid">%%{init: {'theme': 'dark'}}%%
flowchart TD
    subgraph DEV ["docker-compose.yml  ·  Development"]
        DH["Browser / curl\nlocalhost:3000"]:::host
        DC["myapp:dev\nnode:20-alpine + nodemon"]:::dev
        DV[("node_modules\nnamed volume")]:::vol
        DS[("./src  bind mount\nread-only into container")]:::vol
        DH --&gt;|"port 3000:3000"| DC
        DC --&gt; DV
        DC &lt;--&gt;|"file changes trigger reload"| DS
    end

    subgraph PROD ["docker-compose.prod.yml  ·  Production-like"]
        PH["Browser / curl\nlocalhost:3000"]:::host
        PC["myapp:production\ndistroless · nonroot"]:::prod
        PT[("tmpfs: /tmp\nread-only filesystem")]:::vol
        PH --&gt;|"port 3000:3000"| PC
        PC --&gt; PT
    end

    classDef host fill:#1c2128,stroke:#30363d,color:#8b949e
    classDef dev fill:#1a2744,stroke:#58a6ff,color:#e6edf3
    classDef prod fill:#0d2818,stroke:#3fb950,color:#3fb950
    classDef vol fill:#1f2210,stroke:#d29922,color:#d29922
</code></pre>

<p><strong><code class="language-plaintext highlighter-rouge">docker-compose.yml</code></strong> — development:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.9'</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">app</span><span class="pi">:</span>
    <span class="na">build</span><span class="pi">:</span>
      <span class="na">context</span><span class="pi">:</span> <span class="s">.</span>
      <span class="na">dockerfile</span><span class="pi">:</span> <span class="s">Dockerfile</span>
      <span class="na">target</span><span class="pi">:</span> <span class="s">deps</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:dev</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">myapp-dev</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">3000:3000"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./src:/app/src:ro</span>
      <span class="pi">-</span> <span class="s">node_modules:/app/node_modules</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">NODE_ENV</span><span class="pi">:</span> <span class="s">development</span>
      <span class="na">PORT</span><span class="pi">:</span> <span class="m">3000</span>
    <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">node_modules/.bin/nodemon"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">--watch"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">src"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">src/index.js"</span><span class="pi">]</span>
    <span class="na">healthcheck</span><span class="pi">:</span>
      <span class="na">test</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">CMD</span>
        <span class="pi">-</span> <span class="s">node</span>
        <span class="pi">-</span> <span class="s">-e</span>
        <span class="pi">-</span> <span class="s2">"</span><span class="s">require('http').get('http://localhost:3000/health',</span><span class="nv"> </span><span class="s">r</span><span class="nv"> </span><span class="s">=&gt;</span><span class="nv"> </span><span class="s">process.exit(r.statusCode===200?0:1)).on('error',()=&gt;process.exit(1))"</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s">30s</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="s">10s</span>
      <span class="na">retries</span><span class="pi">:</span> <span class="m">3</span>
      <span class="na">start_period</span><span class="pi">:</span> <span class="s">15s</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">node_modules</span><span class="pi">:</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">docker-compose.prod.yml</code></strong> — production-like local testing:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.9'</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="na">app</span><span class="pi">:</span>
    <span class="na">build</span><span class="pi">:</span>
      <span class="na">context</span><span class="pi">:</span> <span class="s">.</span>
      <span class="na">dockerfile</span><span class="pi">:</span> <span class="s">Dockerfile</span>
      <span class="na">target</span><span class="pi">:</span> <span class="s">production</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">myapp:production</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">myapp-prod</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">3000:3000"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">NODE_ENV</span><span class="pi">:</span> <span class="s">production</span>
      <span class="na">PORT</span><span class="pi">:</span> <span class="m">3000</span>
    <span class="na">read_only</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">tmpfs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">/tmp</span>
    <span class="na">security_opt</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">no-new-privileges:true</span>
    <span class="na">cap_drop</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">ALL</span>
    <span class="na">deploy</span><span class="pi">:</span>
      <span class="na">resources</span><span class="pi">:</span>
        <span class="na">limits</span><span class="pi">:</span>
          <span class="na">cpus</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0.50'</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s">256M</span>
        <span class="na">reservations</span><span class="pi">:</span>
          <span class="na">cpus</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0.10'</span>
          <span class="na">memory</span><span class="pi">:</span> <span class="s">64M</span>
</code></pre></div></div>

<p>Start development:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">--build</span>
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[+] Building 19.3s (11/11) FINISHED
[+] Running 2/2
 ✔ Volume "docker-best-practices_node_modules"  Created
 ✔ Container myapp-dev                          Started
myapp-dev  | [nodemon] 3.0.2
myapp-dev  | [nodemon] watching path(s): src/**/*
myapp-dev  | Server running on port 3000 [development]
</code></pre></div></div>

<p>Test hot reload without rebuilding:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># In a second terminal</span>
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'s/"healthy"/"all-systems-go"/'</span> src/routes/health.js

<span class="c"># Watch the container log — nodemon restarts automatically</span>
<span class="c"># Then verify the change</span>
curl <span class="nt">-s</span> http://localhost:3000/health | <span class="nb">grep </span>status
<span class="c"># {"status":"all-systems-go",...}</span>

<span class="c"># Revert</span>
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'s/"all-systems-go"/"healthy"/'</span> src/routes/health.js
</code></pre></div></div>

<p>Stop and clean up:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose down <span class="nt">-v</span>   <span class="c"># -v also removes named volumes</span>
</code></pre></div></div>

<hr />

<h2 id="part-6-docker-scout-vulnerability-scan">Part 6: Docker Scout vulnerability scan</h2>

<p>Compare both images side by side:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Naive image — expect many CVEs</span>
<span class="nb">echo</span> <span class="s2">"=== naive image ==="</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
docker scout cves myapp:naive <span class="nt">--only-severity</span> critical,high <span class="nt">--format</span> table 2&gt;/dev/null | <span class="nb">head</span> <span class="nt">-30</span>

<span class="nb">echo</span> <span class="s2">""</span>

<span class="c"># Production image — expect zero</span>
<span class="nb">echo</span> <span class="s2">"=== production image ==="</span> <span class="o">&amp;&amp;</span> <span class="se">\</span>
docker scout cves myapp:production <span class="nt">--only-severity</span> critical,high 2&gt;/dev/null
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>=== naive image ===
  CRITICAL CVE-2023-38545  curl/libcurl4  ...
  HIGH     CVE-2024-0727   openssl        ...
  HIGH     CVE-2023-44487  nghttp2        ...
  ... (30+ vulnerabilities) ...

=== production image ===
    ✓ Analyzed image myapp:production
    No critical or high vulnerabilities found
</code></pre></div></div>

<p>Run a full comparison:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker scout compare myapp:production <span class="nt">--to</span> myapp:naive
</code></pre></div></div>

<p>Expected output:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Comparing myapp:production to myapp:naive

Image reference: myapp:production

Changes:
  ✓ Critical vulnerabilities:  0  (-3)
  ✓ High vulnerabilities:      0  (-12)
  ✓ Medium vulnerabilities:    0  (-18)
  ✓ Low vulnerabilities:       0  (-14)
  ✓ Image size:                47.2 MB  (-1.17 GB)
</code></pre></div></div>

<hr />

<h2 id="part-7-the-reusable-template">Part 7: The reusable template</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> docker-template
<span class="nb">cp </span>Dockerfile docker-template/
<span class="nb">cp</span> .dockerignore docker-template/
<span class="nb">cp </span>docker-compose.yml docker-template/
<span class="nb">cp </span>docker-compose.prod.yml docker-template/
<span class="nb">cp</span> .env.example docker-template/

<span class="nb">cat</span> <span class="o">&gt;</span> docker-template/bootstrap.sh <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">SCRIPT</span><span class="sh">'
#!/bin/bash
set -euo pipefail

APP="</span><span class="k">${</span><span class="nv">1</span><span class="k">:-</span><span class="nv">myapp</span><span class="k">}</span><span class="sh">"

echo "Bootstrapping Docker setup for: </span><span class="nv">$APP</span><span class="sh">"

# Preflight checks
command -v docker  &gt;/dev/null || { echo "Docker not found"; exit 1; }
docker info &amp;&gt;/dev/null      || { echo "Docker daemon not running"; exit 1; }

# Create .env from example if missing
[ ! -f .env ] &amp;&amp; [ -f .env.example ] &amp;&amp; cp .env.example .env &amp;&amp; </span><span class="se">\</span><span class="sh">
  echo "Created .env from .env.example — update before production use"

echo "Building dev image..."
DOCKER_BUILDKIT=1 docker build --target deps --tag "</span><span class="k">${</span><span class="nv">APP</span><span class="k">}</span><span class="sh">:dev" .

echo "Building production image..."
DOCKER_BUILDKIT=1 docker build --target production --tag "</span><span class="k">${</span><span class="nv">APP</span><span class="k">}</span><span class="sh">:production" .

echo ""
echo "=== Image sizes ==="
docker images | grep "</span><span class="nv">$APP</span><span class="sh">"

echo ""
echo "Ready."
echo "  Development:   docker compose up"
echo "  Production:    docker compose -f docker-compose.prod.yml up"
echo "  Scan:          docker scout cves </span><span class="k">${</span><span class="nv">APP</span><span class="k">}</span><span class="sh">:production"
</span><span class="no">SCRIPT

</span><span class="nb">chmod</span> +x docker-template/bootstrap.sh
</code></pre></div></div>

<hr />

<h2 id="how-it-works-under-the-hood">How it works under the hood</h2>

<h3 id="why-multi-stage-builds-achieve-such-dramatic-size-reduction">Why multi-stage builds achieve such dramatic size reduction</h3>

<p>Docker images are a stack of layers. Every instruction adds a layer — and layers can only be added, never removed, in a single-stage build. Even if you run <code class="language-plaintext highlighter-rouge">RUN rm -rf /build-tools</code> after installing them, those files still exist in the lower layer; the removal just adds a new layer that marks them as deleted. The final image carries every byte from every layer.</p>

<p>Multi-stage builds escape this entirely. The <code class="language-plaintext highlighter-rouge">deps</code> stage can install gcc, python, and every native build tool it needs to compile npm packages. The <code class="language-plaintext highlighter-rouge">production</code> stage uses <code class="language-plaintext highlighter-rouge">COPY --from=deps</code> to lift only the finished <code class="language-plaintext highlighter-rouge">node_modules</code> directory out. The build tools never touch the production stage.</p>

<h3 id="why-the-order-of-copy-matters-for-build-speed">Why the order of COPY matters for build speed</h3>

<p>Every layer is cached. When a layer changes, Docker rebuilds it and every layer after it. Copy order determines how often the expensive <code class="language-plaintext highlighter-rouge">npm ci</code> step gets skipped.</p>

<pre><code class="language-mermaid">%%{init: {'theme': 'dark'}}%%
flowchart LR
    CHANGE(["src/index.js\nchanged"]):::src

    subgraph WRONG ["❌  Slow — cache busted on every change"]
        W1["COPY . ."]:::bad --&gt; W2["RUN npm install\nre-runs every build"]:::bad --&gt; W3["Ready"]:::neutral
    end

    subgraph RIGHT ["✓  Fast — npm ci stays cached"]
        R1["COPY package*.json"]:::good --&gt; R2["RUN npm ci\ncached until deps change"]:::good --&gt; R3["COPY src/\nonly this re-runs"]:::good --&gt; R4["Ready"]:::neutral
    end

    CHANGE --&gt;|"+45 sec"| WRONG
    CHANGE --&gt;|"+2 sec"| RIGHT

    classDef src fill:#1a2744,stroke:#58a6ff,color:#e6edf3
    classDef bad fill:#2d1215,stroke:#f85149,color:#f85149
    classDef good fill:#0d2818,stroke:#3fb950,color:#3fb950
    classDef neutral fill:#1c2128,stroke:#30363d,color:#8b949e
</code></pre>

<p>Copy only <code class="language-plaintext highlighter-rouge">package.json</code> and <code class="language-plaintext highlighter-rouge">package-lock.json</code> first, run <code class="language-plaintext highlighter-rouge">npm ci</code>, then copy source. Now <code class="language-plaintext highlighter-rouge">npm ci</code> only reruns when your dependency manifest changes. Source code changes are a fast copy operation.</p>

<h3 id="why-distroless-instead-of-alpine">Why distroless instead of Alpine</h3>

<p>Alpine Linux (~5 MB) is the standard lightweight base. It’s a real improvement over <code class="language-plaintext highlighter-rouge">node:20</code> (1.1 GB). But Alpine still contains a shell (<code class="language-plaintext highlighter-rouge">ash</code>), a package manager (<code class="language-plaintext highlighter-rouge">apk</code>), and standard Unix utilities. If an attacker achieves remote code execution in your container, they have a complete Unix environment to work with.</p>

<p>Distroless contains none of that. The <code class="language-plaintext highlighter-rouge">gcr.io/distroless/nodejs20-debian12</code> image contains: the Debian C library, the Node.js binary, and nothing else. No bash. No sh. No curl. No wget. An attacker who gets RCE can only execute your Node.js binary — there are no other tools available. The attack surface reduction is measurable. The trade-off is debugging: <code class="language-plaintext highlighter-rouge">docker exec mycontainer bash</code> won’t work. Add a debug stage to your Dockerfile for development use.</p>

<h3 id="why-exec-form-cmd-matters-for-signal-handling">Why exec form CMD matters for signal handling</h3>

<p><code class="language-plaintext highlighter-rouge">CMD node src/index.js</code> is shell form. Docker wraps it as <code class="language-plaintext highlighter-rouge">/bin/sh -c node src/index.js</code>. Your Node process runs as a child of <code class="language-plaintext highlighter-rouge">/bin/sh</code>, not as PID 1. When Kubernetes or Docker sends <code class="language-plaintext highlighter-rouge">SIGTERM</code> to stop your container, it sends it to PID 1 — the shell. The shell may or may not forward the signal. Your graceful shutdown handler may never fire. Connections get dropped mid-request. Databases get dirty disconnects.</p>

<p><code class="language-plaintext highlighter-rouge">CMD ["/nodejs/bin/node", "src/index.js"]</code> is exec form. Node becomes PID 1 directly. SIGTERM reaches your <code class="language-plaintext highlighter-rouge">process.on('SIGTERM')</code> handler reliably. Graceful shutdown works as designed.</p>

<hr />

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

<h3 id="error-1-npm-ci-fails--missing-express4182">Error 1: <code class="language-plaintext highlighter-rouge">npm ci</code> fails — <code class="language-plaintext highlighter-rouge">missing: express@^4.18.2</code></h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm error missing: express@4.18.2, required by myapp@1.0.0
</code></pre></div></div>

<p><strong>Cause:</strong> <code class="language-plaintext highlighter-rouge">package-lock.json</code> is out of sync with <code class="language-plaintext highlighter-rouge">package.json</code>, or <code class="language-plaintext highlighter-rouge">package-lock.json</code> was not committed.</p>

<p><strong>Fix:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Regenerate the lockfile</span>
<span class="nb">rm </span>package-lock.json
npm <span class="nb">install
</span>git add package-lock.json
git commit <span class="nt">-m</span> <span class="s2">"chore: regenerate package-lock.json"</span>

<span class="c"># Now rebuild</span>
docker build <span class="nt">--target</span> production <span class="nt">-t</span> myapp:production <span class="nb">.</span>
</code></pre></div></div>

<hr />

<h3 id="error-2-container-exits-immediately--no-output">Error 2: Container exits immediately — no output</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run myapp:production
<span class="c"># (exits instantly, no logs)</span>
</code></pre></div></div>

<p><strong>Cause:</strong> Shell form CMD in a distroless image. There is no shell to parse it.</p>

<p><strong>Fix:</strong> Use exec form (array syntax) and the correct Node path for distroless:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Wrong — shell form, silently fails in distroless</span>
<span class="k">CMD</span><span class="s"> node src/index.js</span>

<span class="c"># Correct — exec form with distroless node path</span>
<span class="k">CMD</span><span class="s"> ["/nodejs/bin/node", "src/index.js"]</span>
</code></pre></div></div>

<p>Verify the correct node path in the distroless image:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--rm</span> gcr.io/distroless/nodejs20-debian12 <span class="se">\</span>
  /nodejs/bin/node <span class="nt">--version</span>
<span class="c"># v20.x.x</span>
</code></pre></div></div>

<hr />

<h3 id="error-3-copy---fromdeps-results-in-empty-node_modules">Error 3: <code class="language-plaintext highlighter-rouge">COPY --from=deps</code> results in empty node_modules</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error: Cannot find module 'express'
</code></pre></div></div>

<p><strong>Cause:</strong> WORKDIR differs between stages, or the path in <code class="language-plaintext highlighter-rouge">COPY --from</code> doesn’t match.</p>

<p><strong>Fix:</strong> Ensure WORKDIR is identical in all stages and COPY path is absolute:</p>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">node:20-alpine</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">deps</span>
<span class="k">WORKDIR</span><span class="s"> /app                          # ← must be /app</span>
<span class="k">RUN </span>npm ci <span class="nt">--omit</span><span class="o">=</span>dev

<span class="k">FROM</span><span class="w"> </span><span class="s">gcr.io/distroless/nodejs20-debian12</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">production</span>
<span class="k">WORKDIR</span><span class="s"> /app                          # ← same: /app</span>
<span class="k">COPY</span><span class="s"> --from=deps /app/node_modules ./node_modules   # ← full path from deps</span>
</code></pre></div></div>

<hr />

<h3 id="error-4-hot-reload-not-working-on-linux">Error 4: Hot reload not working on Linux</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[nodemon] watching: src/**/*
# (file changes have no effect)
</code></pre></div></div>

<p><strong>Cause:</strong> Docker on Linux doesn’t propagate <code class="language-plaintext highlighter-rouge">inotify</code> events into containers by default in some configurations.</p>

<p><strong>Fix:</strong> Switch nodemon to polling mode:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Update the command in docker-compose.yml</span>
<span class="nb">command</span>: <span class="o">[</span><span class="s2">"node_modules/.bin/nodemon"</span>, <span class="s2">"--legacy-watch"</span>, <span class="s2">"--watch"</span>, <span class="s2">"src"</span>, <span class="s2">"src/index.js"</span><span class="o">]</span>
</code></pre></div></div>

<p>Or add <code class="language-plaintext highlighter-rouge">nodemon.json</code> to the project root:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"watch"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"src"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"ext"</span><span class="p">:</span><span class="w"> </span><span class="s2">"js,json"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"legacy-watch"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"delay"</span><span class="p">:</span><span class="w"> </span><span class="mi">500</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<hr />

<h3 id="error-5-build-context-is-850-mb-despite-dockerignore">Error 5: Build context is 850 MB despite <code class="language-plaintext highlighter-rouge">.dockerignore</code></h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>=&gt; transferring context: 850.00MB
</code></pre></div></div>

<p><strong>Cause:</strong> <code class="language-plaintext highlighter-rouge">.dockerignore</code> is in the wrong location, not saved, or the <code class="language-plaintext highlighter-rouge">node_modules/</code> line has a typo.</p>

<p><strong>Fix:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Confirm .dockerignore exists in the build context directory</span>
<span class="nb">ls</span> <span class="nt">-la</span> .dockerignore

<span class="c"># Check the node_modules line is correct (no trailing space)</span>
<span class="nb">grep</span> <span class="s2">"node_modules"</span> .dockerignore
<span class="c"># node_modules/</span>

<span class="c"># Test: measure context size with a dry run</span>
<span class="nv">DOCKER_BUILDKIT</span><span class="o">=</span>1 docker build <span class="nt">--progress</span><span class="o">=</span>plain <span class="nt">--target</span> deps <span class="nb">.</span> 2&gt;&amp;1 <span class="se">\</span>
  | <span class="nb">grep</span> <span class="s2">"transferring context"</span>
<span class="c"># Should show &lt; 100kB</span>
</code></pre></div></div>

<hr />

<h3 id="error-6-docker-scout-permission-denied">Error 6: <code class="language-plaintext highlighter-rouge">docker scout</code> permission denied</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error response from daemon: permission denied while trying to connect
</code></pre></div></div>

<p><strong>Cause:</strong> User not in the <code class="language-plaintext highlighter-rouge">docker</code> group, or group change not applied to current session.</p>

<p><strong>Fix:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker <span class="nv">$USER</span>
newgrp docker

<span class="c"># Verify</span>
<span class="nb">groups</span> | <span class="nb">grep </span>docker

<span class="c"># Test</span>
docker scout version
</code></pre></div></div>

<hr />

<h2 id="whats-next--extend-this-project">What’s next — extend this project</h2>

<ol>
  <li>
    <p><strong>Wire this into a full CI pipeline.</strong> Day 4 of this series builds a GitHub Actions workflow that builds your Docker image, runs the test stage as a CI gate, scans with Docker Scout, and pushes to GitHub Container Registry — automatically on every PR.</p>
  </li>
  <li>
    <p><strong>Add multi-platform builds for ARM.</strong> AWS Graviton2/3 instances and Apple Silicon both run ARM64. Build once for both architectures:</p>
  </li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker buildx create <span class="nt">--name</span> multibuilder <span class="nt">--use</span>
docker buildx build <span class="se">\</span>
  <span class="nt">--platform</span> linux/amd64,linux/arm64 <span class="se">\</span>
  <span class="nt">--target</span> production <span class="se">\</span>
  <span class="nt">--tag</span> ghcr.io/syssignals/myapp:latest <span class="se">\</span>
  <span class="nt">--push</span> <span class="se">\</span>
  <span class="nb">.</span>
</code></pre></div></div>

<ol>
  <li><strong>Add a debug target.</strong> Distroless has no shell, which is the point — but debugging a production issue is hard without one. Add a debug stage that includes busybox:</li>
</ol>

<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="w"> </span><span class="s">gcr.io/distroless/nodejs20-debian12:debug</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">debug</span>
<span class="k">WORKDIR</span><span class="s"> /app</span>
<span class="k">COPY</span><span class="s"> --from=deps /app/node_modules ./node_modules</span>
<span class="k">COPY</span><span class="s"> src/ ./src/</span>
<span class="k">COPY</span><span class="s"> package.json ./</span>
<span class="k">CMD</span><span class="s"> ["/nodejs/bin/node", "src/index.js"]</span>
</code></pre></div></div>

<p>Build with <code class="language-plaintext highlighter-rouge">--target debug</code> when you need a shell for investigation. Never ship this tag.</p>

<hr />

<h2 id="day-2-recap">Day 2 recap</h2>

<p>You now have:</p>

<ul>
  <li>A working multi-stage Dockerfile that reduces image size by 96% (1.21 GB → 47 MB)</li>
  <li>A distroless production image with zero critical or high CVEs</li>
  <li>A non-root runtime (uid 65532) that can’t escalate privileges</li>
  <li>A <code class="language-plaintext highlighter-rouge">.dockerignore</code> that reduces build context from 180 MB to 38 KB</li>
  <li>A Docker Compose dev setup with hot reload working in under 2 seconds</li>
  <li>A production Compose override with read-only filesystem and dropped capabilities</li>
  <li>Scan output proving the security improvement with Docker Scout</li>
</ul>

<p>The difference between a working Docker image and a production-grade one is everything you leave out.</p>

<hr />

<h2 id="day-3-preview">Day 3 preview</h2>

<p><strong>Day 3: Docker Compose for a full local dev environment</strong></p>

<p>Full local dev environment: Node.js app + PostgreSQL + Redis + Nginx reverse proxy. Health checks, named volumes, environment variable injection, and service dependency ordering. The <code class="language-plaintext highlighter-rouge">docker compose up</code> your team actually deserves.</p>

<hr />

<p><em>This is Day 2 of the <a href="https://x.com/syssignals">30 Days of DevOps</a> series.</em><br />
<em>Follow <a href="https://x.com/syssignals">@syssignals</a> on X — Day 3 drops tomorrow.</em><br />
<em>Found a command that doesn’t work? Reply on X with your OS and Docker version.</em></p>]]></content><author><name>Vishwas Sharma</name></author><category term="Docker" /><category term="docker" /><category term="containers" /><category term="dockerfile" /><category term="multi-stage-builds" /><category term="docker-compose" /><category term="docker-scout" /><category term="security" /><category term="devops" /><summary type="html"><![CDATA[Take a 1.2 GB naive image down to 47 MB with multi-stage builds, a distroless base, and a non-root runtime — and prove the security improvement with Docker Scout.]]></summary></entry></feed>