<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mazlum Tosun</title>
    <description>The latest articles on DEV Community by Mazlum Tosun (@mazlum_tosun).</description>
    <link>https://dev.to/mazlum_tosun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4099159%2F75d1f3c8-a06c-4574-99df-58dc217957fc.png</url>
      <title>DEV Community: Mazlum Tosun</title>
      <link>https://dev.to/mazlum_tosun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mazlum_tosun"/>
    <language>en</language>
    <item>
      <title>Stop rebuilding from scratch: cache Docker layers on Cloud Build</title>
      <dc:creator>Mazlum Tosun</dc:creator>
      <pubDate>Mon, 07 Sep 2026 22:31:01 +0000</pubDate>
      <link>https://dev.to/gde/stop-rebuilding-from-scratch-cache-docker-layers-on-cloud-build-41m0</link>
      <guid>https://dev.to/gde/stop-rebuilding-from-scratch-cache-docker-layers-on-cloud-build-41m0</guid>
      <description>&lt;p&gt;&lt;em&gt;How BuildKit + Artifact Registry turn your CI/CD pipelines around: if nothing changed, &lt;code&gt;uv&lt;/code&gt; doesn't re-download a single package.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📦 All the code in this article is on GitHub: &lt;a href="https://github.com/tosun-si/docker-cloud-build-ci-cd-cache" rel="noopener noreferrer"&gt;tosun-si/docker-cloud-build-ci-cd-cache&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  How I got here
&lt;/h2&gt;

&lt;p&gt;Early in my career, when I built CI pipelines, I didn't have much of an&lt;br&gt;
optimization or GreenOps sensitivity. Pipelines ran, images got built, and I&lt;br&gt;
never really questioned the wasted work.&lt;/p&gt;

&lt;p&gt;That changed after a side collaboration with my friend &lt;a href="https://www.linkedin.com/in/gleroypro/" rel="noopener noreferrer"&gt;Guillaume Leroy&lt;/a&gt; a while back. It got me&lt;br&gt;
paying attention to build efficiency — and once I started, the benefits were&lt;br&gt;
obvious: my pipelines were faster, leaner, and I wasn't burning machines for&lt;br&gt;
nothing. That last part matters: an optimized pipeline is also a &lt;em&gt;greener&lt;/em&gt; one.&lt;/p&gt;

&lt;p&gt;I became a convert. Today I apply these techniques everywhere — in my personal&lt;br&gt;
projects, and as a platform engineer at my clients'. The developers love it&lt;br&gt;
(CI/CD pipelines are dramatically faster), and the clients care too: carbon&lt;br&gt;
footprint and FinOps are real topics for them. So I bring this discipline to&lt;br&gt;
every CI/CD tool I work with.&lt;/p&gt;

