<?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>ELT with Dataform on Google Cloud</title>
      <dc:creator>Mazlum Tosun</dc:creator>
      <pubDate>Wed, 16 Sep 2026 16:29:00 +0000</pubDate>
      <link>https://dev.to/gde/elt-with-dataform-on-google-cloud-kc9</link>
      <guid>https://dev.to/gde/elt-with-dataform-on-google-cloud-kc9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://medium.com/google-cloud/elt-with-dataform-on-google-cloud-e69fb8484510" rel="noopener noreferrer"&gt;Medium (Google Cloud Community)&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Explanation of the use case presented in this article
&lt;/h2&gt;

&lt;p&gt;In a previous article, I demonstrated how to build an &lt;a href="https://medium.com/google-cloud/elt-with-dbt-on-cloud-run-job-0fcadce4eb1f" rel="noopener noreferrer"&gt;ELT pipeline using dbt on Google Cloud&lt;/a&gt;. In this article, we'll explore the same use case — this time using &lt;strong&gt;Dataform&lt;/strong&gt;, Google Cloud's native solution for managing SQL-based data workflows.&lt;/p&gt;

&lt;p&gt;One of the key advantages of Dataform on Google Cloud is that it's a fully managed, serverless service, natively integrated with BigQuery.&lt;/p&gt;

&lt;p&gt;As with the dbt-focused article, I'll use a football-related, real-world ELT pipeline that includes both &lt;strong&gt;staging&lt;/strong&gt; and &lt;strong&gt;mart&lt;/strong&gt; layers. I'll walk through how to create a Dataform repository manually, link it with a GitHub repository, and then automate the entire setup using &lt;strong&gt;Terraform&lt;/strong&gt;. This approach enables a GitOps workflow, where the GitHub repository becomes the single source of truth for managing your Dataform resources.&lt;/p&gt;

&lt;p&gt;Here is the diagram of this use case:&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%2Fkc4p09o29yc2fhp0yp9s.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%2Fkc4p09o29yc2fhp0yp9s.png" alt="Architecture of the ELT pipeline with Dataform, BigQuery, Cloud Build and Terraform" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I also created a video on this topic on my &lt;strong&gt;GCP YouTube&lt;/strong&gt; channel. Feel free to &lt;a href="https://www.youtube.com/@GCPLearning-ce9bg" rel="noopener noreferrer"&gt;&lt;strong&gt;subscribe&lt;/strong&gt;&lt;/a&gt; to the channel to support my work for the Google Cloud community.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;English version:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/c70ry7rrm6w" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;French version:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/b-6naX68YRg" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  🔧 CI/CD layer
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The CI/CD pipeline is implemented using &lt;strong&gt;Cloud Build&lt;/strong&gt; and &lt;strong&gt;Infrastructure as Code (IaC)&lt;/strong&gt; with &lt;strong&gt;Terraform&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It automates the creation of the &lt;strong&gt;Dataform repository&lt;/strong&gt; and establishes a link with a &lt;strong&gt;GitHub repository&lt;/strong&gt;, enabling a GitOps-driven workflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  📦 Applicative ELT pipeline
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data ingestion&lt;/strong&gt;: raw files are loaded into &lt;strong&gt;BigQuery&lt;/strong&gt; using &lt;code&gt;bq load&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging layer&lt;/strong&gt;: reads raw data and applies initial &lt;strong&gt;data cleaning and standardization&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mart layer&lt;/strong&gt;: transforms cleaned data into &lt;strong&gt;business-ready domain models&lt;/strong&gt;, applying domain-specific logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic views generation&lt;/strong&gt;: creates &lt;strong&gt;views for player statistics&lt;/strong&gt;, dynamically generated from the domain data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic tables generation&lt;/strong&gt;: builds &lt;strong&gt;country-specific tables&lt;/strong&gt; to expose &lt;strong&gt;player statistics by country&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Usual structure of a Dataform project
&lt;/h2&gt;

&lt;p&gt;Dataform is fully managed and deeply integrated with BigQuery, making it seamless to use within the Google Cloud ecosystem. When getting started, the Dataform console makes it easy to generate boilerplate code and a project structure, helping you ramp up quickly.&lt;/p&gt;

&lt;p&gt;On the Dataform main page, the first step is to click the &lt;strong&gt;"Create repository"&lt;/strong&gt; button to start a new project.&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%2Ft2tadlppawavuxk6vkj4.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%2Ft2tadlppawavuxk6vkj4.png" alt="Dataform main page with the Create repository button" width="800" height="120"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the repository creation menu, you can assign any name, select the &lt;code&gt;europe-west1&lt;/code&gt; region, and specify a service account with the necessary permissions — typically &lt;code&gt;Dataform Editor&lt;/code&gt; and &lt;code&gt;BigQuery Data Editor&lt;/code&gt; to get started. For production setups, use more restricted roles aligned with the principle of least privilege.&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%2Frohb5eur4s9p3l62mex1.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%2Frohb5eur4s9p3l62mex1.png" alt="Dataform repository creation form" width="800" height="832"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then click on &lt;strong&gt;"Go to repositories"&lt;/strong&gt;:&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%2Fc1wb6xmvi6hqymr05p47.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%2Fc1wb6xmvi6hqymr05p47.png" alt="Repository created, Go to repositories button" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on &lt;strong&gt;"Create development workspace"&lt;/strong&gt;:&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%2Ftfjfpupx7064roq4jg6t.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%2Ftfjfpupx7064roq4jg6t.png" alt="Create development workspace button" width="800" height="386"&gt;&lt;/a&gt;&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%2Fvsul0p21l54tc72ybb97.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%2Fvsul0p21l54tc72ybb97.png" alt="Development workspace creation form" width="799" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then open the created workspace:&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%2Fddwku73i1dfwk7iahst2.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%2Fddwku73i1dfwk7iahst2.png" alt="List of development workspaces" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on &lt;strong&gt;"Initialize workspace"&lt;/strong&gt;:&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%2F6t37oty36hssi31tvruo.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%2F6t37oty36hssi31tvruo.png" alt="Initialize workspace button" width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After this step, Dataform generates the example project structure. Next, open the &lt;code&gt;workflow_settings.yaml&lt;/code&gt; file and click on &lt;strong&gt;"Install Packages"&lt;/strong&gt; to set up the required dependencies.&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%2F4knjp5e1eige3vsmm1z1.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%2F4knjp5e1eige3vsmm1z1.png" alt="workflow_settings.yaml file with the Install Packages button" width="799" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The boilerplate project provides code that's ready to run out of the box. It includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;workflow_settings.yaml&lt;/code&gt; configuration file&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;definitions/&lt;/code&gt; folder containing sample models&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;includes/&lt;/code&gt; folder for reusable JavaScript functions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can execute the pipeline directly by clicking &lt;strong&gt;"Start execution" → "Execute actions"&lt;/strong&gt; in the UI.&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%2Fwq5qmh7wdz19s4dyciss.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%2Fwq5qmh7wdz19s4dyciss.png" alt="Start execution menu in the workspace" width="800" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;"Selection of actions"&lt;/strong&gt; panel, choose &lt;strong&gt;all actions&lt;/strong&gt; for this example, then click on &lt;strong&gt;"Start execution"&lt;/strong&gt; to run the pipeline.&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%2F15nvj6g0wgo3f63gkzr0.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%2F15nvj6g0wgo3f63gkzr0.png" alt="Selection of actions panel" width="800" height="809"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on &lt;strong&gt;"Details"&lt;/strong&gt;:&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%2Fnu6irq083p7rlw35u2o5.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%2Fnu6irq083p7rlw35u2o5.png" alt="Execution started notification with the Details link" width="800" height="827"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The execution is successful:&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%2Fpyf4lxkhev983158cmcc.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%2Fpyf4lxkhev983158cmcc.png" alt="Successful workflow execution" width="800" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By clicking on &lt;strong&gt;"View details"&lt;/strong&gt;, you can access the &lt;strong&gt;compiled SQL queries&lt;/strong&gt; that Dataform generates from your models.&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%2F415r2ut9xc2du2nlqqva.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%2F415r2ut9xc2du2nlqqva.png" alt="Compiled SQL query of an action" width="800" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also view the &lt;strong&gt;compiled dependency graph&lt;/strong&gt;, which visualizes the relationships between models and their execution order.&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%2Fd5s51h1iz8e0jtqaeeur.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%2Fd5s51h1iz8e0jtqaeeur.png" alt="Compiled dependency graph" width="799" height="258"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the next section, we'll walk through our use case and explore a more realistic, real-world scenario.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Structure of the project presented in this use case
&lt;/h2&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%2F3l8zo7owd7rdkbizjao9.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%2F3l8zo7owd7rdkbizjao9.png" alt="Project structure" width="800" height="884"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Dataform configuration
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;workflow_settings.yaml&lt;/code&gt; file, located at the root of the GitHub repo and of the Dataform project, defines key configuration parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the GCP project ID&lt;/li&gt;
&lt;li&gt;the location (region) where the Dataform workflow runs&lt;/li&gt;
&lt;li&gt;the BigQuery dataset used to store the Dataform-generated tables&lt;/li&gt;
&lt;li&gt;the BigQuery dataset used to store assertion results&lt;/li&gt;
&lt;li&gt;the Dataform core version used for execution
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;defaultProject&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gb-poc-373711&lt;/span&gt;
&lt;span class="na"&gt;defaultLocation&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;defaultDataset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qatar_fifa_world_cup_dataform&lt;/span&gt;
&lt;span class="na"&gt;defaultAssertionDataset&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qatar_fifa_world_cup_dataform_assertions&lt;/span&gt;
&lt;span class="na"&gt;dataformCoreVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3.0.42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  3.2 Definitions and models
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;definitions&lt;/code&gt; folder contains all the Dataform models, organized into &lt;strong&gt;staging&lt;/strong&gt; and &lt;strong&gt;mart&lt;/strong&gt; layers.&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%2Ff5gfe8u6bj299d38u26b.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%2Ff5gfe8u6bj299d38u26b.png" alt="Definitions folder with staging and marts sub-folders" width="800" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Dataform models are defined using either &lt;strong&gt;SQLX&lt;/strong&gt; or &lt;strong&gt;JavaScript&lt;/strong&gt; files.&lt;/p&gt;
&lt;h3&gt;
  
  
  3.3 Staging layer
&lt;/h3&gt;

