Storyie has gone through three pricing iterations since launch. Each time, the workflow was the same: open the Stripe dashboard, create a new Price, copy the ID, write a SQL migration to swap it into the subscription_plans table, then repeat the whole thing for the staging account. By the third round we had enough.
This post covers how we moved Stripe Product and Price management to Terraform — why the problem was worth solving, what the setup looks like, and how Price IDs flow back into the running application.
TL;DR
- Stripe's dashboard-plus-SQL-migration workflow breaks down once you have multiple environments and revisit pricing more than once.
- The community
lukasaron/stripeTerraform provider handles Products, Prices, and outputs cleanly. - We use directory-per-environment (
staging/,production/) rather than Terraform workspaces for full state isolation. terraform outputpipes Price IDs into SQL migrations or SST environment variables — no more manual copying.
Problem | Before | After |
|---|---|---|
Adding a new environment | Manually re-create every Product and Price |
|
Revising a price | Dashboard → copy ID → SQL migration → deploy | Edit |
Audit trail | Stripe audit log only | Git commit history with commit message context |
Pre-change visibility | None |
|
Why the manual approach stops scaling
Here is the migration we kept writing every time pricing changed:
-- Written by hand every time we revised the Pro plan price
UPDATE "subscription_plans"
SET "stripe_price_id_monthly" = 'price_1T1cfGPt9XoaRJBnqJ836kRp',
"stripe_price_id_yearly" = 'price_1T1cfGPt9XoaRJBnPP8NecDR',
"price_monthly" = 499,
"price_yearly" = 3999,
"updated_at" = now()
WHERE "name" = 'pro' AND "is_active" = true;Four problems compound over time:
- No reproducibility. Getting staging to match production means manually re-creating everything in a second Stripe account.
- No change history in Git. The Stripe audit log shows what changed, but not why — that context lives in Slack threads or nowhere.
- Friction in the ID relay. Create Price → copy ID → paste into SQL → run migration → deploy. Any mistake in that chain and the wrong Price ID is live.
- Late error detection. If a Price was created with the wrong currency or billing interval, you typically find out when the first real transaction fails.
Terraform addresses all four directly.
Provider setup
The community provider lukasaron/stripe is the practical choice here — there is no official Stripe provider. It covers Products, Prices, and Webhook endpoints, which is everything we need for subscription billing.
terraform {
required_providers {
stripe = {
source = "lukasaron/stripe"
version = "~> 1.9"
}
}
}
provider "stripe" {
api_key = var.stripe_secret_key
}
variable "stripe_secret_key" {
type = string
sensitive = true
}Pass the API key via terraform.tfvars or the environment variable TF_VAR_stripe_secret_key. Never hard-code a secret key in a .tf file.
Defining Products and Prices
Storyie has two plans — Free and Pro. Pro has monthly and yearly billing. The HCL maps naturally onto that structure.
# products.tf
resource "stripe_product" "free" {
name = "Storyie Free"
description = "Basic diary features"
active = true
metadata = {
plan_name = "free"
}
}
resource "stripe_product" "pro" {
name = "Storyie Pro"
description = "Unlimited diaries and advanced features"
active = true
metadata = {
plan_name = "pro"
}
}The for_each pattern keeps both Pro billing cycles in a single resource block:
# prices.tf
locals {
pro_prices = {
monthly = {
unit_amount = 499 # $4.99
interval = "month"
}
yearly = {
unit_amount = 3999 # $39.99
interval = "year"
}
}
}
resource "stripe_price" "pro" {
for_each = local.pro_prices
product = stripe_product.pro.id
currency = "usd"
unit_amount = each.value.unit_amount
recurring = {
interval = each.value.interval
}
metadata = {
billing_cycle = each.key
}
}Outputs expose the IDs to other systems:
# outputs.tf
output "free_product_id" {
value = stripe_product.free.id
}
output "pro_product_id" {
value = stripe_product.pro.id
}
output "pro_price_id_monthly" {
value = stripe_price.pro["monthly"].id
}
output "pro_price_id_yearly" {
value = stripe_price.pro["yearly"].id
}Handling price changes
Stripe Prices are immutable. Changing unit_amount in Terraform and running apply creates a new Price — the old one falls out of state but remains in Stripe. You need to archive it manually, or configure the lifecycle to handle ordering:
# Changing unit_amount from 499 to 699:
# - Terraform creates a new Price
# - The old Price is no longer managed (archive it manually in the dashboard)
resource "stripe_price" "pro" {
for_each = local.pro_prices
# ...
unit_amount = 699 # updated
}A few things worth knowing before running that:
- Existing active subscriptions stay on the old Price. Stripe does not migrate them automatically.
- To move existing subscribers to the new Price, you need to iterate over subscriptions via the Stripe API and update each subscription item explicitly.
lifecycle { create_before_destroy = true }controls whether the new Price exists before the old one leaves state — useful when your webhook handler validates that a Price ID is known before processing events.
Environment separation
We use one directory per environment rather than Terraform workspaces:
terraform/stripe/
├── staging/
│ ├── main.tf
│ ├── products.tf
│ ├── prices.tf
│ ├── variables.tf
│ ├── webhook.tf
│ └── outputs.tf
└── production/
├── main.tf
├── products.tf
├── prices.tf
├── variables.tf
├── webhook.tf
└── outputs.tfThe advantages over workspaces:
- State is completely isolated. A botched
applyin staging cannot touch production's state file. - Environments can diverge intentionally. Staging has additional webhook endpoints for local testing; production does not. With workspaces that gets messy.
- The target is explicit. Running
terraform applyfromproduction/is unambiguous; the equivalent workspace command is easier to run from the wrong context.
# staging
cd terraform/stripe/staging
TF_VAR_stripe_secret_key=sk_test_xxx terraform apply
# production
cd terraform/stripe/production
TF_VAR_stripe_secret_key=sk_live_xxx terraform applyConnecting Terraform outputs back to the application
There are a few reasonable ways to route Price IDs from Terraform into your running app.
SQL migration from output
PRO_MONTHLY=$(terraform output -raw pro_price_id_monthly)
PRO_YEARLY=$(terraform output -raw pro_price_id_yearly)
cat <<SQL > migrations/update_stripe_ids.sql
UPDATE subscription_plans
SET stripe_price_id_monthly = '${PRO_MONTHLY}',
stripe_price_id_yearly = '${PRO_YEARLY}',
updated_at = now()
WHERE name = 'pro' AND is_active = true;
SQLThis replaces the hand-written migration entirely. The IDs come from state rather than being copied by hand.
SST environment variables
Storyie deploys with SST, so Price IDs can be wired directly into the Next.js deployment:
// sst.config.ts
new sst.aws.Nextjs("Web", {
environment: {
STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY!,
STRIPE_PRICE_PRO_YEARLY: process.env.STRIPE_PRICE_PRO_YEARLY!,
},
});The CI pipeline reads the Terraform outputs, sets them as environment variables, and SST picks them up during deploy. No hard-coded IDs in the codebase.
Remote state reference (advanced)
If your entire infrastructure is managed in Terraform, the terraform_remote_state data source lets one module read outputs from another. The Stripe module's Price IDs become available as inputs wherever the infrastructure needs them, with no shell scripting in between.
Importing existing resources
If Products and Prices already exist in Stripe from dashboard creation, import them before running apply:
# Import an existing Product
terraform import stripe_product.pro prod_TXLt8df1Nl61Cw
# Import an existing Price (note the for_each key syntax)
terraform import 'stripe_price.pro["monthly"]' price_1T1cfGPt9XoaRJBnqJ836kRpAlways run terraform plan after import to confirm the state matches the live resource before making changes. Skipping this results in Terraform attempting to create a duplicate.
What worked, and what to watch for
The primary win is terraform plan. Before any pricing change, we see the exact diff — which resources are created, which fall out of state, what the new IDs will be. That pre-change visibility alone has caught more than one mistake that would have gone unnoticed until a transaction failed.
Rebuilding an environment from scratch is also effectively free. When we spun up a new developer environment, reproducing the correct Stripe product catalog took one apply instead of a manual walkthrough of the dashboard.
A few things to keep in mind:
- The community provider does not cover every Stripe resource. Coupons, promotion codes, and tax rates may require direct API calls or dashboard configuration. Check the provider changelog before assuming coverage.
terraform importis required for any resource created outside Terraform. Missing this step leads to duplicate resources and confused state.- Existing subscribers are not migrated when you change a Price. That requires a separate operation against the Stripe API.
Takeaways
If you are managing more than one Stripe environment or have revised pricing even once, IaC is worth the setup cost. The return on investment is front-loaded: the first terraform plan before a price change, the first time you rebuild a dev environment in under a minute, the first time someone asks "why did we change the price?" and the answer is in a commit message.
The setup described here — community provider, directory-per-environment, outputs piped into SQL or SST — scales from a two-plan indie app to something considerably more complex without fundamental rethinking.
Related Posts
- Building a Monorepo with pnpm and TypeScript — workspace and dependency conventions behind the Storyie codebase
- Deploying Next.js with SST on AWS — how the Terraform outputs feed into SST deployments
Try Storyie
The subscription system described here powers the Pro plan at storyie.com. The iOS app uses the same plan structure via RevenueCat, mapped to the same Stripe products.