-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Solo-engineer cloud stack as code: Vercel, Supabase, Cloudflare and DigitalOcean wired together in a single Terraform repo.
Apply once and you get a Vercel project linked to a GitHub repo, a Supabase Postgres database, a Cloudflare zone with DNS plus R2 and Workers KV, and an optional DigitalOcean droplet for the work that does not belong on a serverless platform. This is the infrastructure I run under sarmalinux.com, factored into reusable modules so I can stand up the same shape for a new side-project, a client engagement, or a demo environment in minutes. The root module composes everything in the correct dependency order so a single terraform apply brings the whole stack up and a single terraform destroy tears it down.
- Architecture in depth
- How the modules fit together
- Real-world examples
- State and backends
- Troubleshooting
- Per-feature pages
The repo is a multi-provider Terraform configuration. The root main.tf declares four providers and composes four modules. Each module owns exactly one provider and exposes a small, explicit set of inputs and outputs. There is no generic abstraction layer: each module encodes the opinionated defaults of the stack rather than trying to wrap a provider in full.
flowchart LR
subgraph Source
GH[GitHub repo]
end
subgraph Edge
V[Vercel project]
CF_DNS[Cloudflare DNS]
CF_R2[Cloudflare R2]
CF_KV[Cloudflare Workers KV]
end
subgraph Data
SB[Supabase project]
PW[Generated DB password]
end
subgraph Compute_Optional
DO[DigitalOcean droplet]
FW[DO firewall]
end
GH --> V
SB --> V
CF_R2 --> V
CF_KV --> V
CF_DNS --> V
SB --- PW
DO --- FW
The dependency graph matters. The Vercel module is planned last because its environment variables resolve from the Supabase and Cloudflare module outputs (api_url, anon_key, service_role_key, and so on). Terraform builds the graph from these references automatically, so you never declare ordering by hand. The DigitalOcean module is gated behind a count on the enable_droplet variable, which means it contributes nothing to the plan unless you opt in. That keeps the default footprint to three fully-managed services with no long-running compute and a low monthly cost.
Secrets flow one way: tokens come in as sensitive root variables, providers consume them, and only derived values (database password, project ids, keys) appear as outputs. The Supabase module generates a 32-character database password with the random provider and marks it, the anon key, and the service-role key as sensitive so they are redacted from CLI output. Anything sensitive still lands in state, so an encrypted remote backend is mandatory for real deployments.
The root module wires the four modules together. The shape below is the load-bearing part; see the Modules page for the full input and output reference.
module "supabase" {
source = "./modules/supabase"
project_name = var.project_name
region = "eu-west-2"
org_id = var.supabase_org_id
}
module "cloudflare" {
source = "./modules/cloudflare"
domain = var.domain
}
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
}
}
module "digitalocean" {
count = var.enable_droplet ? 1 : 0
source = "./modules/digitalocean"
project_name = var.project_name
region = "lon1"
size = "s-1vcpu-1gb"
ssh_key_id = var.digitalocean_ssh_key_id
}Each module is independently usable. If you only want Vercel and Cloudflare, copy those two module blocks into your own configuration and drop the rest. Nothing in a module reaches across to another provider; the only coupling is through the root module's env_vars map.
The smallest realistic deployment: a Next.js app on Vercel, a Supabase database, Cloudflare DNS plus R2 and KV, and no long-running compute. The repo ships this as examples/single-region-saas/, which sources the repository root as a module so you can copy the directory, set your variables, and apply.
cd examples/single-region-saas
cp terraform.tfvars.example terraform.tfvars
# edit terraform.tfvars with your tokens and project details
terraform init
terraform plan
terraform applyWhen you need a long-running process (a queue consumer, a cron host, a websocket service) that does not fit Vercel's serverless model, flip on the DigitalOcean droplet. It comes with monitoring enabled and a firewall that allows only SSH, HTTP, and HTTPS inbound.
enable_droplet = true
digitalocean_ssh_key_id = "12345678"The example sources ../.. so it always tracks the modules in this repo. For a client deployment you want reproducibility, so copy the example directory out of the repo and change the module source to a tagged Git ref:
module "stack" {
source = "github.com/sarmakska/terraform-stack//?ref=v1.2.0"
# ...
}State is local by default, which is fine for a first plan but not for anything you actually deploy. Configure an encrypted remote backend before you apply for real, because the Supabase database password and the service-role key are stored in state.
terraform {
backend "s3" {
bucket = "my-tf-state"
key = "stacks/sarma-prod/terraform.tfstate"
region = "eu-west-2"
}
}Cloudflare R2 speaks the S3 protocol, so if you want to keep state in the same provider as the rest of your infrastructure you can point the S3 backend at an R2 bucket and skip AWS entirely.
terraform fmt -check fails in CI. Run terraform fmt -recursive locally and commit the result. CI runs the check in non-mutating mode, so any unformatted file fails the validate job.
Supabase region not available. The region argument must be one your organisation can actually use. Free-tier organisations are restricted to a subset of regions; match the region value in the root module to one listed against your org in the Supabase dashboard.
Cloudflare token scope errors during apply. The Cloudflare API token needs Zone:Edit on the specific zone for DNS records and Account:Edit for R2 and Workers KV. A global API key works but is broader than necessary; prefer a scoped token.
Cloudflare zone not found. The domain you pass must already exist as a zone in your Cloudflare account. This stack manages records inside an existing zone; it does not create the zone or transfer the domain.
Vercel project lands on the wrong account. A personal token creates the project on your personal account. If you deploy to a team, use a team-scoped token so the project and its environment variables resolve against the team.
terraform validate cannot find the random provider. Run terraform init (or terraform init -backend=false) first so the lock file and provider plugins are present. The Supabase module declares hashicorp/random for the generated database password.
terraform test fails locally with credential errors. It should not: the test in tests/smoke.tftest.hcl uses mock_provider blocks for every provider, so no real tokens are needed. If you see auth errors you are likely running an old Terraform; the test command requires Terraform 1.6 or newer.
Destroy deletes the Supabase project irreversibly. terraform destroy removes the Supabase project and its data permanently. It removes the Vercel project and Cloudflare records too, but it leaves the Cloudflare zone in place so you keep your domain.
- Architecture: module layout, dependency graph, and what is deliberately out of scope.
- Quick-Start: provision the stack against your own accounts in under ten minutes.
- Modules: per-module input and output reference, plus how to add a new provider module.
- Roadmap: planned modules including Resend, Stripe, GitHub Actions, and a state-backend bootstrap.
- Repository: https://github.com/sarmakska/terraform-stack
- Whitepaper: https://sarmalinux.com/products/terraform-stack/whitepaper
- All open source: https://sarmalinux.com/open-source
- License: MIT