-
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 project with auth configured and a health edge function deployed, a Cloudflare zone with DNS plus R2, Workers KV and an edge Worker bound to both, 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
- Continuous integration and deployment
- 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]
CF_W[Cloudflare edge Worker]
end
subgraph Data
SB[Supabase project]
AUTH[Auth config]
EF[Edge function: health]
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
CF_R2 --> CF_W
CF_KV --> CF_W
SB --- AUTH
SB --- EF
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, r2_bucket, kv_namespace). 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. The Cloudflare Worker and the Supabase edge functions are likewise gated behind their own feature flags. 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 appear as outputs. The Supabase module generates a 32-character database password with the random provider and reads the real anon and service-role keys back from the management API through the supabase_apikeys data source, marking all three 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 = 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
}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 project with auth and an edge function, Cloudflare DNS plus R2, KV and an edge Worker, 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 Docker pre-installed, monitoring enabled, and a firewall that serves HTTPS publicly while keeping SSH closed by default. SSH opens only to the addresses you name in digitalocean_ssh_allowed_cidrs, so the droplet is never world-reachable on port 22 unless you ask for it.
enable_droplet = true
digitalocean_ssh_key_id = "12345678"
digitalocean_ssh_allowed_cidrs = ["203.0.113.4/32"] # your address; SSH stays closed if omittedThe 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"
# ...
}Two workflows ship with the repo. ci.yml runs terraform fmt -check, validates the root and every module, and runs terraform test against mocked providers on push to main and on pull requests, so no credentials are needed for CI. deploy.yml is a manually triggered workflow that plans and then applies, with the apply job gated behind a protected GitHub environment so a human approves before any real resource is provisioned. Configure that environment under Settings then Environments with required reviewers, and add the provider tokens as environment secrets.
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 supabase_region value 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, Workers KV, and Workers scripts. 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.
Worker route not matching. The default route is assets.<domain>/*. If you want a different path, set worker_route_pattern. The hostname in the pattern must fall inside the managed zone.
Edge function fails to deploy. The Supabase edge function is bundled at modules/supabase/functions/health/index.ts and deployed through supabase_edge_function. If you move or rename the file, update the module's entrypoint. Set supabase_enable_edge_functions = false to skip it entirely.
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 tests in tests/ use 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.9 or newer.
The deploy workflow never applies. The apply job is gated behind the protected production-apply environment. Until a required reviewer approves the run in the Actions tab, the apply stays queued. That gate is deliberate.
Destroy deletes the Supabase project irreversibly. terraform destroy removes the Supabase project and its data permanently. It removes the Vercel project, the Cloudflare records, and the Worker too, but it leaves the Cloudflare zone in place so you keep your domain.
- Architecture: module layout, dependency graph, secrets flow, 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: shipped features and planned modules including Resend, Stripe, 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