Your stack as code, Vercel + Supabase + Cloudflare in one apply.
Four small Terraform modules describe the providers a solo engineer ships SaaS on in 2026. One terraform apply stands the stack up. One terraform destroy tears it down. The wiring between them is the repo.
Why this exists
A solo engineer shipping SaaS in 2026 typically uses six providers in the first month: Vercel for the frontend, Supabase for database and auth, Cloudflare for DNS and storage and KV, GitHub for source, a registrar, and an email service. Each console is excellent. Wiring them together by clicking is correct, slow, and not reproducible.
The generic AWS-everywhere Terraform stacks are wrong-shaped for this. They are heavy, they assume a lot of long-running compute, and they target providers a solo engineer is not actually using. The dashboard approach is fine for one environment and actively hostile to two.
terraform-stack is a small, opinionated set of modules that target the providers you actually use. Four modules. A working reference example. A WHITEPAPER explaining the choices. You apply once, you have a stack. You apply with different variables, you have staging or a per-PR preview.
Why this matters
The next stack is the same as the last stack. The next preview environment is the same as production with different variables. Writing the wiring down once turns "set up a new client engagement" from a multi-day project into a single command run from a sandbox tfvars file. The cost of a new environment falls to roughly zero.
Four modules
Each independent. Each under 50 lines of meaningful Terraform. Use one, use all four.
Frontend + edge runtime
Next.js Vercel project linked to a GitHub repo, custom domain attached, environment variables wired from the Supabase and Cloudflare outputs so the deploy is correct on first push.
Database, auth, edge functions
Supabase project provisioned in your region with a generated database password, auth configured through supabase_settings (site URL, redirect allow-list, signup policy, JWT lifetime), a deployed health edge function, and the real anon and service-role keys read back from the management API.
DNS, R2, KV, Workers
Zone apex A record and www CNAME pointing at Vercel, an R2 bucket, a Workers KV namespace, and an edge Worker mapped to a route and bound to both R2 and KV.
Optional compute
Ubuntu droplet with Docker pre-installed and monitoring on, behind a firewall that allows only SSH, HTTP and HTTPS inbound. Off by default. Enable with enable_droplet = true.
Module dependency graph
Vercel is planned last because its environment variables resolve from Supabase and Cloudflare outputs. Terraform infers the order from the references; you do not declare it.
What an apply looks like
From a clean state to a live stack. Same shape every time.
Quick start
From git clone to a live stack in twenty minutes.
git clone https://github.com/sarmakska/terraform-stack.git cd terraform-stack cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars: project_name, domain, github_repo, supabase_org_id export VERCEL_API_TOKEN=... export SUPABASE_ACCESS_TOKEN=... export CLOUDFLARE_API_TOKEN=... # only if enable_droplet = true: export DIGITALOCEAN_TOKEN=...
terraform init terraform plan terraform apply
terraform output vercel_project_id terraform output -raw supabase_api_url terraform output -raw supabase_anon_key terraform output -raw supabase_service_role_key # sensitive terraform output r2_bucket terraform output kv_namespace_id
terraform destroy
The whole main.tf
Eighty lines of HCL, the entire root of the repo. Modules below it, variables and outputs beside it. Nothing hidden.
terraform {
required_version = ">= 1.9"
required_providers {
vercel = { source = "vercel/vercel", version = "~> 2.0" }
supabase = { source = "supabase/supabase", version = "~> 1.5" }
cloudflare = { source = "cloudflare/cloudflare", version = "~> 4.0" }
digitalocean = { source = "digitalocean/digitalocean", version = "~> 2.0" }
}
}
provider "vercel" { api_token = var.vercel_api_token }
provider "supabase" { access_token = var.supabase_access_token }
provider "cloudflare" { api_token = var.cloudflare_api_token }
provider "digitalocean" { token = var.digitalocean_token }
module "supabase" {
source = "./modules/supabase"
project_name = var.project_name
region = var.supabase_region
org_id = var.supabase_org_id
site_url = "https://${var.domain}"
additional_redirect_urls = ["https://www.${var.domain}", "http://localhost:3000"]
enable_signup = var.supabase_enable_signup
jwt_expiry = var.supabase_jwt_expiry
enable_edge_functions = var.supabase_enable_edge_functions
}
module "cloudflare" {
source = "./modules/cloudflare"
domain = var.domain
enable_worker = var.cloudflare_enable_worker
}
module "vercel" {
source = "./modules/vercel"
project_name = var.project_name
domain = var.domain
github_repo = var.github_repo
env_vars = {
NEXT_PUBLIC_SUPABASE_URL = module.supabase.api_url
NEXT_PUBLIC_SUPABASE_ANON_KEY = module.supabase.anon_key
SUPABASE_SERVICE_ROLE_KEY = module.supabase.service_role_key
R2_BUCKET = module.cloudflare.r2_bucket
KV_NAMESPACE_ID = module.cloudflare.kv_namespace
}
}
module "digitalocean" {
count = var.enable_droplet ? 1 : 0
source = "./modules/digitalocean"
project_name = var.project_name
region = var.digitalocean_region
size = var.digitalocean_size
ssh_key_id = var.digitalocean_ssh_key_id
}The variables that matter
Every other knob in the repo has a sensible default. These are the ones you actually set.
| Variable | Sample value | Purpose |
|---|---|---|
| project_name | "my-app" | Used as the Vercel project name and the prefix for Cloudflare resources. |
| domain | "example.com" | Apex domain. DNS, TLS and Workers routes are built from it. |
| github_repo | "you/my-app" | Vercel links to this repo and auto-deploys on push. |
| supabase_region | "eu-west-2" | Supabase region. Defaults to a region close to the Cloudflare zone. |
| supabase_org_id | "abcd1234" | Supabase organisation that owns the project. |
| enable_droplet | false | Provisions the optional DigitalOcean droplet when true. |
| cloudflare_enable_worker | true | Toggles the edge Worker and its R2 + KV bindings. |
What is in the box
Clone, edit terraform.tfvars, apply. Every item below is implemented in the repo.
Vercel project, fully described
Project linked to GitHub, custom domain, environment variables templated from sibling-module outputs. Terraform plans the dependency order so the project is created with the right secrets on the first apply.
Supabase project, end to end
Project, generated database password marked sensitive, auth configuration through supabase_settings, edge function deployment, and a management-API read-back of the real anon and service-role keys. No console fishing.
Cloudflare zone, R2, KV, Workers
DNS at the apex and www, an R2 bucket and a Workers KV namespace named after the domain, and an edge Worker mapped to a route. The Worker is bound to R2 and KV so its bindings work from the first request.
DigitalOcean droplet, behind a firewall
Ubuntu image with Docker pre-installed, monitoring on, firewalled to SSH + HTTP + HTTPS. Off by default; enable when you need long-running compute outside Vercel.
Reproducible across environments
Production runs from production.tfvars, staging from staging.tfvars, per-PR previews from a unique workspace. Same modules, different variables, same shape every time.
Each module fits in your head
Every module is under 50 lines of meaningful Terraform. No deep abstractions, no library charts, no inherited inputs. Read it once, own it forever.
Cost discipline as a side effect
Default footprint is three fully-managed services with no long-running compute. No orphaned LBs, no surprise vendors, predictable monthly bill.
Sensitive outputs are sensitive
Database passwords and service-role keys are marked sensitive so they never appear in plain-text plan output. Mark your remote-state backend encrypted and the secret never sees disk in the clear.
No vendor lock by accident
Want to swap Supabase for Neon? Replace one module. The rest of the repo does not change. Same with Cloudflare R2 for S3 or Vercel for Netlify.
Pairs with k8s-ops-toolkit
The DigitalOcean module can provision a DOKS cluster. The k8s-ops-toolkit chart deploys onto it. Same opinionated solo-engineer stack, with self-hosted Kubernetes added when you need it.
CI plan-then-apply with manual approval
.github/workflows/deploy.yml runs terraform plan unconditionally, then a terraform apply gated behind a protected GitHub environment. A human approves before infrastructure changes.
Real provider versions, real test suite
tests/ runs terraform fmt -check, validate, and the terraform test framework against the root module and every submodule. The same checks run in CI on every push and pull request.
Use cases
What people actually run this for.
First-day SaaS provisioning
New project, no infrastructure yet, want everything stood up in one command instead of clicking around four dashboards.
Reproducible staging
Production runs from main, staging runs from staging.tfvars. Same modules, different region or smaller Vercel team, the rest is identical.
Per-PR preview environments
CI runs terraform apply with a unique workspace per PR. The teardown happens on close. No more long-lived shared staging.
Onboarding a new team member
They run terraform apply against a sandbox tfvars file. Twenty minutes later they have their own full stack to learn against.
Client engagement on the same stack
Bring up a client's entire stack in their own provider accounts in twenty minutes. Hand them the repo at the end.
Disaster recovery rehearsal
Run terraform apply against a fresh empty project. If the rehearsal succeeds the runbook is real.
terraform-stack vs alternatives
How the stack compares to other ways to provision the same four providers.
| Feature | terraform-stack | Stock Terraform | Pulumi | AWS CDK | Dashboard clicks |
|---|---|---|---|---|---|
| Targets a specific solo-engineer stack | Yes, four providers | Generic | Generic | AWS-shaped | N/A |
| Terraform fmt + validate + test in CI | On every PR | Bring your own | Yes (pulumi preview) | Yes (cdk synth) | No |
| Manual approval before apply | Protected environment | Bring your own | Bring your own | Bring your own | N/A |
| Modules fit in your head | Under 50 lines each | Often heavy | Varies | Heavy | N/A |
| Real-key read-back from Supabase | Via management API | Manual | Manual | Manual | Manual copy |
| Vercel + Supabase + Cloudflare wired together | Outputs flow as env_vars | Manual wiring | Manual wiring | Manual wiring | Manual |
| Optional DigitalOcean compute | enable_droplet flag | Bring your own | Bring your own | Different cloud | Separate dashboard |
| Licence | MIT | MIT components | Apache 2.0 | Apache 2.0 | Commercial |
Provider versions
Pinned in required_providers and validated by terraform validate in CI.
Documentation & guides
Wiki pages mirror the docs/ folder in the repo.
Frequently asked
The questions that come up most.
Why not Pulumi or the CDK?+
Terraform is the lingua franca for the providers in this stack: Vercel, Supabase, Cloudflare and DigitalOcean all ship first-class Terraform providers. Pulumi works fine for the same providers if you prefer real code; the trade-off is more language surface area for the same outcome.
Why not AWS?+
A solo engineer running a SaaS on AWS-everywhere in 2026 is paying for a lot of surface area they will not use. The four providers in this stack cover frontend, database, auth, storage, KV, DNS and edge compute with managed pricing and very small consoles. The stack is opinionated on purpose.
Can I use only some modules?+
Yes. Each module is independent. Import only modules/vercel into a different root and skip the rest. The outputs are stable contracts; nothing forces you to use all four together.
Where does the database password live?+
random_password generates it. It is written to state and marked sensitive in the outputs. Use an encrypted remote-state backend (R2-backed S3, Terraform Cloud, GCS with KMS, S3 with SSE) so it never sees disk in the clear.
How are real Supabase keys obtained?+
The Supabase Terraform provider reads them back from the management API after the project exists. They appear as sensitive outputs in your root, ready to feed into env_vars on the Vercel project in the same apply.
Does this stand up multi-region?+
No. Each module assumes a single region. Multi-region is a different design (Supabase read replicas, regional Workers, regional Vercel functions, replicated R2 buckets) and is not a flag on this repo.
What about Resend, Stripe, observability?+
On the roadmap. The repo deliberately ships small: Resend and Stripe are providers people forget about for months and break in production; they belong in their own modules with their own tests.
Is this safe to run in CI?+
Yes. The deploy workflow runs terraform plan on every push and a terraform apply only after a human approves the protected environment. A token mistake never silently turns into infrastructure.
Your full stack, one apply away
From scratch, every time. Same shape on day one and day one thousand.
Related projects
Part of a portfolio of production-shaped open-source repos.