&lt;p&gt;In the &lt;strong&gt;staging layer&lt;/strong&gt;, the SQLX file cleans the raw data and applies light transformations, typically producing &lt;strong&gt;BigQuery views&lt;/strong&gt; as output.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;team_players_stat_raw_cleaned.sqlx&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"view"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;goalsScored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"Goal scored for the player."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;assistsProvided&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"Assists provided by the player."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;appearances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"Appearances for the player."&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;nationality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;goalsScored&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;INT64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;goalsScored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assistsProvided&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;INT64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;assistsProvided&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dribblesPerNinety&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;FLOAT64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dribblesPerNinety&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;appearances&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;INT64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;appearances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalDuelsWonPerNinety&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;FLOAT64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;totalDuelsWonPerNinety&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interceptionsPerNinety&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;FLOAT64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;interceptionsPerNinety&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SAFE_CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tacklesPerNinety&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;FLOAT64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tacklesPerNinety&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;brandSponsorAndUsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;club&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;savePercentage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;savePercentage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;isGoalKeeperStatsExist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fifaRanking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;playerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cleanSheets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nationalTeamKitSponsor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nationalTeamJerseyNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;playerDob&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`qatar_fifa_world_cup_dataform.team_players_stat_raw`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Similar to dbt, the &lt;code&gt;config&lt;/code&gt; block in a Dataform model lets you define settings specific to that model. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;type&lt;/code&gt;: how the model is materialized (e.g. a &lt;strong&gt;view&lt;/strong&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;columns&lt;/code&gt;: &lt;strong&gt;documentation and descriptions&lt;/strong&gt; for specific columns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of the model is a standard BigQuery SQL query that defines the transformation logic.&lt;/p&gt;
&lt;h3&gt;
  
  
  3.4 Mart layer
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;team_players_stat.sqlx&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"Description of the table."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;team_player_stat_columns_descriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns_descriptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;bigquery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;partitionBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"ingestionDate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;"day"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;clusterBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;"teamName"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;WITH&lt;/span&gt;

&lt;span class="n"&gt;team_players_stat_raw&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"team_players_stat_raw_cleaned"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;

&lt;span class="n"&gt;goalKeepersStats&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;nationality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;STRUCT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;playerName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;appearances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;savePercentage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cleanSheets&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;goalKeeperStatsStruct&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;team_players_stat_raw&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;isGoalKeeperStatsExist&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;

&lt;span class="n"&gt;goalKeeperStatsPerTeam&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;nationality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ARRAY_AGG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;goalKeeperStatsStruct&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;goalKeeperStatsStruct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;savePercentage&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="k"&gt;OFFSET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;goalKeepersStats&lt;/span&gt;
    &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt;
        &lt;span class="n"&gt;nationality&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;statRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nationality&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;teamName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nationalTeamKitSponsor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fifaRanking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;goalsScored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;teamTotalGoals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ingestionDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;goalKeeperStatsPerTeam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;goalKeeper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;team_players_stat_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nv"&gt;"goalsScored"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"brandSponsorAndUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"club"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerDob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerName"&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;topScorers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;team_players_stat_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nv"&gt;"assistsProvided"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"brandSponsorAndUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"club"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerDob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerName"&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bestPassers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;team_players_stat_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nv"&gt;"dribblesPerNinety"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"brandSponsorAndUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"club"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerDob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerName"&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bestDribblers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;team_players_stat_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"brandSponsorAndUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"club"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerDob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerName"&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;playersMostAppearances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;team_players_stat_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nv"&gt;"totalDuelsWonPerNinety"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"brandSponsorAndUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"club"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerDob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerName"&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;playersMostDuelsWon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;team_players_stat_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nv"&gt;"interceptionsPerNinety"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"brandSponsorAndUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"club"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerDob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerName"&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;playersMostInterception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;team_players_stat_functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nv"&gt;"tacklesPerNinety"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"appearances"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"brandSponsorAndUsed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"club"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerDob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;"playerName"&lt;/span&gt;
    &lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;playersMostSuccessfulTackles&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;team_players_stat_raw&lt;/span&gt; &lt;span class="n"&gt;statRaw&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;goalKeeperStatsPerTeam&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;statRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nationality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;goalKeeperStatsPerTeam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nationality&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt;
    &lt;span class="n"&gt;statRaw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nationality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nationalTeamKitSponsor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fifaRanking&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;goalKeeperStatsPerTeam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;For the mart step, the &lt;code&gt;config&lt;/code&gt; block declares a &lt;strong&gt;table&lt;/strong&gt; as the model type:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Description of the table.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;team_player_stat_columns_descriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columns_descriptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;bigquery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;partitionBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ingestionDate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;dataType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;day&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;clusterBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;teamName&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;We also added clustering and partitioning options in the &lt;code&gt;bigquery&lt;/code&gt; block to improve performance and reduce cost.&lt;/p&gt;
&lt;h3&gt;
  
  
  3.5 Mart step: column descriptions
&lt;/h3&gt;

&lt;p&gt;Instead of embedding the column descriptions directly in the SQLX model, we extracted them into a separate JavaScript file. This is particularly useful when descriptions are long, as it keeps the model clean and readable.&lt;/p&gt;

&lt;p&gt;You have two options for column descriptions: embed them in the SQLX model, or move them to a JS file. When descriptions are verbose, I recommend the JS file for clarity, maintainability and readability.&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;config&lt;/code&gt; block, the &lt;code&gt;columns&lt;/code&gt; parameter directly references the JavaScript constant that holds the descriptions:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;team_player_stat_columns_descriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columns_descriptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This constant is declared in the &lt;code&gt;team_player_stat_columns_descriptions.js&lt;/code&gt; file, in the &lt;code&gt;includes&lt;/code&gt; folder. The nested objects (&lt;code&gt;top_scorers_columns&lt;/code&gt;, &lt;code&gt;best_passers_columns&lt;/code&gt;, …) are declared in the same file and describe the fields of each &lt;code&gt;STRUCT&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top_scorers_columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;An object containing the top scorers fields.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;goals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Total number of goals scored by the top scorers.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;players&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;List of top-scoring players&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;playerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name of the top-scoring player.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;playerDob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Date of birth of the player.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Playing position of the player.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;club&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Club that the player is affiliated with.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;brandSponsorAndUsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Brand sponsor of the player's gear.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;appearances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Number of matches the player has played in.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// ... same pattern for the other statistics&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns_descriptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;teamName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name of the national football team.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;teamTotalGoals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Total number of goals scored by the team in the tournament.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fifaRanking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Current FIFA ranking of the national team.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;nationalTeamKitSponsor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Official sponsor providing kits for the national team.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;topScorers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;top_scorers_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bestPassers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;best_passers_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bestDribblers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;best_dribblers_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;goalKeeper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;goal_keeper_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;playersMostAppearances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;players_most_appearances_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;playersMostDuelsWon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;players_most_duels_won_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;playersMostInterception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;players_most_interceptions_columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;playersMostSuccessfulTackles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;players_most_successful_tackles_columns&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;columns_descriptions&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  3.6 Mart step: reusable function to compute player statistics
&lt;/h3&gt;

&lt;p&gt;Most player statistics — such as top scorers and best passers — are computed the same way. To avoid duplicating code, I created a reusable function that encapsulates this logic.&lt;/p&gt;

&lt;p&gt;In Dataform, one way to define reusable logic is to create a JavaScript file in the &lt;code&gt;includes&lt;/code&gt; folder. Here, the &lt;code&gt;team_players_stat_functions.js&lt;/code&gt; file holds this function:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;build_player_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;statIndicator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;appearances&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brandSponsorAndUsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;club&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;playerDob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;playerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`
    STRUCT(
      MAX(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;statIndicator&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) AS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;statIndicator&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,
      ARRAY_AGG(
        IF(
          &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;statIndicator&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = 0 OR &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;statIndicator&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = 0.00,
          NULL,
          STRUCT(
            &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;appearances&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,
            &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;brandSponsorAndUsed&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,
            &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;club&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,
            &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,
            &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;playerDob&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;,
            &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;playerName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
          )
        )
        ORDER BY &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;statIndicator&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; DESC LIMIT 1
      )[OFFSET(0)] AS players
    )
  `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;build_player_stats&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;build_player_stats&lt;/code&gt; function takes the column names as parameters and injects them into the generated SQL. To make it available in SQLX or JavaScript models, it must be exported with &lt;code&gt;module.exports&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I gave more details on this use case and its data modeling in the &lt;a href="https://medium.com/google-cloud/elt-with-dbt-on-cloud-run-job-0fcadce4eb1f" rel="noopener noreferrer"&gt;article I previously wrote on &lt;strong&gt;dbt&lt;/strong&gt;&lt;/a&gt;, which covers the same context.&lt;/p&gt;
&lt;h3&gt;
  
  
  3.7 Mart step: dynamic tables and views
&lt;/h3&gt;

&lt;p&gt;One great feature of Dataform is the ability to write models in JavaScript instead of SQLX when needed. This is particularly useful when you need to generate logic or structure dynamically.&lt;/p&gt;

&lt;p&gt;That's exactly what we demonstrate here: after computing the domain data, we dynamically generate one view per statistic and one table per country. JavaScript models are well suited for this kind of logic, letting us create models programmatically with more flexibility.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;stat_dynamic_tables_and_views.js&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statViews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;columnToSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;goalKeeper&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;viewName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;goal_keeper&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;columnToSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;topScorers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;viewName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;top_scorers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;columnToSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bestPassers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;viewName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;best_passers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statPerCountryTables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;countryName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;France&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;france_players&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;countryName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Argentina&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;argentina_players&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nx"&gt;statViews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewName&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_stat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
            SELECT &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columnToSelect&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
            FROM &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team_players_stat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
        `&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;statPerCountryTables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tableName&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_stat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`
                SELECT *
                FROM &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;team_players_stat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
                WHERE teamName = "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;countryName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"
            `&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;We declare two &lt;code&gt;const&lt;/code&gt; arrays — one for the views and one for the tables — and loop over each with &lt;code&gt;forEach&lt;/code&gt;. The &lt;code&gt;publish&lt;/code&gt; function then defines each view and table dynamically.&lt;/p&gt;

&lt;p&gt;Below is the diagram of the ELT pipeline built with Dataform, with the data lineage showing how each component connects across the workflow.&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%2Fgtqimbwxbht3p1ju9ryr.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%2Fgtqimbwxbht3p1ju9ryr.png" alt="Dataform dependency graph and data lineage of the pipeline" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Connect the GitHub repo to the Dataform repo from the console
&lt;/h2&gt;

&lt;p&gt;In real-world projects, Dataform code is typically managed in GitHub repositories. Dataform can synchronize a repository over either &lt;strong&gt;HTTPS&lt;/strong&gt; or &lt;strong&gt;SSH&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I prefer &lt;strong&gt;SSH&lt;/strong&gt;, as it's both more secure and more convenient with GitHub. In this example, I had already added an SSH key to my GitHub &lt;strong&gt;account&lt;/strong&gt;, which lets me reuse it across multiple repositories. You could also add a key at the &lt;strong&gt;repository level&lt;/strong&gt; (deploy key), but managing it at the account level is more practical in my case.&lt;/p&gt;

&lt;p&gt;To retrieve the &lt;strong&gt;GitHub SSH public host key&lt;/strong&gt; in the format expected by a &lt;code&gt;known_hosts&lt;/code&gt; file, run:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keyscan &lt;span class="nt"&gt;-t&lt;/span&gt; rsa github.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It displays GitHub's public host key:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# github.com:22 SSH-2.0-4c545346
github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Make sure to copy the entire value, starting from &lt;code&gt;ssh-rsa&lt;/code&gt; (or &lt;code&gt;ssh-ed25519&lt;/code&gt;) all the way to the end of the line, without the &lt;code&gt;github.com&lt;/code&gt; hostname.&lt;/p&gt;

&lt;p&gt;Next, you need the &lt;strong&gt;private key&lt;/strong&gt; of your SSH key pair, which Dataform uses to authenticate with your GitHub repository.&lt;/p&gt;

&lt;p&gt;Use one of the following commands, depending on the type of key you generated:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_rsa
&lt;span class="c"&gt;# or&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Be careful&lt;/strong&gt; — never share this private key. It must be kept secure at all times.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Store the private key as a &lt;strong&gt;secret&lt;/strong&gt; in &lt;strong&gt;Secret Manager&lt;/strong&gt;. Dataform reads it from there to establish the SSH connection with your GitHub repository.&lt;/p&gt;

&lt;p&gt;Open the Dataform repository you created earlier and go to the &lt;strong&gt;Settings&lt;/strong&gt; tab. From there, click on &lt;strong&gt;"Connect with Git"&lt;/strong&gt; to link your repository to a Git provider such as GitHub.&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%2Fvnvzhmxap4rizv2r6ugb.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%2Fvnvzhmxap4rizv2r6ugb.png" alt="Connect with Git button in the repository settings" width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then fill in the &lt;strong&gt;Git connection&lt;/strong&gt; form:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;SSH URL&lt;/strong&gt; of the remote GitHub repository&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;default branch&lt;/strong&gt; (e.g. &lt;code&gt;main&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;secret version&lt;/strong&gt; containing your private SSH key (from Secret Manager)&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;SSH public host key&lt;/strong&gt;, in &lt;code&gt;known_hosts&lt;/code&gt; format (from &lt;code&gt;ssh-keyscan&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To let Dataform read the private key from Secret Manager, grant the &lt;code&gt;Secret Manager Secret Accessor&lt;/code&gt; role (&lt;code&gt;roles/secretmanager.secretAccessor&lt;/code&gt;) to the Dataform service agent:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service-{PROJECT_NUMBER}@gcp-sa-dataform.iam.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fxy4m5ztlftgegkepgd4h.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%2Fxy4m5ztlftgegkepgd4h.png" alt="Git connection form over SSH" width="800" height="984"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The connection is successful:&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%2F4ur2qzv79ump9c2mp8t3.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%2F4ur2qzv79ump9c2mp8t3.png" alt="Repository successfully connected to GitHub" width="800" height="823"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you create a new Dataform workspace, it automatically pulls the files and project structure from the linked GitHub repository, using the configured default branch (typically &lt;code&gt;main&lt;/code&gt;).&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%2Fx3rgc03w2cub1e0bu4lj.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%2Fx3rgc03w2cub1e0bu4lj.png" alt="New workspace created from the GitHub repository" width="800" height="509"&gt;&lt;/a&gt;&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%2Faklkonse6tghm0krwmik.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%2Faklkonse6tghm0krwmik.png" alt="Workspace content synchronized from GitHub" width="800" height="361"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Connect the GitHub repo to the Dataform repo with Terraform
&lt;/h2&gt;

&lt;p&gt;In this section, we automate the GitHub repository link with &lt;strong&gt;Terraform&lt;/strong&gt; instead of configuring it manually. This aligns with &lt;strong&gt;GitOps best practices&lt;/strong&gt;, where &lt;strong&gt;Git is the single source of truth&lt;/strong&gt; for infrastructure and configuration.&lt;/p&gt;

&lt;p&gt;The Terraform code structure:&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%2Fs54cbo5zw1kobvwc70g0.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%2Fs54cbo5zw1kobvwc70g0.png" alt="Terraform code structure in the infra folder" width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; folder contains all the Terraform configuration files.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;backend.tf&lt;/code&gt; file defines the &lt;strong&gt;remote state backend&lt;/strong&gt;, which uses &lt;strong&gt;Google Cloud Storage (GCS)&lt;/strong&gt; to persist the Terraform state:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"gcs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;versions.tf&lt;/code&gt; file pins the Terraform and &lt;strong&gt;Google Cloud provider&lt;/strong&gt; versions:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.9.8"&lt;/span&gt;

  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;google&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"= 6.14.0"&lt;/span&gt;
    &lt;span class="nx"&gt;google-beta&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"= 6.14.0"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Input variables are declared in the &lt;code&gt;variables.tf&lt;/code&gt; file to keep the configuration flexible:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"project_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Location for load balancer and Cloud Run resources"&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"europe-west1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"dataform_repo_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Dataform repo name."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"service_account_email"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Service Account email for the creation of the Dataform repo."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&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;locals.tf&lt;/code&gt; file declares constants such as the &lt;strong&gt;SSH public host key&lt;/strong&gt; and the &lt;strong&gt;Secret Manager secret version&lt;/strong&gt; of the private SSH key. These locals centralize values used across the Terraform configuration.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;github_account_host_public_ssh_key_value&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk="&lt;/span&gt;
  &lt;span class="nx"&gt;github_account_private_ssh_key_secret_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"projects/975119474255/secrets/github_account_mazlum_tosun_private_key/versions/latest"&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;main.tf&lt;/code&gt; file creates the &lt;strong&gt;Dataform repository&lt;/strong&gt; and configures the connection to the &lt;strong&gt;GitHub repository&lt;/strong&gt; over SSH:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_dataform_repository"&lt;/span&gt; &lt;span class="s2"&gt;"world_cup_elt_dataform_repo"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;google-beta&lt;/span&gt;

  &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataform_repo_name&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;

  &lt;span class="nx"&gt;service_account&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_account_email&lt;/span&gt;

  &lt;span class="nx"&gt;workspace_compilation_overrides&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;default_database&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;git_remote_settings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ssh://git@github.com/tosun-si/${var.dataform_repo_name}.git"&lt;/span&gt;
    &lt;span class="nx"&gt;default_branch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
    &lt;span class="nx"&gt;ssh_authentication_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;user_private_key_secret_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_account_private_ssh_key_secret_version&lt;/span&gt;
      &lt;span class="nx"&gt;host_public_key&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_account_host_public_ssh_key_value&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This article presented a real-world ELT pipeline built with &lt;strong&gt;Dataform&lt;/strong&gt;, covering key concepts such as &lt;strong&gt;staging and mart layers&lt;/strong&gt;, concrete data &lt;strong&gt;transformations&lt;/strong&gt;, &lt;strong&gt;column documentation&lt;/strong&gt;, and &lt;strong&gt;JavaScript functions&lt;/strong&gt; for dynamic logic.&lt;/p&gt;

&lt;p&gt;It also included a DevOps and IaC part, so you can reproduce a complete, hands-on setup that follows modern best practices.&lt;/p&gt;

&lt;p&gt;Dataform is a powerful choice for teams working with &lt;strong&gt;BigQuery&lt;/strong&gt;, thanks to its &lt;strong&gt;fully managed experience&lt;/strong&gt; and &lt;strong&gt;tight integration with GCP&lt;/strong&gt;. Its support for a &lt;strong&gt;GitOps workflow&lt;/strong&gt; — where GitHub repositories are the single source of truth — makes it even more appealing for collaborative, production-grade data projects.&lt;/p&gt;

&lt;p&gt;Personally, I find using a &lt;strong&gt;programming language like JavaScript&lt;/strong&gt; for dynamic logic far more effective than Jinja templating. JavaScript may not be the default choice for data engineers, but it offers better readability and more power when implementing complex or repetitive logic across your models.&lt;/p&gt;

&lt;p&gt;All the code presented in this article is available in this GitHub repository:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/tosun-si" rel="noopener noreferrer"&gt;
        tosun-si
      &lt;/a&gt; / &lt;a href="https://github.com/tosun-si/world-cup-qatar-elt-dataform" rel="noopener noreferrer"&gt;
        world-cup-qatar-elt-dataform
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Project showing a use case with an ELT using Dataform in Google Cloud
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;world-cup-qatar-elt-dataform&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;This repo shows a real world use case with Dataform, BigQuery and Google Cloud
The raw and input data are represented by the Qatar Fifa World Cup Players stats
some transformations are applied with the ELT pattern and Dataform to apply aggregation and business transformations.&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/tosun-si/world-cup-qatar-elt-dataform/diagram/elt_bigquery_dataform.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Ftosun-si%2Fworld-cup-qatar-elt-dataform%2FHEAD%2Fdiagram%2Felt_bigquery_dataform.png" alt="elt_bigquery_dataform.png"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The video in English:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://youtu.be/c70ry7rrm6w" rel="nofollow noopener noreferrer"&gt;https://youtu.be/c70ry7rrm6w&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The video in French:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://youtu.be/b-6naX68YRg" rel="nofollow noopener noreferrer"&gt;https://youtu.be/b-6naX68YRg&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Airflow DAG - ELT pipeline orchestration&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;The pipeline is orchestrated by an Airflow DAG (Cloud Composer) with the following steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Load raw data to BigQuery&lt;/strong&gt; — Loads NDJSON player stats from GCS into a BigQuery raw table using &lt;code&gt;GCSToBigQueryOperator&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invoke Dataform workflow&lt;/strong&gt; — Invokes the Dataform workflow config of the environment (released by CI/CD) using &lt;code&gt;DataformCreateWorkflowInvocationOperator&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move processed files to cold storage&lt;/strong&gt; — Moves the input file to a cold bucket using &lt;code&gt;GCSToGCSOperator&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The DAG configuration is managed via Airflow Variables, loaded from &lt;code&gt;world_cup_qatar_elt_dataform_dags/config/variables/{env}/variables.json&lt;/code&gt;.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Deploy the Airflow DAG to Cloud Composer&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;The DAG…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/tosun-si/world-cup-qatar-elt-dataform" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;If you enjoyed this article, follow me for more content on Google Cloud, BigQuery, Dataform, DevOps and data engineering:&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://x.com/MazlumTosun3" rel="noopener noreferrer"&gt;X&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;/ul&gt;

</description>
      <category>dataform</category>
      <category>bigquery</category>
      <category>googlecloud</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Running an AI Agent Locally: ADK, Gemma 4, and Docker Model Runner</title>
      <dc:creator>Mazlum Tosun</dc:creator>
      <pubDate>Wed, 16 Sep 2026 15:39:30 +0000</pubDate>
      <link>https://dev.to/gde/running-an-ai-agent-locally-adk-gemma-4-and-docker-model-runner-44db</link>
      <guid>https://dev.to/gde/running-an-ai-agent-locally-adk-gemma-4-and-docker-model-runner-44db</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://medium.com/google-cloud/running-an-ai-agent-locally-adk-gemma-4-and-docker-model-runner-95ca9e6f506d" rel="noopener noreferrer"&gt;Medium (Google Cloud Community)&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cloud LLMs are great for production. But during development, every API call has latency, costs money, and requires credentials. What if the LLM ran on your machine, right next to your agent?&lt;/p&gt;

&lt;p&gt;In my &lt;a href="https://medium.com/google-cloud/end-to-end-ai-agent-on-gcp-adk-bigquery-mcp-agent-engine-and-cloud-run-4843fec27c13" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;, I built a football statistics agent using &lt;strong&gt;Google ADK&lt;/strong&gt;, &lt;strong&gt;BigQuery MCP&lt;/strong&gt; via Cloud API Registry, and &lt;strong&gt;Gemini 2.5 Flash&lt;/strong&gt; as the LLM — deployed to Cloud Run and Vertex AI Agent Engine.&lt;/p&gt;

&lt;p&gt;In this article, I take the same agent and &lt;strong&gt;replace Gemini with Gemma 4 running locally&lt;/strong&gt; via Docker Model Runner. The agent code barely changes. The BigQuery MCP tools stay the same. But now the LLM runs on my laptop — no cloud inference, no API key, no cost.&lt;/p&gt;

&lt;p&gt;The full source code is available on &lt;a href="https://github.com/tosun-si/football-agent-adk-gemma-dmr" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&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%2F2ji59ni181rapxu0w4dv.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%2F2ji59ni181rapxu0w4dv.png" alt="Architecture Diagram" width="800" height="351"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What is Docker Model Runner?
&lt;/h2&gt;

&lt;p&gt;Docker Model Runner is a built-in feature of Docker Desktop that lets you &lt;strong&gt;pull and run LLMs locally&lt;/strong&gt; — just like pulling container images. It exposes an &lt;strong&gt;OpenAI-compatible API&lt;/strong&gt; on your machine, so any tool that speaks the OpenAI protocol can use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable the Model Runner with TCP access&lt;/span&gt;
docker desktop &lt;span class="nb"&gt;enable &lt;/span&gt;model-runner &lt;span class="nt"&gt;--tcp&lt;/span&gt; 12434

&lt;span class="c"&gt;# Pull the model&lt;/span&gt;
docker model pull ai/gemma4:E4B

&lt;span class="c"&gt;# Verify&lt;/span&gt;
docker model &lt;span class="nb"&gt;ls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; On Docker Engine (Linux), TCP access is enabled by default on port 12434 — no extra flag needed. On Docker Desktop (macOS/Windows), TCP must be explicitly enabled with &lt;code&gt;--tcp 12434&lt;/code&gt; to expose the API on localhost. In this article, I'm using Docker Desktop on macOS, which is why this activation step is required.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once pulled, the model is available at &lt;code&gt;http://localhost:12434/engines/v1&lt;/code&gt; — the same endpoint format as the OpenAI API. You can test it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:12434/engines/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "model": "ai/gemma4:E4B",
    "messages": [{"role": "user", "content": "Say hello"}],
    "max_tokens": 20
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No API key needed. No cloud dependency. The model runs entirely on your machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is Gemma 4?
&lt;/h2&gt;

&lt;p&gt;Gemma 4 is Google's latest open-weight model family, available on Docker Hub in several sizes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;On-disk size*&lt;/th&gt;
&lt;th&gt;Raw params&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai/gemma4:E2B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.94 GiB&lt;/td&gt;
&lt;td&gt;4.65B (2B effective)&lt;/td&gt;
&lt;td&gt;Fastest, edge-friendly — great for rapid iteration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ai/gemma4:E4B&lt;/code&gt; &lt;em&gt;(= &lt;code&gt;latest&lt;/code&gt;)&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;4.74 GiB&lt;/td&gt;
&lt;td&gt;7.52B (4B effective)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Best trade-off for local agents&lt;/strong&gt; — current default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai/gemma4:4B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~6 GB&lt;/td&gt;
&lt;td&gt;4B dense (Q4_K_XL quant)&lt;/td&gt;
&lt;td&gt;Traditional dense variant, quantized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai/gemma4:26B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~18 GB&lt;/td&gt;
&lt;td&gt;26B MoE (~4B active)&lt;/td&gt;
&lt;td&gt;High quality, sparse mixture-of-experts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ai/gemma4:31B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~20 GB&lt;/td&gt;
&lt;td&gt;31B dense&lt;/td&gt;
&lt;td&gt;Maximum quality, heavy on resources&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* Sizes reported by &lt;code&gt;docker model ls&lt;/code&gt; after pull. The Docker Hub manifest shows slightly larger figures (e.g. E4B = 6.09 GB on Hub) because the GGUF is repackaged locally.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;E2B&lt;/code&gt; and &lt;code&gt;E4B&lt;/code&gt; variants use the &lt;strong&gt;Per-Layer Embeddings (PLE)&lt;/strong&gt; architecture — they have more actual parameters than their "effective" size, but run at the compute cost of the smaller effective size. It's the same Matryoshka-style approach introduced with Gemma 3n.&lt;/p&gt;

&lt;p&gt;For an agent that makes &lt;strong&gt;3-4 LLM roundtrips per question&lt;/strong&gt; (reasoning, tool call, error handling, final answer), inference speed matters. The &lt;strong&gt;E4B variant&lt;/strong&gt; gives the best trade-off between quality and speed for local development — it's also now the &lt;code&gt;latest&lt;/code&gt; default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: what changes, what stays
&lt;/h2&gt;

&lt;p&gt;The beauty of this approach is how little changes. The agent framework (ADK), the tools (BigQuery MCP via Cloud API Registry), and the system instructions are &lt;strong&gt;identical&lt;/strong&gt; to the cloud version. Only the model endpoint changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Question
     │
     ▼
┌──────────────┐     ┌──────────────────────┐
│  Google ADK  │────▶│  Docker Model Runner │
│  LlmAgent    │◀────│  Gemma 4 (local)     │
└──────┬───────┘     └──────────────────────┘
       │
       ▼
┌──────────────────┐     ┌───────────┐
│ Cloud API Registry│────▶│ BigQuery  │
│ BigQuery MCP      │◀────│ (cloud)   │
└──────────────────┘     └───────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cloud version (previous article)&lt;/th&gt;
&lt;th&gt;Local version (this article)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemini 2.5 Flash (Vertex AI)&lt;/td&gt;
&lt;td&gt;Gemma 4 E4B (Docker Model Runner)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inference cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per-token pricing&lt;/td&gt;
&lt;td&gt;Free (runs on your CPU/GPU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LlmAgent&lt;/code&gt; + API Registry MCP&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tools&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;BigQuery MCP&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;System instruction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full schema + business rules&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the details on the ADK agent setup, BigQuery MCP via Cloud API Registry, the dataset, and the system instruction, refer to my &lt;a href="https://medium.com/google-cloud/end-to-end-ai-agent-on-gcp-adk-bigquery-mcp-agent-engine-and-cloud-run-4843fec27c13" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;. Here, I'll focus on what's new: the Docker Model Runner integration and the challenges of running Gemma locally with an agent framework.&lt;/p&gt;




&lt;h2&gt;
  
  
  The agent code: one model string change
&lt;/h2&gt;

&lt;p&gt;Here's the original &lt;code&gt;agent.py&lt;/code&gt; from the cloud version:&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="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini-2.5-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;football_stats_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_INSTRUCTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;toolset&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the local version:&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="n"&gt;GEMMA_MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GEMMA_MODEL&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;ai/gemma4:E4B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;GEMMA_MODEL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;football_stats_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instruction&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_INSTRUCTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;toolset&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;openai/&lt;/code&gt; prefix tells ADK to route through &lt;strong&gt;LiteLLM&lt;/strong&gt; (included in &lt;code&gt;google-adk[extensions]&lt;/code&gt;) to the OpenAI-compatible endpoint — which is Docker Model Runner. The &lt;code&gt;OPENAI_API_BASE&lt;/code&gt; environment variable points LiteLLM to the local endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:12434/engines/v1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker-model-runner  &lt;span class="c"&gt;# Dummy key, required by LiteLLM&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GEMMA_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ai/gemma4:E4B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it for the model swap. But there's a catch.&lt;/p&gt;




&lt;h2&gt;
  
  
  The function calling problem
&lt;/h2&gt;

&lt;p&gt;When I first ran the agent with Gemma, it crashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Conversation roles must alternate user/assistant/user/assistant/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue: &lt;strong&gt;Gemma doesn't have native function calling support&lt;/strong&gt;. When ADK sends tool-call messages (system → user → tool_result → assistant), Gemma's chat template rejects the non-alternating roles.&lt;/p&gt;

&lt;p&gt;Gemini handles this natively — it understands function declarations and returns structured &lt;code&gt;functionCall&lt;/code&gt; responses. Gemma needs a different approach: &lt;strong&gt;convert tool declarations into text prompts&lt;/strong&gt; that the model can understand, then &lt;strong&gt;extract function calls from the model's text output&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;ADK already has this built-in via &lt;code&gt;GemmaFunctionCallingMixin&lt;/code&gt;. It also has a &lt;code&gt;Gemma3Ollama&lt;/code&gt; class that combines this mixin with the Ollama provider. But Docker Model Runner uses the &lt;strong&gt;OpenAI API&lt;/strong&gt; (not Ollama's &lt;code&gt;/api/chat&lt;/code&gt;), so I needed a custom class.&lt;/p&gt;




&lt;h2&gt;
  
  
  GemmaModelRunner: bridging Gemma and Docker Model Runner
&lt;/h2&gt;

&lt;p&gt;The solution is a custom LLM class that combines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LiteLLM's OpenAI provider&lt;/strong&gt; — routes requests to Docker Model Runner's &lt;code&gt;/engines/v1/chat/completions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADK's &lt;code&gt;GemmaFunctionCallingMixin&lt;/code&gt;&lt;/strong&gt; — converts tool declarations to text and extracts function calls from responses
&lt;/li&gt;
&lt;/ul&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;google.adk.models.gemma_llm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GemmaFunctionCallingMixin&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.models.lite_llm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LiteLlm&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.adk.models.registry&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LLMRegistry&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GemmaModelRunner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GemmaFunctionCallingMixin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LiteLlm&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Gemma model served by Docker Model Runner.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;supported_models&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&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;list&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai/ai/gemma.*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_content_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm_request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_move_function_calls_into_system_instruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;generate_content_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm_request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_extract_function_calls_from_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;


&lt;span class="n"&gt;LLMRegistry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GemmaModelRunner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This class:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Registers itself&lt;/strong&gt; in ADK's LLM registry for model names matching &lt;code&gt;openai/ai/gemma.*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before each LLM call&lt;/strong&gt;: moves function declarations from the tool config into the system instruction as text, and converts tool-result messages into user messages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After each LLM call&lt;/strong&gt;: parses the model's text output to extract any function calls (e.g., when Gemma writes &lt;code&gt;execute_sql(projectId="...", query="...")&lt;/code&gt; as text)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The import in &lt;code&gt;agent.py&lt;/code&gt; triggers the registration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;football_stats_agent.gemma_model_runner&lt;/span&gt;  &lt;span class="c1"&gt;# noqa: F401 — registers GemmaModelRunner
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Dependencies
&lt;/h2&gt;

&lt;p&gt;The cloud version only needs &lt;code&gt;google-adk&lt;/code&gt;. The local version needs &lt;code&gt;google-adk[extensions]&lt;/code&gt; to include LiteLLM:&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;"football-agent-adk-gemma"&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;"google-adk[extensions]&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.27&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;    &lt;span class="py"&gt;"google-cloud-aiplatform&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.141&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Running the agent locally
&lt;/h2&gt;

&lt;h3&gt;
  
  
  With ADK web UI
&lt;/h3&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 model&lt;/span&gt;
docker desktop &lt;span class="nb"&gt;enable &lt;/span&gt;model-runner &lt;span class="nt"&gt;--tcp&lt;/span&gt; 12434
docker model pull ai/gemma4:E4B

&lt;span class="c"&gt;# Install dependencies&lt;/span&gt;
direnv allow
uv &lt;span class="nb"&gt;sync&lt;/span&gt;

&lt;span class="c"&gt;# Authenticate with GCP (for BigQuery MCP)&lt;/span&gt;
gcloud auth application-default login

&lt;span class="c"&gt;# Run&lt;/span&gt;
uv run adk web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;a href="http://localhost:8000" rel="noopener noreferrer"&gt;http://localhost:8000&lt;/a&gt;, select &lt;code&gt;football_stats_agent&lt;/code&gt;, and ask a question.&lt;/p&gt;

&lt;h3&gt;
  
  
  With Docker Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adk-agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GCP_PROJECT_ID=gb-poc-373711&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GEMMA_MODEL=ai/gemma4:E4B&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OPENAI_API_BASE=http://host.docker.internal:12434/engines/v1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OPENAI_API_KEY=docker-model-runner&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GOOGLE_APPLICATION_CREDENTIALS=/tmp/adc.json&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${HOME}/.config/gcloud/application_default_credentials.json:/tmp/adc.json:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;host.docker.internal&lt;/code&gt; — from inside a container, this is how you reach Docker Model Runner running on the host.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;






&lt;h2&gt;
  
  
  The agent in action
&lt;/h2&gt;

&lt;p&gt;I ran three test queries against the E4B agent to see how it handles different difficulty levels:&lt;/p&gt;

&lt;h3&gt;
  
  
  Simple query — top 3 scorers from France (~22s, one roundtrip)
&lt;/h3&gt;

&lt;p&gt;When you ask &lt;em&gt;"List the top 3 scorers from France"&lt;/em&gt;, Gemma 4 E4B gets it right on the &lt;strong&gt;first try&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reasons&lt;/strong&gt; about the question and picks the &lt;code&gt;execute_sql_readonly&lt;/code&gt; tool&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generates correct SQL&lt;/strong&gt; directly using &lt;code&gt;SAFE_CAST&lt;/code&gt; (the system instruction warns about string-typed numeric columns)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BigQuery returns results&lt;/strong&gt; — Kylian Mbappe (8), Olivier Giroud (4), Aurelien Tchouaméni (1)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Formats the answer&lt;/strong&gt; in natural language&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No self-correction needed — E4B follows the system prompt's &lt;code&gt;SAFE_CAST&lt;/code&gt; guidance upfront. A smaller model like E2B typically needs a failed attempt before switching to &lt;code&gt;SAFE_CAST&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Complex query — performance rating comparison (~2.5 min, three roundtrips)
&lt;/h3&gt;

&lt;p&gt;When you ask &lt;em&gt;"Compare France and Argentina players by performance rating. Top 3 from each team"&lt;/em&gt;, things get more interesting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First attempt&lt;/strong&gt; — generates a complex window-function SQL, BigQuery rejects it with a syntax error&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second attempt&lt;/strong&gt; — fixes the syntax but fails on &lt;code&gt;Bad int64 value: "-"&lt;/code&gt; (some numeric string columns contain &lt;code&gt;"-"&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third attempt&lt;/strong&gt; — wraps every cast in &lt;code&gt;SAFE_CAST&lt;/code&gt;, query succeeds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Formats the answer&lt;/strong&gt; with the performance rating formula applied correctly&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The self-correction behavior is the same pattern the original Gemini cloud version produced — read the BigQuery error, adjust the SQL, retry. That Gemma 4 E4B handles this loop reliably on a local ~8B-raw-param model is a strong quality signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing the right model variant
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hardware used for these benchmarks
&lt;/h3&gt;

&lt;p&gt;All timings below were measured on my personal laptop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MacBook Pro — Apple M1 Max&lt;/strong&gt; (10 CPU cores: 8 performance + 2 efficiency)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;32-core GPU&lt;/strong&gt;, Metal 3 backend&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;32 GB unified memory&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;macOS 15.3.1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The M1 Max's unified memory architecture is a big part of why Gemma 4 runs comfortably here — the GPU and CPU share the same 32 GB pool, so the model weights don't need to be copied across a PCIe bus. With 32 GB total, &lt;strong&gt;E2B (2.94 GiB) and E4B (4.74 GiB) both leave plenty of headroom&lt;/strong&gt; for Docker Desktop, the ADK runtime, the browser, and the rest of your dev environment. The larger &lt;code&gt;26B&lt;/code&gt; / &lt;code&gt;31B&lt;/code&gt; variants are technically loadable but you'll feel the squeeze.&lt;/p&gt;

&lt;p&gt;Performance will vary significantly on other hardware (base Apple Silicon, Intel Macs, Linux with or without a discrete GPU). I haven't benchmarked those, so treat the timings above as one data point on one specific machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timings
&lt;/h3&gt;

&lt;p&gt;Full agent loop end-to-end including BigQuery roundtrips:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query type&lt;/th&gt;
&lt;th&gt;Roundtrips&lt;/th&gt;
&lt;th&gt;E4B elapsed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple (e.g. "top 3 scorers")&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~22s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex (e.g. "compare two teams by performance rating")&lt;/td&gt;
&lt;td&gt;3 (with self-correction)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~2.5 min&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Simple queries feel snappy. Complex queries requiring multiple SQL self-corrections take the time you'd expect from a local model making several inference passes.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;E4B variant&lt;/strong&gt; (now the &lt;code&gt;latest&lt;/code&gt; default) is the sweet spot: ~8B raw params running at the compute cost of a 4B model thanks to PLE. It generates correct SQL on the first try for straightforward questions and self-corrects cleanly on complex ones.&lt;/p&gt;

&lt;p&gt;If you want maximum speed at the cost of some quality, drop down to &lt;strong&gt;E2B&lt;/strong&gt; — smaller, roughly &lt;strong&gt;2x faster&lt;/strong&gt; per inference, but more likely to need an extra self-correction round before getting the SQL right.&lt;/p&gt;

&lt;p&gt;If you need maximum quality, use &lt;code&gt;26B&lt;/code&gt; / &lt;code&gt;31B&lt;/code&gt; locally (expect multi-minute loops even on a beefy machine) — or switch back to Gemini for production, which is what I recommend:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local dev (default)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemma 4 E4B (Docker Model Runner)&lt;/td&gt;
&lt;td&gt;Free, no API key, good quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local dev (fastest)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemma 4 E2B (Docker Model Runner)&lt;/td&gt;
&lt;td&gt;2x faster, lower quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Production&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemini 2.5 Flash (Vertex AI)&lt;/td&gt;
&lt;td&gt;Higher quality, faster, scalable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The agent code is the same — only the &lt;code&gt;GEMMA_MODEL&lt;/code&gt; environment variable (or the model ID for Gemini) changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker Model Runner version matters
&lt;/h2&gt;

&lt;p&gt;One issue I hit: an older Docker Model Runner version bundled with my Docker Desktop install &lt;strong&gt;cannot load Gemma 4 models at all&lt;/strong&gt;. Every attempt fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unable to load runner: error waiting for runner to be ready:
inference backend took too long to initialize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks like a memory or timeout issue, but it's actually an &lt;strong&gt;architecture incompatibility&lt;/strong&gt;. Gemma 4 uses a newer model architecture that older versions of the llama.cpp backend don't support.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;update Docker Desktop&lt;/strong&gt; to get a recent Model Runner release. You can check your version with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker model version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Docker Model Runner makes local LLMs trivial&lt;/strong&gt; — &lt;code&gt;docker model pull&lt;/code&gt; and you have an OpenAI-compatible API on localhost. No Ollama, no manual setup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gemma doesn't do native function calling&lt;/strong&gt; — You need ADK's &lt;code&gt;GemmaFunctionCallingMixin&lt;/code&gt; to convert tool declarations into text prompts. The custom &lt;code&gt;GemmaModelRunner&lt;/code&gt; class bridges this gap for Docker Model Runner's OpenAI API.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The agent code barely changes&lt;/strong&gt; — Same ADK framework, same BigQuery MCP tools, same system instruction. Only the model string and one custom class change.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pick the right Gemma 4 variant for local agents&lt;/strong&gt; — &lt;code&gt;26B&lt;/code&gt; / &lt;code&gt;31B&lt;/code&gt; are too slow for multi-roundtrip loops. &lt;strong&gt;E4B&lt;/strong&gt; (the &lt;code&gt;latest&lt;/code&gt; default) is the sweet spot: ~22s for simple queries, ~2.5 min for complex ones requiring SQL self-correction. Drop to &lt;strong&gt;E2B&lt;/strong&gt; only if you need maximum speed. Both use PLE so they punch above their effective size.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep Docker Desktop updated&lt;/strong&gt; — Older Model Runner versions can't load newer model architectures. The error message is misleading — it's not a timeout, it's an incompatibility.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Local for dev, cloud for prod&lt;/strong&gt; — Run Gemma 4 locally during development (free, private, fast feedback). Switch to Gemini on Vertex AI for production (better quality, scalable). The swap is one environment variable.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;In the next article, I'll rebuild the &lt;strong&gt;same use case&lt;/strong&gt; — football statistics agent on the Qatar 2022 World Cup dataset — but replace &lt;strong&gt;Google ADK&lt;/strong&gt; with &lt;strong&gt;Docker Agent&lt;/strong&gt;. Same Gemma 4 via Docker Model Runner, same BigQuery MCP tools, same system instructions — only the agent framework changes. A direct side-by-side comparison of the two approaches.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;The full source code is available on &lt;a href="https://github.com/tosun-si/football-agent-adk-gemma-dmr" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Clone it, pull the model, and ask the agent about the 2022 World Cup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"Who scored the most goals?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Show me the top 5 players by assists"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Which goalkeepers had the highest save percentage?"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Compare France and Argentina players by performance rating"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you enjoyed this article, follow me for more content on AI agents, Google Cloud, Software, DevOps, Tech and data engineering:&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://x.com/MazlumTosun3" rel="noopener noreferrer"&gt;X&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;/ul&gt;

</description>
      <category>ai</category>
      <category>docker</category>
      <category>llm</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Docker Bake in Practice — Part 1: From Bash Scripts to Declarative Builds</title>
      <dc:creator>Mazlum Tosun</dc:creator>
      <pubDate>Fri, 11 Sep 2026 22:39:09 +0000</pubDate>
      <link>https://dev.to/gde/docker-bake-in-practice-part-1-from-bash-scripts-to-declarative-builds-3pak</link>
      <guid>https://dev.to/gde/docker-bake-in-practice-part-1-from-bash-scripts-to-declarative-builds-3pak</guid>
      <description>&lt;p&gt;&lt;em&gt;The first of a two-part series. Part 1 covers the fundamentals: what Bake is, why it exists, and the features that make it worth adopting. Part 2 will cover CI/CD integrations — Cloud Build, GitHub Actions, GitLab CI, and Dagger.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this article exists
&lt;/h2&gt;

&lt;p&gt;For the past two years I've been giving a talk about Docker Bake at conferences across France and Morocco — &lt;strong&gt;DevLille&lt;/strong&gt;, &lt;strong&gt;DevFest Toulouse&lt;/strong&gt;, &lt;strong&gt;DevFest Lyon&lt;/strong&gt;, &lt;strong&gt;Devoxx Morocco&lt;/strong&gt;, and &lt;strong&gt;Cloud Native Days France&lt;/strong&gt;. Every time I deliver it, the same thing happens: people come up afterwards and say &lt;em&gt;"I had no idea Bake could do that"&lt;/em&gt; or &lt;em&gt;"I've been writing 200-line bash scripts to do exactly this."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you want the video version in French, you can watch the &lt;a href="https://youtu.be/WVWzwRLinzc" rel="noopener noreferrer"&gt;Cloud Native Days France recording&lt;/a&gt;. An English video walking through these articles is coming soon on my &lt;a href="https://www.youtube.com/channel/UCPnHZ14R5oF8LQAc7f4mKNg/?sub_confirmation=1" rel="noopener noreferrer"&gt;YouTube channel&lt;/a&gt; — subscribe if you'd like to be notified. If you prefer to read at your own pace, with copy-pasteable snippets, this article is for you.&lt;/p&gt;

&lt;p&gt;All the code in this article comes from my companion repository — each section links to the relevant file so you can run the examples yourself.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/tosun-si" rel="noopener noreferrer"&gt;
        tosun-si
      &lt;/a&gt; / &lt;a href="https://github.com/tosun-si/docker-bake-playground" rel="noopener noreferrer"&gt;
        docker-bake-playground
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A curated collection of concrete, practical, and reusable examples for mastering Docker Bake.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;docker-bake-playground&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;A curated collection of concrete, practical, and reusable examples for mastering Docker Bake.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Build the images with Docker Bake locally&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake -f vars.hcl -f docker-bake-app-and-infra.hcl&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Build and publish the images with Docker Bake locally&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake -f vars.hcl -f docker-bake-app-and-infra.hcl --push&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Printing the Bake file with the --print flag shows the interpolated value in the resolved build configuration.&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake -f vars.hcl -f docker-bake-app-and-infra.hcl --print&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Build the images for linter and tests&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake -f vars.hcl -f docker-bake-lint-and-test.hcl validate&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run linter and tests with Compose&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker compose -f compose_lint_and_test.yaml up&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Build the multiples Bake files locally&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake -f vars.hcl -f docker-bake-app-and-infra.hcl -f docker-bake-lint-and-test.hcl default validate&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Print multiple Bake files&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake -f vars.hcl -f docker-bake-app-and-infra.hcl -f docker-bake-lint-and-test.hcl default validate --print&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Build Bake file with inheritance&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake -f vars.hcl -f docker-bake-inheritance.hcl&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Build Bake file with matrix variants&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;docker buildx bake&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/tosun-si/docker-bake-playground" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;em&gt;The code examples push to Google Cloud Artifact Registry, but Docker Bake is registry-agnostic — every snippet works against Docker Hub, ECR, ACR, or GHCR by changing the target tag.&lt;/em&gt;&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%2F5te5tndhqe3s7dmk2ep4.jpg" 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%2F5te5tndhqe3s7dmk2ep4.jpg" alt="The architecture diagram" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The foundation: BuildKit, Buildx, and Bake
&lt;/h2&gt;

&lt;p&gt;Before we touch a single HCL file, let's clear up the layering. These three names get thrown around as if they were interchangeable, but they sit on top of each other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----------------------------------------------------+
|  Bake                (declarative orchestration)   |
|  docker buildx bake -f file.hcl                    |
+----------------------------------------------------+
|  Buildx              (CLI plugin / frontend)       |
|  docker buildx build ...                           |
+----------------------------------------------------+
|  BuildKit            (build engine / backend)      |
|  parallel stages, cache mounts, multi-platform     |
+----------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;BuildKit&lt;/strong&gt; is the modern Docker build engine. It replaced the legacy builder a few years ago and is now the default in Docker Engine 23+. BuildKit is the piece doing the actual work: it parses your Dockerfile, builds stages in parallel where it can, manages the cache, handles multi-platform builds, mount caches, secrets, SSH forwarding, and so on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buildx&lt;/strong&gt; is a Docker CLI plugin that exposes BuildKit's features through a friendlier interface. When you type &lt;code&gt;docker buildx build ...&lt;/code&gt;, you're using Buildx as the frontend and BuildKit as the engine. Buildx also manages &lt;em&gt;builders&lt;/em&gt; — named BuildKit instances you can swap between (local, remote, container-driven).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bake&lt;/strong&gt; is a subcommand of Buildx: &lt;code&gt;docker buildx bake&lt;/code&gt;. It takes one or more declarative files (HCL, JSON, or a Compose file) and orchestrates many builds at once. Think of it as &lt;code&gt;docker-compose&lt;/code&gt; but for &lt;em&gt;building&lt;/em&gt; instead of &lt;em&gt;running&lt;/em&gt;. You define your images once, in one place, and Bake builds them all — in parallel, with shared variables, inheritance, matrices, and groups.&lt;/p&gt;

&lt;p&gt;That's the whole stack. You won't lose anything by treating BuildKit as "the engine," Buildx as "the CLI," and Bake as "the orchestrator."&lt;/p&gt;

&lt;p&gt;A practical note on what you actually have installed: if you've installed &lt;strong&gt;Docker Desktop&lt;/strong&gt; in the last few years, Buildx ships by default — and Buildx bundles BuildKit, so the engine comes with it. Bake is a subcommand of Buildx (&lt;code&gt;docker buildx bake&lt;/code&gt;), which means the moment you have Buildx, you have Bake too. On Linux with Docker Engine 23+, Buildx is bundled as a CLI plugin out of the box as well. There's nothing extra to install — unless your Docker is unusually old, you can run every example in this article today.&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%2Fvowkpubwylis2zg9zx5j.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%2Fvowkpubwylis2zg9zx5j.png" alt="A whale diving in, ready to build" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The traditional approach: bash scripts
&lt;/h2&gt;

&lt;p&gt;To appreciate why Bake exists, let's start with the pain. Imagine you have two images to build and push: an &lt;code&gt;app&lt;/code&gt; and an &lt;code&gt;infra&lt;/code&gt;. Multi-platform (amd64 + arm64), with provenance and SBOM attestations. Here's the straightforward bash version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

docker build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; linux/amd64,linux/arm64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; app/Dockerfile &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPO_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/app_bake:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IMAGE_TAG_VERSION_APP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provenance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sbom&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;.&lt;/span&gt;

docker build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; linux/amd64,linux/arm64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; infra/Dockerfile &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPO_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/infra_bake:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IMAGE_TAG_VERSION_INFRA&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provenance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sbom&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works. But notice four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's sequential.&lt;/strong&gt; The &lt;code&gt;infra&lt;/code&gt; build doesn't start until the &lt;code&gt;app&lt;/code&gt; build finishes — even though they're completely independent. Parallelizing in bash means wrestling with &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;wait&lt;/code&gt;, and &lt;code&gt;trap&lt;/code&gt; to propagate exit codes correctly. Most teams don't bother, and ship a pipeline that's slower than it needs to be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It duplicates everything.&lt;/strong&gt; The platform list, the attestation flags, the &lt;code&gt;--push&lt;/code&gt; — copy-pasted across every image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It scales linearly.&lt;/strong&gt; Five images? Five blocks. Ten images? Ten blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not portable across operating systems.&lt;/strong&gt; This is the silent killer in teams that mix Linux, macOS, and Windows laptops. The bash script above looks innocent, but the moment you refactor it into anything non-trivial you start relying on features that don't exist everywhere — and you end up maintaining a different version of the script per OS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The natural reflex is to refactor into a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;declare&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nv"&gt;paths&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt;app]&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"app/Dockerfile"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt;infra]&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"infra/Dockerfile"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;service &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;!paths[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;upper_service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="p"&gt;^^&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;tag_var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"IMAGE_TAG_VERSION_&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;upper_service&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;tag_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;!tag_var&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  docker build &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--platform&lt;/span&gt; linux/amd64,linux/arm64 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$service&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--tag&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPO_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;service&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_bake:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;tag_version&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--provenance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--sbom&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We've removed duplication, but at a cost: this script now uses associative arrays, indirect variable expansion (&lt;code&gt;${!tag_var}&lt;/code&gt;), and bash string manipulation (&lt;code&gt;${service^^}&lt;/code&gt;). Anyone who hasn't written bash in six months will need a few minutes and an LLM or Stack Overflow tab to read this. And it's &lt;em&gt;still&lt;/em&gt; sequential.&lt;/p&gt;

&lt;p&gt;The OS-portability problem gets worse here too. &lt;code&gt;declare -A&lt;/code&gt; (associative arrays) and &lt;code&gt;${service^^}&lt;/code&gt; (uppercase expansion) require &lt;strong&gt;bash 4 or newer&lt;/strong&gt;. macOS still ships &lt;strong&gt;bash 3.2&lt;/strong&gt; by default — Apple froze it years ago over the GPL v3 licensing change — so this exact script silently fails on a fresh Mac unless the developer has manually installed a newer bash via Homebrew. Windows developers need WSL or Git Bash. Linux users are fine. The end result is the dreaded "works on my machine" problem, and it lives in a script that's supposed to be the &lt;em&gt;standard&lt;/em&gt; way to build images.&lt;/p&gt;

&lt;p&gt;This is the moment Bake earns its place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enter Bake: same images, declaratively
&lt;/h2&gt;

&lt;p&gt;Here's the same two builds expressed as a Bake file (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/docker-bake-app-and-infra.hcl" rel="noopener noreferrer"&gt;&lt;code&gt;docker-bake-app-and-infra.hcl&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"infra"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/app/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;platforms&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"linux/amd64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"linux/arm64"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"${REPO_URL}/app_bake:${IMAGE_TAG_VERSION_APP}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;attest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"type=provenance,mode=max"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"type=sbom"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"infra"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/infra/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;platforms&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"linux/amd64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"linux/arm64"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"${REPO_URL}/infra_bake:${IMAGE_TAG_VERSION_INFRA}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;attest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"type=provenance,mode=max"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"type=sbom"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To build both images:&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 bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-app-and-infra.hcl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To build and push:&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 bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-app-and-infra.hcl &lt;span class="nt"&gt;--push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The intent is obvious.&lt;/strong&gt; Anyone who reads HCL or Terraform feels at home immediately. No &lt;code&gt;${!var}&lt;/code&gt; tricks, no array juggling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel by default.&lt;/strong&gt; Bake schedules the &lt;code&gt;app&lt;/code&gt; and &lt;code&gt;infra&lt;/code&gt; builds concurrently, and BuildKit shares cache layers between them. No &lt;code&gt;&amp;amp;&lt;/code&gt;, no &lt;code&gt;wait&lt;/code&gt;, no exit-code plumbing — parallelism is the default behavior, not a feature you opt into. Every target declared in an HCL file is fair game for parallel scheduling. Bash, by contrast, runs a &lt;code&gt;for&lt;/code&gt; loop sequentially unless you go out of your way to fan it out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One file, every OS.&lt;/strong&gt; The same &lt;code&gt;.hcl&lt;/code&gt; file produces the same builds on Linux, macOS, and Windows — Docker Desktop and Docker Engine normalize the invocation. No bash-version constraints, no per-OS scripts, no Homebrew gymnastics. New team members can clone the repo and &lt;code&gt;docker buildx bake&lt;/code&gt; regardless of what laptop they're on. This is the standardization point that bash scripts will never give you, no matter how clean you write them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;group "default"&lt;/code&gt; block&lt;/strong&gt; tells Bake what to build when you don't pass a target. You can have multiple groups for different purposes (release builds, dev builds, validation, etc.).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgfjx4fatk2lz6v9r0cqv.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%2Fgfjx4fatk2lz6v9r0cqv.png" alt="Docker Bake versus Bash scripts" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also inspect the resolved configuration before building anything:&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 bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-app-and-infra.hcl &lt;span class="nt"&gt;--print&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prints the fully-interpolated JSON. Invaluable when something behaves unexpectedly.&lt;/p&gt;

&lt;p&gt;And if you just want to validate the file — check the HCL syntax, the target definitions, and that referenced variables resolve — without building anything, there's &lt;code&gt;--check&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-app-and-infra.hcl &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run this in pre-commit hooks and as the first step of every CI pipeline. It catches typos, missing variables, and broken &lt;code&gt;inherits&lt;/code&gt; references in under a second, before any expensive build kicks off.&lt;/p&gt;




&lt;h2&gt;
  
  
  Variables and validators
&lt;/h2&gt;

&lt;p&gt;You probably noticed &lt;code&gt;${REPO_URL}&lt;/code&gt; and &lt;code&gt;${IMAGE_TAG_VERSION_APP}&lt;/code&gt; in the file above. Those come from a separate file, &lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/vars.hcl" rel="noopener noreferrer"&gt;&lt;code&gt;vars.hcl&lt;/code&gt;&lt;/a&gt;, which I always pass first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"PROJECT_ID"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PROJECT_ID&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The variable 'PROJECT_ID' must not be empty."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"LOCATION"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"europe-west1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"REPO_NAME"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;REPO_NAME&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The variable 'REPO_NAME' must not be empty."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"IMAGE_TAG_VERSION_APP"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_TAG_VERSION_APP&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The variable 'IMAGE_TAG_VERSION_APP' must not be empty."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"REPO_URL"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this worth highlighting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defaults and composition.&lt;/strong&gt; &lt;code&gt;LOCATION&lt;/code&gt; defaults to &lt;code&gt;europe-west1&lt;/code&gt;. &lt;code&gt;REPO_URL&lt;/code&gt; is composed from three other variables, so I never type the full Artifact Registry URL anywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validators.&lt;/strong&gt; Bake supports &lt;code&gt;validation&lt;/code&gt; blocks on variables. If &lt;code&gt;PROJECT_ID&lt;/code&gt; is missing or empty at build time, the build fails immediately with a clear message — &lt;em&gt;before&lt;/em&gt; a single layer gets built. This is the kind of thing that, in bash, becomes a forest of &lt;code&gt;if [[ -z "${X:-}" ]]; then echo "..." &amp;amp;&amp;amp; exit 1&lt;/code&gt; blocks at the top of every script. Here, it's declarative and lives next to the variable definition.&lt;/p&gt;

&lt;p&gt;Variables are populated from the environment, so the usage pattern is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"my-gcp-project"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;REPO_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"internal-images"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;IMAGE_TAG_VERSION_APP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;IMAGE_TAG_VERSION_INFRA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0.1.0"&lt;/span&gt;

docker buildx bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-app-and-infra.hcl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Inheritance: the &lt;code&gt;_common&lt;/code&gt; pattern
&lt;/h2&gt;

&lt;p&gt;Look back at the &lt;code&gt;app&lt;/code&gt; and &lt;code&gt;infra&lt;/code&gt; targets. They share &lt;code&gt;context&lt;/code&gt;, &lt;code&gt;platforms&lt;/code&gt;, and &lt;code&gt;attest&lt;/code&gt;. Bake supports inheritance, so you can factor that out (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/docker-bake-inheritance.hcl" rel="noopener noreferrer"&gt;&lt;code&gt;docker-bake-inheritance.hcl&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"infra"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"_common"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
  &lt;span class="nx"&gt;platforms&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"linux/amd64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"linux/arm64"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;attest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"type=provenance,mode=max"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"type=sbom"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;inherits&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"_common"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/app/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"${REPO_URL}/app_bake:${IMAGE_TAG_VERSION_APP}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"infra"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;inherits&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"_common"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/infra/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"${REPO_URL}/infra_bake:${IMAGE_TAG_VERSION_INFRA}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By convention I prefix the inherited target with an underscore (&lt;code&gt;_common&lt;/code&gt;) to signal "this is a base, don't build it directly." Bake doesn't enforce this — it's just a hint to readers.&lt;/p&gt;




&lt;h2&gt;
  
  
  A real-world example: Python lint and test images with &lt;code&gt;uv&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Enough abstract examples. Here's something concrete: a Python application I want to lint with Ruff and test with pytest, both run inside Docker so the CI environment matches local exactly.&lt;/p&gt;

&lt;p&gt;I use &lt;a href="https://github.com/astral-sh/uv" rel="noopener noreferrer"&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/a&gt; as the Python package manager because it's an order of magnitude faster than pip or Poetry. The lint image looks like this (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/images/python_linter/Dockerfile" rel="noopener noreferrer"&gt;&lt;code&gt;images/python_linter/Dockerfile&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ghcr.io/astral-sh/uv:python3.11-alpine&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;ENV&lt;/span&gt;&lt;span class="s"&gt; UV_COMPILE_BYTECODE=1 \&lt;/span&gt;
    UV_LINK_MODE=copy \
    WORKDIR=/usr/local/src/app

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; $WORKDIR&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;--locked&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; python_app $WORKDIR/python_app&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.11-alpine&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; WORKDIR=/usr/local/src/app&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; $WORKDIR&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder $WORKDIR $WORKDIR&lt;/span&gt;

&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PATH="$WORKDIR/.venv/bin:$PATH"&lt;/span&gt;

&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["ruff"]&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["check", "python_app", "--exclude", "tests", "--output-format=concise", "--color=always"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two patterns worth highlighting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-stage build.&lt;/strong&gt; The &lt;code&gt;builder&lt;/code&gt; stage uses the &lt;code&gt;ghcr.io/astral-sh/uv&lt;/code&gt; image, which already ships with &lt;code&gt;uv&lt;/code&gt; installed. It resolves dependencies and creates a &lt;code&gt;.venv&lt;/code&gt;. The final stage is plain &lt;code&gt;python:3.11-alpine&lt;/code&gt; — no &lt;code&gt;uv&lt;/code&gt; binary, no build tools, just the runtime and the venv. The result is a smaller, cleaner runtime image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--mount=type=cache&lt;/code&gt; for uv.&lt;/strong&gt; The line &lt;code&gt;RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked&lt;/code&gt; is a BuildKit feature: it gives the &lt;code&gt;uv sync&lt;/code&gt; step a persistent cache directory that survives across builds. The first build downloads every wheel; every subsequent build reuses them. On a typical project this drops dependency-install time from ~30s to under 2s. It's the single biggest speedup you can add to a Python Dockerfile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer order matters.&lt;/strong&gt; Look closely at the order of operations in the builder stage: we &lt;code&gt;COPY pyproject.toml uv.lock&lt;/code&gt; &lt;em&gt;first&lt;/em&gt;, run &lt;code&gt;uv sync&lt;/code&gt; &lt;em&gt;second&lt;/em&gt;, and only &lt;em&gt;then&lt;/em&gt; &lt;code&gt;COPY python_app&lt;/code&gt;. This is not cosmetic — it's the single most important rule for keeping Docker's layer cache useful. Docker invalidates a layer (and every layer after it) the moment any of its inputs change. If we copied the whole source tree before &lt;code&gt;uv sync&lt;/code&gt;, every change to a Python file — even a one-character typo fix — would invalidate the dependency-install layer and trigger a full reinstall of every package. By copying &lt;em&gt;only&lt;/em&gt; the dependency manifest first, the expensive &lt;code&gt;uv sync&lt;/code&gt; layer stays cached as long as &lt;code&gt;pyproject.toml&lt;/code&gt; and &lt;code&gt;uv.lock&lt;/code&gt; are untouched. Code changes only invalidate the cheap final &lt;code&gt;COPY&lt;/code&gt;. This rule is surprisingly easy to get wrong, and getting it wrong is what turns a 30-second incremental build into a 4-minute one.&lt;/p&gt;

&lt;p&gt;The Bake file that ties the lint and test images together is small (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/docker-bake-lint-and-test.hcl" rel="noopener noreferrer"&gt;&lt;code&gt;docker-bake-lint-and-test.hcl&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="s2"&gt;"validate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"lint"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/python_linter/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"${REPO_URL}/python-linter:latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/python_tests/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"${REPO_URL}/python-tests:latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the group name is &lt;code&gt;validate&lt;/code&gt;, not &lt;code&gt;default&lt;/code&gt;. You build it explicitly:&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 bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-lint-and-test.hcl validate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Composing multiple Bake files in one command
&lt;/h2&gt;

&lt;p&gt;In a real project you don't have one Bake file — you have several, each grouped by purpose: app images, infra images, lint/test, scanning, base images. Bake lets you pass any number of &lt;code&gt;-f&lt;/code&gt; flags and any number of targets:&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 bake &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-app-and-infra.hcl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-lint-and-test.hcl &lt;span class="se"&gt;\&lt;/span&gt;
  default validate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one command builds &lt;strong&gt;four images&lt;/strong&gt; — app, infra, lint, test — &lt;strong&gt;all in parallel&lt;/strong&gt;. BuildKit deduplicates shared layers across them, so the Python base layer (for example) is fetched once.&lt;/p&gt;

&lt;p&gt;Note the trailing &lt;code&gt;default validate&lt;/code&gt;: when composing multiple Bake files, you have to name the groups (or individual targets) to build — &lt;code&gt;default&lt;/code&gt; from the app/infra file, &lt;code&gt;validate&lt;/code&gt; from the lint/test file. Without them, Bake only runs the &lt;code&gt;default&lt;/code&gt; group and the lint/test images stay unbuilt.&lt;/p&gt;

&lt;p&gt;Compare that to the bash version, where parallelizing four &lt;code&gt;docker build&lt;/code&gt; calls means wrestling with &lt;code&gt;&amp;amp;&lt;/code&gt;, &lt;code&gt;wait&lt;/code&gt;, and &lt;code&gt;trap&lt;/code&gt; to propagate exit codes correctly. With Bake, parallelism is free and correct by default.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bake from a remote GitHub repository
&lt;/h2&gt;

&lt;p&gt;This one surprises a lot of people: Bake can pull configuration directly from a remote Git repository. No &lt;code&gt;git clone&lt;/code&gt; needed.&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 bake &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; bake.hcl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://github.com/crazy-max/buildx.git#remote-with-local"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--print&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL acts as both the build context &lt;em&gt;and&lt;/em&gt; the source of the Bake file. The &lt;code&gt;#remote-with-local&lt;/code&gt; part is a Git ref (branch, tag, or commit).&lt;/p&gt;

&lt;p&gt;Even better, you can &lt;strong&gt;combine a remote Bake file with a local one&lt;/strong&gt; using the &lt;code&gt;cwd://&lt;/code&gt; prefix:&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 bake &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; bake.hcl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; cwd://local.hcl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://github.com/crazy-max/buildx.git#remote-with-local"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--print&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remote file provides the shared definitions; your local file overrides values for your environment. This is a clean pattern for sharing reusable Bake configurations across teams without making everyone vendor the same HCL into every repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Matrix: building variants of the same image
&lt;/h2&gt;

&lt;p&gt;Here's a use case that comes up constantly in real projects — and the canonical example is the &lt;a href="https://hub.docker.com/_/postgres" rel="noopener noreferrer"&gt;official PostgreSQL image on Docker Hub&lt;/a&gt;, which ships the same database across multiple base-image variants (Alpine, Bullseye, Bookworm) and multiple Postgres versions for each. That's exactly the pattern I want to demonstrate: you have one application and you need to ship it as many images — different OS bases, different versions per OS. Alpine for size-conscious users, Bullseye and Bookworm for compatibility with glibc-dependent libraries.&lt;/p&gt;

&lt;p&gt;A quick guard rail before we go further: &lt;strong&gt;this pattern applies when you're shipping something downstream consumers will pick a base for&lt;/strong&gt; — a database, a runtime, a developer tool, a library. Postgres, Python, Node, Ruby, MySQL — every major official image does this. If you're shipping a business application, you typically pick one base and stay there. Don't take the example below and fan your billing service out across five Alpine and Debian variants; you'll just multiply your image count, your scan surface, and your registry bill for no benefit. The point of matrix-variants is to serve multiple downstream audiences from one source, not to spray the same app across every base image you can think of.&lt;/p&gt;

&lt;p&gt;In bash, that's a nested loop (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/scripts/build_matrix_variants.sh" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/build_matrix_variants.sh&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;variants&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"alpine"&lt;/span&gt; &lt;span class="s2"&gt;"bullseye"&lt;/span&gt; &lt;span class="s2"&gt;"bookworm"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;declare&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; versions
versions[alpine]&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"3.17 3.21"&lt;/span&gt;
versions[bullseye]&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"11.7 11.8"&lt;/span&gt;
versions[bookworm]&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"12.2 12.5"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;variant &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  for &lt;/span&gt;version &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;versions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$variant&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"images/app-matrix/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;variant&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="p"&gt;//./-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;tag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"myapp:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;variant&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;version&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    docker buildx build &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;/Dockerfile"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--tag&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--platform&lt;/span&gt; &lt;span class="s2"&gt;"linux/amd64"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. It's also six images built strictly one after another, and the syntax is hard to read and maintain. Every image you add — and every bit of conditional logic on top — makes it worse.&lt;/p&gt;

&lt;p&gt;Here's the same thing in Bake (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/docker-bake-matrix-variants.hcl" rel="noopener noreferrer"&gt;&lt;code&gt;docker-bake-matrix-variants.hcl&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"alpine_apps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"bullseye_apps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"bookworm_apps"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"_common"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"alpine_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;inherits&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"_common"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-${variant}-${replace(version, "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="s2"&gt;")}"&lt;/span&gt;
  &lt;span class="nx"&gt;matrix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"alpine"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"3.17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"3.21"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/app-matrix/${variant}-${replace(version, "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="s2"&gt;")}/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"myapp:${variant}-${version}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"bullseye_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;inherits&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"_common"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-${variant}-${replace(version, "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="s2"&gt;")}"&lt;/span&gt;
  &lt;span class="nx"&gt;matrix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bullseye"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"11.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"11.8"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/app-matrix/${variant}-${replace(version, "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="s2"&gt;")}/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"myapp:${variant}-${version}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"bookworm_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;inherits&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"_common"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-${variant}-${replace(version, "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="s2"&gt;")}"&lt;/span&gt;
  &lt;span class="nx"&gt;matrix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bookworm"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"12.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"12.5"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/app-matrix/${variant}-${replace(version, "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="s2"&gt;")}/Dockerfile"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"myapp:${variant}-${version}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;matrix&lt;/code&gt; block expands into one target per combination of values. With &lt;code&gt;variant = ["alpine"]&lt;/code&gt; and &lt;code&gt;version = ["3.17", "3.21"]&lt;/code&gt;, Bake produces two targets: &lt;code&gt;app-alpine-3-17&lt;/code&gt; and &lt;code&gt;app-alpine-3-21&lt;/code&gt;. The &lt;code&gt;name&lt;/code&gt; template controls the generated names, and &lt;code&gt;${variant}&lt;/code&gt; and &lt;code&gt;${version}&lt;/code&gt; are available everywhere in the target.&lt;/p&gt;

&lt;p&gt;Run it:&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 bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-matrix-variants.hcl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All six images build in parallel.&lt;/strong&gt; No nested loops, no exit-code propagation, no string substitution gymnastics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Matrix with a list of items
&lt;/h2&gt;

&lt;p&gt;The matrix above uses cartesian-product expansion (every variant × every version). Sometimes you want something different: a flat list of heterogeneous items, each with their own properties. Bake supports that too (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/docker-bake-matrix-items.hcl" rel="noopener noreferrer"&gt;&lt;code&gt;docker-bake-matrix-items.hcl&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-${item.tgt}-${replace(item.version, "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;", "&lt;/span&gt;&lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="s2"&gt;")}"&lt;/span&gt;
  &lt;span class="nx"&gt;matrix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;tgt&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lint"&lt;/span&gt;
        &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0"&lt;/span&gt;
        &lt;span class="nx"&gt;ctx&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
        &lt;span class="nx"&gt;dockerf&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/python_linter/Dockerfile"&lt;/span&gt;
        &lt;span class="nx"&gt;tag&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${REPO_URL}/python-linter-matrix:latest"&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;tgt&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt;
        &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2.0"&lt;/span&gt;
        &lt;span class="nx"&gt;ctx&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;
        &lt;span class="nx"&gt;dockerf&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"images/python_tests/Dockerfile"&lt;/span&gt;
        &lt;span class="nx"&gt;tag&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${REPO_URL}/python-tests-matrix:latest"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;
  &lt;span class="nx"&gt;dockerfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dockerf&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each entry in the &lt;code&gt;item&lt;/code&gt; list is an object with its own fields. Inside the target, you reference them as &lt;code&gt;item.tgt&lt;/code&gt;, &lt;code&gt;item.dockerf&lt;/code&gt;, etc. This pattern is great when each image has its own Dockerfile path, its own tag, and its own context — but you want to define them all in one place rather than write a target per image.&lt;/p&gt;

&lt;p&gt;Run it:&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 bake &lt;span class="nt"&gt;-f&lt;/span&gt; vars.hcl &lt;span class="nt"&gt;-f&lt;/span&gt; docker-bake-matrix-items.hcl app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Bake from a Compose file: one source of truth
&lt;/h2&gt;

&lt;p&gt;Most projects already have a &lt;code&gt;compose.yaml&lt;/code&gt; that describes how services run together. Bake can read that file directly and use it as build input (&lt;a href="https://github.com/tosun-si/docker-bake-playground/blob/main/compose_bake_example.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;compose_bake_example.yaml&lt;/code&gt;&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-bake-example:latest&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;images/app/Dockerfile&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gb-poc-373711&lt;/span&gt;
        &lt;span class="na"&gt;LOCATION&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_NAME&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_TAG_VERSION_APP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
      &lt;span class="na"&gt;x-bake&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/app_bake:${IMAGE_TAG_VERSION_APP}&lt;/span&gt;
        &lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;linux/amd64&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;linux/arm64&lt;/span&gt;

  &lt;span class="na"&gt;infra&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;infra-bake-example:latest&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;images/infra/Dockerfile&lt;/span&gt;
      &lt;span class="na"&gt;x-bake&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;${LOCATION}-docker.pkg.dev/${PROJECT_ID}/${REPO_NAME}/infra_bake:${IMAGE_TAG_VERSION_INFRA}&lt;/span&gt;
        &lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;linux/amd64&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;linux/arm64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;build&lt;/code&gt; section is standard Compose. The &lt;code&gt;x-bake&lt;/code&gt; extension is what Bake reads to enrich the build with multi-platform targets, tags, attestations, and other Bake-specific fields that Compose doesn't natively understand.&lt;/p&gt;

&lt;p&gt;Build everything with:&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 bake &lt;span class="nt"&gt;-f&lt;/span&gt; compose_bake_example.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same services with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose_bake_example.yaml up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Same file, both lifecycles.&lt;/strong&gt; Build configuration and runtime configuration live in one place. For projects where the dev loop is "build, then run, then build again," this is a meaningful ergonomic win.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up Part 1
&lt;/h2&gt;

&lt;p&gt;We've covered a lot of ground:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;BuildKit / Buildx / Bake&lt;/strong&gt; stack and what each layer does.&lt;/li&gt;
&lt;li&gt;The pain of &lt;strong&gt;bash scripts&lt;/strong&gt; for multi-image builds, and the two big things Bake gives you that bash never will: &lt;strong&gt;parallel builds by default&lt;/strong&gt; (no &lt;code&gt;&amp;amp;&lt;/code&gt;/&lt;code&gt;wait&lt;/code&gt; gymnastics) and &lt;strong&gt;a single file that works identically on Linux, macOS, and Windows&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HCL targets&lt;/strong&gt;, &lt;strong&gt;groups&lt;/strong&gt;, &lt;strong&gt;variables with validators&lt;/strong&gt;, and &lt;strong&gt;inheritance&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A real &lt;strong&gt;Python multi-stage build&lt;/strong&gt; with &lt;code&gt;uv&lt;/code&gt; and &lt;code&gt;--mount=type=cache&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Composing &lt;strong&gt;multiple Bake files&lt;/strong&gt; in a single command.&lt;/li&gt;
&lt;li&gt;Pulling Bake files from a &lt;strong&gt;remote Git repository&lt;/strong&gt; and combining them with local overrides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matrix builds&lt;/strong&gt; — both cartesian-product (variants × versions) and item lists.&lt;/li&gt;
&lt;li&gt;Using a &lt;strong&gt;Compose file&lt;/strong&gt; as Bake input for a single source of truth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've been writing bash scripts to coordinate &lt;code&gt;docker build&lt;/code&gt; calls, you now have everything you need to throw them away.&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%2Frizlj28l32mdrb35315i.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%2Frizlj28l32mdrb35315i.png" alt="Bake it clean, build it smart" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Coming in Part 2
&lt;/h3&gt;

&lt;p&gt;Part 2 will focus on &lt;strong&gt;Bake in CI/CD&lt;/strong&gt;, which is where the parallelism, declarative configuration, and registry-cache features compound:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google Cloud Build&lt;/strong&gt; — running Bake with registry cache against Artifact Registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; — using the native Buildx action with GitHub Actions cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitLab CI&lt;/strong&gt; — pipeline stages with Bake on GitLab.com runners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dagger&lt;/strong&gt; — a portable, programmable pipeline (with the Python SDK) that runs the same way locally and in the cloud. Build → Trivy scan → push, with the whole pipeline expressed as code.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;GreenOps&lt;/strong&gt; angle: how registry-based Docker cache cuts CI compute time and energy, and how to wire it up correctly across all four tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Talks and resources
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Talk recording (French) — &lt;a href="https://www.youtube.com/watch?v=NB7L3FNXcLs&amp;amp;t=5s" rel="noopener noreferrer"&gt;Docker Bake at DevLille&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Talk recording (French) — &lt;a href="https://www.youtube.com/watch?v=iC5ovhVhaLI&amp;amp;list=PLuZ_sYdawLiVt43B2CU1Cm1GVcVApPAlN&amp;amp;index=18&amp;amp;t=7s" rel="noopener noreferrer"&gt;Docker Bake at DevFest Toulouse&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Talk recording (French) — &lt;a href="https://youtu.be/WVWzwRLinzc" rel="noopener noreferrer"&gt;Docker Bake at Cloud Native Days France&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/WVWzwRLinzc" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;English video version (coming soon) — &lt;a href="https://www.youtube.com/channel/UCPnHZ14R5oF8LQAc7f4mKNg/?sub_confirmation=1" rel="noopener noreferrer"&gt;my YouTube channel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Talk venues — DevLille, DevFest Toulouse, DevFest Lyon, Devoxx Morocco, Cloud Native Days France&lt;/li&gt;
&lt;li&gt;Companion repository — &lt;a href="https://github.com/tosun-si/docker-bake-playground" rel="noopener noreferrer"&gt;&lt;code&gt;docker-bake-playground&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Official Bake documentation — &lt;a href="https://docs.docker.com/build/bake/" rel="noopener noreferrer"&gt;docs.docker.com/build/bake&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have feedback, questions, or a use case you'd like to see covered in Part 2, reach out on LinkedIn or open an issue on the repo.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you enjoyed this article, follow me for more content on Docker, AI agents, Google Cloud, Software, Devops, Tech and data engineering:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/channel/UCPnHZ14R5oF8LQAc7f4mKNg/?sub_confirmation=1" 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://medium.com/@mazlum.tosun" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/mazlum_tosun"&gt;Dev.to&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>dockerbake</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <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="799" height="345"&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>
