The Philosophical Developer -- Chapter 31: Pulumi on GCP
2026-07-21 · 5 min read

The AWS port proved the methodology transfers to a different IaC tool. The GCP port proves it transfers to a different cloud provider. If the same test patterns, the same code structure, and the same CI pipeline work for both AWS and GCP, then the methodology is truly provider-independent.
I sat down with OrsonRius and we ported the local-cloud-gcp infrastructure to Pulumi Go. Five modules this time, each one a GCP service with its own resource types and its own configuration surface.
The GCP module structure
The GCP local-cloud has five modules. Each one maps to a Google Cloud service.
| Module | What It Creates | OpenTofu Equivalent |
|---|---|---|
| Storage | GCS bucket with versioning | modules/storage |
| IAM | Service account + IAM binding | modules/iam |
| Pub/Sub | Topic + subscription | modules/pubsub |
| Secret Manager | Secret with replication | modules/secret-manager |
| Firestore | Native-mode database | modules/firestore |
The code structure is identical to the AWS version. Each module is a single Go file. Each function takes a *pulumi.Context and returns an error. Each function exports its outputs.
The GCP storage module
The GCS bucket is the GCP equivalent of the S3 bucket. The API is different, but the pattern is the same.
func createStorage(ctx *pulumi.Context) error {
bucket, err := storage.NewBucket(ctx, "local-cloud-gcp-storage",
&storage.BucketArgs{
Name: pulumi.String("local-cloud-gcp-artifacts"),
Location: pulumi.String("US"),
ForceDestroy: pulumi.Bool(true),
Versioning: &storage.BucketVersioningArgs{
Enabled: pulumi.Bool(true),
},
})
if err != nil {
return err
}
ctx.Export("bucketName", bucket.Name)
return nil
}
The key difference from the AWS version is the Versioning field. In the AWS SDK, versioning is a separate resource. In the GCP SDK, it is a field on the bucket arguments. The Pulumi SDKs reflect the underlying provider conventions, not a unified interface.
The IAM module
The GCP IAM module creates a service account and binds it to a project-level IAM role. The pattern is different from AWS IAM, which uses roles, policies, and attachments. GCP uses service accounts and IAM members.
func createIAM(ctx *pulumi.Context) error {
sa, err := serviceaccount.NewAccount(ctx, "local-cloud-gcp-sa",
&serviceaccount.AccountArgs{
AccountId: pulumi.String("local-cloud-sa"),
DisplayName: pulumi.String("Local Cloud Service Account"),
})
if err != nil {
return err
}
_, err = projects.NewIAMMember(ctx, "local-cloud-gcp-iam-binding",
&projects.IAMMemberArgs{
Project: pulumi.String("floci-local"),
Role: pulumi.String("roles/storage.objectViewer"),
Member: pulumi.Sprintf("serviceAccount:%s", sa.Email),
})
if err != nil {
return err
}
ctx.Export("email", sa.Email)
return nil
}
The pulumi.Sprintf function is a powerful tool. It constructs the IAM member string from the service account email, similar to how Terraform’s format function works. The Pulumi SDK evaluates this at deployment time, not at code time.
The Pub/Sub module
The Pub/Sub module creates a topic and a subscription. The subscription has a 20-second acknowledgment deadline and inherits the topic’s labels.
The Secret Manager module
The Secret Manager module creates a secret with user-managed replication in a single region. The Pulumi SDK exposes the replication configuration as a nested struct, mirroring the GCP API structure.
secret, err := secretmanager.NewSecret(ctx, "local-cloud-gcp-secret",
&secretmanager.SecretArgs{
SecretId: pulumi.String("local-cloud-secret"),
Replication: &secretmanager.SecretReplicationArgs{
UserManaged: &secretmanager.SecretReplicationUserManagedArgs{
Replicas: secretmanager.SecretReplicationUserManagedReplicaArray{
&secretmanager.SecretReplicationUserManagedReplicaArgs{
Location: pulumi.String("us-central1"),
},
},
},
},
})
The Firestore module
The Firestore module creates a native-mode database. The configuration includes the concurrency mode, the App Engine integration mode, and the location.
db, err := firestore.NewDatabase(ctx, "local-cloud-gcp-firestore",
&firestore.DatabaseArgs{
Name: pulumi.String("local-cloud-db"),
LocationId: pulumi.String("us-central1"),
Type: pulumi.String("FIRESTORE_NATIVE"),
ConcurrencyMode: pulumi.String("OPTIMISTIC"),
AppEngineIntegrationMode: pulumi.String("DISABLED"),
})
The tests
The GCP tests follow the same pattern as the AWS tests. A mock struct that intercepts the resource creation calls and returns controlled responses. Each module gets its own test function. A full stack test composes all five modules.
The mock configuration is more extensive for GCP because the GCP resources have more computed properties. The service account, for example, generates an email address that depends on the project ID. The mock returns a predictable email that the test can assert on.
=== RUN TestGCPStorageModule --- PASS
=== RUN TestGCPIAMModule --- PASS
=== RUN TestGCPPubSubModule --- PASS
=== RUN TestGCPSecretManagerModule --- PASS
=== RUN TestGCPFirestoreModule --- PASS
=== RUN TestGCPFullStack --- PASS
PASS ok local-cloud-gcp-pulumi 0.011s
What we learned
The GCP port confirmed that the methodology transfers across providers. The code structure is the same. The test patterns are the same. The CI pipeline is the same. Only the SDK packages change.
The GCP SDK is more opinionated than the AWS SDK. The AWS SDK uses flat argument lists with many optional fields. The GCP SDK uses nested structs that mirror the GCP API structure. The Secret Manager replication configuration, for example, requires three levels of nesting. It is verbose but it is explicit.
The test speed is comparable. The GCP test suite runs in 11 milliseconds, about half the time of the AWS suite. The difference is the number of modules (five vs seven) and the complexity of the mocks.
The code is in the local-cloud-gcp repo. The tests pass. The same methodology, the same patterns, a different cloud.
Repos:
- github.com/dark5un/local-cloud-gcp – infrastructure/pulumi/
LinkedIn:
Here I geek out with my young Padawan, OrsonRius.