Skip to content
sarmakska edited this page Jun 7, 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 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.

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]
    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
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, 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.

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

Real-world examples

Single-region SaaS (the default)

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 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 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 omitted

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"
  # ...
}

Continuous integration and deployment

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

Per-feature pages

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

Project links