&lt;p&gt;And that's where Cloud Build surprised me. &lt;strong&gt;Almost nobody around me — including&lt;br&gt;
in the GDE and Google community — persists the Docker cache on Cloud Build&lt;/strong&gt;,&lt;br&gt;
Google's serverless CI/CD tool. When I dug into why, the official guidance&lt;br&gt;
explained it: Google's own docs point you to &lt;code&gt;--cache-from &amp;lt;previous image&amp;gt;&lt;/code&gt;&lt;br&gt;
(which, as we'll see, silently misses your multi-stage builder layers) or to&lt;br&gt;
Kaniko — a tool Google itself &lt;strong&gt;archived on June 3, 2025&lt;/strong&gt; ("this project is&lt;br&gt;
archived and no longer developed or maintained"). Neither path mentions&lt;br&gt;
BuildKit's registry cache with &lt;code&gt;mode=max&lt;/code&gt;, the one that actually persists the&lt;br&gt;
expensive dependency layer. A handful of scattered blog posts cover it; the&lt;br&gt;
official guidance still doesn't.&lt;/p&gt;

&lt;p&gt;Everything ships as a container these days, so this is a lever almost every team&lt;br&gt;
leaves on the table. It saves me real time every day, on my own projects and&lt;br&gt;
with the teams I work with — and I wanted to write this up to share it with the&lt;br&gt;
community.&lt;/p&gt;

&lt;p&gt;One more reason it stays a blind spot: the default behavior genuinely makes it&lt;br&gt;
feel like "Docker caching just doesn't work on Cloud Build." Every build starts&lt;br&gt;
from scratch, re-downloads every dependency, rebuilds every layer. On a Python&lt;br&gt;
app with a few dozen packages, that's 30–60 seconds wasted &lt;strong&gt;on every push&lt;/strong&gt;,&lt;br&gt;
even though nothing changed on the dependency side. The good news: it's not a&lt;br&gt;
fatality. With BuildKit and a cache stored in Artifact Registry, you get a real&lt;br&gt;
persistent cache shared across pipelines — and once you understand &lt;em&gt;why&lt;/em&gt; the&lt;br&gt;
default fails, the fix is three lines.&lt;/p&gt;

&lt;p&gt;This is the first article in a series. Here we start from the classic&lt;br&gt;
&lt;code&gt;docker build&lt;/code&gt; (via &lt;code&gt;docker buildx&lt;/code&gt;). In a second article, I'll show the same&lt;br&gt;
mechanism with &lt;strong&gt;Docker Bake&lt;/strong&gt;, which is just a declarative layer on top of the&lt;br&gt;
same cache engine.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why the cache "disappears" on Cloud Build
&lt;/h2&gt;

&lt;p&gt;Locally, Docker's layer cache feels like magic: you rebuild, and Docker reuses unchanged layers straight from the &lt;strong&gt;local daemon&lt;/strong&gt;. The cache storage &lt;em&gt;is&lt;/em&gt; your machine.&lt;/p&gt;

&lt;p&gt;Cloud Build runs on &lt;strong&gt;ephemeral workers&lt;/strong&gt;. Every build starts on a fresh VM, with no state from previous builds. The VM's Docker daemon is empty. As a result:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There is &lt;strong&gt;no local cache to reuse&lt;/strong&gt;, because there is no "local" that persists.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This isn't a bug, it's the model: isolation and reproducibility. But it means a bare &lt;code&gt;docker build&lt;/code&gt; on Cloud Build will &lt;strong&gt;never&lt;/strong&gt; cache anything between runs.&lt;/p&gt;

&lt;p&gt;So the solution isn't to keep a local cache — that's impossible — it's to &lt;strong&gt;externalize the cache into a registry&lt;/strong&gt; that every build shares. And you already have that registry: Artifact Registry.&lt;/p&gt;


&lt;h2&gt;
  
  
  Quick refresher: Docker's layer cache
&lt;/h2&gt;

&lt;p&gt;Each instruction in a &lt;code&gt;Dockerfile&lt;/code&gt; produces a layer. Docker reuses a layer as long as:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the instruction is identical, &lt;strong&gt;and&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;its input context (copied files, parent layer) is identical.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The moment a layer is invalidated, &lt;strong&gt;every layer below it&lt;/strong&gt; is too. Hence the golden rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Copy what changes &lt;strong&gt;rarely&lt;/strong&gt; first (the dependencies), what changes &lt;strong&gt;often&lt;/strong&gt; next (the source code).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's exactly what makes the Python + &lt;code&gt;uv&lt;/code&gt; example so telling: resolving and installing dependencies is a heavy layer, but a &lt;strong&gt;stable&lt;/strong&gt; one. As long as &lt;code&gt;pyproject.toml&lt;/code&gt; and &lt;code&gt;uv.lock&lt;/code&gt; don't move, we should never rebuild it.&lt;/p&gt;


&lt;h2&gt;
  
  
  The example app: FastAPI + uv
&lt;/h2&gt;

&lt;p&gt;A minimal app, just enough to have real dependencies (FastAPI + uvicorn and their transitive tree — 21 packages).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[project]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cloud-build-cache-demo"&lt;/span&gt;
&lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.1.0"&lt;/span&gt;
&lt;span class="py"&gt;requires-python&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3.13&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="py"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="py"&gt;"fastapi&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.115&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;    &lt;span class="py"&gt;"uvicorn[standard]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.32&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c"&gt;# No [build-system]: this is an application, not a reusable package.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there is &lt;strong&gt;no &lt;code&gt;[build-system]&lt;/code&gt;&lt;/strong&gt;. This is deliberate: it makes the app a &lt;em&gt;virtual&lt;/em&gt; uv project — uv resolves and installs the &lt;strong&gt;dependencies&lt;/strong&gt;, but never tries to build or install the app itself. The code just runs from source. That keeps the Docker build trivial and the dependency layer perfectly cacheable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app/main.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cloud Build cache demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;root&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello from a cache-friendly Cloud Build pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;uv.lock&lt;/code&gt; is generated once with &lt;code&gt;uv lock&lt;/code&gt; and &lt;strong&gt;committed&lt;/strong&gt; — that's what makes the build reproducible and the cache deterministic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why a flat &lt;code&gt;app/&lt;/code&gt; folder at the repo root, not a &lt;code&gt;src/&lt;/code&gt; layout?&lt;/strong&gt; The &lt;em&gt;src layout&lt;/em&gt; earns its keep for a &lt;strong&gt;reusable package&lt;/strong&gt;: it stops Python from importing your working tree instead of the installed wheel, and forces tests to run against what you actually ship. But this is a &lt;strong&gt;deployed application&lt;/strong&gt;, not a library — we don't package it into a wheel at all. A root-level folder named after the app is simpler and reads better, and since uv treats it as a virtual project (no &lt;code&gt;[build-system]&lt;/code&gt;), there's nothing to install and nothing to shadow.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Dockerfile: multi-stage and cache-friendly
&lt;/h2&gt;

&lt;p&gt;Two stages: a &lt;code&gt;builder&lt;/code&gt; based on the &lt;code&gt;uv&lt;/code&gt; image (which ships &lt;code&gt;uv&lt;/code&gt; + the right CPython), and a &lt;code&gt;runtime&lt;/code&gt; &lt;code&gt;python:slim&lt;/code&gt; image &lt;strong&gt;without &lt;code&gt;uv&lt;/code&gt;&lt;/strong&gt;, running non-root.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1.7&lt;/span&gt;

&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; APP_DIR=/usr/local/src/app&lt;/span&gt;

&lt;span class="c"&gt;# ---------- builder ----------&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ghcr.io/astral-sh/uv:python3.13-bookworm-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; APP_DIR&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; APP_DIR=${APP_DIR}&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; ${APP_DIR}&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; UV_COMPILE_BYTECODE=1 \&lt;/span&gt;
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=0

&lt;span class="c"&gt;# Dependencies only — a virtual uv project installs the deps but never the&lt;/span&gt;
&lt;span class="c"&gt;# app itself. This layer is reused as long as pyproject.toml + uv.lock don't&lt;/span&gt;
&lt;span class="c"&gt;# change: THIS is the layer we want to survive across Cloud Build runs.&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pyproject.toml uv.lock ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.cache/uv &lt;span class="se"&gt;\
&lt;/span&gt;    uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--frozen&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt;

&lt;span class="c"&gt;# ---------- runtime (no uv) ----------&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;python:3.13-slim-bookworm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; APP_DIR=/usr/local/src/app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; APP_DIR=${APP_DIR} \&lt;/span&gt;
    PATH="${APP_DIR}/.venv/bin:${PATH}" \
    PYTHONPATH="${APP_DIR}" \
    PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; ${APP_DIR}&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;groupadd &lt;span class="nt"&gt;--system&lt;/span&gt; app &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--gid&lt;/span&gt; app &lt;span class="nt"&gt;--home-dir&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin app &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown &lt;/span&gt;app:app &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APP_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# The venv (deps) from the builder, then the app source. Copying the code last&lt;/span&gt;
&lt;span class="c"&gt;# means a code change never invalidates the dependency layer above.&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=app:app ${APP_DIR}/.venv ${APP_DIR}/.venv&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --chown=app:app app ./app&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; app&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8000&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;Two details do all the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The heavy &lt;code&gt;uv sync&lt;/code&gt; layer is built from &lt;code&gt;pyproject.toml&lt;/code&gt; + &lt;code&gt;uv.lock&lt;/code&gt; alone&lt;/strong&gt;, and the app source is copied &lt;em&gt;after&lt;/em&gt; it (into the runtime stage). A code change never touches the dependency layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--mount=type=cache&lt;/code&gt; on &lt;code&gt;/root/.cache/uv&lt;/code&gt;.&lt;/strong&gt; uv's download cache, on top of the layer cache. We'll come back to it: on Cloud Build, that layer needs &lt;code&gt;mode=max&lt;/code&gt; to survive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus two hygiene points straight from the conventions: &lt;strong&gt;non-root runtime&lt;/strong&gt; and a &lt;strong&gt;final image without &lt;code&gt;uv&lt;/code&gt;&lt;/strong&gt; (we only copy the produced &lt;code&gt;.venv&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  The tempting-but-wrong fix: &lt;code&gt;--cache-from&lt;/code&gt; on the previous image
&lt;/h2&gt;

&lt;p&gt;The trick most people reach for first — and, notably, &lt;strong&gt;the one Google's own&lt;br&gt;
&lt;a href="https://cloud.google.com/build/docs/optimize-builds/speeding-up-builds" rel="noopener noreferrer"&gt;Best practices for speeding up builds&lt;/a&gt;&lt;br&gt;
recommends&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pull the previous image, use it as cache&lt;/span&gt;
docker pull &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;:latest"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;docker build &lt;span class="nt"&gt;--cache-from&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;:latest"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;:latest"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It &lt;em&gt;seems&lt;/em&gt; logical, but it's disappointing with multi-stage builds, for two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Only the final-stage layers live in the image.&lt;/strong&gt; The &lt;code&gt;builder&lt;/code&gt; stage layers (where &lt;code&gt;uv sync&lt;/code&gt; runs!) aren't there. So &lt;code&gt;uv sync&lt;/code&gt; re-runs on &lt;strong&gt;every build&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Without BuildKit and its "inline" cache, layer matching is brittle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In other words: the expensive layer — installing the dependencies — is precisely the one this cache &lt;strong&gt;doesn't&lt;/strong&gt; recover. That's where the "Docker caching is useless on Cloud Build" belief comes from. The docs' other suggestion, &lt;a href="https://cloud.google.com/build/docs/kaniko-cache" rel="noopener noreferrer"&gt;Kaniko cache&lt;/a&gt;, does handle intermediate layers — but Google &lt;a href="https://github.com/GoogleContainerTools/kaniko" rel="noopener noreferrer"&gt;archived Kaniko in June 2025&lt;/a&gt;, so building your pipeline on it today means adopting an unmaintained tool. Which leaves the approach the docs &lt;em&gt;don't&lt;/em&gt; mention.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real fix: BuildKit's registry cache
&lt;/h2&gt;

&lt;p&gt;Let's put the two versions side by side. &lt;strong&gt;Same &lt;code&gt;docker buildx build&lt;/code&gt;, two lines of difference.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without cache&lt;/strong&gt; — the baseline. Every run on a fresh worker rebuilds everything, &lt;code&gt;uv sync&lt;/code&gt; included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With cache&lt;/strong&gt; — BuildKit exports the &lt;strong&gt;entire build graph&lt;/strong&gt; (including the intermediate layers of every stage) to a dedicated cache image, and imports it on the next run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cache-from&lt;/span&gt; &lt;span class="s2"&gt;"type=registry,ref=&lt;/span&gt;&lt;span class="nv"&gt;$CACHE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cache-to&lt;/span&gt;   &lt;span class="s2"&gt;"type=registry,ref=&lt;/span&gt;&lt;span class="nv"&gt;$CACHE&lt;/span&gt;&lt;span class="s2"&gt;,mode=max"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it — two flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--cache-to type=registry,mode=max&lt;/code&gt;&lt;/strong&gt; pushes &lt;em&gt;all&lt;/em&gt; layers (final &lt;strong&gt;and&lt;/strong&gt; intermediate) into &lt;code&gt;$CACHE&lt;/code&gt;. It's &lt;code&gt;mode=max&lt;/code&gt; that changes everything — &lt;code&gt;mode=min&lt;/code&gt; (the default) would only export the final-stage layers, and we'd fall right back into the previous trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--cache-from type=registry&lt;/code&gt;&lt;/strong&gt;: on the next build, BuildKit imports those layers from the registry before building. The &lt;code&gt;uv sync&lt;/code&gt; layer is found by its hash → &lt;strong&gt;reused, nothing re-downloaded&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache is stored as an ordinary image, tagged &lt;code&gt;:buildcache&lt;/code&gt; by convention in the &lt;strong&gt;same Artifact Registry repo&lt;/strong&gt; as the app image. That registry is shared by every Cloud Build worker: &lt;em&gt;that's&lt;/em&gt; the "inter-pipeline" cache.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;mode=max&lt;/code&gt; is the keystone.&lt;/strong&gt; It's the one setting that makes the dependency layer survive between builds on ephemeral workers. Without it, the whole exercise is pointless.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's the whole flow at a glance — build #1 exports the cache, build #2 (a fresh, unrelated worker) imports it and skips &lt;code&gt;uv sync&lt;/code&gt; entirely:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekl9uynaxhuy0qzqtwmg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fekl9uynaxhuy0qzqtwmg.png" alt="Docker layer cache flow across Cloud Build runs: an ephemeral cold worker exports all layers with cache-to mode=max to Artifact Registry, and a later fresh worker imports them with cache-from so the uv sync layer is CACHED." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cloud Build config: &lt;code&gt;build-python-app.cloudbuild.yaml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;We're focusing on the cache, so this config does one thing: build + push with&lt;br&gt;
the registry cache. (Deploying the image — to Cloud Run or anywhere else — is a&lt;br&gt;
separate concern; more on that at the end.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Build + push with a container-driver Buildx builder.&lt;/span&gt;
  &lt;span class="c1"&gt;# The container driver is REQUIRED to export/import cache to a registry —&lt;/span&gt;
  &lt;span class="c1"&gt;# the default "docker" driver cannot do type=registry cache.&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gcr.io/cloud-builders/docker'&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-push&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;IMAGE_PATH=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO}/${_IMAGE}'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GIT_SHA=${SHORT_SHA}'&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;#!/usr/bin/env bash&lt;/span&gt;
      &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;

      &lt;span class="s"&gt;TAG="${GIT_SHA:-manual}"&lt;/span&gt;
      &lt;span class="s"&gt;CACHE="${IMAGE_PATH}:buildcache"&lt;/span&gt;

      &lt;span class="s"&gt;docker buildx create --name cloudbuilder --driver docker-container --use&lt;/span&gt;

      &lt;span class="s"&gt;docker buildx build \&lt;/span&gt;
        &lt;span class="s"&gt;--tag "${IMAGE_PATH}:${TAG}" \&lt;/span&gt;
        &lt;span class="s"&gt;--tag "${IMAGE_PATH}:latest" \&lt;/span&gt;
        &lt;span class="s"&gt;--cache-from "type=registry,ref=${CACHE}" \&lt;/span&gt;
        &lt;span class="s"&gt;--cache-to   "type=registry,ref=${CACHE},mode=max" \&lt;/span&gt;
        &lt;span class="s"&gt;--push \&lt;/span&gt;
        &lt;span class="s"&gt;.&lt;/span&gt;

&lt;span class="na"&gt;substitutions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;_REGION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;europe-west1&lt;/span&gt;
  &lt;span class="na"&gt;_REPO&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal-images&lt;/span&gt;
  &lt;span class="na"&gt;_IMAGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloud-build-cache-demo&lt;/span&gt;

&lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;logging&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CLOUD_LOGGING_ONLY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker buildx create --driver docker-container&lt;/code&gt;&lt;/strong&gt; is the non-obvious bit. Cloud Build's default &lt;code&gt;docker&lt;/code&gt; driver does &lt;strong&gt;not&lt;/strong&gt; support &lt;code&gt;type=registry&lt;/code&gt; cache. So we spin up a containerized BuildKit builder for the duration of the build. This one detail is what 90% of attempts are missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;options.logging: CLOUD_LOGGING_ONLY&lt;/code&gt;&lt;/strong&gt; isn't cosmetic. As soon as you run under a custom service account (impersonation / WIF), Cloud Build &lt;em&gt;requires&lt;/em&gt; you to pick a log destination or the build fails with &lt;code&gt;you must specify logging&lt;/code&gt;. &lt;code&gt;CLOUD_LOGGING_ONLY&lt;/code&gt; sends logs to Cloud Logging only — no GCS bucket to manage. It's the recommended default today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;machineType&lt;/code&gt;&lt;/strong&gt; — the default (&lt;code&gt;e2-standard-2&lt;/code&gt;) is plenty for a small Python build. Bump to &lt;code&gt;E2_HIGHCPU_8&lt;/code&gt; only for CPU-bound or multi-target builds; it's faster but billed at a higher per-minute rate. It has no effect on caching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: on the very first build, &lt;code&gt;--cache-from&lt;/code&gt; on a non-existent cache is a harmless warning — there's simply nothing to import yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  GCP prerequisites
&lt;/h2&gt;

&lt;p&gt;A Docker Artifact Registry repo — reuse an existing one (here it's &lt;code&gt;internal-images&lt;/code&gt;) or create it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud artifacts repositories create internal-images &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app image and the &lt;code&gt;:buildcache&lt;/code&gt; image both live in this repo, so a single repo is all you need.&lt;/p&gt;

&lt;p&gt;IAM for the Cloud Build service account (the default &lt;code&gt;&amp;lt;PROJECT_NUMBER&amp;gt;-compute@developer.gserviceaccount.com&lt;/code&gt;, or your dedicated SA) — it needs to push both the app image and the cache image, which live in the same repo, so a single binding covers it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud artifacts repositories add-iam-policy-binding internal-images &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;europe-west1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:&amp;lt;CLOUD_BUILD_SA&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/artifactregistry.writer"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire the config to a &lt;strong&gt;push trigger&lt;/strong&gt; on your main branch — &lt;code&gt;$SHORT_SHA&lt;/code&gt; is then populated automatically on every merge, and the cache does its job silently from the second build onward. No manual invocation needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The demo: before / after
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build #1 (cold cache)&lt;/strong&gt; — nothing in &lt;code&gt;:buildcache&lt;/code&gt;. BuildKit resolves and installs the 21 packages, then pushes every layer to the cache. This is the slowest build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build #2 (change &lt;code&gt;main.py&lt;/code&gt; only)&lt;/strong&gt; — &lt;code&gt;pyproject.toml&lt;/code&gt;/&lt;code&gt;uv.lock&lt;/code&gt; unchanged:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BuildKit imports the cache from Artifact Registry;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;COPY pyproject.toml uv.lock&lt;/code&gt; layer → &lt;strong&gt;CACHED&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;uv sync&lt;/code&gt; layer → &lt;strong&gt;CACHED&lt;/strong&gt; (zero packages downloaded);&lt;/li&gt;
&lt;li&gt;only the layers from &lt;code&gt;COPY app&lt;/code&gt; down are rebuilt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the Cloud Build logs you'll see it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt; =&amp;gt; CACHED [builder 4/5] COPY pyproject.toml uv.lock ./
 =&amp;gt; CACHED [builder 5/5] RUN uv sync --frozen --no-dev
 =&amp;gt; [runtime 6/7] COPY app ./app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;CACHED&lt;/code&gt; on &lt;code&gt;uv sync&lt;/code&gt;, on a brand-new VM that has never seen this project, &lt;strong&gt;is the whole point of this article&lt;/strong&gt;. The layer comes from Artifact Registry, not from a local disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build #3 (change a dependency)&lt;/strong&gt; — now &lt;code&gt;uv.lock&lt;/code&gt; changes, the layer is correctly invalidated and re-downloaded. That's the right behavior: the cache tracks exactly what actually changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measured impact
&lt;/h2&gt;

&lt;p&gt;Numbers make the case. Here's the same pipeline across the three scenarios,&lt;br&gt;
&lt;strong&gt;one real Cloud Build run each&lt;/strong&gt; (default machine type, region&lt;br&gt;
&lt;code&gt;europe-west1&lt;/code&gt;, build-only — no deploy step):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Total build&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;uv sync&lt;/code&gt; layer&lt;/th&gt;
&lt;th&gt;Packages installed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;#1 — cold cache (first build)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;rebuilt (2.6 s)&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#2 — code change only (&lt;code&gt;main.py&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;36 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;CACHED&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#3 — dependency bump (&lt;code&gt;+httpx&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;rebuilt (2.6 s)&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The proof is right there in the Cloud Build logs — the exact same &lt;code&gt;uv sync&lt;/code&gt;&lt;br&gt;
step, on three fresh, unrelated workers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Build #1 (cold)        →  #16 [builder 4/4] RUN ... uv sync --frozen --no-dev
                            #16 DONE 2.6s

# Build #2 (code change) →  #14 [builder 4/4] RUN ... uv sync --frozen --no-dev
                            #14 CACHED            ← pulled from Artifact Registry

# Build #3 (dep bump)    →  #15 [builder 4/4] RUN ... uv sync --frozen --no-dev
                            #15 DONE 2.6s         ← uv.lock changed, correctly re-run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;#14 CACHED&lt;/code&gt;, on a worker that had never seen this project, is the whole&lt;br&gt;
point: the dependency layer was rebuilt on build #1, &lt;strong&gt;exported to Artifact&lt;br&gt;
Registry&lt;/strong&gt;, and &lt;strong&gt;imported&lt;/strong&gt; on build #2 — zero packages downloaded, &lt;code&gt;uv sync&lt;/code&gt;&lt;br&gt;
skipped entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read it honestly.&lt;/strong&gt; On this deliberately tiny app, &lt;code&gt;uv sync&lt;/code&gt; is only ~2.6 s&lt;br&gt;
and 19 pure-Python wheels, so the total delta (51 s → 36 s) is dominated by&lt;br&gt;
things the cache &lt;em&gt;doesn't&lt;/em&gt; remove on an ephemeral worker: base-image pulls,&lt;br&gt;
BuildKit startup, cache import/export I/O. The headline isn't "15 seconds&lt;br&gt;
saved" — it's &lt;strong&gt;&lt;code&gt;0&lt;/code&gt; packages installed and the &lt;code&gt;uv sync&lt;/code&gt; layer fully skipped&lt;/strong&gt;.&lt;br&gt;
On a real service (dozens of deps, compiled wheels, a &lt;code&gt;numpy&lt;/code&gt;/&lt;code&gt;pyarrow&lt;/code&gt; in the&lt;br&gt;
tree), that skipped layer is &lt;em&gt;minutes&lt;/em&gt;, not seconds — and it's skipped on every&lt;br&gt;
push where &lt;code&gt;uv.lock&lt;/code&gt; hasn't moved.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Want to see it on video?&lt;/strong&gt; The repo ships a &lt;code&gt;heavy&lt;/code&gt; dependency group&lt;br&gt;
(&lt;code&gt;pandas&lt;/code&gt; + &lt;code&gt;numpy&lt;/code&gt;, compiled wheels) that's off by default. Flip it on&lt;br&gt;
(&lt;code&gt;--build-arg INSTALL_HEAVY=1&lt;/code&gt;, or &lt;code&gt;_INSTALL_HEAVY=1&lt;/code&gt; on Cloud Build) and the&lt;br&gt;
&lt;code&gt;uv sync&lt;/code&gt; step jumps from ~3 s to minutes — so the cold-vs-&lt;code&gt;CACHED&lt;/code&gt; contrast&lt;br&gt;
is impossible to miss on screen. Same cache mechanism, just a louder signal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reproduce it yourself with three runs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run #1 — cold cache.&lt;/strong&gt; Delete the &lt;code&gt;:buildcache&lt;/code&gt; tag first (&lt;code&gt;gcloud artifacts docker images delete .../&amp;lt;image&amp;gt; --delete-tags&lt;/code&gt;), then build. Baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run #2 — code only.&lt;/strong&gt; Change a string in &lt;code&gt;app/main.py&lt;/code&gt;, rebuild. &lt;code&gt;uv.lock&lt;/code&gt; untouched → &lt;code&gt;uv sync&lt;/code&gt; shows &lt;code&gt;CACHED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run #3 — dependency bump.&lt;/strong&gt; &lt;code&gt;uv add &amp;lt;pkg&amp;gt;&lt;/code&gt;, rebuild. &lt;code&gt;uv.lock&lt;/code&gt; changed → &lt;code&gt;uv sync&lt;/code&gt; correctly re-runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pull the durations and the per-step cache hits straight from the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud builds list &lt;span class="nt"&gt;--limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'table(id, duration, status)'&lt;/span&gt;
gcloud builds log &amp;lt;BUILD_ID&amp;gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'CACHED|uv sync|DONE'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker-container&lt;/code&gt; driver is mandatory.&lt;/strong&gt; Without it, &lt;code&gt;--cache-to type=registry&lt;/code&gt; is ignored or fails silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;mode=max&lt;/code&gt; or nothing.&lt;/strong&gt; In &lt;code&gt;mode=min&lt;/code&gt;, the &lt;code&gt;builder&lt;/code&gt; stage's intermediate layers aren't exported → &lt;code&gt;uv sync&lt;/code&gt; re-runs every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;:buildcache&lt;/code&gt; tag grows.&lt;/strong&gt; Each build stacks layers onto it. Set an Artifact Registry cleanup policy, or rewrite the tag periodically. The cache is a convenience, not a source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache network cost.&lt;/strong&gt; Pushing/pulling the cache has an I/O cost. On a small image it's a net win from build #2; on huge images, measure — sometimes &lt;code&gt;mode=min&lt;/code&gt; on select stages is the better trade.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The angle nobody talks about: this is GreenOps
&lt;/h2&gt;

&lt;p&gt;We frame CI caching as a &lt;em&gt;speed&lt;/em&gt; win. It's also a &lt;strong&gt;sustainability&lt;/strong&gt; one, and that's rarely said out loud.&lt;/p&gt;

&lt;p&gt;Every uncached build re-runs &lt;code&gt;uv sync&lt;/code&gt;: CPU cycles to resolve the graph, network to pull wheels, CPU again to unpack and byte-compile them. Multiply that by the number of builds a team ships per day — every push, every PR, every retry — across every developer. A 30-second dependency step that runs 50 times a day is &lt;strong&gt;25 minutes of pure CPU burn, daily, producing an artifact bit-for-bit identical to the previous one&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Caching turns most of those runs into a near-instant registry pull. Concretely, that's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Less compute&lt;/strong&gt; → fewer CPU-seconds → less energy drawn in the datacenter → a smaller carbon footprint. This is textbook &lt;strong&gt;GreenOps&lt;/strong&gt;: don't recompute what hasn't changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less money&lt;/strong&gt; → Cloud Build is billed by the build-minute, so the green win is also a &lt;strong&gt;FinOps&lt;/strong&gt; win. The two point the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster feedback&lt;/strong&gt; → shorter pipelines, less waiting, less context-switching for the whole team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest caveat: the cache isn't free. Pushing and pulling it has its own I/O, storage, and (small) compute footprint. It's net-green when the compute you &lt;em&gt;avoid&lt;/em&gt; is larger than the cache transfer you &lt;em&gt;add&lt;/em&gt; — which is exactly the case for dependency-heavy builds like this one, and the reason ordering the Dockerfile well matters so much. For a trivial image with no real dependency layer, the math can flip; measure before assuming.&lt;/p&gt;

&lt;p&gt;The takeaway: &lt;strong&gt;CI optimization isn't just about developer experience (DX). Recomputing an identical artifact on every push is waste — of time, of money, and of energy.&lt;/strong&gt; A well-placed cache removes all three at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  What about deploying?
&lt;/h2&gt;

&lt;p&gt;You may have noticed this config stops at "push to Artifact Registry" — no&lt;br&gt;
&lt;code&gt;gcloud run deploy&lt;/code&gt;, no rollout. That's on purpose. Deployment is its own topic&lt;br&gt;
(runtime service accounts, IAM, IAP, traffic splitting…) and folding it in here&lt;br&gt;
would only blur the one thing this article is about: the cache. Once the image&lt;br&gt;
is in Artifact Registry, adding a deploy step — Cloud Run, GKE, wherever — is a&lt;br&gt;
few extra lines, and I'll cover that shape in a dedicated article.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next: Docker Bake
&lt;/h2&gt;

&lt;p&gt;This config works, but the &lt;code&gt;--cache-from&lt;/code&gt; / &lt;code&gt;--cache-to&lt;/code&gt; flags get verbose fast once you have several images (API, worker, front…). That's exactly the problem &lt;strong&gt;Docker Bake&lt;/strong&gt; solves: you declare targets, tags and cache in a &lt;code&gt;docker-bake.hcl&lt;/code&gt;, and a single &lt;code&gt;docker buildx bake --push&lt;/code&gt; orchestrates all of it, registry cache included.&lt;/p&gt;

&lt;p&gt;That's the topic of the &lt;strong&gt;second article&lt;/strong&gt; — same BuildKit cache engine, same Artifact Registry, but a declarative, multi-target config. A YouTube video will follow to watch the whole thing run live.&lt;/p&gt;




&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Cloud Build runs on &lt;strong&gt;ephemeral workers&lt;/strong&gt; → no local cache persists.&lt;/li&gt;
&lt;li&gt;The fix: &lt;strong&gt;externalize the cache into Artifact Registry&lt;/strong&gt; with BuildKit's registry cache.&lt;/li&gt;
&lt;li&gt;The winning combo: &lt;strong&gt;&lt;code&gt;docker buildx&lt;/code&gt; + &lt;code&gt;docker-container&lt;/code&gt; driver + &lt;code&gt;--cache-to type=registry,mode=max&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;well-ordered Dockerfile&lt;/strong&gt; (dependencies before code) turns that cache into a concrete win: &lt;code&gt;uv&lt;/code&gt; re-downloads nothing as long as &lt;code&gt;uv.lock&lt;/code&gt; hasn't changed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mode=max&lt;/code&gt; isn't a detail — it's &lt;strong&gt;the&lt;/strong&gt; reason this works where the classic &lt;code&gt;--cache-from&lt;/code&gt; fails.&lt;/li&gt;
&lt;li&gt;It's not just speed: skipping the recompute of an identical artifact is &lt;strong&gt;GreenOps + FinOps&lt;/strong&gt; — less CPU, less energy, less money, on every single push.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full demo code is &lt;a href="https://github.com/tosun-si/docker-cloud-build-ci-cd-cache" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. Clone it, point it at your GCP project, and watch the second build print &lt;code&gt;CACHED&lt;/code&gt; on &lt;code&gt;uv sync&lt;/code&gt;. 🎯&lt;/p&gt;




&lt;p&gt;If you found this useful, follow me for more hands-on content on &lt;strong&gt;Google Cloud, Platform Engineering, Docker, DevOps, Data Engineering and AI agents&lt;/strong&gt; — practical patterns from real projects and client work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/mazlum_tosun"&gt;dev.to&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/@mazlum.tosun" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bit.ly/gcp-learning-mazlum-gb" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.linkedin.com/in/mazlum-tosun-900b1812/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://x.com/MazlumTosun3" rel="noopener noreferrer"&gt;X&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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