<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>GitHub Actions | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/github-actions/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/github-actions/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 18 Oct 2026 06:03:01 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>GitHub Actions | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/github-actions/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Dockerfile Layer Caching: Why Every Build Reinstalls Everything</title>
		<link>https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 18 Oct 2026 06:03:01 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Build Cache]]></category>
		<category><![CDATA[BuildKit]]></category>
		<category><![CDATA[Cache Mounts]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Dockerfile]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[Layer Caching]]></category>
		<category><![CDATA[Multi-Stage Builds]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=409</guid>

					<description><![CDATA[<p>Your Dockerfile copies the lockfile before the source, and CI still reinstalls every dependency on every commit. That gap between a correctly ordered Dockerfile and a build that behaves as if it isn't comes down to five distinct failure families: ordering traps, a build context that poisons checksums, a cache that never reaches CI, cache mounts that vanish with the runner, and multi-stage export modes. Here is how invalidation actually works and how to prove which one is costing you.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/">Dockerfile Layer Caching: Why Every Build Reinstalls Everything</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The pipeline log scrolls past and there it is again. The dependency install step, running in full, on a commit that touched one template file. Nobody edited the lockfile. The Dockerfile copies the lockfile before the source, exactly the way every guide says to. And it still reinstalls everything, every single time.</p>



<p class="wp-block-paragraph">That gap, between a Dockerfile that looks correctly ordered and a build that behaves as if it isn&#8217;t, is where most Dockerfile layer caching problems live. The ordering advice everyone repeats is real, but it&#8217;s maybe a third of the story. The rest is about what actually invalidates a layer, where the cache physically lives, and whether the machine running your build can reach it at all.</p>



<p class="wp-block-paragraph">So this is organized by failure family rather than by feature. Ordering that looks right and isn&#8217;t. A build context that quietly poisons your checksums. A cache that exists but is unreachable from CI. Cache mounts that promise more than they deliver. Multi-stage layouts and the <code>COPY --link</code> argument. Then how to prove which one you&#8217;re hitting instead of guessing.</p>



<h2 class="wp-block-heading">How the builder actually decides to reuse a layer</h2>



<p class="wp-block-paragraph">Two different rules are running, and mixing them up causes half the confusion.</p>



<p class="wp-block-paragraph">For most instructions, the builder compares the instruction string against the cached layer. That is the entire check. It does not look inside the container to see what changed. <code>RUN apt-get update</code> matches a cached layer whenever the text matches, regardless of what the package mirrors are serving today.</p>



<p class="wp-block-paragraph">For <code>COPY</code>, <code>ADD</code>, and <code>RUN</code> instructions that use a bind mount, the builder computes a cache checksum from the metadata of the files involved. Modification time is deliberately excluded, which is why a fresh <code>git clone</code> on a CI runner doesn&#8217;t invalidate every copy by itself.</p>



<p class="wp-block-paragraph">Then the rule that ties it together: once a layer misses, every layer after it rebuilds. No skipping ahead, no partial reuse. One bad instruction near the top costs you the whole tail of the file.</p>



<p class="wp-block-paragraph">Three consequences catch people out. A <code>RUN</code> line that installs OS packages caches indefinitely, so rebuilding months later reproduces the original package versions and &#8220;just rebuild the image&#8221; is not a security response on its own. Build arguments do invalidate the cache, so an <code>ARG</code> carrying a commit SHA near the top of the file is expensive. And build secrets do <em>not</em>: their contents are excluded from the checksum, though their IDs and mount paths are part of it.</p>



<p class="wp-block-paragraph">One more that bites teams chasing reproducible builds. <code>WORKDIR</code> respects the <code>SOURCE_DATE_EPOCH</code> build argument. Set it to a git commit timestamp for provenance and you invalidate <code>WORKDIR</code> and everything below it on every single commit. Pin it to a fixed value if you want deterministic timestamps without paying that.</p>



<h2 class="wp-block-heading">Failure family one: ordering that looks right</h2>



<p class="wp-block-paragraph">The canonical fix is to split the copy so dependency installation sits above the source code. Cheap to write, and it works:</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM node:22-bookworm-slim
WORKDIR /app

# Manifest and lockfile only. Changes rarely.
COPY package.json package-lock.json ./
RUN npm ci

# Source. Changes constantly. Everything below rebuilds, and that is fine.
COPY . .
RUN npm run build</code></pre>



<p class="wp-block-paragraph">What breaks this in real repositories is usually not the copy order. It&#8217;s something above it that nobody thinks of as a layer.</p>



<ul class="wp-block-list">
<li><strong>An <code>ARG</code> near the top.</strong> A build arg whose value changes per build invalidates everything that follows. Declare args as low in the file as the build allows.</li>



<li><strong>Labels carrying commit metadata.</strong> <code>LABEL</code> lines that embed a revision are cheap to run but they are still layers. Put them at the very bottom.</li>



<li><strong>Wildcard copies that pick up more than you think.</strong> <code>COPY package*.json ./</code> looks tidy until a generated file starts matching the glob. Name the files explicitly.</li>



<li><strong>Monorepo manifests scattered across packages.</strong> If installing needs twelve manifest files from twelve directories, copy them individually or accept you&#8217;re copying a subtree and lean hard on <code>.dockerignore</code>.</li>
</ul>



<p class="wp-block-paragraph">The general test: walk the file top to bottom and ask, for each line, &#8220;what makes this change?&#8221; Anything answering &#8220;every commit&#8221; belongs below anything expensive.</p>



<h2 class="wp-block-heading">Failure family two: the build context poisons the checksum</h2>



<p class="wp-block-paragraph">When you run <code>COPY . .</code>, the checksum covers everything in the context that isn&#8217;t excluded. That means <code>.git</code>, <code>node_modules</code>, coverage output, local <code>.env</code> files, and whatever your last test run left behind. A colleague running the test suite before pushing is enough to change the checksum and rebuild the tail of your image.</p>



<p class="wp-block-paragraph">A <code>.dockerignore</code> file in the root of the context fixes it, and the rules apply to subdirectories too:</p>



<pre class="wp-block-code"><code>.git
node_modules
coverage
dist
*.log
.env
.env.*
Dockerfile
.dockerignore</code></pre>



<p class="wp-block-paragraph">This pays twice. Smaller contexts transfer faster to the builder, and unrelated local files stop ending up inside your published image. If you have ever found a <code>.git</code> directory or a stray credentials file in a production image, this is the line of defense that was missing.</p>



<p class="wp-block-paragraph">There&#8217;s a second option when the source is only needed to produce an artifact and shouldn&#8217;t live in the image at all. A bind mount makes the context available for the duration of one <code>RUN</code> without adding it to any layer:</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM golang:1-bookworm
WORKDIR /build
RUN --mount=type=bind,target=. go build -o /app/server ./cmd/server</code></pre>



<p class="wp-block-paragraph">Two rules keep this from surprising you. The mount is read-only by default, so the output has to be written outside the mount target, which is why <code>/app/server</code> sits outside <code>/build</code>. And mounted files are not persisted into the final image or the build cache. Only what the <code>RUN</code> produced survives.</p>



<h2 class="wp-block-heading">Failure family three: Dockerfile layer caching that never reaches CI</h2>



<p class="wp-block-paragraph">This is the invisible one, and it&#8217;s behind most &#8220;my Dockerfile is ordered correctly and CI is still slow&#8221; tickets.</p>



<p class="wp-block-paragraph">The build cache is internal to the builder instance. Not to your account, not to your registry. To the builder. Switch builders and the cache doesn&#8217;t follow. Start a fresh ephemeral runner and there is no cache at all, so every instruction runs from scratch no matter how beautifully the file is ordered. Nothing warns you. The build just takes as long as a cold build, because it is one.</p>



<p class="wp-block-paragraph">The fix is an external cache. <code>--cache-to</code> exports the cache somewhere durable, <code>--cache-from</code> imports it at the start of the next build.</p>



<pre class="wp-block-code"><code>docker buildx build 
  --cache-from type=registry,ref=registry.example.com/app:buildcache 
  --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max 
  --tag registry.example.com/app:latest 
  --push .</code></pre>



<p class="wp-block-paragraph">Four details decide whether this works or quietly does nothing:</p>



<ul class="wp-block-list">
<li><strong><code>mode=max</code> versus the default.</strong> The default exports only the layers of the resulting image. In a multi-stage build the expensive work happens in a stage that never ships, so the default exports almost nothing useful. <code>mode=max</code> includes intermediate stages. It costs storage and upload time, and it is usually the right trade.</li>



<li><strong>Use a separate ref for the cache.</strong> A different tag or repository, anything that isn&#8217;t the tag you push the image to.</li>



<li><strong>Registry compatibility.</strong> The registry cache defaults to writing an image index and not every registry accepts one, with Amazon ECR the usual casualty. Setting <code>image-manifest=true</code> alongside <code>oci-mediatypes=true</code> writes a single image manifest instead.</li>



<li><strong>The default driver is limited.</strong> The plain <code>docker</code> driver supports the inline, local, registry and GitHub Actions backends only when the containerd image store is enabled. Otherwise create a builder with the docker-container driver first.</li>
</ul>



<p class="wp-block-paragraph">On GitHub Actions the equivalent lives in the build action:</p>



<pre class="wp-block-code"><code>- uses: docker/setup-buildx-action@v4

- uses: docker/build-push-action@v7
  with:
    push: true
    tags: ghcr.io/acme/app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max</code></pre>



<p class="wp-block-paragraph">The GitHub Actions backend is convenient because it needs no credentials, but it draws from a per-repository storage budget shared with every other cache in that repo, and eviction is silent. A build that was fast last week can go cold with nothing changed in your Dockerfile. When that starts happening, move to a registry backend, or to an S3 or Azure Blob backend if you&#8217;d rather pay object storage rates than registry rates.</p>



<h2 class="wp-block-heading">Failure family four: cache mounts that don&#8217;t survive the job</h2>



<p class="wp-block-paragraph">Layer caching is all or nothing per instruction. Add one dependency, the install layer misses, and you re-download the four hundred packages you already had. Cache mounts fix exactly that: a directory that persists across builds even when the layer around it rebuilds.</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM python:3-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip 
    pip install -r requirements.txt</code></pre>



<p class="wp-block-paragraph">Point the target at whatever directory that tool uses for its own cache. Go wants its module and build caches, Rust the Cargo directories, .NET the NuGet package folder. Apt is the one needing care, because it expects exclusive access to its data:</p>



<pre class="wp-block-code"><code>RUN --mount=type=cache,target=/var/cache/apt,sharing=locked 
    --mount=type=cache,target=/var/lib/apt,sharing=locked 
    apt-get update &amp;&amp; apt-get install --no-install-recommends -y gcc</code></pre>



<p class="wp-block-paragraph">Without <code>sharing=locked</code>, parallel builds write to the same apt cache simultaneously and you get an inconsistent package database, intermittently, which is a genuinely unpleasant thing to debug.</p>



<p class="wp-block-paragraph">Now the part that isn&#8217;t in most tutorials. <strong>Cache mounts live on the builder&#8217;s local storage and they are not exported by <code>--cache-to</code>.</strong> The external cache carries layers. It does not carry cache mount contents. On a fleet of ephemeral CI runners, every cache mount you add is a no-op: a fresh runner starts with an empty mount, fills it during the build, then throws the machine away.</p>



<p class="wp-block-paragraph">They&#8217;re still worth adding for local development, where they pay for themselves immediately. Making them pay off in CI needs a builder with persistent storage behind it: a self-hosted runner that keeps its builder between jobs, a long-lived buildkitd on a VPS from somewhere like Contabo or InterServer reached over the remote driver, the Kubernetes driver with a volume attached to the builder pods, or a managed build service such as Docker Build Cloud or Depot that holds persistent cache volumes for you.</p>



<p class="wp-block-paragraph">One hard rule regardless: never put something the image needs into a cache mount. Installing into <code>/app/node_modules</code> behind a cache mount produces an image that works on your machine and has no dependencies in production, because the mount contents are discarded when the instruction finishes. Cache the package manager&#8217;s download directory, not its output.</p>



<h2 class="wp-block-heading">Failure family five: multi-stage builds and the COPY &#8211;link argument</h2>



<p class="wp-block-paragraph">Multi-stage builds help caching for a reason that has nothing to do with image size. They let you put expensive, rarely-changing work in a stage whose inputs are stable, and copy only the artifact forward.</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]</code></pre>



<p class="wp-block-paragraph">The catch wastes an afternoon if you hit it cold: export cache in the default mode and the <code>deps</code> stage isn&#8217;t in the export, because it isn&#8217;t part of the final image. Multi-stage plus external cache means <code>mode=max</code>, or the whole structure buys you nothing in CI.</p>



<p class="wp-block-paragraph">Then there&#8217;s <code>COPY --link</code>, which comes up in every thread on this topic and deserves an honest treatment rather than a recommendation. The case for it is real: a linked copy creates an independent snapshot layer that doesn&#8217;t depend on the filesystem beneath it, then merges on top. The layer can be reused with <code>--cache-from</code> even when earlier instructions in the same stage changed, and it enables rebasing an image onto an updated base without re-running the build.</p>



<p class="wp-block-paragraph">The case against is also real, and it&#8217;s mostly semantics you didn&#8217;t ask to change. Linked layers start from an empty filesystem, so a destination that resolved through a symlink created in an earlier layer no longer behaves the same, and ambiguous destinations resolve differently. Use explicit trailing slashes for directories. There are also reports of cache accounting misbehaving with linked layers, and a long-standing open report that the cache behavior doesn&#8217;t match the announcement.</p>



<p class="wp-block-paragraph">Where I land: worth trying on artifact copies into a final stage, where destinations are plain directories and the win is largest. I wouldn&#8217;t blanket-apply it across a repository on the strength of a blog post, mine included. Measure a warm rebuild before and after. If the numbers don&#8217;t move, you&#8217;ve added a semantic footgun for nothing.</p>



<h2 class="wp-block-heading">Troubleshooting: proving which layer missed and why</h2>



<p class="wp-block-paragraph">Stop guessing. The build output tells you, if you ask for the plain progress format:</p>



<pre class="wp-block-code"><code>docker buildx build --progress=plain --tag app:dev .</code></pre>



<ol class="wp-block-list">
<li><strong>Find the first step not marked CACHED.</strong> Everything after it is guaranteed to rebuild, so everything after it is noise. There is exactly one problem and it is that step.</li>



<li><strong>If it&#8217;s a COPY or ADD, the inputs changed.</strong> List what the context actually contains, compare against <code>.dockerignore</code>, and look for generated files or lockfiles being rewritten by a pre-build script.</li>



<li><strong>If it&#8217;s a RUN, something above it changed.</strong> Scroll up. A build arg, a label, a base image tag that moved, or <code>SOURCE_DATE_EPOCH</code> set from a commit timestamp.</li>



<li><strong>If the earliest steps miss in CI but not locally,</strong> the import didn&#8217;t happen. Check the log for the cache manifest being imported. A missing or unreadable cache ref usually isn&#8217;t a hard failure, the build just proceeds cold.</li>



<li><strong>If it&#8217;s intermittent, suspect eviction.</strong> Garbage collection or a cache storage budget removed what you were relying on.</li>
</ol>



<p class="wp-block-paragraph">Two commands earn their place here. The first shows what the builder is currently holding, the second clears it so you can measure a genuine cold build:</p>



<pre class="wp-block-code"><code>docker buildx du
docker buildx prune --all</code></pre>



<p class="wp-block-paragraph">And when you want one stage to run fresh while keeping the rest cached, target it by name instead of nuking everything:</p>



<pre class="wp-block-code"><code>docker build --no-cache-filter deps --tag app:dev .</code></pre>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Assuming the Dockerfile is the whole problem.</strong> If CI has no external cache configured, no amount of reordering helps. Check that first.</li>



<li><strong>Adding cache mounts to fix CI.</strong> Great locally, invisible in the pipeline unless the builder persists.</li>



<li><strong>Exporting cache in the default mode with a multi-stage build.</strong> The expensive stage isn&#8217;t in the export. This is the most common reason external caching looks configured and doesn&#8217;t help.</li>



<li><strong>Squashing everything into one giant RUN.</strong> Fewer layers is a size optimization, not a caching one. Collapsing distinct steps means any change reruns all of them.</li>



<li><strong>Treating a cached rebuild as a patched rebuild.</strong> A cached package install gives you the versions from the original build. If you rebuilt for a CVE, you need to break that cache on purpose.</li>



<li><strong>Using <code>latest</code> as a base tag.</strong> The digest moves under you, invalidating everything below unpredictably and making builds non-reproducible at the same time.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li>Order instructions by how often their inputs change, not by what feels logical. Stable and expensive at the top, volatile and cheap at the bottom.</li>



<li>Write the <code>.dockerignore</code> before the Dockerfile. Thirty seconds of work that prevents both cache churn and accidental secret inclusion.</li>



<li>Configure an external cache in CI on day one, with <code>mode=max</code> if the build has more than one stage.</li>



<li>Add cache mounts for every package manager in the build. One line each, and the difference between &#8220;added one dependency&#8221; and &#8220;re-downloaded the world&#8221;.</li>



<li>Track warm build duration as a metric rather than a vibe. A Grafana panel showing build time per branch turns &#8220;the build feels slow&#8221; into something you can point at.</li>



<li>Keep a scheduled no-cache build in the pipeline. It proves the Dockerfile still works from scratch and picks up base image updates that cached builds silently skip.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Why does my Docker build ignore the cache even though nothing changed?</h3>



<p class="wp-block-paragraph">Usually because something did change and it wasn&#8217;t the file you were watching. Build with <code>--progress=plain</code> and find the first step not marked CACHED. If it&#8217;s a COPY, a gap in <code>.dockerignore</code> is letting local artifacts into the context. If it&#8217;s early in the file, check build args, labels carrying commit metadata, or a base image tag that moved. In CI, the usual answer is that there was no cache to hit at all.</p>



<h3 class="wp-block-heading">Do fewer layers make Dockerfile layer caching better?</h3>



<p class="wp-block-paragraph">No, and the advice is often applied backwards. Combining commands into one <code>RUN</code> reduces image size and stops intermediate junk being committed into separate layers. It also means any change reruns all of it. Combine steps that genuinely belong together, like an update-and-install pair, and keep independent work in separate instructions.</p>



<h3 class="wp-block-heading">Do cache mounts work in GitHub Actions?</h3>



<p class="wp-block-paragraph">Not usefully on the default hosted runners. Cache mounts live on the builder&#8217;s local storage and are not exported by <code>--cache-to</code>, so an ephemeral runner starts with an empty mount every time. You need a builder with persistent storage: a self-hosted runner keeping its builder between jobs, a remote buildkitd on your own host, the Kubernetes driver with a volume, or a managed build service.</p>



<h3 class="wp-block-heading">Registry cache backend or the GitHub Actions one?</h3>



<p class="wp-block-paragraph">Start with the GitHub Actions backend because it needs no credentials and no extra infrastructure. Move to a registry backend when you outgrow the repository cache budget, when you want the cache shared across repositories or with local developer builds, or when silent evictions make build times unpredictable. Registry caching costs a round trip and some storage in exchange for being yours.</p>



<h3 class="wp-block-heading">Does changing a build secret invalidate the cache?</h3>



<p class="wp-block-paragraph">No. Secret contents are deliberately excluded from the cache checksum, so rotating a token leaves every layer valid. The secret&#8217;s ID and mount path do participate, so renaming one invalidates. If a changed secret needs to force a rebuild, pair it with a build argument you also change, since build args do invalidate.</p>



<h3 class="wp-block-heading">Why does a cached image still contain old, vulnerable packages?</h3>



<p class="wp-block-paragraph">Because a <code>RUN</code> instruction is matched on its text, not on what it would produce today. The package install layer stays valid indefinitely, so rebuilding without breaking that cache reproduces the original package set. Force it with <code>--no-cache</code>, target the relevant stage with <code>--no-cache-filter</code>, or move the base image tag so the layers below invalidate.</p>



<h3 class="wp-block-heading">Is COPY &#8211;link safe to use everywhere?</h3>



<p class="wp-block-paragraph">Not everywhere. It changes destination semantics because linked layers start from an empty filesystem, so anything relying on symlinks created by earlier layers can break and ambiguous destinations resolve differently. It&#8217;s most defensible on artifact copies into a final stage with explicit directory paths. Try it, measure a warm rebuild, keep it only if the numbers justify the change.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Dockerfile layer caching fails in two categories that need completely different fixes. Either a layer is being invalidated when it shouldn&#8217;t be, which is a Dockerfile and build context problem, or the cache was never there to begin with, which is a builder and pipeline problem. Reordering instructions solves the first and does nothing at all for the second.</p>



<p class="wp-block-paragraph">So before you spend an afternoon rearranging COPY lines, run one build with plain progress output and find the first step that isn&#8217;t CACHED. If it&#8217;s step one, your problem isn&#8217;t the Dockerfile. It&#8217;s that the machine running the build has never seen your cache and nobody told it where to look.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your build pipeline?</h2>



<p class="wp-block-paragraph">Slow container builds are usually three or four small problems stacked, not one big one, and they unpick quickly once you can see the whole pipeline. I help teams with:</p>



<ul class="wp-block-list">
<li>Auditing a Dockerfile end to end for invalidation traps, oversized build contexts and stages that never cache</li>



<li>Setting up external cache backends in GitHub Actions, GitLab CI or Jenkins, including registry and object storage backends and the awkward ECR manifest cases</li>



<li>Standing up a persistent remote buildkitd on your own infrastructure so cache mounts actually pay off in CI</li>



<li>Restructuring multi-stage builds so the expensive stages are cacheable and the runtime image stays small</li>



<li>Adding build duration and cache hit visibility to your existing dashboards so regressions get caught early</li>



<li>Separating &#8220;rebuild for a code change&#8221; from &#8220;rebuild for a security patch&#8221; so cached layers stop hiding unpatched packages</li>
</ul>



<p class="wp-block-paragraph">Send me your Dockerfile and a build log with <code>--progress=plain</code> output, and I&#8217;ll tell you which step is costing you and why.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/">Dockerfile Layer Caching: Why Every Build Reinstalls Everything</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>CI/CD Pipeline Bottlenecks: Where Your Build Minutes Actually Go</title>
		<link>https://john-nessime.com/blog/devops/ci-cd-pipeline-bottlenecks/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 01 Oct 2026 06:02:12 +0000</pubDate>
				<category><![CDATA[Continuous Delivery]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Actions Runner Controller]]></category>
		<category><![CDATA[Build Cache]]></category>
		<category><![CDATA[BuildKit]]></category>
		<category><![CDATA[Cache Mounts]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Engineering Metrics]]></category>
		<category><![CDATA[Flaky Tests]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[GitLab CI]]></category>
		<category><![CDATA[Multi-Stage Builds]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<category><![CDATA[Queue Time]]></category>
		<category><![CDATA[Required Status Checks]]></category>
		<category><![CDATA[Test Parallelization]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=706</guid>

					<description><![CDATA[<p>The build duration graph looks fine and shipping is still slow. Most CI/CD pipeline bottlenecks are waiting, not working: queue time, caches that report a hit and deliver nothing, stage ordering nobody revisited, and required checks stuck at pending. Here is how to find yours.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/ci-cd-pipeline-bottlenecks/">CI/CD Pipeline Bottlenecks: Where Your Build Minutes Actually Go</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The pipeline takes eleven minutes. The pull request took two days to merge.</p>



<p class="wp-block-paragraph">That gap is where the real cost lives, and it is almost never on anyone&#8217;s dashboard. The build duration graph looks fine. The team still says shipping is slow. Somebody proposes buying bigger runners, and six weeks later the graph has moved a little and the complaints are identical.</p>



<p class="wp-block-paragraph">Most CI/CD pipeline bottlenecks are not the thing people go looking for. Compute is usually the smallest slice. The expensive parts are minutes spent waiting for a runner, a cache that reports a hit and delivers nothing, a stage blocking four jobs that do not depend on it, and a required check sitting at &#8220;Pending&#8221; until someone notices. This post is organized by those failure families rather than by tool. Examples use GitHub Actions and GitLab CI, but the shapes apply to Jenkins, Buildkite, CircleCI and Argo Workflows too.</p>



<h2 class="wp-block-heading">First, split the number you are staring at</h2>



<p class="wp-block-paragraph">&#8220;The build takes eleven minutes&#8221; is not a measurement. It is three measurements added together and then thrown away:</p>



<ul class="wp-block-list">
<li><strong>Queue time.</strong> From job creation to a runner picking it up. You pay this in wall clock, not compute.</li>

<li><strong>Compute time.</strong> The job actually running. The only part most dashboards show.</li>

<li><strong>Gate time.</strong> Waiting on a human, an approval, a shared environment, or a check that never fired.</li>
</ul>



<p class="wp-block-paragraph">Until you split those, every optimization is a guess. On GitHub Actions the jobs API hands you the split directly, because each job carries a creation timestamp and a start timestamp:</p>



<pre class="wp-block-code"><code>gh api repos/OWNER/REPO/actions/runs/RUN_ID/jobs 
  --jq '.jobs[] | {name, created_at, started_at, completed_at}'</code></pre>



<p class="wp-block-paragraph">Creation to start is queue. Start to completion is compute. Run that across your last fifty runs on the default branch and the answer is usually sitting there in plain sight. GitLab reports the same idea on its jobs API, with a queued duration alongside the run duration. If the two numbers are roughly equal, you have a capacity problem rather than a build problem, and faster CPUs will fix half of nothing.</p>



<h2 class="wp-block-heading">Queue time: the minutes you pay for twice</h2>



<p class="wp-block-paragraph">Queue time has two causes and they need different fixes.</p>



<h3 class="wp-block-heading">Not enough runners</h3>



<p class="wp-block-paragraph">On hosted runners you are bounded by the concurrency your plan allows. On self-hosted you are bounded by whatever fleet you provisioned, which for most small teams is a fixed number of always-on boxes that sit idle overnight and saturate at 4pm.</p>



<p class="wp-block-paragraph">If you run your own fleet on Kubernetes, Actions Runner Controller is where most people land. It creates runner scale sets that scale on workflow demand and can drop to zero when the queue is empty, with ephemeral pods so every job starts clean. That last part matters more than the scaling: a persistent runner accumulates state, and state is where &#8220;works on my machine&#8221; comes back as a CI flake.</p>



<p class="wp-block-paragraph">If Kubernetes is more machinery than you want, a couple of dedicated boxes from a provider like Contabo or InterServer running the runner agent under systemd gets you a long way, with hosted runners as overflow when the queue backs up. Managed runner services such as Depot and BuildJet sell faster hardware behind the same interface. Their honest pitch is that you stop maintaining a fleet. The honest counterpoint is another vendor in the path of every merge, and a bill that scales with commit volume.</p>



<h3 class="wp-block-heading">Runners busy on work that is already obsolete</h3>



<p class="wp-block-paragraph">Check this one first because it is free. Push three commits to a branch in five minutes and by default you get three pipelines. The first two are answering a question nobody is asking anymore, and they are holding the runners the third one needs.</p>



<p class="wp-block-paragraph">On GitHub Actions, a concurrency group keyed on the ref does it:</p>



<pre class="wp-block-code"><code>concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true</code></pre>



<p class="wp-block-paragraph">Put that at workflow level on PR builds. Do not put it on a deploy workflow with cancellation enabled, or you will eventually kill a release halfway through and spend an afternoon working out what actually shipped.</p>



<p class="wp-block-paragraph">GitLab splits the same job across two settings that have to agree. The <code>interruptible</code> keyword marks a job as safe to kill and defaults to false. The workflow-level auto-cancel setting decides what happens on a new commit:</p>



<pre class="wp-block-code"><code>workflow:
  auto_cancel:
    on_new_commit: interruptible

test:
  interruptible: true
  script:
    - make test

deploy:
  interruptible: false
  script:
    - make deploy</code></pre>



<p class="wp-block-paragraph">The default mode is conservative, which means once any non-interruptible job has started, the whole pipeline stops being cancelable. Setting it to <code>interruptible</code> cancels only the jobs that opted in and leaves the rest running. That distinction is the difference between a policy that works and one that quietly does nothing, and it is the usual reason teams turn auto-cancel on and see no change.</p>



<h2 class="wp-block-heading">Caches that look like they are working</h2>



<p class="wp-block-paragraph">My favorite family of CI/CD pipeline bottlenecks, because the failure is completely silent. The step is green. The log says the cache was restored. The install takes exactly as long as before.</p>



<h3 class="wp-block-heading">Branch scoping</h3>



<p class="wp-block-paragraph">GitHub Actions caches are scoped to the branch that created them, plus the default branch as a fallback. A feature branch reads its own caches and the default branch&#8217;s caches. It cannot read a sibling branch&#8217;s cache.</p>



<p class="wp-block-paragraph">The consequence catches a lot of people: if your caching step only runs on pull requests, the default branch never populates a cache, so every new branch starts cold forever. Make sure at least one workflow on the default branch writes it. Runs triggered from fork pull requests get read-only cache access, so those jobs restore but never save.</p>



<h3 class="wp-block-heading">Keys that never hit, or hit and never update</h3>



<p class="wp-block-paragraph">Cache entries are immutable. Once a key exists, writing to it again does nothing. So a key like <code>node-modules-v1</code> gets populated once, on some machine, in some state, and then freezes. A key that hashes the lockfile is correct but creates a brand new entry on every dependency change, which burns through storage.</p>



<p class="wp-block-paragraph">The pattern that survives contact is an exact key plus a prefix fallback: hash the lockfile for the primary key, then use restore keys to fall back to the most recent entry sharing that prefix. You get a partial restore and a fast install instead of a cold one.</p>



<p class="wp-block-paragraph">Then watch the ceiling. Repositories have a default cache storage limit, entries untouched for a week are removed, and once you hit the limit the least recently accessed entries get evicted. Push enough large caches and you get thrashing, where entries are created and evicted at roughly the rate you create them. The tell is a hit rate that looks random. Check the repository&#8217;s cache usage in Actions settings before assuming the config is wrong.</p>



<h3 class="wp-block-heading">Docker layer cache that does not cross machines</h3>



<p class="wp-block-paragraph">On ephemeral runners the local BuildKit cache dies with the machine, so it has to be exported somewhere. Registry-backed cache is the one I reach for first:</p>



<pre class="wp-block-code"><code>docker buildx build 
  --cache-from type=registry,ref=registry.example.com/app:buildcache 
  --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max 
  --tag registry.example.com/app:${GIT_SHA} 
  --push .</code></pre>



<p class="wp-block-paragraph">Two details do the work. <code>mode=max</code> exports intermediate stage layers, not just the ones that landed in the final image. If the expensive work happens in a builder stage that gets discarded, the default mode caches none of it, which is exactly the case you were trying to fix. Inline cache, the kind that ships alongside the image, only supports the minimal mode, so it cannot help multi-stage builds either.</p>



<p class="wp-block-paragraph">The second detail is a trap. BuildKit cache mounts, the <code>RUN --mount=type=cache</code> kind, are not part of the exported layer cache. They live on the builder. Restoring a registry cache does not restore them, so if the instruction runs again the package manager starts from an empty download cache:</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci</code></pre>



<p class="wp-block-paragraph">That line is excellent on a persistent builder and close to useless on a runner destroyed after every job. Know which one you have before you credit it with a speedup.</p>



<h2 class="wp-block-heading">Pipelines shaped like a staircase</h2>



<p class="wp-block-paragraph">Stages are a scheduling constraint most pipelines inherit by accident. In a stage-based model nothing in the test stage starts until everything in the build stage finishes. Put a forty-second lint job in the same stage as a six-minute compile and your lint feedback arrives six minutes late for no reason.</p>



<p class="wp-block-paragraph">GitLab&#8217;s fix is <code>needs</code>, which lets a job start as soon as its own dependencies finish and ignore stage order entirely:</p>



<pre class="wp-block-code"><code>lint:
  stage: test
  needs: []
  script:
    - make lint

unit:
  stage: test
  needs: ["build"]
  script:
    - make unit</code></pre>



<p class="wp-block-paragraph"><code>needs: []</code> means start immediately, wait for nothing. Lint now reports in under a minute while the compile is still running.</p>



<p class="wp-block-paragraph">GitHub Actions has the opposite default: jobs run in parallel unless you add <code>needs</code>. So the equivalent problem there is over-declared dependencies, usually added early to force a readable graph and never revisited. Go read your <code>needs</code> lines and ask which are real data dependencies and which are narrative ordering.</p>



<p class="wp-block-paragraph">The trade-off is real. A wide graph surfaces every failure at once and costs more compute per run. A narrow graph fails fast and saves minutes, but developers make several round trips to find three problems. For PR pipelines, wide is usually right. For expensive nightly jobs, narrow is.</p>



<h2 class="wp-block-heading">The test suite is not one bottleneck</h2>



<p class="wp-block-paragraph">&#8220;Tests are slow&#8221; gets treated as a single problem. It is usually three, and they want different fixes.</p>



<ul class="wp-block-list">
<li><strong>The suite is serial.</strong> Split by shard across parallel jobs. The cost is per-job setup overhead paid N times, so past a point more shards makes things slower. Find your own break-even instead of assuming more is better.</li>

<li><strong>The suite is flaky.</strong> A retry costs more than one rerun. It costs attention, a context switch, and eventually the team&#8217;s belief that red means anything. Quarantine flaky tests into a non-blocking job with an owner and a deadline. Leaving them in the required path while everyone knows to just re-run is worse than deleting them.</li>

<li><strong>The suite tests the wrong layer.</strong> A test that boots a database and a message broker to assert a validation rule is a unit test wearing a costume. Slowest fix, largest payoff.</li>
</ul>



<p class="wp-block-paragraph">Before touching any of it, get per-test timing out of your test runner and sort descending. Most suites have a handful of tests that dominate, and they are rarely the ones people suspect.</p>



<h2 class="wp-block-heading">The wait that nobody is measuring</h2>



<p class="wp-block-paragraph">Here is the one that costs days rather than minutes, and it appears in no CI metric because from CI&#8217;s point of view nothing is happening.</p>



<p class="wp-block-paragraph">If a workflow is skipped because of path filtering, branch filtering, or a skip directive in the commit message, its checks stay pending. Not skipped. Pending. And if that check is required, the pull request cannot merge. Nothing fails, nothing alerts, the PR just sits there looking almost ready until someone investigates by hand.</p>



<p class="wp-block-paragraph">This bites hardest in monorepos, which is exactly where path filters are most attractive. You add <code>paths</code> to skip the service test suite on documentation-only changes, you save real minutes, and you accidentally build a merge trap.</p>



<p class="wp-block-paragraph">The distinction that matters: a job skipped by an <code>if</code> condition inside a workflow that did run reports success. A workflow that never ran at all reports pending. So either keep the workflow triggering on everything and move the filtering inside, or pair each filtered workflow with a companion using the same name and inverted path filters that does nothing and passes. GitHub documents that second pattern and it works, but it means two files that must stay in sync.</p>



<p class="wp-block-paragraph">The other gate-time offenders are less subtle and just as expensive: one shared staging environment serializing every team, and approvals routed to a single person asleep in another time zone. Plot merge timestamps against last-commit timestamps for a month. The shape of that distribution tells you which problem you have.</p>



<h2 class="wp-block-heading">Troubleshooting: finding your bottleneck in an afternoon</h2>



<ol class="wp-block-list">
<li>Pull the last fifty runs on your default branch and compute queue time and compute time separately. If queue is a meaningful share, fix capacity and cancellation before anything else.</li>

<li>Read your slowest run as a graph, not a list. Wide gaps between a dependency finishing and its dependent starting are either queue or bad ordering.</li>

<li>Compare install duration on a cache hit against a cache miss. If they are similar, the cache is decorative.</li>

<li>Check repository cache storage usage. Thrashing looks like an erratic hit rate with no config changes behind it.</li>

<li>Count retries. The rate of second attempts on the same commit is your flakiness tax, and it is usually higher than anyone guesses.</li>

<li>Measure merge latency, last commit to merge, across a month. The difference between that and pipeline duration is gate time, and that is where the days are.</li>
</ol>



<p class="wp-block-paragraph">Pipeline run data is worth shipping somewhere queryable. Most CI platforms emit webhooks on job completion; feeding those into Prometheus and Grafana, or a hosted platform like Grafana Cloud or Datadog if you would rather not run the storage, turns &#8220;the build feels slow lately&#8221; into a graph you can argue with.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Buying bigger runners before splitting queue time from compute time. The most common wasted spend in CI.</li>

<li>Turning on auto-cancel without setting the per-job interruptible flags, so the setting is technically on and functionally off.</li>

<li>Caching a directory the tool does not read from, then declaring victory because the step went green.</li>

<li>Using the default Docker cache export mode on a multi-stage build and wondering why the builder stage rebuilds every time.</li>

<li>Adding path filters to workflows that back required checks, creating pull requests that can never merge.</li>

<li>Optimizing the pipeline that runs on merge instead of the PR pipeline developers actually wait on.</li>
</ul>



<h2 class="wp-block-heading">Practices that hold up</h2>



<ul class="wp-block-list">
<li>Treat queue time as a first-class metric with its own threshold, not a footnote on build duration.</li>

<li>Cancel superseded runs on branches, never on the default branch and never on deploys.</li>

<li>Make the default branch populate every cache other branches depend on.</li>

<li>Order jobs by how fast they fail, not by how the file reads top to bottom. Cheap checks first, always unblocked.</li>

<li>Give every flaky test an owner and an expiry date, or take it out of the blocking path.</li>

<li>Re-measure after every change and keep the number. Pipelines rot, and the bottleneck moves as soon as you fix the current one.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">How do I find the bottleneck in my CI/CD pipeline?</h3>



<p class="wp-block-paragraph">Split every job into queue time and compute time using the timestamps your platform&#8217;s API already exposes, then compare pipeline duration against real merge latency. Those two comparisons put you in one of three buckets: capacity, build work, or gates. Fixing the wrong bucket is why most optimization efforts show no result.</p>



<h3 class="wp-block-heading">Are self-hosted runners faster than hosted ones?</h3>



<p class="wp-block-paragraph">Per job, often yes, because you pick the hardware and can keep caches warm. Across a working day, frequently no, because a fixed fleet queues under load while a hosted pool does not. The right answer depends on which number is hurting you, compute or queue, which is why measurement comes first. A hybrid setup, self-hosted by default with hosted overflow, avoids committing to either.</p>



<h3 class="wp-block-heading">Why is my cache not speeding anything up?</h3>



<p class="wp-block-paragraph">Usual suspects in order of frequency: the cache is scoped to a branch that never populated it, the key is static so it froze at whatever went in first, the cached directory is not the one the tool reads, or entries are evicted before the next run. Compare install duration on a hit against a miss. Identical duration means the restore is doing nothing regardless of what the log says.</p>



<h3 class="wp-block-heading">Should I parallelize tests or make them faster?</h3>



<p class="wp-block-paragraph">Parallelize first because it is cheap and reversible, then fix the slow tests, because parallelism has a floor. Every shard pays setup cost, so a suite split too many ways spends more time booting containers than running assertions. Sharding buys time to do the real work. It is not the real work.</p>



<h3 class="wp-block-heading">Why does my pull request say pending forever?</h3>



<p class="wp-block-paragraph">Almost certainly a required status check attached to a workflow that was skipped by path filtering, branch filtering, or a commit message directive. A skipped workflow reports pending rather than skipped, so the merge requirement is never satisfied. Either drop the path filter and skip inside the workflow instead, or add a companion workflow with the same name that always passes.</p>



<h3 class="wp-block-heading">Do bigger runners actually help?</h3>



<p class="wp-block-paragraph">They help work that is genuinely CPU or memory bound, such as compilation and container builds. They do nothing for queue time, network-bound dependency downloads, tests waiting on external services, or gate time. Before upgrading, confirm the slow job is saturating the resource you are about to buy more of.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Most CI/CD pipeline bottlenecks are waiting, not working. Queue time, cache misses that report as hits, jobs blocked behind stages they do not depend on, and checks that sit pending because a workflow was never triggered. None of them show up on a build duration graph, and all of them are cheap to fix once you can see them. Split the number before you spend anything; the measurement takes an afternoon and usually points at a config change rather than a purchase order.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need someone to find where your pipeline is losing time?</h2>



<p class="wp-block-paragraph">This is a large part of what I do as a freelance DevOps engineer. Typical work looks like:</p>



<ul class="wp-block-list">
<li>Instrumenting your pipeline so queue time, compute time and merge latency become three separate numbers you can watch over time</li>

<li>Auditing GitHub Actions and GitLab CI caching to find the restores that report success and deliver nothing</li>

<li>Restructuring stage-based pipelines into dependency graphs so cheap checks report first</li>

<li>Setting up autoscaling self-hosted runners, on Kubernetes with Actions Runner Controller or on plain VMs, with hosted overflow</li>

<li>Fixing Docker build caching for multi-stage images on ephemeral runners, including registry-backed export</li>

<li>Untangling required checks and path filters in monorepos so pull requests stop getting stuck</li>
</ul>



<p class="wp-block-paragraph">Send me a workflow file, a job timeline, or the output of your slowest run, and I will tell you where the minutes are going before we talk about anything else.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/ci-cd-pipeline-bottlenecks/">CI/CD Pipeline Bottlenecks: Where Your Build Minutes Actually Go</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>GitLab CI vs GitHub Actions vs Jenkins: Choosing Without Regretting It Later</title>
		<link>https://john-nessime.com/blog/devops/gitlab-ci-vs-github-actions-vs-jenkins/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 13 Aug 2026 10:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[GitLab CI]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Jenkins]]></category>
		<category><![CDATA[OIDC]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<category><![CDATA[Secrets Management]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Supply Chain Security]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=201</guid>

					<description><![CDATA[<p>A practical comparison of GitLab CI, GitHub Actions and Jenkins that skips the feature table. What actually decides the choice is where your code lives, whether the runner can reach the deploy target, and who owns the control plane at 2am. Includes the cost mechanics, the security failure modes, and a decision procedure you can run in an afternoon.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/gitlab-ci-vs-github-actions-vs-jenkins/">GitLab CI vs GitHub Actions vs Jenkins: Choosing Without Regretting It Later</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The question almost always arrives the same way. Someone pastes a long Jenkinsfile into a channel, adds a shrug emoji, and asks how hard it would be to move this to GitHub Actions. Nobody in the thread is asking the questions that will actually decide the outcome, because those questions are boring and the YAML is right there looking portable.</p>



<p class="wp-block-paragraph">Here is the thing that catches teams out: the pipeline definition is the cheapest part of a CI system to move. Rewriting stages and steps is a few days of tedious work for a normal service. What does not move is everything underneath it, and that is where a two-week migration turns into a two-quarter one.</p>



<p class="wp-block-paragraph">This post compares <strong>GitLab CI vs GitHub Actions vs Jenkins</strong> on the things that actually differ once you are running them in anger: who owns the runners, where secrets live, how the bill is calculated, and what breaks when an outside contributor opens a pull request. There is a profile of each with an honest &#8220;where it wins and where it doesn&#8217;t&#8221;, a section on cost mechanics, a section on the security failure modes, and a decision procedure at the end you can run in an afternoon.</p>



<h2 class="wp-block-heading">The part of the decision that actually bites</h2>



<p class="wp-block-paragraph">Draw a line under your pipeline file. Everything above the line is syntax. Everything below is platform.</p>



<p class="wp-block-paragraph">Below the line sits the runner fleet and how it is scaled, the secret store and how jobs authenticate to it, artifact and container registry storage with its retention rules, deploy credentials and the network path they travel, approval gates and who can click them, and the audit trail that someone in compliance will eventually ask about. None of that ports. All of it has to be rebuilt on the new platform, and each piece has a person or a team attached to it who has opinions.</p>



<p class="wp-block-paragraph">The failure mode this produces is specific and predictable. A team migrates the build and test half of the pipeline because that part is easy and demos well. Deploy stays on the old system &#8220;for now&#8221;, because deploy touches production and nobody wants to be the one who broke it during a migration. Six months later there are two CI systems, two secret stores, two sets of runners to patch, two audit trails, and a deploy path that nobody can explain end to end. The migration never technically failed. It just never finished, and the running cost doubled permanently.</p>



<p class="wp-block-paragraph">So the real question is not which YAML dialect you prefer. It is whether you are prepared to move the whole thing, and which platform makes the &#8220;below the line&#8221; half cheapest for your specific situation.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">What each platform is really optimising for</h2>



<p class="wp-block-paragraph">Feature tables make these three look more similar than they are. They are optimising for genuinely different things, and the differences show up as soon as you leave the happy path.</p>



<h3 class="wp-block-heading">GitHub Actions: shortest path from push to green</h3>



<p class="wp-block-paragraph">Actions is built around events in the GitHub repository. Something happens, a workflow fires, jobs land on hosted runners, and you did not have to provision anything. The marketplace means most common tasks are already someone else&#8217;s problem. Reusable workflows let a platform team define a pipeline once and have every service call it.</p>



<p class="wp-block-paragraph">A minimal hardened job header looks like this, and every line in it is there for a reason:</p>



<pre class="wp-block-code"><code>name: ci

on:
  pull_request:
    branches: [main]

permissions:
  contents: read      # start read-only, raise per job where needed
  id-token: write     # only for jobs that federate to a cloud provider

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@PIN_TO_A_40_CHAR_COMMIT_SHA
      - name: Use the PR title safely
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: echo "$PR_TITLE"</code></pre>



<p class="wp-block-paragraph">The <code>permissions</code> block caps what the automatically provided job token can do, so a compromised step cannot push to your branches. The <code>concurrency</code> block cancels superseded runs when someone pushes three times in a minute, which is the single easiest compute saving available. Pinning the action to a commit SHA rather than a tag matters because tags are mutable and have been repointed to malicious commits in real supply chain incidents. And binding the pull request title to an environment variable instead of interpolating it directly into the shell is what stops an attacker-controlled string from being executed as a command.</p>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> your code is already on GitHub, you want CI running today, and your deploy targets are cloud APIs you can reach over the internet with a federated identity token. For open source it is close to unbeatable, since standard hosted runners are free for public repositories.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> anything needing deep control of the execution environment, long-running or stateful builds, deploys into networks the internet cannot reach, or a compliance posture that requires build logs and secrets to stay inside your perimeter. Self-hosted runners solve the network problem but hand you back the maintenance you were trying to avoid.</p>



<h3 class="wp-block-heading">GitLab CI: one platform owning the whole lifecycle</h3>



<p class="wp-block-paragraph">GitLab&#8217;s pitch is that source control, CI, package registry, container registry, security scanning and issue tracking are one product with one permission model and one audit trail. That is a real architectural advantage, not marketing. When a scanner finding, the merge request that introduced it, the pipeline that caught it and the registry image that contains it are all the same system, you spend a lot less time gluing tools together.</p>



<p class="wp-block-paragraph">The pipeline language rewards you for using its graph features rather than treating stages as a queue:</p>



<pre class="wp-block-code"><code>workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

default:
  interruptible: true

stages: [build, test, deploy]

build:
  stage: build
  script:
    - make build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

unit:
  stage: test
  needs: [build]
  script:
    - make test

deploy:
  stage: deploy
  needs: [unit]
  environment: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  script:
    - ./deploy.sh</code></pre>



<p class="wp-block-paragraph">The <code>workflow:rules</code> block is the one people skip and then wonder why every push runs two pipelines. It decides whether a pipeline is created at all. <code>needs</code> turns the stage list into a directed graph, so a job starts the moment its own dependency finishes rather than waiting for every job in the previous stage. <code>interruptible: true</code> makes jobs eligible for automatic cancellation when a newer pipeline supersedes them, which you then have to enable in the project&#8217;s CI settings; the keyword alone does nothing. And <code>expire_in</code> is worth setting deliberately, because artifact storage is billed separately from compute and quietly accumulates.</p>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> you want one vendor for the whole lifecycle, you have compliance requirements that benefit from a single auth model and audit trail, or you need self-managed hosting without giving up the integrated experience. The self-managed edition with your own runners is genuinely one of the better value propositions in this space at moderate to high build volume.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> your code is not in GitLab. The CI is tightly coupled to GitLab repositories, and running it against code hosted elsewhere is possible but perpetually awkward. The third-party ecosystem is also smaller, so more of what you need you will write yourself. And self-managing GitLab is a real operational commitment, not a Docker Compose file you forget about.</p>



<h3 class="wp-block-heading">Jenkins: whatever you need, on hardware you control</h3>



<p class="wp-block-paragraph">Jenkins gets dismissed as legacy by people who have never had a requirement it was the only tool to meet. It runs on your hardware, in your network, with no dependency on a vendor&#8217;s availability or pricing decisions. It talks to source control systems the other two do not. It can drive hardware test rigs, mainframe jobs, and build agents on operating systems nobody else offers. If your pipeline data legally cannot leave your network, this is a short list and Jenkins is on it.</p>



<pre class="wp-block-code"><code>pipeline {
  agent { label 'linux-ephemeral' }

  options {
    timeout(time: 30, unit: 'MINUTES')
    disableConcurrentBuilds()
    buildDiscarder(logRotator(numToKeepStr: '30'))
    timestamps()
  }

  stages {
    stage('Build') {
      steps {
        sh 'make build'
      }
    }
    stage('Deploy') {
      when { branch 'main' }
      steps {
        withCredentials([string(credentialsId: 'deploy-token', variable: 'DEPLOY_TOKEN')]) {
          sh './deploy.sh'
        }
      }
    }
  }

  post {
    always {
      cleanWs()
    }
  }
}</code></pre>



<p class="wp-block-paragraph">Two things in that file are worth pointing at. <code>timeout</code> and <code>buildDiscarder</code> are the difference between a controller that runs for years and one that fills its disk and hangs on stuck jobs; set them on every pipeline, not as an afterthought. And <code>timestamps()</code> and <code>cleanWs()</code> both come from plugins rather than core. That is Jenkins in a sentence: the capability exists, and it arrives as a dependency you now own, patch, and eventually discover is unmaintained.</p>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> air-gapped or heavily restricted networks, source control that is not GitHub or GitLab, orchestration of things that are not code builds, and organisations with genuinely unusual workflows that no hosted product models. If you already have working Jenkins infrastructure and a person who understands it, the case for ripping it out is weaker than the migration slides suggest.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> the controller is a stateful, single-point-of-failure server that someone has to own. Plugins are the extensibility model and also the attack surface, and the project publishes security advisories regularly enough that patching is a standing chore rather than an event. The Java baseline also moves; recent LTS lines have dropped support for older Java versions, so an upgrade is periodically a JVM migration too. If you cannot name the person responsible for that work, do not choose Jenkins.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">GitLab CI vs GitHub Actions vs Jenkins on cost</h2>



<p class="wp-block-paragraph">Published rates change often enough that quoting them ages a post badly. The billing mechanics change far more slowly, and the mechanics are what you actually need to model.</p>



<ul class="wp-block-list"><li><strong>GitHub Actions.</strong> Standard hosted runners are free for public repositories. Private repositories draw from a monthly pool of included minutes tied to your plan, and those minutes are Linux-equivalent: Windows and macOS jobs drain the pool at a multiplier, with macOS by far the steepest. Larger runners do not draw from the included pool at all and bill per minute from the first second. Billing is per job rounded up to the minute, minutes do not roll over, and usage is charged to the repository owner. Self-hosted runner usage carries no per-minute fee, though GitHub has floated changing that and then walked it back, so verify against current billing docs before you build a model on it.</li><li><strong>GitLab CI.</strong> Jobs on GitLab-hosted instance runners consume compute minutes, calculated as job duration multiplied by a cost factor for the machine size. The included pool is per top-level namespace, not per seat, which is the detail that surprises people: a five-person group and a fifty-person group on the same tier start from the same pool. Self-managed runners consume zero quota on every tier including Free, which is why &#8220;pay for seats, run your own runners&#8221; is such a common GitLab shape.</li><li><strong>Jenkins.</strong> No licence cost, which is where most cost comparisons stop and where the interesting part starts. You are paying for the controller, the agent fleet, the storage, and the engineering time to patch all three. That last item is the largest line and the one nobody puts in the spreadsheet.</li></ul>



<p class="wp-block-paragraph">Two observations that matter more than the platform choice itself. First, the levers with the biggest effect on a CI bill are the same everywhere: cancel superseded runs, cache dependencies properly, skip workflows on paths that did not change, and stop running the full matrix on every draft commit. A team that does none of these will pay more on the cheapest platform than a disciplined team pays on the most expensive one.</p>



<p class="wp-block-paragraph">Second, self-hosted runners are cheaper per minute and are not free. A pool of runners on a VPS provider like Hetzner, DigitalOcean or InterServer is inexpensive to rent and still needs image maintenance, autoscaling, disk cleanup and a security model. The break-even against hosted minutes is real, but it arrives later than people expect once you price the engineering hours honestly.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Security is where the differences stop being cosmetic</h2>



<p class="wp-block-paragraph">Every CI system is a machine that holds production credentials and executes code on demand. The platforms differ mostly in which mistakes they make easy.</p>



<ul class="wp-block-list"><li><strong>Untrusted pull requests.</strong> This is the classic GitHub Actions trap. The <code>pull_request</code> trigger runs fork code without access to your secrets. The <code>pull_request_target</code> trigger runs in the privileged context of the base repository, and if a workflow using it checks out the pull request&#8217;s code, an outside contributor can execute arbitrary code with your secrets in scope. GitHub&#8217;s own guidance is to avoid that trigger unless the workflow genuinely needs the privileged context, and never to check out untrusted code in it.</li><li><strong>Expression injection.</strong> Any attacker-controlled string, such as a branch name, issue title or commit message, that gets interpolated directly into a shell command is a code execution path. Bind it to an environment variable and reference the variable instead. This applies equally to GitLab, where the same values arrive as predefined CI variables.</li><li><strong>Self-hosted runners on public repositories.</strong> Don&#8217;t. A persistent runner that executes fork code is compromised for every subsequent job on it, including jobs that do have secrets. If you must self-host, use ephemeral runners that are destroyed after a single job, and keep public repositories on hosted runners.</li><li><strong>Mutable action and image references.</strong> Pin third-party actions to a full commit SHA. Tag repointing has been used in real supply chain compromises to reach thousands of repositories at once. The same logic applies to base images referenced by tag in GitLab jobs.</li><li><strong>Credential scope.</strong> Prefer short-lived federated credentials over long-lived secrets wherever the target supports it. GitHub and GitLab both issue OIDC tokens that cloud providers and HashiCorp Vault can exchange for short-lived access, and a token that expires in an hour is a much smaller incident than a static key that nobody rotates. Where you do use variables, mark them protected so they are only exposed on protected branches.</li><li><strong>Jenkins specifically.</strong> The controller holds the credentials store, so builds should never execute on it. Restrict agent-to-controller access, keep the plugin list as short as you can defend, and subscribe to the project&#8217;s security advisories, because plugin vulnerabilities are the most common way Jenkins installations get owned.</li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A decision procedure you can run in an afternoon</h2>



<ol class="wp-block-list"><li><strong>Follow platform gravity first.</strong> Where does the code live, and where will it live in three years? CI that lives next to the code is cheaper to operate than CI that has to be integrated with it. If that single question has a clear answer, it decides most cases and the rest of this list is confirmation.</li><li><strong>Check whether a runner can reach the deploy target.</strong> Not the build. The deploy. If production sits behind a private network with no inbound path, you need self-hosted runners, a tunnel, or a system that already lives inside the perimeter. This is the requirement that most often overrides answer one.</li><li><strong>Name the on-call owner.</strong> Write down who gets paged when the CI system itself is down at 2am on a Sunday. If the honest answer is nobody, choose a hosted platform. Jenkins and self-managed GitLab both need that name to exist.</li><li><strong>Model the compute, not the sticker price.</strong> Take last month&#8217;s build minutes, split them by operating system and machine size, and apply each platform&#8217;s mechanics. macOS-heavy and Windows-heavy workloads change the answer significantly.</li><li><strong>Inventory what the plugins actually do.</strong> If you are leaving Jenkins, go through the plugin list and mark each one as replaced by a native feature, replaced by a marketplace action, needs writing, or can be dropped. The &#8220;needs writing&#8221; pile is your real migration estimate.</li><li><strong>Trial with your ugliest pipeline.</strong> Not the greenfield service. The one with the flaky integration test, the hardware dependency and the manual approval. Every platform handles a clean Node build beautifully. Only one of them will handle yours.</li></ol>



<h2 class="wp-block-heading">The hybrid pattern nobody puts on the slide</h2>



<p class="wp-block-paragraph">A very common real-world arrangement: GitHub Actions or GitLab CI runs build, test, scan and image publish, then a retained Jenkins instance handles deployment into restricted environments. It works, and for regulated organisations it is sometimes the only thing that does.</p>



<p class="wp-block-paragraph">Be honest about what it costs, though. You now have two systems to patch, two places secrets live, and a handoff between them that needs its own monitoring. It should be a deliberate architecture with a documented boundary, not the accidental result of a migration that stalled. The tell is whether anyone can draw the handoff on a whiteboard without hedging.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<ul class="wp-block-list"><li><strong>&#8220;Jenkins is legacy.&#8221;</strong> It is old, which is not the same thing. It is actively maintained and still the only option for several categories of requirement. The real objection is the maintenance cost, so make that argument instead; it is stronger and it is true.</li><li><strong>&#8220;We&#8217;ll save money with self-hosted runners.&#8221;</strong> On per-minute compute, yes. Once you price the image maintenance, autoscaling and patching, the saving is much smaller than the spreadsheet suggested, and it only exists at volume.</li><li><strong>&#8220;GitLab CI is just GitHub Actions with different YAML.&#8221;</strong> The pipeline languages are comparable. The products are not. One is an event platform around a repository, the other is an integrated lifecycle platform. The difference shows up in permissions, scanning and audit, not in syntax.</li><li><strong>&#8220;We&#8217;ll stay vendor-neutral by keeping all logic in shell scripts.&#8221;</strong> Good instinct, incomplete. The scripts port. The triggers, permissions, secret injection, artifact handling and approval gates do not, and those are most of the migration work.</li><li><strong>&#8220;The bigger marketplace wins.&#8221;</strong> Marketplace size counts unreviewed third-party code you are about to execute with your credentials. It is a convenience and a supply chain liability at the same time. Treat it as both.</li></ul>



<h2 class="wp-block-heading">How I&#8217;d decide</h2>



<ul class="wp-block-list"><li>Code on GitHub, cloud deploy targets, no unusual compliance requirement: GitHub Actions, hosted runners, OIDC to the cloud provider, actions pinned to SHAs. This is the one I reach for first because it is the least infrastructure to own.</li><li>Wanting one vendor for source, CI, registry and scanning, especially with compliance pressure: GitLab, and take self-managed seriously if data residency matters.</li><li>Restricted networks, unusual hardware, or existing Jenkins that works and has an owner: keep Jenkins, but put the pipeline in a Jenkinsfile, the controller config in Configuration as Code, and the plugin list under version control. Jenkins pain is almost always click-configured Jenkins pain.</li><li>Whichever you pick, wire pipeline duration and failure rate into whatever you already use for metrics, whether that is Grafana, Datadog or something homegrown. A CI system you cannot see is one you cannot tune, and slow pipelines are a cost problem and a morale problem at the same time.</li><li>Do not migrate for aesthetics. Migrate because a specific requirement is unmet, and write that requirement down before you start.</li></ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is Jenkins still worth using?</h3>



<p class="wp-block-paragraph">Yes, for specific situations. Air-gapped networks, source control that is not GitHub or GitLab, hardware-attached builds, and orchestration of things that are not software builds are all cases where Jenkins is the practical answer. For a standard cloud-deployed web service with code on GitHub, a hosted platform will cost you less operationally. The deciding factor is whether you have someone who owns the controller.</p>



<h3 class="wp-block-heading">Which is cheaper, GitLab CI or GitHub Actions?</h3>



<p class="wp-block-paragraph">It depends on your operating system mix and team size, because the two use different mechanics. GitHub applies multipliers so Windows and especially macOS jobs drain the included pool much faster than Linux. GitLab&#8217;s included compute pool is per namespace rather than per seat, so it does not grow as you hire. Both make self-hosted runners free of per-minute charges. Model your own last month of build minutes rather than trusting a general answer.</p>



<h3 class="wp-block-heading">Can I use GitLab CI with a GitHub repository?</h3>



<p class="wp-block-paragraph">There are mirroring and integration options, but the pipeline features are built around GitLab-hosted repositories and the experience degrades away from that. If your code is staying on GitHub, treat GitLab CI as a poor fit rather than a configuration challenge.</p>



<h3 class="wp-block-heading">How long does migrating from Jenkins to GitHub Actions or GitLab CI take?</h3>



<p class="wp-block-paragraph">Translating pipeline stages is usually days per service. The schedule is set by the parts that are not the pipeline: replacing plugin functionality, rebuilding the secret and identity model, provisioning runners with network access to deploy targets, and reproducing approval gates. Inventory those first, because that inventory is the estimate.</p>



<h3 class="wp-block-heading">Are self-hosted runners safe?</h3>



<p class="wp-block-paragraph">They are safe for private repositories with trusted contributors, provided the runner is ephemeral and destroyed after each job. They are not safe for public repositories, because a fork pull request can execute code on a persistent runner and reach everything that runs on it afterwards. Keep public repositories on hosted runners.</p>



<h3 class="wp-block-heading">Do I need to pin GitHub Actions to a commit SHA?</h3>



<p class="wp-block-paragraph">For any third-party action that runs with access to secrets, yes. Tags are mutable, and repointing a widely used tag to a malicious commit is a demonstrated attack path that has affected large numbers of repositories at once. Pinning costs you a dependency update per release and removes an entire class of compromise.</p>



<h3 class="wp-block-heading">Is it reasonable to run more than one CI system?</h3>



<p class="wp-block-paragraph">Reasonable if it is deliberate, expensive if it is accidental. A documented split where hosted CI builds and tests while an internal system deploys into restricted networks is a legitimate architecture. A split that exists because a migration stalled halfway is a permanent tax with no owner.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Comparing <strong>GitLab CI vs GitHub Actions vs Jenkins</strong> on syntax and feature checklists produces a decision that feels rigorous and predicts almost nothing. The pipeline file is the part that ports. The runner fleet, the secret and identity model, the artifact storage and the network path to your deploy target are the parts that get rebuilt, and they are where the schedule and the risk actually live.</p>



<p class="wp-block-paragraph">Pick the platform closest to where your code lives, unless a hard requirement about network access, data residency or unusual execution environments overrides it. Then spend your effort on the layer beneath the YAML, because that is the layer you will be living with.</p>



<h2 class="wp-block-heading">Need help choosing or moving a CI pipeline?</h2>



<p class="wp-block-paragraph">This is a large part of what I do as a freelance DevOps engineer. Typical engagements around CI/CD platform decisions look like:</p>



<ul class="wp-block-list"><li>A platform evaluation with your actual build minutes, operating system mix and deploy targets modelled, so the recommendation is arithmetic rather than opinion.</li><li>Jenkins to GitHub Actions or GitLab CI migrations, including the plugin inventory, the secret and identity rebuild, and a staged cutover that does not leave you running two systems forever.</li><li>Pipeline security review: fork pull request triggers, expression injection paths, action pinning, runner isolation and token permission scoping.</li><li>Replacing long-lived cloud keys with OIDC federation on GitHub Actions or GitLab CI, including the trust policy and claim conditions on the cloud side.</li><li>Self-hosted runner fleets that autoscale, stay ephemeral, and do not quietly fill their disks.</li><li>CI cost reduction: caching, concurrency cancellation, path filters and matrix pruning, measured before and after.</li><li>Jenkins hardening and Configuration as Code, so the controller is reproducible instead of a server nobody wants to touch.</li></ul>



<p class="wp-block-paragraph">If you want a second opinion, send me the pipeline file, a plugin list or a month of build minutes and I will tell you what I would actually do with it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/gitlab-ci-vs-github-actions-vs-jenkins/">GitLab CI vs GitHub Actions vs Jenkins: Choosing Without Regretting It Later</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Two Sources of Truth: Git-Based WordPress Deployment From Local to Production</title>
		<link>https://john-nessime.com/blog/devops/git-based-wordpress-deployment/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:28 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Composer]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[rsync]]></category>
		<category><![CDATA[Staging]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[WordPress Database]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WordPress Migration]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=60</guid>

					<description><![CDATA[<p>A plugin quietly reverts after every deploy and nobody knows why. That is what happens when Git and wp-admin both think they own the file tree. Here is how to build a deployment workflow where the repo is the source of truth and the database only ever travels one way.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/git-based-wordpress-deployment/">Two Sources of Truth: Git-Based WordPress Deployment From Local to Production</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone reports that the contact form stopped working. You check the log, and nothing has touched that plugin in three weeks. You SSH into production, look at the plugin directory, and the version on disk is older than the version in your repo. Older than production had yesterday, in fact.</p>



<p class="wp-block-paragraph">Then it clicks. Somebody logged into wp-admin last month, saw an update notice, clicked it. The plugin got updated on the server and never in git. Last night&#8217;s deploy synced the repo over the top and put the old version back, along with the vulnerability the update was patching. No error, no warning. The deploy did exactly what it was told.</p>



<p class="wp-block-paragraph">That is the failure mode that kills most attempts at <strong>Git-based WordPress deployment</strong>, and it has nothing to do with your pipeline. WordPress is designed to modify itself. It installs plugins, updates core, writes files, all from the browser. The moment you put it under version control you have two systems that both believe they own the file tree, and they will quietly overwrite each other until somebody notices.</p>



<p class="wp-block-paragraph">This post covers the whole chain: what belongs in the repo, how to shut the second write path, how configuration differs per environment, which way the database is allowed to travel, and how to make staging safe enough that you can restore production data into it without emailing your customers by accident.</p>



<h2 class="wp-block-heading">The two things that make WordPress awkward under Git</h2>



<p class="wp-block-paragraph">Everything in this workflow follows from two facts.</p>



<p class="wp-block-paragraph"><strong>State is split across files and a database.</strong> Your theme is code. Your posts are data. But so are your plugin settings, your menus, your widget layouts, and your permalink structure, and those live in the database next to the content. Git only manages half your site, and it is not obvious which half a given change landed in.</p>



<p class="wp-block-paragraph"><strong>The two halves travel in opposite directions.</strong> Code moves forward, local to staging to production. Content moves backward, production to staging to local. Once a site is live, production is the only authoritative source of orders, comments and posts, and pushing a database upward destroys everything written since your last pull.</p>



<p class="wp-block-paragraph">Write that rule somewhere the whole team can see it. Almost every catastrophic WordPress deployment story is somebody pushing a database in the wrong direction.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Decide what actually goes in the repo</h2>



<p class="wp-block-paragraph">Three approaches, in increasing order of discipline and payoff. Pick one deliberately rather than drifting into the first.</p>



<ol class="wp-block-list">
<li><strong>Theme only.</strong> The repo contains one custom theme, sometimes a custom plugin. Everything else is installed through wp-admin. Easy to start, and it means you have no reproducible build: rebuilding the site from the repo is impossible.</li>
<li><strong>All of wp-content, minus uploads.</strong> Themes, plugins and mu-plugins in git; core and media excluded. This is the pragmatic middle and where most teams land. You get reproducible plugin versions and a real diff when a plugin changes.</li>
<li><strong>Composer-managed, Bedrock-style.</strong> Core, plugins and themes are all declared as dependencies. The repo holds a <code>composer.json</code> and your own code, nothing else. The lockfile is the source of truth and <code>composer install</code> rebuilds the site anywhere.</li>
</ol>



<p class="wp-block-paragraph">Option three is the one I reach for on anything with more than one developer, because &#8220;which plugin version is production running&#8221; becomes a question with an answer. The honest cost: premium plugins that are not on a Composer repository need either a private repository or a manual step, and that friction is real. If most of your plugin stack is commercial, option two is a reasonable place to stop.</p>



<p class="wp-block-paragraph">One thing to check if you are following an older tutorial. The Composer repository landscape for WordPress plugins shifted recently: WPackagist was acquired by WP Engine, and the Roots team launched WP Packages as an independent alternative with different package naming. Both work. Any guide written before that change will use the older <code>wpackagist-plugin/</code> prefix, so make sure the repository URL and the prefixes in your <code>composer.json</code> match each other.</p>



<h3 class="wp-block-heading">The gitignore that matters</h3>



<pre class="wp-block-code"><code># Secrets and machine-specific config. Never committed.
wp-config.php
.env

# Content, not code. This gets pulled down from production,
# it never gets deployed up.
wp-content/uploads/

# Generated at runtime or by the build.
wp-content/cache/
wp-content/upgrade/
wp-content/debug.log
node_modules/
vendor/</code></pre>



<p class="wp-block-paragraph">Ignoring <code>vendor/</code> assumes your deploy runs <code>composer install</code>. If your pipeline just rsyncs the repo with no build step, commit it instead, or you will ship a site with no dependencies. Decide which, then be consistent, because half-and-half is how you end up with a missing autoloader at two in the morning.</p>



<h2 class="wp-block-heading">Close the second write path</h2>



<p class="wp-block-paragraph">This is the step people skip, and it is the one that makes everything else work. If production can still install and update plugins from the browser, your repo is not the source of truth. It is a suggestion.</p>



<pre class="wp-block-code"><code>// wp-config.php on production and staging.

// Removes the ability to install, update or delete plugins and
// themes from the admin. Also disables the built-in file editor,
// so DISALLOW_FILE_EDIT is implied by this one.
define( 'DISALLOW_FILE_MODS', true );

// Stops core from updating itself behind your back, including
// the automatic minor updates that would otherwise change files
// your deploy is about to overwrite.
define( 'AUTOMATIC_UPDATER_DISABLED', true );</code></pre>



<p class="wp-block-paragraph">Be clear-eyed about the trade-off, because it is a real one. You have just taken away automatic security updates. That is only an improvement if you replace them with something: a scheduled dependency update job, a weekly review of the update list, a bot that opens a pull request when a plugin version changes. If you disable updates and then do nothing, you have made the site less safe, not more controlled.</p>



<p class="wp-block-paragraph">The update flow now runs through the repo. On your local machine:</p>



<pre class="wp-block-code"><code># Composer-managed: bump the lockfile, commit it, deploy.
composer update wp-plugin/some-plugin

# wp-content-in-git: let WP-CLI do the download locally,
# then commit the resulting file changes as a normal diff.
wp plugin update some-plugin</code></pre>



<p class="wp-block-paragraph">The second form is worth appreciating. A plugin update becomes a reviewable diff. When something breaks two days later, <code>git log</code> tells you exactly which files changed and <code>git revert</code> puts them back.</p>



<h2 class="wp-block-heading">Configuration per environment</h2>



<p class="wp-block-paragraph">Database credentials, salts and API keys differ per environment and none of them belong in git. Keep <code>wp-config.php</code> out of the repo and place it on each server, or use an environment file with a committed <code>.env.example</code> showing the required keys with no values.</p>



<p class="wp-block-paragraph">Core has a first-class way to tell environments apart, and it is underused:</p>



<pre class="wp-block-code"><code>// Per environment, in wp-config.php.
// Recognised values: 'local', 'development', 'staging', 'production'.
define( 'WP_ENVIRONMENT_TYPE', 'staging' );</code></pre>



<pre class="wp-block-code"><code>&lt;?php
// mu-plugin, committed to the repo, safe on every environment
// because it checks where it is running.

if ( wp_get_environment_type() !== 'production' ) {

    // Keep non-production sites out of search results.
    add_filter( 'pre_option_blog_public', '__return_zero' );

    // Short-circuit wp_mail() entirely. Returning a non-null
    // value stops WordPress before it hands anything to PHPMailer.
    add_filter( 'pre_wp_mail', '__return_false' );
}</code></pre>



<p class="wp-block-paragraph">That mail filter is the single most valuable line in this post. Restore a production database onto staging, forget this, and the first cron run mails real order confirmations, password resets and abandoned-cart nudges to real customers from a URL that does not work. It is silent until it is very loud.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Moving the database in the allowed direction</h2>



<p class="wp-block-paragraph">Set up WP-CLI aliases once and the rest becomes short commands instead of a runbook nobody follows.</p>



<pre class="wp-block-code"><code># wp-cli.yml in the project root, committed to the repo.
@staging:
  ssh: deploy@staging.example.com/var/www/staging.example.com
@production:
  ssh: deploy@example.com/var/www/example.com</code></pre>



<p class="wp-block-paragraph">Now pulling production down to your machine is one pipe. The trailing hyphen means &#8220;write the dump to standard output&#8221;, and the leading hyphen on the import means &#8220;read it from standard input&#8221;, so nothing touches disk:</p>



<pre class="wp-block-code"><code># Back up your local database first, then overwrite it.
wp db export local-backup.sql
wp @production db export - | wp db import -</code></pre>



<p class="wp-block-paragraph">The URLs inside that dump still point at production, and they are buried in serialized PHP arrays where a plain SQL find-and-replace corrupts the string length prefixes. This is exactly what <code>wp search-replace</code> exists for: it unserializes, replaces, and reserializes properly.</p>



<pre class="wp-block-code"><code># Always dry-run first and actually read the table list it prints.
wp search-replace 'https://example.com' 'https://example.test' 
  --all-tables-with-prefix --skip-columns=guid --dry-run

# Then for real.
wp search-replace 'https://example.com' 'https://example.test' 
  --all-tables-with-prefix --skip-columns=guid

wp cache flush</code></pre>



<p class="wp-block-paragraph">Two flags worth understanding rather than copying:</p>



<ul class="wp-block-list">
<li><strong><code>--skip-columns=guid</code></strong> because the <code>guid</code> column is a permanent identifier that feed readers and external systems use to decide whether they have already seen a post. Rewriting it makes subscribers re-receive your entire archive, and you find out from an angry email weeks later.</li>
<li><strong><code>--all-tables-with-prefix</code></strong> because plugins create their own tables using your prefix, and those tables are not registered with WordPress. Without this, the replacement misses them and you get half-migrated URLs in places nobody checks.</li>
</ul>



<p class="wp-block-paragraph">Uploads are the other half of the content problem. Do not put them in git; a media library will outgrow a repository quickly and every clone pays for it. Sync them separately with <code>rsync</code> when you need them, or skip them entirely locally and let a plugin proxy missing images from production. Offloading media to object storage sidesteps the question completely and is worth considering on any site with a large library.</p>



<h2 class="wp-block-heading">The deploy itself</h2>



<p class="wp-block-paragraph">Three broad options. <code>git pull</code> on the server is the simplest and the one I would move away from first: it puts a <code>.git</code> directory next to your document root, it has no build step, and a failed pull leaves the site half-updated. Rsync from CI is the pragmatic default. Atomic releases with a symlink swap, the way Deployer or Capistrano work, are the most correct because the site switches versions instantly and rollback is a symlink change.</p>



<p class="wp-block-paragraph">A workable rsync deploy from GitHub Actions, with staging on merge and production on tag:</p>



<pre class="wp-block-code"><code>name: Deploy
on:
  push:
    branches: [main]     # goes to staging
    tags: ['v*']         # goes to production

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4    # pin to a full SHA in production

      - name: Build
        run: |
          composer install --no-dev --optimize-autoloader
          npm ci
          npm run build

      - name: Sync files
        run: |
          echo "$DEPLOY_KEY" &gt; deploy_key
          chmod 600 deploy_key
          rsync -az --delete 
            -e "ssh -i deploy_key -o StrictHostKeyChecking=accept-new" 
            --exclude-from=.deployignore 
            ./ "$TARGET"
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          TARGET: ${{ secrets.DEPLOY_TARGET }}</code></pre>



<p class="wp-block-paragraph"><code>--delete</code> is what makes the target match the source exactly, which is the entire point of a deploy. It is also the flag that will erase your uploads directory if <code>.deployignore</code> is wrong. Test the exclude list against staging before you ever point this at production, and use <code>--dry-run</code> the first time.</p>



<p class="wp-block-paragraph">Files landing on the server is not the same as a working deploy. Run a post-deploy step over SSH:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

cd /var/www/example.com

# Applies schema changes that a core or plugin version bump needs.
# Safe to run when there is nothing to do.
wp core update-db

# Rewrite rules live in the database, so a plugin that registers
# new routes needs this or you get 404s on the new URLs.
wp rewrite flush

# Options and transients are cached; stale ones survive a file deploy.
wp cache flush

# Prove the site answers before you call the deploy finished.
# --fail makes curl exit non-zero on a 5xx instead of shrugging.
curl --fail --silent --show-error https://example.com/ &gt; /dev/null</code></pre>



<p class="wp-block-paragraph">If you run PHP-FPM with OPcache, add a reset to that script. Otherwise PHP happily serves the previous version of your code from its compiled cache and you spend twenty minutes convinced the deploy did not run.</p>



<h2 class="wp-block-heading">Making staging safe to hold real data</h2>



<p class="wp-block-paragraph">Staging is only useful if it mirrors production, and the moment it does it becomes dangerous. Every one of these has bitten somebody:</p>



<ul class="wp-block-list">
<li><strong>Outbound email.</strong> Covered above. Kill it at the <code>wp_mail()</code> level rather than trusting a plugin setting somebody can toggle.</li>
<li><strong>Cron.</strong> A restored production database brings production&#8217;s scheduled jobs with it. Set <code>DISABLE_WP_CRON</code> on staging and do not add a system cron job unless you specifically need to test scheduling.</li>
<li><strong>Payment gateways.</strong> The database carries live API keys. Overwrite them with test-mode credentials as part of your restore script, not by hand afterwards.</li>
<li><strong>Search indexing.</strong> Filter <code>blog_public</code> as shown, and put HTTP basic auth in front of the whole environment. Duplicate content ranking above your real site is an avoidable embarrassment.</li>
<li><strong>Personal data.</strong> A production restore is real customer data on a server that probably has weaker access controls. Either anonymise user records during the restore or treat staging with the same seriousness as production.</li>
</ul>



<p class="wp-block-paragraph">Write the restore as a single script that does all of this in order, so nobody has to remember step four. If it is a checklist in a wiki, it will be skipped.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">The deploy ran but the site is unchanged</h3>



<p class="wp-block-paragraph">In order: OPcache serving compiled bytecode, a page cache or CDN holding the old response, or rsync excluding the directory you edited. Check them in that order. Confirm the file on disk actually changed before blaming anything upstream of it.</p>



<h3 class="wp-block-heading">White screen immediately after deploying</h3>



<p class="wp-block-paragraph">Usually a missing autoloader, meaning <code>vendor/</code> was neither committed nor built. Check the PHP error log rather than guessing. A memory limit lower on the server than locally is the other common cause, and it produces the same blank page.</p>



<h3 class="wp-block-heading">Site works, admin redirects in a loop</h3>



<p class="wp-block-paragraph"><code>siteurl</code> and <code>home</code> in the options table disagree with the URL you are actually using. Almost always a search-replace that missed. Check with <code>wp option get siteurl</code> and <code>wp option get home</code>, and set them explicitly with <code>wp option update</code> rather than running the replacement again.</p>



<h3 class="wp-block-heading">Serialized data broken after a migration</h3>



<p class="wp-block-paragraph">Somebody ran <code>sed</code> or a SQL <code>REPLACE</code> on the dump. The length prefix inside each serialized string no longer matches the string, so PHP silently fails to unserialize and widgets, theme options and ACF fields come back empty. Restore from the dump and redo it with <code>wp search-replace</code>. There is no partial recovery worth attempting.</p>



<h3 class="wp-block-heading">Permalinks return 404 after deploy</h3>



<p class="wp-block-paragraph">Rewrite rules are stored in the database and did not get regenerated. Run <code>wp rewrite flush</code>. If it recurs on every deploy, add it to the post-deploy script permanently.</p>



<h3 class="wp-block-heading">Plugin reverts to an older version after every deploy</h3>



<p class="wp-block-paragraph">Somebody is still updating in wp-admin. That is not a pipeline bug, it is the second write path being open. Set <code>DISALLOW_FILE_MODS</code> and give the team a documented way to request an update.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Committing <code>wp-config.php</code>, and with it your database password and salts.</li>
<li>Putting <code>wp-content/uploads</code> in the repo, then wondering why clones take ten minutes.</li>
<li>Pushing a staging database over production because &#8220;the content is the same&#8221;. It never is.</li>
<li>Using <code>sed</code> or SQL to swap URLs instead of <code>wp search-replace</code>.</li>
<li>Replacing the <code>guid</code> column, and re-sending your archive to every RSS subscriber.</li>
<li>Running <code>rsync --delete</code> against production without testing the exclude list.</li>
<li>Leaving updates enabled in wp-admin, so the repo and the server drift apart silently.</li>
<li>Disabling automatic updates without putting a review process in its place.</li>
<li>Restoring production data onto staging with mail still enabled.</li>
<li>Forgetting <code>DISABLE_WP_CRON</code> on staging, so scheduled jobs fire twice across two environments.</li>
<li>Treating &#8220;the files copied&#8221; as proof the deploy worked, with no smoke test.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Make the repo the only way code reaches a server, and enforce it with <code>DISALLOW_FILE_MODS</code>.</li>
<li>Keep code moving forward and content moving backward, and state that rule where the team can see it.</li>
<li>Deploy staging from a branch and production from a tag, so production releases are deliberate.</li>
<li>Dry-run every search-replace and every rsync the first time.</li>
<li>Back up the target database immediately before any deploy that touches it.</li>
<li>Script the staging restore end to end, including mail, cron, keys and anonymisation.</li>
<li>Finish every deploy with a real HTTP request through the public URL, not a file listing.</li>
<li>Use environment-aware code with <code>wp_get_environment_type()</code> instead of commented-out blocks.</li>
<li>Keep uploads out of git and sync or offload them separately.</li>
<li>Replace automatic updates with a scheduled dependency review, and put it in a calendar.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Should WordPress core be in the repository?</h3>



<p class="wp-block-paragraph">Only if you are not managing it another way. Committing core works and makes the repo self-contained, at the cost of noisy diffs on every update. Declaring core as a Composer dependency is cleaner and gives you a pinned version without the noise. What you should not do is leave core unmanaged, so nobody can say which version production runs.</p>



<h3 class="wp-block-heading">How do I handle the database in a Git workflow?</h3>



<p class="wp-block-paragraph">You do not put it in Git. Git handles code; the database is handled by scripted pulls in one direction. Production to staging to local, with a search-replace on import. The only time a database legitimately goes upward is the initial launch, before there is real content to lose.</p>



<h3 class="wp-block-heading">What about plugin settings changed in staging?</h3>



<p class="wp-block-paragraph">This is the genuinely hard part and there is no clean answer. Settings live in the database, so they do not deploy. Options are: reapply them by hand in production with a documented checklist, script them as WP-CLI commands in a deploy step, or use a plugin that exports configuration to files. Whichever you pick, write it down, because &#8220;I&#8217;ll remember&#8221; is how a setting gets applied to staging and never production.</p>



<h3 class="wp-block-heading">Is Bedrock necessary?</h3>



<p class="wp-block-paragraph">No. It is a well-made set of defaults for Composer-managed WordPress with environment-based configuration, and it saves assembling those pieces yourself. Plenty of teams run a perfectly disciplined Git deployment with a standard directory layout. Adopt it if you want the conventions; do not adopt it as a prerequisite.</p>



<h3 class="wp-block-heading">Do I need three environments?</h3>



<p class="wp-block-paragraph">You need local and production. Staging earns its cost when you have multiple people, a client who signs off on changes, or a site where breakage costs money. On a personal blog it is overhead. On a store, skipping it means production is your test environment.</p>



<h3 class="wp-block-heading">Can I do this on shared hosting?</h3>



<p class="wp-block-paragraph">Partly. You need SSH and ideally WP-CLI, and plenty of shared plans have neither. Some managed WordPress hosts offer a git push deployment target, which handles the file half for you. If you want the full workflow with your own build steps and post-deploy hooks, a small VPS from a provider like InterServer gives you the SSH access and cron control the workflow assumes.</p>



<h3 class="wp-block-heading">How do I roll back a bad deploy?</h3>



<p class="wp-block-paragraph">Files are easy: redeploy the previous tag, or swap the symlink back if you use atomic releases. The database is the hard part, which is why you take a dump immediately before any deploy that runs a migration. Rolling files back without rolling the database back can leave you worse off than the broken version, so know which of the two actually changed before you act.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Git-based WordPress deployment is not really a pipeline problem. The pipeline is the easy part, and you can build a working one in an afternoon. The hard part is making the repo the only way code reaches a server, and then keeping the database flowing in exactly one direction.</p>



<p class="wp-block-paragraph">Get those two right and everything else is detail. Get them wrong and you will keep having the same confusing morning, staring at a plugin version that nobody remembers changing.</p>



<h2 class="wp-block-heading">Want this set up properly on your site?</h2>



<p class="wp-block-paragraph">Retrofitting version control onto a live WordPress site is fiddly work, mostly because you have to do it without downtime and without losing whatever has been changed directly on the server. Things I take on:</p>



<ul class="wp-block-list">
<li>Moving an existing production site into Git without downtime, including reconciling whatever has drifted on the server.</li>
<li>Converting a site to Composer-managed plugins and core, with a working lockfile and a sane update process.</li>
<li>Building local, staging and production environments that actually match, with per-environment configuration.</li>
<li>Deploy pipelines in GitHub Actions or GitLab CI: build, rsync or atomic releases, post-deploy WP-CLI steps, smoke test, rollback path.</li>
<li>Scripted database refreshes from production to staging with mail, cron, API keys and personal data handled automatically.</li>
<li>Server setup for the workflow: SSH deploy users, correct file ownership, OPcache resets, cron.</li>
</ul>



<p class="wp-block-paragraph">Tell me how your site is hosted and how plugins currently get updated, and I will tell you what the first step should be.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/git-based-wordpress-deployment/">Two Sources of Truth: Git-Based WordPress Deployment From Local to Production</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully</title>
		<link>https://john-nessime.com/blog/devops/github-actions-vps-deployment/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:21 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[OpenSSH]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[rsync]]></category>
		<category><![CDATA[SSH]]></category>
		<category><![CDATA[Supply Chain Security]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=66</guid>

					<description><![CDATA[<p>A deploy key in GitHub secrets is a shell on your production server, handed to a container you don't control, running code from maintainers you've never met. Here's how to build the pipeline so a leaked key isn't worth much: forced commands, scoped sudo, pinned actions and a real approval gate.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/github-actions-vps-deployment/">Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Stop and look at what you are about to build. A private SSH key that can log into your production server, pasted into a text box on github.com, handed to a container you do not control, which then runs code from repositories maintained by people you have never met.</p>



<p class="wp-block-paragraph">That is not hypothetical caution. In March 2025 a widely used third-party action was compromised: the attacker got hold of a maintainer&#8217;s token, pushed a malicious commit, and then <em>retroactively repointed every version tag</em> at it. Repositories that referenced the action by tag, which is nearly all of them, picked up the payload automatically. It dumped the runner&#8217;s secrets into the build log. On public repositories, those logs are readable by anyone.</p>



<p class="wp-block-paragraph">Nobody&#8217;s workflow file changed. Nobody clicked anything. The pipeline just quietly handed out its credentials one Friday.</p>



<p class="wp-block-paragraph">A <strong>GitHub Actions VPS deployment</strong> is fundamentally a credential handoff, and most guides on the topic skip straight to the YAML. This post covers the YAML too, but the useful part is what surrounds it: how to narrow the key so it cannot do much, how to verify you are talking to the right server, how to stop an unreviewed commit reaching production, and which of the failures here are silent.</p>



<h2 class="wp-block-heading">Who can actually reach that key</h2>



<p class="wp-block-paragraph">Worth being explicit, because it is a longer list than people expect:</p>



<ul class="wp-block-list">
<li><strong>Anyone with write access to the repository.</strong> A workflow file is code. Push a branch with a step that prints the key somewhere, trigger it, done. Write access to the repo is effectively access to the server.</li>
<li><strong>Every action in the job.</strong> Secrets are exposed to the whole job, not just the step that references them. A compromised action anywhere in that job can read the environment.</li>
<li><strong>Anyone who can influence what runs.</strong> This is why <code>pull_request_target</code> combined with checking out the pull request&#8217;s own code is such a well-known foot-gun: it runs the fork&#8217;s code with the base repository&#8217;s secrets in scope.</li>
</ul>



<p class="wp-block-paragraph">The goal is not to make the key unreachable. It is to make a stolen key not worth much.</p>



<h2 class="wp-block-heading">Decide how the runner authenticates</h2>



<p class="wp-block-paragraph">Three options, and the right one depends on what you already run.</p>



<ol class="wp-block-list">
<li><strong>A dedicated SSH key in repository secrets.</strong> The default. Simple, works everywhere, and it is a long-lived credential sitting in a place several people can reach. Acceptable when the key is narrowed properly, which is most of the rest of this post.</li>
<li><strong>An overlay network.</strong> Put the runner and the server on a private network with Tailscale or WireGuard for the duration of the job, using an ephemeral auth key, and close SSH to the public internet entirely. More moving parts, and it removes port 22 from the internet rather than defending it. If you already run an overlay for other reasons, this is clearly better.</li>
<li><strong>A self-hosted runner on the server.</strong> No inbound SSH at all, since the runner polls outward. The trade-off is real and often understated: you have just installed a general-purpose code execution service on your production box, and for a public repository that is close to indefensible. On a private repository with a trusted team it is reasonable.</li>
</ol>



<p class="wp-block-paragraph">One thing that sounds appealing and is not practical: allowlisting GitHub&#8217;s runner IP ranges at your firewall. The ranges are published through GitHub&#8217;s meta API, but they are enormous and they change, and every other GitHub customer&#8217;s job runs from inside them. It buys you very little for a lot of maintenance.</p>



<h2 class="wp-block-heading">Narrow the key until it is boring</h2>



<p class="wp-block-paragraph">Assume the key leaks. What can the holder do? Work backwards from there.</p>



<p class="wp-block-paragraph"><strong>Give it its own user.</strong> A <code>deploy</code> account that owns the application directory and nothing else. Not your admin account, not root, and definitely not a key you also use from your laptop.</p>



<p class="wp-block-paragraph"><strong>Restrict the key in <code>authorized_keys</code>.</strong> SSH lets you attach options to an individual key, and they apply regardless of what the client asks for:</p>



<pre class="wp-block-code"><code># ~deploy/.ssh/authorized_keys
#
# restrict = no port forwarding, no agent forwarding, no X11,
#            no PTY, no user rc file. Deny everything, then
#            re-enable only what you need.
# command  = ignore whatever the client asked to run and run
#            this instead. The client's command is available
#            to the script as $SSH_ORIGINAL_COMMAND.

restrict,command="/usr/local/bin/deploy-guard" ssh-ed25519 AAAA...  deploy@github-actions</code></pre>



<p class="wp-block-paragraph">The guard script decides what the key is allowed to do. Keep it short enough to read in one go:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
# /usr/local/bin/deploy-guard
set -euo pipefail

case "${SSH_ORIGINAL_COMMAND:-}" in
  # rsync sends its own server-side invocation. Match it loosely
  # enough to work, tightly enough to pin the destination.
  "rsync --server "*" /var/www/app/")
    exec $SSH_ORIGINAL_COMMAND
    ;;
  "deploy:reload")
    exec sudo /bin/systemctl reload nginx
    ;;
  *)
    echo "refused: $SSH_ORIGINAL_COMMAND" &gt;&amp;2
    exit 1
    ;;
esac</code></pre>



<p class="wp-block-paragraph">Now a stolen key can write to one directory and reload one service. It cannot open a shell, cannot forward a port into your private network, and cannot read anything else on the box.</p>



<p class="wp-block-paragraph"><strong>Scope the sudo entry too.</strong> The deploy user needs to reload a service, not run arbitrary commands as root:</p>



<pre class="wp-block-code"><code># visudo -f /etc/sudoers.d/deploy
# One command, no password, no wildcards. A wildcard here
# often turns back into a general-purpose root shell.
deploy ALL=(root) NOPASSWD: /bin/systemctl reload nginx</code></pre>



<p class="wp-block-paragraph">Be honest about the cost: the guard script is one more thing to maintain, and the day you add a new deploy step it will refuse it and you will spend ten minutes confused. That is the trade you are making, and on anything handling customer data it is worth it. On a hobby project, a plain unrestricted <code>deploy</code> user with no sudo is a reasonable stopping point.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Verify the server, not just the key</h2>



<p class="wp-block-paragraph">Almost every tutorial on this topic contains <code>StrictHostKeyChecking=no</code>, which turns off the check that stops you handing your credentials to whatever machine answered. It is there because the alternative is mildly annoying, and the alternative takes one command.</p>



<pre class="wp-block-code"><code># Run this once, from a machine you trust, and compare the
# fingerprint against what the server reports locally.
ssh-keyscan -t ed25519 example.com</code></pre>



<p class="wp-block-paragraph">Store that output as a repository variable, not a secret. It is public information, and keeping it visible means you can actually see it when it changes, which matters because it will change the first time you rebuild the server.</p>



<h2 class="wp-block-heading">The workflow</h2>



<pre class="wp-block-code"><code>name: Deploy

on:
  push:
    tags: ['v*']          # production ships from tags, not branches

# This job reads code and talks to a server. It has no business
# writing to the repository, so take that away.
permissions:
  contents: read

concurrency:
  group: deploy-production
  # Deliberately NOT cancel-in-progress. Killing a deploy halfway
  # leaves the server in a state nobody designed.
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment:
      name: production      # scoped secrets and required reviewers live here
      url: https://example.com

    steps:
      # Pin every action to a full 40-character commit SHA, with the
      # version in a trailing comment so Dependabot can still bump it.
      # Get the SHA from the action's releases page.
      - uses: actions/checkout@PUT_THE_FULL_COMMIT_SHA_HERE  # pinned

      - name: Build
        run: |
          npm ci
          npm run build

      - name: Configure SSH
        # Pass secrets through env, never interpolate ${{ }} directly
        # into a run block. Interpolation happens before the shell
        # sees the script, so a value with the wrong characters
        # either breaks the script or becomes part of it.
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%sn' "$DEPLOY_KEY" &gt; ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%sn' "$KNOWN_HOSTS" &gt; ~/.ssh/known_hosts

      - name: Ship
        run: |
          rsync -az --delete --exclude-from=.deployignore 
            -e "ssh -o BatchMode=yes" 
            ./dist/ deploy@example.com:/var/www/app/

      - name: Reload and verify
        run: |
          ssh -o BatchMode=yes deploy@example.com 'deploy:reload'
          curl --fail --silent --show-error https://example.com/healthz &gt; /dev/null</code></pre>



<p class="wp-block-paragraph">Three details in there worth calling out, because they are the ones people leave out.</p>



<p class="wp-block-paragraph"><code>-o BatchMode=yes</code> stops SSH prompting for anything. Without it, a missing key or an unknown host produces a prompt that nobody is there to answer, and the job hangs until the timeout instead of failing in three seconds.</p>



<p class="wp-block-paragraph"><code>--delete</code> is what makes the target match the source, which is the point of a deploy, and also what will erase your uploads directory if <code>.deployignore</code> is wrong. Run it once with <code>--dry-run</code> against staging and read the list before you point it at production.</p>



<p class="wp-block-paragraph">The final <code>curl --fail</code> is the difference between &#8220;the files copied&#8221; and &#8220;the site works&#8221;. Files landing on disk is not a successful deploy, and a job that goes green while the site returns 502 is worse than one that fails.</p>



<h2 class="wp-block-heading">Third-party actions</h2>



<p class="wp-block-paragraph">There are convenient actions that wrap SSH deployment, and they save maybe eight lines. In the one job on your account that holds a production credential, eight lines is a bad trade for another maintainer in your trust chain. The workflow above uses plain <code>ssh</code> and <code>rsync</code> for that reason.</p>



<p class="wp-block-paragraph">Where you do use third-party actions, pin them to a full commit SHA. A tag is a pointer and pointers can be moved, which is precisely what happened in the incident at the top of this post. A SHA is content-addressed: it cannot be repointed at different code.</p>



<p class="wp-block-paragraph">Two things make this sustainable rather than miserable. Dependabot understands SHA pins with a version comment and will open pull requests to bump them. And you can restrict which actions are allowed to run at all, in the repository or organisation settings, which is worth doing once and forgetting about.</p>



<h2 class="wp-block-heading">Gate production properly</h2>



<p class="wp-block-paragraph">The <code>environment:</code> key in that workflow is doing more work than it appears to. Environments give you three things you cannot get from repository secrets alone:</p>



<ul class="wp-block-list">
<li><strong>Scoped secrets.</strong> The production key is only available to jobs that declare the production environment. A branch that adds a job without it gets nothing.</li>
<li><strong>Required reviewers.</strong> The job pauses and waits for a named human to approve it. This is the single control that turns &#8220;anyone with write access can deploy&#8221; into &#8220;anyone with write access can request a deploy&#8221;.</li>
<li><strong>Deployment branch and tag rules.</strong> Restrict the environment so only protected tags or a specific branch can use it, so a feature branch cannot reach production even with the right job definition.</li>
</ul>



<p class="wp-block-paragraph">Deploying from tags rather than branch pushes is worth the small friction. It makes a release a deliberate act, and it gives you an obvious rollback: redeploy the previous tag.</p>



<p class="wp-block-paragraph">For anything with real traffic, put the files somewhere new and switch atomically rather than rsyncing over a live directory. Copy into a timestamped release directory, then move a symlink. The site changes version instantly instead of spending several seconds as a mix of old and new files, and rollback becomes a symlink change rather than a redeploy.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Permission denied (publickey)</h3>



<p class="wp-block-paragraph">The most common cause by far is a missing trailing newline. Private keys must end with one, and pasting into a web form frequently drops it. That is why the workflow uses <code>printf '%sn'</code> rather than <code>echo</code>: it guarantees the newline regardless of what got stored.</p>



<p class="wp-block-paragraph">After that, check permissions on the server: <code>~/.ssh</code> must be 700 and <code>authorized_keys</code> 600, owned by the deploy user, and the home directory must not be group-writable. Add <code>-vvv</code> to the ssh command to see how far the handshake gets, and read <code>journalctl -u ssh</code> on the server for the reason, since the client is deliberately vague.</p>



<h3 class="wp-block-heading">Host key verification failed</h3>



<p class="wp-block-paragraph">Either the <code>known_hosts</code> variable is empty, or the server&#8217;s host key genuinely changed. If you rebuilt the server, regenerate it with <code>ssh-keyscan</code> and update the variable. If you did not rebuild the server, stop and find out why the key changed before you update anything.</p>



<h3 class="wp-block-heading">The job hangs until the timeout</h3>



<p class="wp-block-paragraph">SSH is waiting on a prompt nobody will answer. Add <code>-o BatchMode=yes</code> and it fails immediately with a readable reason instead.</p>



<h3 class="wp-block-heading">rsync works from my laptop, fails from the runner</h3>



<p class="wp-block-paragraph">If you set a forced command, it is rejecting the invocation. The refusal message goes to stderr on the server side, so add a log line to the guard script that records <code>$SSH_ORIGINAL_COMMAND</code>, run the job once, and read what rsync actually sent.</p>



<h3 class="wp-block-heading">Works on push, fails on pull requests</h3>



<p class="wp-block-paragraph">Working as intended. Secrets are not exposed to workflows triggered by pull requests from forks. Do not reach for <code>pull_request_target</code> to work around it. Split the workflow so that pull requests build and test without secrets, and only pushes or tags deploy.</p>



<h3 class="wp-block-heading">A secret appeared in the logs</h3>



<p class="wp-block-paragraph">Masking matches the exact stored string. Transform it in any way and the mask stops working: base64-encoded, JSON-escaped, split across lines, or with whitespace trimmed. Do not treat masking as a control. Rotate the key, delete the run logs, and remove whatever produced the output.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Using an existing personal SSH key instead of generating a dedicated one for the runner.</li>
<li>Giving the deploy user full sudo, or a sudo rule with a wildcard in it.</li>
<li>Disabling host key checking with <code>StrictHostKeyChecking=no</code>.</li>
<li>Referencing third-party actions by tag in the job that holds your production key.</li>
<li>Interpolating <code>${{ secrets.X }}</code> directly into a <code>run:</code> script instead of passing it through <code>env:</code>.</li>
<li>Leaving default write permissions on a job that only needs to read.</li>
<li>Using <code>cancel-in-progress: true</code> on a deploy, so a second push interrupts the first mid-copy.</li>
<li>Running <code>rsync --delete</code> at production without testing the exclude list.</li>
<li>Treating &#8220;the files copied&#8221; as a successful deploy, with no health check.</li>
<li>Deploying straight from branch pushes with no approval step.</li>
<li>Relying on log masking to keep a leaked secret out of the output.</li>
<li>Never rotating the deploy key, including after someone leaves the team.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Dedicated deploy user, dedicated key, used for nothing else.</li>
<li>Restrict the key in <code>authorized_keys</code> with <code>restrict</code> and a forced command.</li>
<li>Scope sudo to the exact commands the deploy needs.</li>
<li>Pin every action to a full commit SHA and let Dependabot bump them.</li>
<li>Keep the deploy job small: fewer steps means fewer things that can read the secret.</li>
<li>Set <code>permissions:</code> explicitly, defaulting to read-only.</li>
<li>Verify host keys from a stored <code>known_hosts</code> value.</li>
<li>Use environments with required reviewers and deployment rules for production.</li>
<li>Deploy from tags, and make rollback a redeploy of the previous tag or a symlink swap.</li>
<li>End every deploy with a real HTTP request against the public URL.</li>
<li>Rotate the deploy key on a schedule and whenever someone leaves.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Is it safe to store an SSH private key in GitHub secrets?</h3>



<p class="wp-block-paragraph">Safe enough, if the key is worth little on its own. Secrets are encrypted at rest and masked in logs, but they are readable by any job that references them and by anyone who can modify a workflow. Assume it can leak and make sure a leaked key only grants write access to one directory and one service reload.</p>



<h3 class="wp-block-heading">Should I use a ready-made SSH deploy action?</h3>



<p class="wp-block-paragraph">They work and they are convenient. My preference is plain <code>ssh</code> and <code>rsync</code> in the job that touches production, because it removes a maintainer from the trust chain for very little added effort. If you do use one, pin it to a SHA like anything else.</p>



<h3 class="wp-block-heading">Can I use OIDC instead of a stored key?</h3>



<p class="wp-block-paragraph">Not directly against sshd, which has no concept of an OIDC token. OIDC removes long-lived secrets when the target is a cloud provider that can verify GitHub&#8217;s token. To get the same benefit for a plain VPS you need something in between that trades the token for short-lived access: a secrets manager, a bastion that supports it, or an overlay network with ephemeral auth keys.</p>



<h3 class="wp-block-heading">Should I allowlist GitHub&#8217;s IP ranges on my firewall?</h3>



<p class="wp-block-paragraph">Generally not worth it. The published ranges are large and change over time, so you are signing up for ongoing maintenance, and every other GitHub customer&#8217;s runners share those ranges. Narrowing what the key can do achieves more.</p>



<h3 class="wp-block-heading">Self-hosted runner or hosted runner?</h3>



<p class="wp-block-paragraph">Hosted for public repositories, without exception, because a self-hosted runner will execute code from pull requests. Self-hosted is reasonable on a private repository with a trusted team, and it removes inbound SSH entirely. Just be clear that you have moved the risk rather than removed it.</p>



<h3 class="wp-block-heading">How do I roll back a bad deploy?</h3>



<p class="wp-block-paragraph">Redeploy the previous tag, or swap the symlink back if you use atomic releases. The part that does not roll back is the database, so if a release ran a migration, know what it did before you revert the files. Files going backwards while the schema stays forwards is often worse than the bug you were fixing.</p>



<h3 class="wp-block-heading">How often should the deploy key be rotated?</h3>



<p class="wp-block-paragraph">On a schedule you will actually keep, and immediately whenever someone with repository access leaves or a run log looks suspicious. Rotation is two steps, adding the new public key to <code>authorized_keys</code> and updating the secret, so there is no good reason for it to be a project.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">The YAML is the easy half. A working GitHub Actions VPS deployment takes an afternoon; the part that decides whether it is a good idea is what that key can do once it is out of your hands, and it will eventually be out of your hands.</p>



<p class="wp-block-paragraph">So build it assuming the key leaks. Dedicated user, forced command, scoped sudo, pinned actions, an approval gate on production. Then the worst case is an inconvenient rotation rather than an incident, and you will not be reading a build log at midnight trying to work out what a stranger&#8217;s code did with your credentials.</p>



<h2 class="wp-block-heading">Want this built or reviewed?</h2>



<p class="wp-block-paragraph">Most deploy pipelines I get asked to look at work fine and hand out far more access than they need. Work I take on:</p>



<ul class="wp-block-list">
<li>Building a GitHub Actions deploy to a VPS end to end: build, ship, reload, health check, rollback path.</li>
<li>Auditing an existing pipeline for what a leaked runner secret would actually grant, and closing the gap.</li>
<li>Locking down the server side: deploy user, forced commands, scoped sudo, atomic release directories.</li>
<li>Moving SSH off the public internet onto an overlay network, or setting up a self-hosted runner safely.</li>
<li>Pinning and auditing third-party actions, plus repository settings that limit what can run.</li>
<li>Environments, required reviewers and tag-based releases for teams that currently deploy on every push to main.</li>
</ul>



<p class="wp-block-paragraph">Send me your workflow file and the output of <code>ssh deploy@yourhost 'id'</code>, and I will tell you what a stolen key would get.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/github-actions-vps-deployment/">Your Deploy Key Is a Root Shell: GitHub Actions VPS Deployment Over SSH, Done Carefully</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</title>
		<link>https://john-nessime.com/blog/devops/fail-fast-pipeline/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 23:12:22 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Exit Codes]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[GitLab CI]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Pipefail]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Shell Scripting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Terraform]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=55</guid>

					<description><![CDATA[<p>A green pipeline that ships a broken build is worse than a red one. Here is how to order stages so failures surface in seconds, stop shell pipes from swallowing exit codes, and write failure output that tells the next engineer exactly what to fix.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/fail-fast-pipeline/">Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The deploy went out at the end of the day. Pipeline green, every stage a tick, nobody thought about it again. Twenty minutes later support asks why checkout is returning 502.</p>



<p class="wp-block-paragraph">You go back through the logs and there it is, four hundred lines up, in a step that reported success: a download that returned an HTML error page, got piped into <code>tar</code>, and produced an archive containing nothing useful. The shell reported the exit status of <code>tar</code>, not of <code>curl</code>. The build carried on. The pipeline was never lying on purpose. It just never asked the right question.</p>



<p class="wp-block-paragraph">Two things go wrong with pipelines, and only one of them gets talked about. The famous one is slow feedback: thirty-four minutes of build and integration tests before a linter tells you a file is missing a trailing newline. The expensive one is a pipeline that passes when it should have stopped. A <strong>fail fast pipeline</strong> fixes the first problem. Making it explain itself fixes the second, and that is the half most teams skip.</p>



<p class="wp-block-paragraph">This post covers both: how to order stages so real failures surface in seconds, how to stop the shell from hiding non-zero exits, how to design failure output that hands the next engineer the fix, and the troubleshooting steps for when a pipeline stubbornly refuses to fail at the right moment.</p>



<h2 class="wp-block-heading">The failure that actually bites: a pipeline that lies</h2>



<p class="wp-block-paragraph">Start here, because everything else is optimisation on top. A slow pipeline wastes your afternoon. A silently passing pipeline ships a broken artifact and burns a day of debugging in production, usually while somebody is on a call with a customer.</p>



<p class="wp-block-paragraph">The shell is where most of this happens. Bash gives you three defaults that are wrong for CI, and one line fixes all three.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

# Without pipefail this line exits 0 even when curl fails,
# because the shell reports the status of the LAST command
# in the pipe, which is tar.
curl -sSL https://example.com/artifact.tar.gz | tar -xz</code></pre>



<h3 class="wp-block-heading">What each flag actually protects</h3>



<ul class="wp-block-list">
<li><strong><code>-e</code></strong> stops the script when a command exits non-zero. Without it, a failed database migration is followed by a successful <code>echo</code>, and the script exits 0 because the last command succeeded.</li>
<li><strong><code>-u</code></strong> turns an unset variable into an error. This is the one that saves you from <code>rm -rf "$PREFIX/build"</code> expanding to <code>rm -rf "/build"</code> when someone renames an environment variable in the pipeline settings and forgets the script.</li>
<li><strong><code>-o pipefail</code></strong> makes a pipeline return the first non-zero status instead of the last one. Every <code>curl | tar</code>, <code>cat | jq</code>, and <code>docker logs | grep</code> in your scripts is currently reporting only the exit code of the thing on the right.</li>
</ul>



<p class="wp-block-paragraph">The exit status is only half the story though. Plenty of tools return 0 while doing something useless, and <code>curl</code> is the classic offender.</p>



<pre class="wp-block-code"><code># curl exits 0 here even when the server returns 500,
# and you end up untarring an HTML error page.
curl -sSL "$ARTIFACT_URL" | tar -xz

# --fail          makes curl exit non-zero on 4xx and 5xx
# --show-error    keeps the reason visible even with --silent
# --location      follows redirects instead of saving a 302 body
curl --fail --silent --show-error --location "$ARTIFACT_URL" | tar -xz</code></pre>



<p class="wp-block-paragraph">Now the download failing stops the build, and <code>pipefail</code> makes sure that failure propagates past the pipe. The first fixes the check, the second makes sure the check is heard.</p>



<h3 class="wp-block-heading">Where <code>set -e</code> quietly gives up</h3>



<p class="wp-block-paragraph">This trips people up constantly. <code>set -e</code> is suspended whenever a command&#8217;s exit status is being consumed by something else. It is not a safety net, it is a default.</p>



<pre class="wp-block-code"><code>set -e

# -e does NOT stop the script here.
# The if statement consumes the exit status.
if grep -q "TODO" src/main.go; then
  echo "found"
fi

# -e does NOT stop the script here either.
# The left side of || is explicitly allowed to fail.
run_migrations || echo "migrations failed, continuing anyway"

# -e DOES stop the script here.
run_migrations</code></pre>



<p class="wp-block-paragraph">The same trap applies inside containers. On Debian-based images <code>/bin/sh</code> is <code>dash</code>, which has no <code>pipefail</code> option at all, so putting <code>set -o pipefail</code> inside a <code>RUN</code> line fails outright. Set the shell at the top of the Dockerfile instead. <a href="https://github.com/hadolint/hadolint" target="_blank" rel="noreferrer noopener">Hadolint</a> flags this as DL4006 and it is worth wiring into your static stage.</p>



<pre class="wp-block-code"><code># Set this before any RUN instruction that contains a pipe.
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN curl --fail --silent --show-error --location 
      https://example.com/tool.tar.gz | tar -xz -C /usr/local/bin</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Order stages by cost, not by category</h2>



<p class="wp-block-paragraph">Most pipelines are laid out the way the team thinks about work: tests, then build, then security, then deploy. That grouping is comfortable and it is the wrong axis. Order by two things instead, cost and failure probability.</p>



<p class="wp-block-paragraph">The rule I use: <strong>every stage should be cheaper than the one after it and more likely to fail than the one after it.</strong> If a check catches ten percent of your broken commits and takes fifteen seconds, it goes in front of the check that catches two percent and takes nine minutes. That is the whole design principle behind a fail fast pipeline.</p>



<ol class="wp-block-list">
<li><strong>Syntax and static analysis.</strong> <code>shellcheck</code>, <code>hadolint</code>, <code>yamllint</code>, <code>terraform validate</code>, <code>tflint</code>, formatter checks, type checks. Seconds, no dependencies, no network. This stage catches an embarrassing share of broken pushes.</li>
<li><strong>Unit tests.</strong> No database, no containers, no network. If they need any of those, they are integration tests and belong further down.</li>
<li><strong>Secrets and dependency scanning.</strong> Fast, and a leaked credential is worth stopping before you build an image around it.</li>
<li><strong>Build.</strong> This is where you start spending real minutes and cache space.</li>
<li><strong>Integration and end-to-end.</strong> Slow, flaky, expensive to run. Everything above should have already caught the boring failures.</li>
<li><strong>Deploy and post-deploy verification.</strong></li>
</ol>



<p class="wp-block-paragraph">A note on the trade-off, because this ordering costs something. Splitting the fast checks into their own stage adds a checkout and a container pull before your unit tests even start. On a busy shared runner that overhead is real. If your static stage takes twelve seconds of work and forty seconds of setup, merge it into the test job and run it as the first step instead. The principle is failure order, not job count.</p>



<p class="wp-block-paragraph">The other thing that keeps the fast lane fast is caching, and it is the piece people get wrong in the opposite direction: they cache so aggressively that a stale dependency tree hides a genuine break. Cache the package manager&#8217;s download directory, not the resolved dependency tree, and key the cache on the lockfile hash.</p>



<h2 class="wp-block-heading">Let the platform kill work you already know is doomed</h2>



<p class="wp-block-paragraph">Ordering stages is only useful if the platform actually stops when something breaks. Three settings do most of the work, and they exist under different names on every runner.</p>



<h3 class="wp-block-heading">GitHub Actions</h3>



<pre class="wp-block-code"><code>concurrency:
  # A new push to the same branch cancels the run that is
  # already in flight. Nobody needs results for a commit
  # that has been superseded.
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  static:
    runs-on: ubuntu-latest
    timeout-minutes: 5          # a hung job is worse than a failed one
    steps:
      - uses: actions/checkout@v4   # pin to a full commit SHA in production
      - run: shellcheck scripts/*.sh
      - run: hadolint Dockerfile

  test:
    needs: static               # will not start until static passes
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false          # see note below
      matrix:
        suite: [unit, contract]
    steps:
      - uses: actions/checkout@v4
      - run: make test-${{ matrix.suite }}</code></pre>



<p class="wp-block-paragraph">That <code>fail-fast: false</code> looks like it contradicts the whole post, so it is worth explaining. Matrix fail-fast defaults to true: one leg fails, the rest get cancelled. That is right when the legs are redundant and you only need to know that something broke. It is wrong when the legs tell you different things. If your matrix is three runtime versions and only one fails, cancelling the others hides whether this is a version-specific problem or a general one, and you will burn a rerun finding out. Fail fast on the pipeline, not on the diagnosis.</p>



<p class="wp-block-paragraph"><code>timeout-minutes</code> is the setting people skip and regret. A job waiting on an interactive prompt, a dead package mirror, or a container that never becomes healthy will sit there consuming a runner until someone notices. Set a timeout on every job, generous enough that a slow day does not trip it and tight enough that a hang gets caught inside one coffee break.</p>



<h3 class="wp-block-heading">GitLab CI</h3>



<pre class="wp-block-code"><code>stages: [static, test, build, deploy]

default:
  interruptible: true     # allows auto-cancel when a newer pipeline starts
  retry:
    max: 2                # capped at 2 by GitLab, so 3 attempts total
    when:
      # Retry only on infrastructure problems.
      # Never retry script_failure: that is your code failing,
      # and retrying it just hides a flaky test.
      - runner_system_failure
      - stuck_or_timeout_failure
      - api_failure

lint:
  stage: static
  timeout: 5m
  script:
    - shellcheck scripts/*.sh

integration:
  stage: test
  needs: [lint]           # DAG: starts as soon as lint passes
  script:
    - make integration</code></pre>



<p class="wp-block-paragraph">The <code>retry: when</code> list is the important bit and it is the one people get lazy about. A bare <code>retry: 2</code> retries on everything including a genuine test failure, which converts an intermittent bug into an invisible one. Scope retries to the failure classes that are genuinely the infrastructure&#8217;s fault. Jenkins has <code>failFast true</code> for parallel stages, Buildkite and CircleCI have their own equivalents, and the reasoning transfers cleanly.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A failure should hand you the answer, not the search</h2>



<p class="wp-block-paragraph">Here is the test I apply to a pipeline failure. Can someone who did not write the change, at an inconvenient hour, fix it from the failure output alone without opening the source? If not, the failure message is incomplete.</p>



<p class="wp-block-paragraph">Four things need to be in there:</p>



<ul class="wp-block-list">
<li><strong>What failed</strong>, named precisely. Not &#8220;build failed&#8221; but the command and its exit code.</li>
<li><strong>Why</strong>, in terms of the input rather than the internals. &#8220;lockfile is out of date with package.json&#8221;, not &#8220;npm ci exited 1&#8221;.</li>
<li><strong>Where</strong>, meaning the file, the line, or the config key that caused it.</li>
<li><strong>What to do next.</strong> One concrete action, even if it is just a runbook link.</li>
</ul>



<p class="wp-block-paragraph">In shell, a small helper plus an <code>ERR</code> trap gets you most of the way there.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

fail() {
  echo "ERROR: $1" &gt;&amp;2
  echo "  fix: $2" &gt;&amp;2
  exit 1
}

# Catches anything you did not write an explicit check for,
# and tells you the line and the command that blew up.
trap 'echo "FAILED at line $LINENO: $BASH_COMMAND" &gt;&amp;2' ERR

[[ -f config/app.yaml ]] || fail 
  "config/app.yaml is missing" 
  "copy config/app.yaml.example and set DATABASE_URL"

[[ -n "${IMAGE_TAG:-}" ]] || fail 
  "IMAGE_TAG is empty" 
  "check the tag job output; it sets IMAGE_TAG from the git SHA"</code></pre>



<p class="wp-block-paragraph">Note <code>${IMAGE_TAG:-}</code> rather than <code>$IMAGE_TAG</code>. Under <code>set -u</code> the bare form aborts with a shell error before your nice message ever prints, which is technically correct and practically useless.</p>



<p class="wp-block-paragraph">On GitHub Actions you can go one step further and attach the error to the file and line, so it shows up as an annotation on the pull request instead of something to be dug out of a log.</p>



<pre class="wp-block-code"><code># Renders as a red annotation on that exact line in the diff view.
echo "::error file=deploy/values.yaml,line=42::image tag is empty"

# Visible, but does not fail the job.
echo "::warning file=Dockerfile,line=7::apt cache not cleaned"</code></pre>



<p class="wp-block-paragraph">Two more habits worth building. Upload logs, test reports, and screenshots as artifacts on failure, conditionally, so the evidence survives the job that produced it. And emit test results as JUnit XML so the platform renders a list of failed test names instead of asking people to scroll. Both are cheap and both cut the time between &#8220;it broke&#8221; and &#8220;I know why&#8221; more than any clever retry logic will.</p>



<h2 class="wp-block-heading">Exit codes are a contract, so use more than one</h2>



<p class="wp-block-paragraph">Most scripts have two states: 0 and &#8220;something went wrong&#8221;. That collapses distinctions the pipeline needs. Terraform gets this right and it is a good model to copy.</p>



<pre class="wp-block-code"><code># -detailed-exitcode changes the meaning of the exit code:
#   0 = succeeded, no changes
#   1 = error
#   2 = succeeded, changes present
set +e
terraform plan -detailed-exitcode -out=tfplan
code=$?
set -e

case "$code" in
  0) echo "No drift. Skipping apply." ;;
  2) echo "Changes detected. Holding for approval." ;;
  *) echo "Plan failed. See log above." &gt;&amp;2; exit "$code" ;;
esac</code></pre>



<p class="wp-block-paragraph">The <code>set +e</code> around it is deliberate: with <code>-e</code> active, exit code 2 would kill the script before you got to interpret it. This is one of the few places where turning the flag off is the correct move, and it is worth a comment in the file explaining why so nobody &#8220;fixes&#8221; it later.</p>



<p class="wp-block-paragraph">Do the same in your own scripts. Reserve a code for &#8220;the thing you asked for is not possible&#8221; versus &#8220;I could not reach the API&#8221; versus &#8220;your input is wrong&#8221;. The pipeline can then retry one and refuse to retry the others, which is the difference between a retry that absorbs a runner hiccup and a retry that hides a race condition for six months.</p>



<h2 class="wp-block-heading">Gate on what you care about, not what is easy to check</h2>



<p class="wp-block-paragraph">This is the deploy-stage version of the same lie. Plenty of commands confirm that a request was accepted, not that the thing you wanted happened.</p>



<pre class="wp-block-code"><code># Returns 0 the moment the API server accepts the manifest,
# even if every pod goes into CrashLoopBackOff a second later.
kubectl apply -f k8s/

# This is the step that actually waits for the rollout,
# and fails the job if it does not converge.
kubectl rollout status deployment/api --timeout=180s

# And this proves the application answers a request,
# not just that a pod reports Ready.
curl --fail --silent --show-error https://api.example.com/healthz</code></pre>



<p class="wp-block-paragraph">Run that last check against the public hostname, through your CDN, rather than against the pod directly. If you front the app with Cloudflare or similar, a smoke test that skips the edge will happily pass while real users hit a cached error page or a misrouted origin. Same reasoning applies to the origin host itself: if the app runs on a VPS from a provider like InterServer behind a reverse proxy, verify through the proxy, because that is the path traffic takes.</p>



<p class="wp-block-paragraph">Then track how long each stage takes over time. Pipeline duration and failure rate per stage are perfectly ordinary metrics to ship into Prometheus and graph in Grafana, or into whatever platform you already use for application monitoring. When the fast lane creeps from forty seconds to four minutes, that is a slow regression nobody will notice from a single run.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">The job passed but the artifact is wrong</h3>



<p class="wp-block-paragraph">Look for pipes first. Grep the scripts for <code>|</code> and check whether <code>pipefail</code> is set in that shell. Then check for tools that return 0 on failure: <code>curl</code> without <code>--fail</code>, <code>docker run</code> where the entrypoint swallows errors, anything wrapped in <code>|| true</code> that somebody added during an incident and never removed.</p>



<h3 class="wp-block-heading"><code>set -e</code> is set but the script keeps going</h3>



<p class="wp-block-paragraph">The failing command is almost certainly inside a condition, on the left of <code>||</code> or <code>&amp;&amp;</code>, or in a function that is itself being called from an <code>if</code>. Add <code>set -x</code> temporarily and read the trace to see exactly where control flow continued. Then use an explicit check with your <code>fail</code> helper rather than relying on <code>-e</code> at that point.</p>



<h3 class="wp-block-heading">Works locally, fails in CI (or the reverse)</h3>



<p class="wp-block-paragraph">Usually a shell difference. Your terminal runs bash or zsh, the runner may execute the script with <code>sh</code>. Add an explicit shebang, and on GitHub Actions set the shell on the step or at the job level rather than assuming. Inside containers, remember <code>dash</code> has no <code>pipefail</code> and no <code>[[ ]]</code>.</p>



<h3 class="wp-block-heading">The job hangs until the platform kills it</h3>



<p class="wp-block-paragraph">Something is waiting on stdin. Package managers prompting for confirmation, <code>ssh</code> asking about a host key, a database client waiting for a password. Add the non-interactive flags, set <code>CI=true</code> where tools respect it, and put a timeout on the job so the next occurrence fails in ten minutes instead of sixty.</p>



<h3 class="wp-block-heading">Fails on the first run, passes on rerun</h3>



<p class="wp-block-paragraph">Resist the retry. Log the failure with enough context to identify it later, quarantine the test into a non-blocking job if it is genuinely blocking the team, and give it an owner and a date. A blanket retry turns a flaky test into a permanent tax on everyone&#8217;s confidence in the pipeline.</p>



<h3 class="wp-block-heading">The failure output is a wall of noise</h3>



<p class="wp-block-paragraph">Suppress progress bars and spinners in CI, they render as thousands of unreadable lines. Then print a short summary block at the very end of the job, after the noise, listing what failed and what to do. People read the bottom of a log first.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Writing <code>set -e</code> without <code>-o pipefail</code> and assuming the script is covered.</li>
<li>Using <code>|| true</code> to silence a step that fails intermittently, instead of finding out why.</li>
<li>Running the slowest, flakiest checks before the ones that take fifteen seconds.</li>
<li>Retrying on every failure class rather than only infrastructure faults.</li>
<li>No job timeouts, so a hang consumes a runner until someone notices.</li>
<li>Treating <code>kubectl apply</code> or a deploy API call as proof the deploy worked.</li>
<li>Failure messages that name the internal function that threw, and nothing the reader can act on.</li>
<li>Caching so aggressively that a stale dependency tree hides a real break.</li>
<li>Discarding logs and test reports when the job fails, which is exactly when you need them.</li>
<li>Turning off matrix fail-fast everywhere by reflex, so every broken commit costs the full matrix.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Put <code>set -euo pipefail</code> at the top of every shell script the pipeline runs, and set the Dockerfile <code>SHELL</code> before any piped <code>RUN</code>.</li>
<li>Order stages so each one is cheaper and more likely to fail than the next.</li>
<li>Give every job a timeout, and cancel superseded runs on the same branch.</li>
<li>Use explicit checks with actionable messages instead of relying on <code>-e</code> to catch everything.</li>
<li>Distinguish exit codes so the pipeline can react differently to different failures.</li>
<li>Verify deploys with a rollout status check plus a real HTTP request through the public path.</li>
<li>Upload artifacts and structured test reports on failure.</li>
<li>Pin action and image references to immutable digests so a green pipeline stays reproducible.</li>
<li>Track stage duration and failure rate over time, the same way you track application metrics.</li>
<li>Fix the failure message the first time somebody has to ask what it means.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What does &#8220;fail fast&#8221; actually mean in a CI/CD pipeline?</h3>



<p class="wp-block-paragraph">Two things. First, the pipeline stops as soon as it knows the result will be a failure, rather than finishing work whose outcome no longer matters. Second, the cheapest checks that are most likely to catch a problem run first, so the average failure surfaces in seconds. The second half is a design choice about ordering, not a platform setting.</p>



<h3 class="wp-block-heading">Is <code>set -e</code> enough on its own?</h3>



<p class="wp-block-paragraph">No. It is suspended inside conditions, on the left side of <code>&amp;&amp;</code> and <code>||</code>, and after <code>!</code>. It also does nothing about pipes, which is why <code>-o pipefail</code> matters, or about commands that return 0 while failing, which is why explicit checks matter. Use it as a baseline, not as your only line of defence.</p>



<h3 class="wp-block-heading">Should I disable matrix fail-fast?</h3>



<p class="wp-block-paragraph">Only when the legs give you different diagnostic information. If the matrix is runtime versions or operating systems and you need to know whether the break is universal or specific, let all legs run. If the legs are interchangeable shards of the same test suite, leave fail-fast on and save the compute.</p>



<h3 class="wp-block-heading">How do I stop a pipeline from passing when a step really failed?</h3>



<p class="wp-block-paragraph">Work through three things in order. Set <code>pipefail</code> so pipes propagate failure. Add the flags that make tools return non-zero on failure, such as <code>curl --fail</code>. Then replace implicit trust with explicit assertions: after a deploy, check that the rollout converged and that the service answers a request. Most silent passes come from one of those three gaps.</p>



<h3 class="wp-block-heading">Are automatic retries a good idea?</h3>



<p class="wp-block-paragraph">Only for failures that are the infrastructure&#8217;s fault: runner crashes, image pull errors, API timeouts from the CI platform itself. Never retry a script failure by default. And make retries visible, because a job that quietly succeeds on the third attempt is telling you something important that nobody sees.</p>



<h3 class="wp-block-heading">How long should a fast feedback stage take?</h3>



<p class="wp-block-paragraph">Short enough that an engineer waits for it rather than switching context. Somewhere under a couple of minutes for the combined lint, format, and unit stage is a reasonable target for most codebases. Past that, people push and walk away, and you lose the benefit of fast feedback even though the pipeline is technically quick.</p>



<h3 class="wp-block-heading">Does this apply to self-hosted runners too?</h3>



<p class="wp-block-paragraph">All of it, and timeouts matter more. On hosted runners a stuck job eventually hits the platform&#8217;s own ceiling. On your own machine it holds a slot until you intervene, so a hung job blocks the queue for everyone. Set explicit timeouts and make sure the runner cleans up its workspace between jobs.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">Speed is the part everyone optimises and honesty is the part that actually costs money. A fail fast pipeline that finishes in ninety seconds is worth very little if one of those seconds contains a swallowed exit code. Fix the lying first: <code>pipefail</code>, tools that return non-zero when they fail, and assertions that check the outcome rather than the request. Then reorder for speed.</p>



<p class="wp-block-paragraph">And judge every failure by one question. Could someone else fix this from the output alone, without reading the source? If the answer is no, the pipeline has not finished its job, no matter how quickly it went red.</p>



<h2 class="wp-block-heading">Need help fixing a pipeline that lies to you?</h2>



<p class="wp-block-paragraph">If any of this sounds like your CI, this is the kind of work I take on. Typical engagements look like:</p>



<ul class="wp-block-list">
<li>Auditing an existing GitHub Actions, GitLab CI, or Jenkins pipeline for silently swallowed failures and false-green stages.</li>
<li>Restructuring stage order and caching so the common failure surfaces in seconds instead of after the build.</li>
<li>Hardening deploy verification: rollout gates, smoke tests through the real public path, and automatic rollback on a failed health check.</li>
<li>Rewriting pipeline shell scripts with proper error handling, exit code contracts, and failure messages an on-call engineer can act on.</li>
<li>Wiring pipeline duration and failure metrics into Prometheus and Grafana so regressions get caught early.</li>
</ul>



<p class="wp-block-paragraph">Send me the workflow file and a link to a failing run, and I will tell you what I would change first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/fail-fast-pipeline/">Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>A Secure-by-Default AWS Account Baseline in Terraform</title>
		<link>https://john-nessime.com/blog/devops/aws-account-baseline-terraform/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 19:30:45 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Checkov]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[IAM]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<category><![CDATA[OIDC]]></category>
		<category><![CDATA[Terraform]]></category>
		<category><![CDATA[VPC]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=42</guid>

					<description><![CDATA[<p>A fresh AWS account ships wide open by default. Here are the seven decisions that make one defensible from the start — OIDC instead of static keys, encrypted state, an isolated database tier, free gateway endpoints — with a working Terraform reference implementation on GitHub.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/aws-account-baseline-terraform/">A Secure-by-Default AWS Account Baseline in Terraform</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A fresh AWS account is not a neutral starting point. It ships with a default VPC whose default security group allows every instance in it to talk to every other instance, a default route to the internet in every subnet, no logging of network traffic, and an expectation that you will create an access key and paste it into your CI system.</p>



<p class="wp-block-paragraph">None of that is a bug. AWS optimises the empty account for getting something running in ten minutes. The problem is that most accounts never get corrected afterwards, and eighteen months later there is production traffic sitting on top of decisions nobody made deliberately.</p>



<p class="wp-block-paragraph">An AWS account baseline is the set of things you lay down before the first workload, so the account starts from a defensible posture instead of a blank console. This walks through the decisions that actually matter, why each one is the way it is, and what it costs. I have put a working reference implementation on GitHub — <a href="https://github.com/JohnNessime/terraform-aws-baseline" target="_blank" rel="noreferrer noopener">terraform-aws-baseline</a> — so you can read the real Terraform rather than take my word for any of it.</p>



<h2 class="wp-block-heading">What a baseline is, and what it is not</h2>



<p class="wp-block-paragraph">A baseline is not a landing zone in the Control Tower sense, and it is not a compliance framework. It does not make you SOC 2 compliant and nothing that fits in one repository will.</p>



<p class="wp-block-paragraph">What it is: the boring foundational layer that everything else sits on. State storage, network segmentation, identity, and the guardrails that stop a careless afternoon becoming an incident. If you get these right once, every workload that lands afterwards inherits them for free.</p>



<p class="wp-block-paragraph">The structure that works is three layers. A bootstrap stack that creates the state backend and runs exactly once. Reusable modules for the things every environment needs. Thin per-environment roots that compose those modules with different parameters — dev gets one NAT gateway and seven-day log retention, prod gets one per availability zone and a year.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Decision one: no long-lived AWS credentials, anywhere</h2>



<p class="wp-block-paragraph">Static access keys in CI are the single most common way AWS accounts get compromised. They live in a secrets store, they get copied into a second one, somebody echoes one into a build log, and nobody rotates them because rotation breaks things.</p>



<p class="wp-block-paragraph">GitHub Actions can assume an AWS role using a short-lived OIDC token that GitHub signs and AWS verifies. There is no key to store, nothing to leak, and nothing to rotate.</p>



<p class="wp-block-paragraph">The important part is the trust policy, and one word in it decides whether this is a security improvement or a vulnerability:</p>



<pre class="wp-block-code"><code>data "aws_iam_policy_document" "ci_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # StringEquals, not StringLike. This is the whole ballgame.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values = [
        "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${var.github_branch}"
      ]
    }
  }
}</code></pre>



<p class="wp-block-paragraph">Swap <code>StringEquals</code> for <code>StringLike</code> and add an asterisk, and you have widened that trust from one branch of one repository to whatever the wildcard covers. Get sloppy enough — a bare <code>repo:*</code> — and <em>any repository on GitHub</em> can assume your role. This is not theoretical; it is a recurring finding in real audits.</p>



<p class="wp-block-paragraph">Scope it to one repository and one branch. If you need more, add more statements, explicitly.</p>



<h2 class="wp-block-heading">Decision two: treat Terraform state as a secret, because it is</h2>



<p class="wp-block-paragraph">Terraform state contains every attribute of every resource it manages. Database passwords, generated keys, connection strings. Marking a variable <code>sensitive</code> redacts it from console output and changes nothing about what is written to the state file.</p>



<p class="wp-block-paragraph">So the state bucket gets four things: server-side encryption with a customer-managed KMS key that rotates, versioning so a corrupted state can be recovered, all four public access block flags, and a bucket policy that refuses plaintext connections outright.</p>



<pre class="wp-block-code"><code>resource "aws_s3_bucket_public_access_block" "state" {
  bucket                  = aws_s3_bucket.state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

data "aws_iam_policy_document" "deny_insecure_transport" {
  statement {
    sid       = "DenyInsecureTransport"
    effect    = "Deny"
    actions   = ["s3:*"]
    resources = [
      aws_s3_bucket.state.arn,
      "${aws_s3_bucket.state.arn}/*",
    ]

    principals {
      type        = "*"
      identifiers = ["*"]
    }

    condition {
      test     = "Bool"
      variable = "aws:SecureTransport"
      values   = ["false"]
    }
  }
}</code></pre>



<p class="wp-block-paragraph">All four public access block flags, not two. They cover different paths — ACLs and bucket policies, existing objects and future ones — and setting a subset leaves a gap that reads as protected on a dashboard.</p>



<p class="wp-block-paragraph">There is a chicken-and-egg problem here that catches everyone: the stack that creates the state bucket cannot store its own state in a bucket that does not exist yet. You run it once with local state, then migrate:</p>



<pre class="wp-block-code"><code>cd bootstrap
terraform init
terraform apply
terraform output          # bucket, lock table, KMS key, region

# Now move bootstrap's own state into the bucket it just created
terraform init -migrate-state</code></pre>



<h2 class="wp-block-heading">Decision three: shut the default security group</h2>



<p class="wp-block-paragraph">Every VPC gets a default security group that permits all traffic between members and all outbound traffic. Anything launched without an explicit security group lands on it. That is a quiet failure mode — nothing errors, the instance works, and it has more network reach than anyone intended.</p>



<p class="wp-block-paragraph">Terraform can adopt and empty it:</p>



<pre class="wp-block-code"><code>resource "aws_default_security_group" "default" {
  vpc_id = aws_vpc.this.id

  # No ingress and no egress blocks. Terraform strips AWS's rules,
  # so anything that accidentally lands here can reach nothing.
}</code></pre>



<p class="wp-block-paragraph">Four lines, and misconfiguration becomes loud instead of silent.</p>



<h2 class="wp-block-heading">Decision four: three subnet tiers, and the bottom one has no way out</h2>



<p class="wp-block-paragraph">The standard two-tier split — public and private — leaves databases in the same tier as application servers. Both can reach the internet through NAT. That is fine right up until something in the data tier is compromised and quietly exfiltrates over 443, which looks like ordinary egress traffic.</p>



<ul class="wp-block-list">
<li><strong>Public</strong> — NAT gateways and load balancers. Route to the internet gateway.</li>

<li><strong>Private</strong> — workloads. Outbound through NAT, no inbound from the internet.</li>

<li><strong>Database</strong> — data stores. No default route at all.</li>
</ul>



<p class="wp-block-paragraph">The third tier is enforced by absence rather than by a rule:</p>



<pre class="wp-block-code"><code>resource "aws_route_table" "database" {
  vpc_id = aws_vpc.this.id

  # Deliberately no 0.0.0.0/0 route. Not to the IGW, not to NAT.
  # Nothing here can reach the internet, and nothing can reach it.
}</code></pre>



<p class="wp-block-paragraph">No security group to misconfigure, no NACL to get wrong. The route simply does not exist. That is a much stronger guarantee than a rule someone can widen during an incident and forget to narrow afterwards.</p>



<h2 class="wp-block-heading">Decision five: gateway endpoints, the one genuinely free win</h2>



<p class="wp-block-paragraph">S3 and DynamoDB gateway endpoints cost nothing. Not &#8220;cheap&#8221; — zero. They add a route so traffic to those services stays on the AWS backbone rather than going out through NAT.</p>



<pre class="wp-block-code"><code>resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.this.id
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"

  route_table_ids = concat(
    aws_route_table.private[*].id,
    aws_route_table.database[*].id,
  )
}</code></pre>



<p class="wp-block-paragraph">Two benefits, and the second is the one people miss. Traffic to S3 never traverses NAT, so you stop paying per-gigabyte processing on it. And the isolated database tier can still reach S3 and DynamoDB despite having no internet route, because the endpoint is a route within the VPC rather than a path out of it.</p>



<p class="wp-block-paragraph">Your Terraform state lives in S3. Without this endpoint, every plan and apply pushes state through the NAT gateway and you are billed for the privilege.</p>



<h2 class="wp-block-heading">Decision six: SSM Session Manager, not SSH</h2>



<p class="wp-block-paragraph">Port 22 open anywhere means key pairs to distribute, a bastion host to patch, and an audit trail that is whatever <code>auth.log</code> happens to contain.</p>



<pre class="wp-block-code"><code>resource "aws_iam_role_policy_attachment" "ssm_core" {
  role       = aws_iam_role.instance.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}</code></pre>



<p class="wp-block-paragraph">That one attachment gets you shell access with no inbound ports, no key pairs, no bastion. Access is IAM-authorised, so revoking someone is a permissions change rather than a key rotation, and every session can be logged centrally.</p>



<p class="wp-block-paragraph">Worth knowing the caveat: SSM agents reach the SSM endpoints over the internet by default, so instances in the private tier still need NAT for this. Removing NAT entirely means adding interface endpoints for <code>ssm</code>, <code>ssmmessages</code> and <code>ec2messages</code>, and those cost roughly seven dollars a month per availability zone each.</p>



<h2 class="wp-block-heading">Decision seven: pin actions to commit SHAs</h2>



<p class="wp-block-paragraph">A Git tag is a movable pointer. If an action&#8217;s repository is compromised, an attacker can repoint <code>v4</code> at whatever they like, and every workflow referencing that tag runs it on the next build — with your OIDC role attached.</p>



<pre class="wp-block-code"><code>steps:
  # A tag can be moved. A commit SHA cannot.
  # Dependabot updates these pins and shows you the diff.
  - uses: actions/checkout@&lt;full-40-character-sha&gt;   # v4.2.2</code></pre>



<p class="wp-block-paragraph">The comment matters as much as the pin. Without it nobody can tell at a glance which version they are looking at, and the pin becomes something people update blindly.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">What it actually costs</h2>



<p class="wp-block-paragraph">Almost all of it is NAT gateways. Everything else is rounding.</p>



<ul class="wp-block-list">
<li><strong>NAT gateways</strong> — roughly $32 a month each, plus about $0.045 per gigabyte processed. One in dev, one per availability zone in prod.</li>

<li><strong>VPC, subnets, route tables, internet gateway</strong> — free.</li>

<li><strong>S3 and DynamoDB gateway endpoints</strong> — free.</li>

<li><strong>KMS keys</strong> — about a dollar a month each.</li>

<li><strong>Flow logs</strong> — around $0.50 per gigabyte ingested. Retention is the lever: seven days in dev, a year in prod.</li>

<li><strong>State bucket and lock table</strong> — cents.</li>
</ul>



<p class="wp-block-paragraph">Ballpark, that lands around $35 a month for dev and $105 for prod, dominated entirely by NAT. If you only need the isolated tiers and nothing requires outbound internet, dropping NAT takes dev close to free.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Per-AZ NAT in production is an availability decision, not a security one. A single NAT gateway is a single point of failure for outbound traffic across every AZ that routes through it.</p>
</blockquote>



<h2 class="wp-block-heading">Gates that run before any credentials exist</h2>



<p class="wp-block-paragraph">Most of what goes wrong in Terraform is catchable statically. Formatting, syntax, provider misuse, and known-insecure patterns can all be found without touching an AWS account, which means they can run on every commit and in CI without the pipeline holding any cloud credentials at all.</p>



<pre class="wp-block-code"><code># On every commit, locally
pre-commit install        # gitleaks, fmt, validate, tflint, checkov, terraform-docs

# The same gates CI runs
make all                  # fmt + validate + tflint + checkov</code></pre>



<p class="wp-block-paragraph">Running the identical command locally and in CI is the part that matters. When those two drift, people stop trusting the local check and start pushing to see what the pipeline says, and the fast feedback loop you built quietly stops existing.</p>



<p class="wp-block-paragraph">Note also what is missing: there is no <code>terraform plan</code> in that pipeline. Plan needs real credentials, and a static-only pipeline is one that cannot leak anything. A production setup would add a plan job running under the OIDC role and posting to the pull request — but that is a deliberate step up in blast radius, not a default.</p>



<h2 class="wp-block-heading">What a baseline like this deliberately leaves out</h2>



<p class="wp-block-paragraph">Being clear about scope is more useful than pretending completeness. The reference implementation covers preventative controls and stops there. Missing, on purpose:</p>



<ul class="wp-block-list">
<li><strong>Detective controls.</strong> No CloudTrail, Config, GuardDuty or Security Hub. A complete account baseline includes them; this is the preventative half.</li>

<li><strong>Compute.</strong> There is an instance role and profile, but nothing uses them. No ASG, no ECS or EKS, no load balancer.</li>

<li><strong>Interface endpoints.</strong> Only the free gateway ones. Fully private SSM needs paid interface endpoints.</li>

<li><strong>Multi-region and DR.</strong> Versioning covers state recovery. There is no cross-region replication and no failover story.</li>

<li><strong>Policy-as-code tests.</strong> Checkov catches known misconfigurations; there are no Terratest or Conftest assertions on module contracts.</li>
</ul>



<p class="wp-block-paragraph">A baseline you can describe the edges of is more useful than one advertised as complete. You know exactly what you still have to build.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button is-style-outline is-style-outline--1"><a class="wp-block-button__link wp-element-button" href="https://github.com/JohnNessime/terraform-aws-baseline" target="_blank" rel="noreferrer noopener">Read the Terraform on GitHub</a></div>
</div>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Using <code>StringLike</code> with a wildcard in an OIDC trust policy. Scope to one repository and one branch.</li>

<li>Assuming <code>sensitive = true</code> keeps values out of state. It does not.</li>

<li>Setting two of the four S3 public access block flags and considering the bucket locked down.</li>

<li>Leaving the default security group as AWS shipped it.</li>

<li>Putting databases in the same tier as application servers because two tiers felt like enough.</li>

<li>Skipping gateway endpoints and paying NAT processing charges on your own state file.</li>

<li>Referencing actions by tag and assuming a tag is immutable.</li>

<li>Committing <code>backend.hcl</code> or <code>terraform.tfvars</code>. State bucket names embed the account ID.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is an AWS account baseline the same as a landing zone?</h3>



<p class="wp-block-paragraph">No. A landing zone in the Control Tower sense governs many accounts — organisational units, service control policies, account vending. A baseline is what you lay down inside a single account. Baselines are what you need when you have one or two accounts and Control Tower would be enormous overkill.</p>



<h3 class="wp-block-heading">Why not just use Control Tower?</h3>



<p class="wp-block-paragraph">Control Tower is a good answer for a multi-account organisation with governance requirements. It is a lot of machinery for a team running a handful of accounts, and it hides the decisions rather than making them legible. Writing the baseline yourself means you can read every choice and change any of them.</p>



<h3 class="wp-block-heading">Do I really need a separate database subnet tier?</h3>



<p class="wp-block-paragraph">It costs nothing and closes an entire exfiltration path. If your data tier has no route to the internet, a compromised database cannot phone home regardless of what the attacker does with it. Two tiers is not wrong, but the third is close to free.</p>



<h3 class="wp-block-heading">How do I cut the NAT gateway cost?</h3>



<p class="wp-block-paragraph">Use one NAT for non-production instead of one per AZ. Add gateway endpoints so S3 and DynamoDB traffic bypasses NAT entirely. Beyond that, work out what genuinely needs outbound internet — often less than you assume — and consider dropping NAT for environments that do not.</p>



<h3 class="wp-block-heading">Can I use this with OpenTofu?</h3>



<p class="wp-block-paragraph">Yes. It is standard HCL against the AWS provider, so it runs on either. Swap <code>terraform</code> for <code>tofu</code> in the commands and everything else is the same.</p>



<h3 class="wp-block-heading">Where does the OIDC provider itself live?</h3>



<p class="wp-block-paragraph">It is account-global, so only one environment can create it and the others reference it. That is workable but awkward. In a real organisation it belongs in a separate account-level stack that runs before any environment.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">None of the decisions in an AWS account baseline are individually clever. Scope the trust policy. Encrypt the state. Empty the default security group. Give the data tier no way out. Take the free endpoints. Use SSM instead of SSH. Pin your actions. Each one is a few lines of Terraform.</p>



<p class="wp-block-paragraph">What makes them valuable is doing them before the first workload arrives, because retrofitting network segmentation into a running account is a project, and doing it at the start is an afternoon.</p>



<p class="wp-block-paragraph">The full implementation is on GitHub at <a href="https://github.com/JohnNessime/terraform-aws-baseline" target="_blank" rel="noreferrer noopener">JohnNessime/terraform-aws-baseline</a>, MIT licensed. It is a reference implementation rather than something that has served production traffic — a worked example of how I would lay an account down, with the reasoning written out in the docs. Read it, disagree with parts of it, take what is useful.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a baseline laid down properly?</h2>



<p class="wp-block-paragraph">I work with teams on AWS foundations — account structure, network segmentation, IAM that does not rely on long-lived keys, Terraform layouts that survive more than one engineer, and CI pipelines that catch problems before they reach an account.</p>



<p class="wp-block-paragraph">If you would rather have this done once and done right, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/aws-account-baseline-terraform/">A Secure-by-Default AWS Account Baseline in Terraform</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
