Skip to content
sarmakska edited this page May 31, 2026 · 5 revisions

terraform-stack

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.

Contents

Architecture in depth

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
Loading

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.

How the modules fit together

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.

Real-world examples

Single-region SaaS (the default)

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 apply

Adding a background worker

When 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"

Pinning to a release

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 and backends

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.

Troubleshooting

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.

Per-feature pages

  • 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.

Project links

Clone this wiki locally