Secure CI/CD Pipeline on GCP
Build an automated pipeline that deploys a Node.js app with least-privilege IAM.
Introduction
30 Second Summary
Every time a developer pushes code, it needs to be built, tested, and deployed. Doing this manually is slow and error-prone. Automating it with security built in from the start is how professional teams ship software safely.
In this project, you will build a fully automated deployment pipeline on Google Cloud Platform that securely builds, stores, and deploys your app every time you push code to GitHub.
What You'll Build
A secure CI/CD pipeline that uses Cloud Build to containerize a Node.js app, store it in Artifact Registry, and deploy it to Cloud Run under a custom least-privilege service account.
By the end of this project, you'll have:
- A fully automated pipeline triggered by GitHub pushes that builds, stores, and deploys your containerized app.
- A custom service account with only the four IAM roles it needs to operate.
- A live Cloud Run service that securely reads secrets from Secret Manager at runtime without embedding credentials in code.
- Secret Mission: Enable vulnerability scanning and inspect security insights for your container image.
Are there any prerequisites?
You need a GCP account with billing enabled (a $300 free trial works perfectly), the Google Cloud CLI installed, a GitHub account, and Docker Desktop installed. This project stays well within GCP's free tier, so you won't be charged.
Enable APIs, Create Service Account, and Artifact Registry
Before you can build or deploy anything on GCP, you need to activate the specific services your pipeline will use. Each GCP API is off by default, so you explicitly turn on only the ones your project needs.
In this step, you will enable five APIs, create a Docker repository to store your container images, and set up a dedicated service account with tightly scoped permissions. This is the security foundation for everything that follows.
In this step, get ready to:
- Enable the required GCP APIs for your pipeline.
- Create an Artifact Registry repository to store Docker images.
- Create a custom service account and grant it least-privilege IAM roles.
Enable the required GCP APIs
GCP keeps every service disabled until you explicitly turn it on. Your pipeline touches five services: Cloud Build for running builds, Artifact Registry for storing container images, Cloud Run for hosting your app, Secret Manager for managing secrets, and IAM for identity and access management.
- Open your terminal.
- Sign in to your GCP account by running this command:
gcloud auth login
A browser window opens for you to authenticate. Select your Google account and grant access.
- Set your active project by running this command (replace the placeholder with your GCP project ID):
gcloud config set project [[GCP_PROJECT_ID="your-gcp-project-id"]]
Where do I find my project ID?
Your project ID appears at the top of the Google Cloud Console, next to the project selector dropdown. It is a lowercase string like my-cool-project-123456.
- Enable all five APIs at once by running this command:
gcloud services enable cloudbuild.googleapis.com artifactregistry.googleapis.com run.googleapis.com secretmanager.googleapis.com iam.googleapis.com
What does this command do?
- cloudbuild.googleapis.com activates Cloud Build so it can run your CI/CD pipeline.
- artifactregistry.googleapis.com activates Artifact Registry for storing Docker images.
- run.googleapis.com activates Cloud Run so you can deploy containers.
- secretmanager.googleapis.com activates Secret Manager for storing sensitive values.
- iam.googleapis.com activates the IAM API for managing service account permissions.
- Confirm the APIs are enabled by running:
gcloud services list --enabled
You should see all five services listed in the output. The command may take 10-20 seconds to complete.
Seeing a permission denied error?
You need the Owner or Editor role on the project to enable APIs. If you created the project yourself, you already have Owner access.
Also confirm your active project is set correctly by running gcloud config get-value project.
Help me troubleshoot this permission error
Create the Artifact Registry repository
Artifact Registry is where your pipeline will push container images after building them. You need a dedicated Docker repository so Cloud Build has a specific place to store images.
- Create a Docker repository named secure-pipeline-repo in us-central1 by running this command:
gcloud artifacts repositories create secure-pipeline-repo \
--repository-format=docker \
--location=us-central1 \
--description="Docker repository for secure CI/CD pipeline"
What does this command do?
- secure-pipeline-repo is the name of your repository. It must be unique within this region and project.
- --repository-format=docker tells Artifact Registry this will store Docker container images.
- --location=us-central1 places the repository in the US Central region, keeping it close to your Cloud Run service.
- Verify the repository was created by running:
gcloud artifacts repositories list --location=us-central1
You should see secure-pipeline-repo listed with format DOCKER and location us-central1.
Repository not showing up?
Make sure you included --location=us-central1 in both the create and list commands. If you omit the location flag, the list command checks a different default location.
Help me troubleshoot my Artifact Registry setup
Create the service account and assign IAM roles
By default, Cloud Build uses a broad service account with more permissions than your pipeline actually needs. Creating a custom service account lets you grant only the exact permissions required.
What is least-privilege IAM?
Least-privilege means granting only the minimum permissions needed to perform a task. If your Cloud Build service account is compromised, the attacker can only access what you explicitly allowed.
In this project, you will scope Artifact Registry write access to a single repository instead of granting it project-wide. The other three roles are project-level because Cloud Run, logging, and service account impersonation require project scope.
- Create the custom service account by running this command:
gcloud iam service-accounts create cloud-build-sa \
--display-name="Cloud Build Pipeline SA"
What does this command do?
This creates a service account named cloud-build-sa in your project. The resulting email address will be cloud-build-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com.
You will use this email when assigning roles and configuring the Cloud Build trigger later.
Now you need to store your project ID in a variable so you can reuse it across multiple commands.
- Store your project ID in a shell variable by running:
export PROJECT_ID=$(gcloud config get-value project)
Next, you will grant four IAM roles to this service account. The first role is scoped to only the secure-pipeline-repo repository, not the entire project.
- Grant Artifact Registry Writer on the specific repository by running:
gcloud artifacts repositories add-iam-policy-binding secure-pipeline-repo \
--location=us-central1 \
--member="serviceAccount:cloud-build-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/artifactregistry.writer"
Why scope to the repository level?
Granting roles/artifactregistry.writer at the repository level means this service account can only push images to secure-pipeline-repo.
If you granted this role at the project level, the service account could write to every Artifact Registry repository in your project. Scoping it to one repository limits the blast radius if the account is ever compromised.
- Grant Cloud Run Admin at the project level by running:
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:cloud-build-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/run.admin"
- Grant Service Account User at the project level by running:
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:cloud-build-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountUser"
- Grant Logs Writer at the project level by running:
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:cloud-build-sa@${PROJECT_ID}.iam.gserviceaccount.com" \
--role="roles/logging.logWriter"
What do these three project-level roles do?
- roles/run.admin lets Cloud Build deploy new services and revisions to Cloud Run.
- roles/iam.serviceAccountUser lets Cloud Build impersonate the Cloud Run runtime service account during deployment.
- roles/logging.logWriter lets Cloud Build write build logs to Cloud Logging.
Each gcloud projects add-iam-policy-binding command prints the updated IAM policy for your project. This is normal. You will see a long JSON output after each command.
Seeing 'service account does not exist' error?
Double-check that the gcloud iam service-accounts create command completed successfully before running the role binding commands.
Also verify your PROJECT_ID variable is set correctly by running echo $PROJECT_ID. It should print your project ID, not an empty line.
Help me fix my service account IAM binding
Your security foundation is in place. Next up, you will build the Express.js app and write the pipeline configuration that uses this service account to build, push, and deploy automatically.
Build the App and Write the Pipeline
Your GCP infrastructure is ready with a secure service account and an Artifact Registry repository. Now it's time to build the application that will flow through your pipeline.
In this step, you'll create a Node.js Express app, containerize it with Docker, and define the Cloud Build pipeline that automates the entire build-push-deploy flow.
In this step, get ready to:
- Create a Node.js Express app that reads a secret from an environment variable.
- Write a Dockerfile to containerize the app.
- Define a cloudbuild.yaml pipeline with build, push, and deploy steps.
Create the Express.js application
The app you're building is intentionally simple. Its job is to prove that the pipeline works and that secrets are injected securely at runtime.
- In your editor, create a new file called package.json in your project folder by pasting the following code:
{
"name": "secure-pipeline-demo",
"version": "1.0.0",
"description": "GCP Secure CI/CD Pipeline Demo",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.2.1"
}
}
What does this file do?
The package.json file defines your Node.js project. It declares Express 5.2.1 as the only dependency and sets server.js as the entry point.
The start script tells Node.js which file to run when the container launches.
- Save package.json.
- Install the project dependencies by running this command in your terminal:
npm install
You should see a message like added X packages confirming Express was installed.
Seeing a permission error or command not found?
Make sure your terminal is inside your project folder (the one containing package.json). Run ls and confirm you see package.json listed.
If npm isn't found, confirm Node.js is installed by running node --version. Need more help?
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json before moving on.
ⓧ I'd like to double check the full code
{
"name": "secure-pipeline-demo",
"version": "1.0.0",
"description": "GCP Secure CI/CD Pipeline Demo",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.2.1"
}
}
- Create a new file called server.js in the same folder and paste the following code:
const express = require('express');
const app = express();
const port = process.env.PORT || 8080;
// Read the secret from an environment variable injected by Cloud Run
const SECRET_VALUE = process.env.MY_SECRET || 'NOT_SET';
app.get('/', (req, res) => {
// Mask the secret so it's never fully exposed in the response
const masked = SECRET_VALUE === 'NOT_SET'
? 'No secret loaded'
: `${SECRET_VALUE.substring(0, 3)}${'*'.repeat(SECRET_VALUE.length - 3)}`;
res.json({
status: 'healthy',
service: 'secure-pipeline-demo',
secret_loaded: SECRET_VALUE !== 'NOT_SET',
secret_preview: masked,
timestamp: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
What does this code do?
This Express app exposes a single health endpoint at /. When a request comes in, it returns a JSON response showing whether a secret is loaded.
The MY_SECRET environment variable is read from process.env. Cloud Run will inject this value from Secret Manager at runtime. The app never displays the full secret. It masks everything after the first three characters.
- Save server.js.
- Test the app locally by running:
node server.js
You should see Server listening on port 8080 in your terminal.
- Open your browser and navigate to http://localhost:8080.
You should see a JSON response with "secret_loaded": false and "secret_preview": "No secret loaded". This confirms your app is running correctly without a secret injected.
- Return to your terminal and press Ctrl+C to stop the server.
Not seeing the JSON response?
If your browser shows a connection error, make sure the terminal still says Server listening on port 8080. If the server crashed, check for typos in server.js by comparing against the full code below.
If port 8080 is already in use, stop any other process using that port and try again. Help me debug my server.js
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and stopped the server before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const app = express();
const port = process.env.PORT || 8080;
const SECRET_VALUE = process.env.MY_SECRET || 'NOT_SET';
app.get('/', (req, res) => {
const masked = SECRET_VALUE === 'NOT_SET'
? 'No secret loaded'
: `${SECRET_VALUE.substring(0, 3)}${'*'.repeat(SECRET_VALUE.length - 3)}`;
res.json({
status: 'healthy',
service: 'secure-pipeline-demo',
secret_loaded: SECRET_VALUE !== 'NOT_SET',
secret_preview: masked,
timestamp: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
Write the Dockerfile
Your app needs a container to run in Cloud Run. A Dockerfile defines the environment, dependencies, and startup command for that container.
- Create a new file called Dockerfile (no file extension) in your project folder and paste the following code:
# Use a lightweight Node.js base image
FROM node:24-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy dependency files first for better caching
COPY package*.json ./
RUN npm ci --omit=dev
# Copy the application code
COPY server.js .
# Declare the port the app listens on
EXPOSE 8080
# Run as a non-root user for security
USER node
# Start the application
CMD ["node", "server.js"]
What does this Dockerfile do?
The node:24-alpine base image gives you Node.js 24 on a minimal Linux image. Alpine images are much smaller than full images, which means faster builds and smaller attack surfaces.
The USER node directive is a security best practice. It runs the container process as a non-root user, limiting the damage if the container is ever compromised.
The npm ci --omit=dev command installs only production dependencies using the lockfile for deterministic builds.
- Save Dockerfile.
- Confirm the file exists by running:
ls
You should see Dockerfile, package.json, server.js, and node_modules listed in the output.
Don't see the Dockerfile listed?
Make sure you named the file exactly Dockerfile with a capital D and no file extension. Some editors add .txt automatically. Check the filename and rename it if needed. Help me fix my Dockerfile
✔️ Awesome, I've got everything!
Great. Double check you've saved your Dockerfile before moving on.
ⓧ I'd like to double check the full code
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js .
EXPOSE 8080
USER node
CMD ["node", "server.js"]
Define the Cloud Build pipeline
The cloudbuild.yaml file tells Cloud Build exactly what to do when a build is triggered. Your pipeline has three steps: build the container image, push it to Artifact Registry, and deploy it to Cloud Run.
- Create a new file called cloudbuild.yaml in your project folder and paste the following code:
steps:
# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '-t'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
- '.'
# Push the image to Artifact Registry
- name: 'gcr.io/cloud-builders/docker'
args:
- 'push'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
# Deploy to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- 'secure-pipeline-demo'
- '--image'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
- '--region'
- 'us-central1'
- '--platform'
- 'managed'
- '--allow-unauthenticated'
- '--set-secrets'
- 'MY_SECRET=my-app-secret:latest'
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
What does each pipeline step do?
- Step 1 uses the gcr.io/cloud-builders/docker builder to build your container image. The $PROJECT_ID and $COMMIT_SHA variables are automatically populated by Cloud Build when a trigger fires.
- Step 2 pushes the built image to your secure-pipeline-repo Artifact Registry repository.
- Step 3 deploys the image to Cloud Run using the gcloud CLI. The --set-secrets flag tells Cloud Run to inject the Secret Manager secret my-app-secret as the MY_SECRET environment variable at runtime.
The images section at the bottom tells Cloud Build to record this image as a build artifact for auditing.
Why --set-secrets instead of the Secret Manager SDK?
Cloud Run's --set-secrets flag injects secrets as environment variables at the platform level. Your app code never makes SDK calls to fetch secrets, and the container itself doesn't need Secret Manager permissions.
This is more secure because the secret never passes through application code. The Cloud Run runtime service account handles the access, keeping your app container minimal and focused.
- Save cloudbuild.yaml.
- Confirm all your project files exist by running:
ls
You should see cloudbuild.yaml, Dockerfile, package.json, server.js, and node_modules in the output.
Missing a file?
If any file is missing from the ls output, check that your terminal is in the correct project folder. Also confirm you saved each file after pasting the code. Help me find my missing files
✔️ Awesome, I've got everything!
Great. Double check you've saved cloudbuild.yaml before moving on.
ⓧ I'd like to double check the full code
steps:
# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args:
- 'build'
- '-t'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
- '.'
# Push the image to Artifact Registry
- name: 'gcr.io/cloud-builders/docker'
args:
- 'push'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
# Deploy to Cloud Run
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- 'run'
- 'deploy'
- 'secure-pipeline-demo'
- '--image'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
- '--region'
- 'us-central1'
- '--platform'
- 'managed'
- '--allow-unauthenticated'
- '--set-secrets'
- 'MY_SECRET=my-app-secret:latest'
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/secure-pipeline-repo/secure-pipeline-demo:$COMMIT_SHA'
Your app is containerized and your pipeline is fully defined. Next up, you'll store a secret in Secret Manager and configure access so Cloud Run can read it at runtime.
Store a Secret and Configure Access
In the previous step, you built the app, wrote the Dockerfile, and created the pipeline configuration. Now it is time to handle the secret your app needs at runtime.
Your server.js reads a MY_SECRET environment variable and displays a masked preview on its health endpoint. For that variable to hold a real value, you need to store the secret in Secret Manager and grant the Cloud Run runtime identity permission to read it.
In this step, get ready to:
- Create a secret in Secret Manager and add a secret version with a sample value.
- Grant the Cloud Run runtime service account scoped access to the secret.
- Verify secret access locally from your terminal.
Create a secret and add a version
Secret Manager stores sensitive values (API keys, database passwords, tokens) separately from your application code. Your pipeline's --set-secrets flag tells Cloud Run to pull the secret at deploy time and inject it as an environment variable.
- Create a new secret called my-app-secret by running this command:
gcloud secrets create my-app-secret --replication-policy="automatic"
What does this command do?
This creates an empty secret container in Secret Manager with automatic replication. Automatic replication means Google manages where the secret data is stored across regions for availability.
- Add a secret version containing a sample value by running:
printf "supersecret123" | gcloud secrets versions add my-app-secret --data-file=-
What does this command do?
The printf command sends the text supersecret123 to gcloud secrets versions add via stdin. The --data-file=- flag tells gcloud to read the secret data from stdin instead of a file.
Secret Manager versions your data automatically. Each time you add a version, the previous ones remain accessible by their version number. Your cloudbuild.yaml references my-app-secret:latest so Cloud Run always pulls the newest version.
- Confirm the secret was created by running:
gcloud secrets list
You should see my-app-secret listed in the output with 1 version.
Seeing a permission denied error?
Make sure the Secret Manager API is enabled on your project. You enabled it in Step 1, but you can confirm by running gcloud services list --enabled and checking for secretmanager.googleapis.com in the output.
help me fix a permission denied error when creating a secret in Secret Manager
Grant the Cloud Run runtime service account access to the secret
When Cloud Run deploys your container, it runs under the Compute Engine default service account. That account needs permission to read your secret. You will scope the Secret Manager Secret Accessor role to this specific secret only, not to the entire project.
Why scope to a single secret?
Granting roles/secretmanager.secretAccessor at the project level would let the service account read every secret in your project. By binding the role to my-app-secret specifically, you follow the principle of least privilege. If the account is compromised, only this one secret is exposed.
- First, retrieve your project number by running:
gcloud projects describe $(gcloud config get-value project) --format="value(projectNumber)"
You will see a numeric value (for example, 123456789012). This is your project number. Copy it.
- Grant the Compute Engine default service account the Secret Accessor role scoped to your secret by running:
gcloud secrets add-iam-policy-binding my-app-secret \
--member="serviceAccount:[[PROJECT_NUMBER="your project number"]]-compute@developer.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
What does this command do?
The gcloud secrets add-iam-policy-binding command adds an IAM policy binding directly on the my-app-secret resource. The --member flag specifies who gets access (the Compute Engine default service account). The --role flag specifies what they can do (access secret versions).
The Compute Engine default service account email follows the format PROJECT_NUMBER-compute@developer.gserviceaccount.com. Cloud Run uses this identity to pull secrets at deploy time.
You should see JSON output showing the updated IAM policy with your new binding.
Getting a member not found error?
Double-check that you used your project number (a numeric value), not your project ID (a text string). The Compute Engine default service account uses the numeric project number in its email address.
help me fix a member not found error when adding an IAM policy binding to my secret
Test secret access locally
Before triggering the full pipeline, confirm that the secret is readable. Your own gcloud identity has project-level access, so this test verifies the secret data is stored correctly.
- Read the secret value by running:
gcloud secrets versions access latest --secret=my-app-secret
You should see supersecret123 printed in your terminal.
Why does this work without the binding?
Your personal gcloud account is a project Owner or Editor, which already includes secretmanager.versions.access permission. The binding you created earlier is for the Compute Engine default service account that Cloud Run uses. This test just confirms the secret data itself is correct.
Seeing an empty output?
If the output is blank, the secret version may not have been added correctly. Run gcloud secrets versions list my-app-secret to confirm at least one version exists with the state ENABLED.
help me troubleshoot why my secret version is empty or not accessible
Your secret is stored, access is locked down, and the value is verified. Next up, you will connect your GitHub repository to Cloud Build and create the trigger that kicks off the pipeline on every push.
Connect GitHub and Create the Trigger
In the previous step, you stored a secret in Secret Manager and configured fine-grained access so only the Cloud Run runtime can read it.
Now it is time to wire everything together. You will push your code to GitHub, connect that repository to Cloud Build, and create an automated trigger that fires on every push to the main branch. Once the trigger is in place, every future commit will kick off the full build-push-deploy pipeline automatically.
In this step, get ready to:
- Push your project code to a GitHub repository.
- Connect the GitHub repository to Cloud Build using the Cloud Build GitHub App.
- Create a push trigger that watches the main branch and uses your custom service account.
Push your code to GitHub
Cloud Build needs your code hosted on GitHub so it can pull the source when a build triggers. You will create a new repository and push your local project files to it.
- Open GitHub in your browser and create a new repository.
- Name the repository secure-pipeline-demo.
- Leave it set to Public and do not initialize it with a README.
- Click Create repository.
GitHub shows a quick setup page with commands you can use. You will use your terminal instead.
- Return to your terminal in the secure-pipeline-demo project folder.
- Initialize a Git repository and push your code by running these commands:
git init
git add .
git commit -m "Initial commit: secure pipeline demo"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/secure-pipeline-demo.git
git push -u origin main
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add . stages all four files (server.js, package.json, Dockerfile, cloudbuild.yaml) for commit.
- git branch -M main renames the default branch to main.
- git push -u origin main uploads your code to the GitHub repository.
- Refresh your GitHub repository page in the browser. You should see all four project files listed.
Push rejected or authentication error?
If Git asks for credentials, you may need a GitHub Personal Access Token instead of your password. Generate one from Settings > Developer settings > Personal access tokens in GitHub, then use it as your password when prompted.
If you see a message about the remote already existing, run git remote remove origin and then re-add it with the correct URL.
help me fix a git push authentication error to GitHub
Connect your GitHub repository to Cloud Build
Cloud Build uses the Cloud Build GitHub App to listen for events on your repository. You will install this app and connect your repository using the 1st gen flow in the Google Cloud Console.
- Open the Cloud Build Triggers page in the Google Cloud Console.
- Click Connect Repository.
- Select us-central1 from the Region dropdown.
- Under source, select GitHub (Cloud Build GitHub App).
- Click Continue.
What happens next?
Google Cloud will redirect you to GitHub to authorize the Cloud Build app. This is a one-time setup that allows Cloud Build to receive webhook events from your repository.
- Click Authorize Google Cloud Build when prompted on GitHub.
- Click Install Google Cloud Build.
- In the installation dialog, select your GitHub username.
- Select Only select repositories and choose secure-pipeline-demo from the dropdown.
- Click Install.
After installation, the Cloud Console reappears with your repository listed.
- Under GitHub account, confirm your username is selected.
- Under Repository, select secure-pipeline-demo.
- Check the consent checkbox to accept the terms.
- Click Connect.
- Click Done (skip the optional trigger creation prompt for now).
Don't see your repository in the list?
Click Edit repositories on GitHub to enable additional repositories in the Cloud Build GitHub App. Then return to the Console and select your repository.
help me connect my GitHub repository to Cloud Build
Create the push trigger
With the repository connected, you can now create a trigger that tells Cloud Build to run your cloudbuild.yaml pipeline every time you push to the main branch. The trigger uses your custom cloud-build-sa service account instead of the default.
- Create the trigger by running this command:
gcloud builds triggers create github \
--name=push-to-main \
--region=us-central1 \
--repo-name=secure-pipeline-demo \
--repo-owner=[[GITHUB_USERNAME="your-github-username"]] \
--branch-pattern=^main$ \
--build-config=cloudbuild.yaml \
--service-account=projects/$(gcloud config get-value project)/serviceAccounts/cloud-build-sa@$(gcloud config get-value project).iam.gserviceaccount.com
What does this command do?
- --name=push-to-main gives the trigger a descriptive name.
- --region=us-central1 places the trigger in the same region as your Artifact Registry repository.
- --branch-pattern=^main$ means the trigger fires only on pushes to the main branch.
- --build-config=cloudbuild.yaml tells Cloud Build which file defines the pipeline steps.
- --service-account assigns your custom least-privilege cloud-build-sa instead of the default Cloud Build service account.
- You should see output confirming the trigger was created, including its name and ID.
Seeing a permission denied error?
Make sure you are authenticated with an account that has owner or editor access on the project. Also verify the repository was connected in the previous substep. Cloud Build requires the repository connection to exist before creating a trigger for it.
help me fix a permission denied error when creating a Cloud Build trigger
Now verify the trigger is configured correctly in the Console.
- Open the Cloud Build > Triggers page in the Google Cloud Console.
- Select us-central1 from the Region dropdown if it is not already selected.
- Confirm you see a trigger named push-to-main in the list.
- Click on push-to-main to open its details.
- Verify the event is set to Push to a branch, the branch pattern is ^main$, and the service account shows cloud-build-sa.
Why use a custom service account on the trigger?
The default Cloud Build service account has broad permissions across your entire project. By assigning cloud-build-sa to the trigger, every build runs with only the four roles you granted earlier. If the service account is ever compromised, the blast radius is limited to one Artifact Registry repository and one Cloud Run service.
Your pipeline is now fully wired. The next time you push code to the main branch, Cloud Build will automatically build, push, and deploy your app. Time to test it.
Deploy, Test, and Audit
Your pipeline is fully wired. The GitHub trigger watches your main branch, ready to build and deploy the moment new code arrives.
In this step, you'll push your code to trigger the pipeline, verify the deployed service reads its secret correctly, and run a security audit to confirm your least-privilege setup holds tight.
In this step, get ready to:
- Push your code to GitHub and watch the build execute in Cloud Build.
- Hit the Cloud Run service URL and verify the secret is loaded.
- Audit your security posture across IAM roles, secret access, and Cloud Audit Logs.
Push your code and watch the build
Your local files are committed to Git but haven't been pushed yet. The moment you push to the main branch, the Cloud Build trigger fires automatically.
- Push your code to GitHub by running this command:
git push origin main
What does this command do?
This pushes your local commits to the main branch on GitHub. Because your Cloud Build trigger watches this branch, the push immediately starts a new build.
- Open the Cloud Build page in the Google Cloud Console.
- Click History in the left sidebar.
- You should see a new build running (or recently completed) triggered by your push.
- Click on the build to expand it. Confirm all three steps completed successfully: docker build, docker push, and gcloud run deploy.
What should I see?
Each of the three build steps shows a green checkmark when successful. The build log for the deploy step shows the Cloud Run service URL at the end.
The total build time is typically 1-3 minutes. If you see a yellow spinner, the build is still running.
Build failed?
Check the build log for the failing step. Common causes:
- Permission denied pushing to Artifact Registry: confirm cloud-build-sa has roles/artifactregistry.writer on secure-pipeline-repo.
- Cloud Run deploy fails: confirm cloud-build-sa has roles/run.admin and roles/iam.serviceAccountUser.
- Secret not found: confirm my-app-secret exists and is spelled correctly in cloudbuild.yaml.
Help me debug my Cloud Build failure
Test the Cloud Run service
With the build complete, your app is live on Cloud Run. The service reads its secret from Secret Manager via environment variable injection. Time to confirm it works.
- Get your Cloud Run service URL by running this command:
gcloud run services describe secure-pipeline-demo --region=us-central1 --format='value(status.url)'
What does this command do?
This queries Cloud Run for the secure-pipeline-demo service and extracts just the HTTPS URL from the output. You'll use this URL to send a request to your deployed app.
- Copy the URL from the output.
- Send a request to your service by running this command (replace the URL with yours):
curl [[SERVICE_URL="your Cloud Run service URL from the previous command"]]
- Verify the JSON response shows secret_loaded as true and secret_preview shows the first three characters of your secret followed by asterisks.
What should I see?
Your response looks something like this:
{
"status": "healthy",
"service": "secure-pipeline-demo",
"secret_loaded": true,
"secret_preview": "sup**********",
"timestamp": "2026-06-10T12:00:00.000Z"
}
The secret_preview value depends on whatever you stored in my-app-secret. The key confirmation is that secret_loaded is true. This proves Cloud Run successfully injected the secret as an environment variable at runtime.
Seeing secret_loaded: false?
- Confirm the Compute Engine default service account has roles/secretmanager.secretAccessor on my-app-secret.
- Check that cloudbuild.yaml uses MY_SECRET=my-app-secret:latest in the --set-secrets flag.
- Verify your secret has at least one active version by running gcloud secrets versions list my-app-secret.
Help me troubleshoot my Cloud Run secret injection
Audit your security posture
The pipeline works. Now you need to verify the security configuration matches what you intended. A working pipeline with over-permissioned accounts is a liability.
You'll run three checks: confirm the service account has exactly 4 roles, confirm the secret has exactly one accessor, and review the Cloud Audit Logs for the build.
- Check the project-level IAM roles assigned to your service account by running this command:
gcloud projects get-iam-policy $(gcloud config get-value project) \
--flatten="bindings[].members" \
--filter="bindings.members:cloud-build-sa" \
--format="table(bindings.role)"
What does this command do?
This queries your project's IAM policy and filters it to show only roles assigned to members containing cloud-build-sa. You should see three project-level roles: roles/run.admin, roles/iam.serviceAccountUser, and roles/logging.logWriter.
- Check the repository-level role by running this command:
gcloud artifacts repositories get-iam-policy secure-pipeline-repo \
--location=us-central1
What does this command do?
This shows the IAM policy scoped to the Artifact Registry repository only. You should see one binding: roles/artifactregistry.writer granted to your cloud-build-sa service account. This is the fourth role, scoped at the repository level instead of project-wide.
- Verify the secret has exactly one accessor by running this command:
gcloud secrets get-iam-policy my-app-secret
What does this command do?
This shows who can access my-app-secret. You should see exactly one binding: roles/secretmanager.secretAccessor granted to the Compute Engine default service account. No other principals should have access.
- Review the Cloud Audit Logs for your build. In the Google Cloud Console, navigate to Logging in the left sidebar.
- Click Logs Explorer.
- In the query field, enter resource.type="build" and click Run Query.
- Expand a recent log entry. Confirm the serviceAccountId field shows your cloud-build-sa account, not the default Cloud Build service account.
Why does the audit log matter?
Cloud Audit Logs prove that your custom service account executed the build, not the default project-wide account. If your service account were ever compromised, audit logs give you the exact timestamps and actions to investigate.
This is the final piece of your security posture: least-privilege permissions confirmed by role check, scoped secret access confirmed by policy check, and an audit trail proving who did what.
Don't see your build in audit logs?
- Make sure the Cloud Build API audit logs are enabled. Navigate to IAM & Admin > Audit Logs and confirm Cloud Build has data access logs enabled.
- Wait 1-2 minutes. Audit logs can take a short time to appear after the build completes.
Help me find my Cloud Build audit logs
Secret mission
Enable Vulnerability Scanning
Your pipeline is secure, but are your container images free of known vulnerabilities? In this bonus challenge, you will enable Artifact Analysis scanning on your repository, inspect the vulnerability report for your deployed image, and research how to automatically fail builds when critical vulnerabilities are detected.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely within GCP's free tier, so there are no ongoing costs while your resources are idle. Cloud Run scales to zero when not receiving traffic, and the other services stay well within their free allowances.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Cloud Run service (secure-pipeline-demo in us-central1).
- Artifact Registry repository (secure-pipeline-repo in us-central1).
- Secret Manager secret (my-app-secret).
- Cloud Build trigger (push-to-main or your chosen name).
- Custom service account (cloud-build-sa).
- Local project files (server.js, package.json, Dockerfile, cloudbuild.yaml).
Keep everything running
No action needed. Choose this if you are still actively building or plan to iterate on your pipeline soon.
- Cloud Run scales to zero when idle, so it costs nothing while not receiving traffic.
- Cloud Build triggers do not incur charges unless a build runs.
- Secret Manager charges only for access operations. Your single secret with one version stays well within the 6 free active versions and 10,000 free access operations per month.
- Artifact Registry stores images at no cost up to 500 MB per month.
Pause - I'll come back to this later
Shut down the live service to free up resources while keeping your configuration intact.
- Delete the Cloud Run service (your trigger will re-deploy it on the next push):
- Keep the Artifact Registry repository, secret, service account, and trigger in place.
- When you return, push a commit to your GitHub repo and the pipeline will re-deploy everything automatically.
gcloud run services delete secure-pipeline-demo --region us-central1
Confirm the deletion when prompted.
Delete - I don't want to use this again
Remove all project resources and start fresh. Run each of the following commands in your terminal.
- Delete the Cloud Run service:
gcloud run services delete secure-pipeline-demo --region us-central1
- Delete the Artifact Registry repository:
gcloud artifacts repositories delete secure-pipeline-repo --location=us-central1
- Delete the Secret Manager secret:
gcloud secrets delete my-app-secret
- Delete the Cloud Build trigger (replace the trigger name if yours differs):
gcloud builds triggers delete push-to-main --region=us-central1
- Delete the custom service account (replace your project ID):
gcloud iam service-accounts delete cloud-build-sa@[[PROJECT_ID="your-gcp-project-id"]].iam.gserviceaccount.com
- Delete your local project folder:
macOS
rm -rf ~/Desktop/secure-pipeline-demo
Windows
Remove-Item -Recurse -Force ~\Desktop\secure-pipeline-demo
- Optionally, disconnect your GitHub repository from Cloud Build in the Cloud Build Console under Settings > Linked repositories.
That's a Wrap!
That's a Wrap!
Nice work! You've just built a production-grade CI/CD pipeline on Google Cloud that automatically builds, secures, and deploys your app on every push to GitHub.
You've learned how to:
- Built a fully automated CI/CD pipeline using Cloud Build that triggers on every GitHub push to build, push, and deploy a containerized app to Cloud Run.
- Created a custom least-privilege service account with Artifact Registry write permissions scoped to a single repository, not project-wide.
- Integrated Secret Manager with Cloud Run using environment variable injection, with the Secret Accessor role granted at the individual secret level.
- (Secret Mission) Enabled vulnerability scanning on your Artifact Registry repository and inspected security insights for your deployed container image.
Ready to quiz yourself?