Open Source · MIT · Terraform 1.9+

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.

4
providers
1
terraform apply
~20 min
to a full stack
<50
lines per module
MIT
licence

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.

modules/vercel

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.

modules/supabase

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.

modules/cloudflare

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.

modules/digitalocean

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.

rendering
terraform apply provisions Supabase and Cloudflare in parallel, then Vercel last, wiring sibling-module outputs into its environment variables.

What an apply looks like

From a clean state to a live stack. Same shape every time.

rendering
Apply sequence: Supabase and Cloudflare run in parallel; Vercel is wired last because its env_vars reference both.

Quick start

From git clone to a live stack in twenty minutes.

01Clone and copy variables
git clone https://github.com/sarmakska/terraform-stack.git
cd terraform-stack
cp terraform.tfvars.example terraform.tfvars
02Set your provider tokens
# 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=...
03Plan, apply, inspect
terraform init
terraform plan
terraform apply
04Read the outputs
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
05Tear it down when you are done
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.

VariableSample valuePurpose
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_dropletfalseProvisions the optional DigitalOcean droplet when true.
cloudflare_enable_workertrueToggles 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.

Featureterraform-stackStock TerraformPulumiAWS CDKDashboard clicks
Targets a specific solo-engineer stackYes, four providersGenericGenericAWS-shapedN/A
Terraform fmt + validate + test in CIOn every PRBring your ownYes (pulumi preview)Yes (cdk synth)No
Manual approval before applyProtected environmentBring your ownBring your ownBring your ownN/A
Modules fit in your headUnder 50 lines eachOften heavyVariesHeavyN/A
Real-key read-back from SupabaseVia management APIManualManualManualManual copy
Vercel + Supabase + Cloudflare wired togetherOutputs flow as env_varsManual wiringManual wiringManual wiringManual
Optional DigitalOcean computeenable_droplet flagBring your ownBring your ownDifferent cloudSeparate dashboard
LicenceMITMIT componentsApache 2.0Apache 2.0Commercial

Provider versions

Pinned in required_providers and validated by terraform validate in CI.

Terraform 1.9+vercel/vercel ~> 2.0supabase/supabase ~> 1.5cloudflare/cloudflare ~> 4.0digitalocean/digitalocean ~> 2.0random_passwordterraform testGitHub Actions CIProtected environments

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.