Keeping infrastructure for several products in one repository with Pulumi and SOPS
Our products run on Cloudflare, GitHub, Stripe, Supabase, Storyblok and Resend. Their configuration lives in one repository, and anything with an API is held as code. This is how it is put together.
Pulumi + TypeScript
Cloudflare accounts are split one to three domains each, so that a mistake or an incident does
not cross products. Each account adds a provider definition, so we chose Pulumi with TypeScript,
where providers can be built by iterating over the account list (config/accounts.ts).
for (const account of cloudflareAccounts) {
providers[account.name] = new cloudflare.Provider(account.name, {
apiToken: process.env[account.tokenRef], // config holds the name of a secret, not its value
});
}
A product declares only which account it belongs to, and the matching provider is injected.
Adding an account is one entry in an array. Stripe uses the official stripe/stripe provider;
Storyblok and Resend use community providers through the Terraform provider bridge.
Secrets are committed, encrypted
- Values are encrypted with SOPS and committed as
secrets/*.enc.yaml - Only the decryption key (an age private key) and the Pulumi passphrase live in 1Password
config/holds the name of a secret (tokenRef), never a value
A change to a secret is therefore a diff in a pull request, and the history shows who replaced
which token and when. Setting up a new machine takes the two keys and git clone; rotating keys
is sops updatekeys across the files.
Pulumi mints the API tokens
There is exactly one Cloudflare API token per account whose value a person holds. It
authenticates Pulumi, which mints the per-purpose tokens (deploy, R2 / D1, Secrets Store, Zero
Trust, DNS) as cloudflare.ApiToken resources. Permissions are declared in
config/products.ts.
deployTokenPermissions: [
"Workers Scripts Write", // wrangler deploy (Worker)
"Pages Write", // Pages deployments
"D1 Write", // D1 binding and applying migrations
"Account Settings Read", // wrangler reads account info
],
deployTokenZonePermissions: ["Workers Routes Write"],
What a deploy token can do is a diff in a pull request.
Minted tokens live in a tokens stack and consumers read them through a StackReference. Wiring
ApiToken.value straight into a downstream provider leaves the value unknown at preview time,
which shows unchanged resources as +- replace and can crash a data source on an empty
response. A StackReference returns an applied value, known at preview time.
The bridged Cloudflare provider does not detect changes to permissionGroups, so adding a
permission means recreating the token with pulumi up --replace <urn>.
What stayed manual
These four have no API and can only be done in a dashboard. They are written down as procedures.
- Creating a Stripe account. The Terraform provider has no resource for it.
- Creating a GitHub App. The private key and ID it produces are then managed by infra.
- Enabling Cloudflare Email Routing. The automatic MX / DKIM records are locked for editing; only the rules are managed.
- Verifying a forwarding address. Clicking the link in a confirmation email cannot be automated.
Result
Adding a product is about 20 lines in config/products.ts, which produces the repository, the
deploy token, DNS, the Pages project, and the GitHub Environment and its secrets.
Nami Smart LLC
Fast, cost-efficient, high-quality app development. If you would like to talk about a project, please get in touch.