Configuring Google Cloud Environments with Pulumi

Recently, I needed to configure a Google Cloud environment for one of my backend projects. At first, I thought I could just click around in the Google Cloud console, create the Cloud Run services, connect the database, add some secrets, and call it a day.
But after doing it once, I already forgot some small details. Which API did I enable first? Which service account owns the deployment? Did this Cloud Run service use the VPC connector or Direct VPC egress? Why does staging have a different setting than production?
So yeah, clicking around the console is okay for trying things. For a real environment, it starts to become scary.
In this article, I will share how I configured the Google Cloud environment using Pulumi. I used Pulumi with Go because the backend service was also written in Go, so writing the infrastructure in the same language feels more natural to me.
The first thing that I did was separate the infrastructure into two Pulumi stacks:
| Stack | Responsibility |
|---|---|
| shared | Network, database, cache, registry, buckets, secrets, CI/CD triggers |
| services | Service accounts, IAM, Cloud Run services, Cloud Tasks queues, jobs |
The shared stack contains resources that should not change too often. For example, the VPC, subnet, Cloud SQL instance, Artifact Registry, Secret Manager secrets, storage buckets, and Cloud Build triggers.
The services stack contains resources that are closer to the application itself. Each service has its own service account, Cloud Run service, environment variables, secret references, IAM roles, and sometimes a Cloud Tasks queue or a Cloud Run job.
To connect both stacks, I use StackReference. So the services stack can read outputs from the shared stack, like the database private IP, Cloud SQL connection name, VPC, subnet, and bucket names.
This is the part that makes Pulumi useful for me. I don’t need to copy the same value to another file manually. The output from one stack can become the input for another stack.
Before creating the resources, I created a Google Cloud Storage bucket for the Pulumi state.
gcloud auth login
gcloud auth application-default login
gcloud storage buckets create gs://my-staging-pulumi-state \
--project=my-staging-project --uniform-bucket-level-access
pulumi login gs://my-staging-pulumi-state
pulumi stack init staging
For secret values, I use PULUMI_CONFIG_PASSPHRASE, so Pulumi can encrypt the secret config.
export PULUMI_CONFIG_PASSPHRASE=<passphrase>
pulumi config set --secret dbPassword <password> -s staging
After the backend is ready, I started creating the shared resources. The first one is enabling the Google Cloud APIs. I prefer to put this in Pulumi because sometimes a service failed to deploy only because one API was not enabled yet. It is a small problem, but still annoying.
Some APIs that I enabled are:
- Cloud Run
- Cloud SQL
- Secret Manager
- Cloud Tasks
- Artifact Registry
- VPC Access
- Compute
- IAM
- Storage
- Service Networking
- Cloud Build
After that, I created the network resources. The setup contains a custom VPC, a subnet, a private IP range for Cloud SQL, and a private service connection. For some environments, I also use a Serverless VPC Access connector.
For the database, I use Cloud SQL for PostgreSQL. In production, I enable backups, point-in-time recovery, query insights, and deletion protection. For staging, I keep it more flexible because I don’t want staging to become too expensive or hard to reset.
This is one thing I like about Pulumi config. Staging and production can use the same code, but still have different values.
After the shared stack is done, I continue with the services stack.
In my case, I have multiple backend services. Each service needs almost the same Cloud Run setup, but not exactly the same. Some services need database access, some need Cloud Tasks, some need object storage, and some only need to expose HTTP.
So I created a service definition in Go, and Pulumi loops through the services to create the resources. For each service, Pulumi creates:
- A service account
- IAM roles for that service
- A Cloud Run service
- Environment variables
- Secret Manager references
- Cloud SQL connection settings
- Ingress configuration
- Invoker IAM binding
For example, if the service needs Cloud SQL, it gets the cloudsql.client role. If the service reads secrets, it gets secretmanager.secretAccessor. If the service publishes a background job, it gets cloudtasks.enqueuer.
I try to avoid giving one big service account to everything. It is easier at the start, but later it becomes hard to know which service can access what. Creating one service account per service is a little more work, but the permission is clearer.
One problem I found while using Pulumi with Cloud Run is deciding who owns the environment variables.
Pulumi should own infrastructure values, for example:
DB_HOSTDB_PORTDB_NAME- Cloud SQL connection name
- Secret Manager references
- Service account email
But the deployment pipeline should own the image tag and some release-time application config.
If Pulumi owns everything, it can overwrite changes from the deployment pipeline. If the deployment pipeline owns everything, the infrastructure becomes hidden again. So I use ignoreChanges for some Cloud Run fields that are updated by CI/CD, and before running Pulumi again, I usually do a refresh.
The CI/CD part is also created by Pulumi. I created Cloud Build triggers for pull requests, staging deploys, production deploys, and manual redeploys.
The pattern is like this:
- Pull request or feature branch runs CI
- Push to main deploys to staging
- Version tag deploys to production
- Manual trigger can redeploy a known image tag
Cloud Build uses a user-managed service account. That service account has permission to build images, push to Artifact Registry, run migrations, update Cloud Run, read secrets, and access logs.
It is more setup compared to using the default service account, but I like it better because the permission is explicit.
Another thing I needed to choose was the Cloud Run network path.
Previously, the common setup was using Serverless VPC Access connector. It works, but the connector has a baseline cost even when the staging environment is idle. For staging, that cost is not always worth it.
Google Cloud also has Direct VPC egress for Cloud Run. With this, Cloud Run can connect to the VPC without the connector. I made this configurable, so staging can use Direct VPC egress while production can continue using the connector until I trust the behavior enough.
This is probably my favorite kind of infrastructure flag. The code can describe both options without duplicating the whole environment.
After doing all of this, the main lesson for me is that Pulumi works best when the ownership is clear.
Pulumi owns resources, networking, IAM, secrets, scaling, and service skeletons.
CI/CD owns container images, migrations, and release-time config.
Secret values are populated outside the repo.
Stack outputs connect everything together.
That’s it. Now the Google Cloud environment feels less like a bunch of settings in the console and more like part of the codebase. When I forget why something exists, I can read the Pulumi code instead of trying to remember which checkbox I clicked last month.