Building Reusable GCP Infrastructure with Pulumi & TypeScript
In our previous post, we covered why we chose to self-host Supabase on GCP. Today, we're diving deeper into how we structure our infrastructure code using Pulumi and TypeScript.
If you've ever inherited a Terraform codebase with 2,000 lines of HCL in a single file, you know why this matters.
Why Pulumi over Terraform?
Let's address this upfront. Terraform is excellent. We've used it on dozens of projects. But for TypeScript-heavy teams, Pulumi offers some compelling advantages:
Real programming constructs
With Pulumi, infrastructure is just code. You get:
- Loops and conditionals — No more
countandfor_eachgymnastics - Type safety — Catch errors before deployment
- IDE support — Autocomplete, go-to-definition, refactoring
- Abstraction — Classes, functions, modules
Example: Creating multiple firewall rules
In Terraform:
variable "firewall_rules" {
type = list(object({
name = string
ports = list(string)
ranges = list(string)
}))
}
resource "google_compute_firewall" "rules" {
count = length(var.firewall_rules)
name = var.firewall_rules[count.index].name
network = google_compute_network.vpc.id
allow {
protocol = "tcp"
ports = var.firewall_rules[count.index].ports
}
source_ranges = var.firewall_rules[count.index].ranges
}In Pulumi:
const firewallRules = [
{ name: "allow-ssh", ports: ["22"], ranges: ["0.0.0.0/0"] },
{ name: "allow-http", ports: ["80", "443"], ranges: ["0.0.0.0/0"] },
];
firewallRules.forEach(rule => {
new gcp.compute.Firewall(rule.name, {
network: vpc.id,
allows: [{ protocol: "tcp", ports: rule.ports }],
sourceRanges: rule.ranges,
});
});Same result, but the Pulumi version is just TypeScript. You can use any JavaScript library, add complex logic, and your IDE understands everything.
Our project structure
Here's how we organize our Pulumi codebase:
├── components/ # Reusable infrastructure modules
│ ├── database.ts # Cloud SQL wrapper
│ ├── networking.ts # VPC, subnets, firewall
│ ├── loadbalancer.ts # Global LB with SSL
│ ├── vpn.ts # Outline VPN server
│ ├── service-account.ts
│ └── index.ts # Re-exports
├── services/ # Application deployments
│ ├── supabase-studio/
│ │ ├── index.ts
│ │ └── startup.sh
│ └── index.ts
├── config/ # Configuration management
│ ├── index.ts
│ └── secrets.ts # Infisical integration
├── shared/ # Shared types
│ └── types.ts
└── index.ts # Main entry point
Let's break down each layer.
The component layer
Components are the heart of our reusability strategy. Each component is a Pulumi ComponentResource that encapsulates related infrastructure.
Anatomy of a component
Here's a simplified version of our Networking component:
export interface NetworkingArgs {
name: string;
region: string;
subnetCidr?: string;
enableVpnAccess?: boolean;
}
export class Networking extends pulumi.ComponentResource {
public readonly vpc: gcp.compute.Network;
public readonly subnet: gcp.compute.Subnetwork;
public readonly router: gcp.compute.Router;
public readonly nat: gcp.compute.RouterNat;
public readonly privateVpcConnection: gcp.servicenetworking.Connection;
constructor(name: string, args: NetworkingArgs, opts?: pulumi.ComponentResourceOptions) {
super("acme:components:Networking", name, {}, opts);
const resourceName = args.name;
const subnetCidr = args.subnetCidr || "10.0.1.0/24";
// VPC
this.vpc = new gcp.compute.Network(
`${resourceName}-vpc`,
{
name: `${resourceName}-vpc`,
autoCreateSubnetworks: false,
},
{ parent: this },
);
// Subnet
this.subnet = new gcp.compute.Subnetwork(
`${resourceName}-subnet`,
{
name: `${resourceName}-subnet`,
ipCidrRange: subnetCidr,
region: args.region,
network: this.vpc.id,
privateIpGoogleAccess: true,
},
{ parent: this },
);
// Cloud Router for NAT
this.router = new gcp.compute.Router(
`${resourceName}-router`,
{
name: `${resourceName}-router`,
region: args.region,
network: this.vpc.id,
},
{ parent: this },
);
// NAT for outbound internet
this.nat = new gcp.compute.RouterNat(
`${resourceName}-nat`,
{
name: `${resourceName}-nat`,
router: this.router.name,
region: args.region,
natIpAllocateOption: "AUTO_ONLY",
sourceSubnetworkIpRangesToNat: "ALL_SUBNETWORKS_ALL_IP_RANGES",
},
{ parent: this },
);
// Register outputs for visibility
this.registerOutputs({
vpcId: this.vpc.id,
subnetId: this.subnet.id,
});
}
}Key patterns:
- Typed args interface — Clear contract for what the component needs
- Public readonly outputs — Expose what other components need to reference
- Parent relationships —
{ parent: this }groups resources in the Pulumi UI - Sensible defaults —
subnetCidrhas a default but can be overridden
Using components
In our main index.ts:
const networking = new Networking("networking", {
name: `acme-${environment}`,
region: config.region,
subnetCidr: environment === "prod" ? "10.1.1.0/24" : "10.0.1.0/24",
enableVpnAccess: true,
});
const database = new Database("database", {
name: `acme-${environment}`,
region: config.region,
network: networking.vpc,
privateVpcConnection: networking.privateVpcConnection,
// ...
});Components reference each other naturally. TypeScript ensures you can't pass the wrong type.
The preset pattern
One pattern that's saved us significant time is the preset pattern. Instead of configuring every option for every environment, we define sensible defaults:
export const DatabasePresets = {
dev: {
tier: "db-perf-optimized-N-4",
edition: "ENTERPRISE_PLUS",
availabilityType: "ZONAL" as const,
diskSize: 250,
dataCacheEnabled: true,
deletionProtection: false,
backupRetainedDays: 35,
},
prod: {
tier: "db-perf-optimized-N-8",
edition: "ENTERPRISE_PLUS",
availabilityType: "REGIONAL" as const,
diskSize: 250,
dataCacheEnabled: true,
deletionProtection: true,
backupRetainedDays: 35,
},
};Then in the component:
constructor(name: string, args: DatabaseArgs, opts?: pulumi.ComponentResourceOptions) {
// Get preset if specified
const preset = args.preset ? DatabasePresets[args.preset] : null;
// Resolve with priority: explicit args > preset > defaults
const tier = args.tier || preset?.tier || "db-perf-optimized-N-4";
const edition = args.edition || preset?.edition || "ENTERPRISE_PLUS";
const deletionProtection = args.deletionProtection ?? preset?.deletionProtection ?? false;
// ...
}Usage is clean:
// Use preset for typical environments
const devDb = new Database("dev-db", {
preset: "dev",
// ...other required args
});
// Override specific settings when needed
const prodDb = new Database("prod-db", {
preset: "prod",
diskSize: 500, // Override preset's 250GB
// ...
});Configuration management
We keep configuration separate from infrastructure code:
// config/index.ts
const config = new pulumi.Config();
const gcpConfig = new pulumi.Config("gcp");
export function getConfig(): AppConfig {
const stack = pulumi.getStack();
const environment = stack as "dev" | "prod";
return {
projectId: gcpConfig.require("project"),
region: config.get("region") || "europe-west2",
environment: environment,
};
}
export function getResourcePrefix(): string {
const stack = pulumi.getStack();
return `acme-${stack}`;
}Each Pulumi stack (dev, prod) has its own configuration file:
# Pulumi.dev.yaml
config:
gcp:project: acme-dev-infrastructure
infisical:clientId:
secure: AAABAD/842pufxvH4OP0...
infisical:clientSecret:
secure: AAABAHrbWYCdh4rMxCeZ...Secrets are encrypted in the YAML file. Only the Pulumi service (or your passphrase) can decrypt them.
Secret management with Infisical
We don't store application secrets in Pulumi config. Instead, we fetch them from Infisical at deployment time:
export function fetchSecrets(
clientId: pulumi.Output<string>,
clientSecret: pulumi.Output<string>,
environment: string,
): pulumi.Output<AllSecrets> {
return pulumi.all([clientId, clientSecret]).apply(async ([id, secret]) => {
const client = new InfisicalSDK({
siteUrl: "https://eu.infisical.com",
});
await client.auth().universalAuth.login({
clientId: id,
clientSecret: secret,
});
const infraSecrets = await client.secrets().listSecrets({
projectId: INFRA_PROJECT_ID,
environment: environment,
secretPath: "/",
});
return {
infra: {
postgresDb: getSecretValue(infraSecrets, "POSTGRES_DB"),
postgresPassword: getSecretValue(infraSecrets, "POSTGRES_PASSWORD"),
},
// ...
};
});
}This keeps secrets out of:
- Git history
- Pulumi state files
- CI/CD logs (when done correctly)
The services layer
Services are deployments of actual applications. They compose infrastructure components:
export class SupabaseStudioService extends pulumi.ComponentResource {
public readonly serviceAccount: ServiceAccount;
public readonly instanceTemplate: gcp.compute.InstanceTemplate;
public readonly instanceGroupManager: gcp.compute.RegionInstanceGroupManager;
public readonly loadBalancer: LoadBalancer;
constructor(name: string, args: SupabaseStudioServiceArgs, opts?: pulumi.ComponentResourceOptions) {
super("acme:services:SupabaseStudio", name, {}, opts);
// Uses the ServiceAccount component
this.serviceAccount = new ServiceAccount(
`${args.name}-compute`,
{
name: `${args.name}-compute`,
projectId: args.projectId,
},
{ parent: this },
);
// Creates compute resources
this.instanceTemplate = new gcp.compute.InstanceTemplate(/* ... */);
// Uses the LoadBalancer component
this.loadBalancer = new LoadBalancer(
`${args.name}-lb`,
{
name: args.name,
domain: args.domain,
backendService: this.backendService,
},
{ parent: this },
);
}
}Services are the bridge between generic infrastructure components and specific application requirements.
Type safety throughout
We define shared types in shared/types.ts:
export interface AllSecrets {
infra: InfraSecrets;
studio: SupabaseStudioSecrets;
}
export interface InfraSecrets {
postgresDb: string;
postgresPassword: string;
}
export interface AppConfig {
projectId: string;
region: string;
environment: "dev" | "prod";
}These types flow through the entire codebase:
- Configuration returns
AppConfig - Secrets are typed as
AllSecrets - Components accept and return typed interfaces
The result: if you mistype a property name or pass the wrong type, TypeScript catches it before you run pulumi up.
Multi-stack deployments
We use Pulumi stacks to manage environments:
# Deploy to dev
pulumi stack select dev
pulumi up
# Deploy to prod
pulumi stack select prod
pulumi upEach stack has:
- Separate state (tracked independently)
- Separate configuration (
Pulumi.dev.yaml,Pulumi.prod.yaml) - Separate GCP project (optional but recommended)
Testing infrastructure code
One often-overlooked benefit of Pulumi: you can write tests.
import * as pulumi from "@pulumi/pulumi";
import { Networking } from "./components/networking";
describe("Networking component", () => {
beforeAll(() => {
// Mock Pulumi runtime
pulumi.runtime.setMocks({
newResource: args => ({ id: `${args.name}-id`, state: args.inputs }),
call: () => ({}),
});
});
it("should create VPC with correct name", async () => {
const networking = new Networking("test", {
name: "test-project",
region: "europe-west2",
});
const vpcName = await networking.vpc.name.promise();
expect(vpcName).toBe("test-project-vpc");
});
it("should use custom subnet CIDR when provided", async () => {
const networking = new Networking("test", {
name: "test-project",
region: "europe-west2",
subnetCidr: "10.99.0.0/24",
});
const cidr = await networking.subnet.ipCidrRange.promise();
expect(cidr).toBe("10.99.0.0/24");
});
});This isn't about testing GCP — it's about testing your logic. Does the naming work? Are defaults applied correctly? Do conditionals behave as expected?
Lessons learned
1. Start with components immediately
Even for small projects. The upfront investment pays off quickly.
2. Use explicit naming
Every resource gets a name property matching its Pulumi resource name. This makes it easy to find resources in the GCP console.
3. Export what you need
Each component should export:
- The main resources other components might reference
- Any outputs useful for debugging or documentation
4. Keep the main entry point clean
index.ts should be readable as a high-level overview. Details live in components.
5. Version your presets
As requirements evolve, you might need to version presets:
const DatabasePresets = {
"dev-v1": {
/* old config */
},
"dev-v2": {
/* new config with data cache */
},
// ...
};This lets you migrate gradually without breaking existing deployments.
The complete picture
Here's how it all fits together:
// index.ts
import { Database, Networking, Vpn } from "./components";
import { fetchSecrets, getConfig } from "./config";
import { SupabaseStudioService } from "./services";
const config = getConfig();
const secrets = fetchSecrets(clientId, clientSecret, config.environment);
const networking = new Networking("networking", {
name: `acme-${config.environment}`,
region: config.region,
enableVpnAccess: true,
});
const database = new Database("database", {
name: `acme-${config.environment}`,
region: config.region,
network: networking.vpc,
privateVpcConnection: networking.privateVpcConnection,
preset: config.environment,
databaseName: secrets.apply(s => s.infra.postgresDb),
password: secrets.apply(s => s.infra.postgresPassword),
});
const vpn = new Vpn("vpn", {
name: `acme-${config.environment}`,
projectId: config.projectId,
region: config.region,
domain: "vpn.example.com",
network: networking.vpc,
subnet: networking.subnet,
});
const studio = new SupabaseStudioService("supabase-studio", {
name: "supabase-studio",
projectId: config.projectId,
region: config.region,
domain: `db.${config.environment}.example.com`,
networking: networking,
database: database,
secrets: secrets,
vpnPublicIp: vpn.publicIp,
});
// Outputs for reference
export const databasePrivateIp = database.privateIp;
export const studioUrl = studio.url;
export const vpnPublicIp = vpn.publicIp;Clean, readable, type-safe. This is the main file for an entire production infrastructure.
Want to level up your infrastructure-as-code game? Get in touch — we can help you build maintainable, reusable infrastructure patterns.
