Terraform as product: how I structure infrastructure for two solo-built SaaS apps

I run two SaaS products on my own: Tourismo, a serverless PWA on Lambda and DynamoDB, and Addris, property GIS on Postgres and PostGIS. Different stacks entirely. But the infrastructure for both is Terraform, structured the same way, because the structure is the part that has to survive me forgetting how any of it works.

The mindset I've settled on: treat infrastructure as a product, with the same review rigour as application code. Not a pile of click-ops I'm afraid to touch. The test is simple — if I come back in four months, can I read it, plan it, and trust the diff?

Modules are the product's components

Tourismo's infra is a set of single-purpose modules, each owning one concern:

infra/modules/
  auth/           # Cognito user pool, clients, hosted UI
  data/           # DynamoDB tables + GSIs
  storage/        # S3 buckets, CloudFront, DNS
  api/            # API Gateway, Lambdas, IAM
  observability/  # alarms, dashboards, log groups

Environments are thin. The whole dev stack is just those modules wired together with the right inputs:

module "data" {
  source      = "../../modules/data"
  env         = var.env
  enable_pitr = false
}

module "api" {
  source              = "../../modules/api"
  env                 = var.env
  user_pool_arn       = module.auth.user_pool_arn
  users_table_name    = module.data.users_table_name
  trips_table_name    = module.data.trips_table_name
  # ...
}

The modules pass outputs to each other as inputs — auth emits the user pool ARN, api consumes it. That explicit wiring is the architecture diagram. You can read the dependency graph straight off the module blocks.

Addris is a slightly different shape, split by the two-tool reality of its stack: Terraform owns the slow-moving foundation (VPC, RDS, RDS Proxy, ElastiCache, the Photon EC2 box, Cognito, WAF), and AWS SAM owns the fast-moving application layer (the Lambdas, API Gateway, SQS). Files are split by concern — networking.tf, database.tf, elasticache.tf, rds-proxy.tf, waf.tf — so when something's wrong with Redis I open one obvious file, not a 2,000-line main.tf.

Environment separation that actually separates

Tourismo has dev, staging, and prod, each its own directory with its own state and its own variables — different callback URLs, different CORS origins, PITR off in dev and on in prod. Crucially, every DynamoDB table is name-scoped by environment:

name = "tourismo_trips_${var.env}"

There is no way for a dev Lambda to accidentally read prod data, because the table literally doesn't exist in that account context. Cheap, total isolation.

Remote state is non-negotiable

State lives in S3 with DynamoDB locking, set up once by a bootstrap module run locally before anything else exists:

backend "s3" {
  bucket         = "drive-tf-state-main"
  key            = "tourismo/prod/terraform.tfstate"
  region         = "eu-west-1"
  dynamodb_table = "drive-tf-locks"
  encrypt        = true
}

The lock table is what lets CI and I touch the same infra without corrupting state. Even as a solo dev this matters — "me on my laptop" and "the GitHub Actions runner" are two actors, and two actors mean you need a lock.

CI/CD with zero stored credentials

This is the part I'd push anyone towards. The Terraform workflow authenticates to AWS via GitHub OIDC — no long-lived access keys in secrets, just a federated role assumed at runtime. A preflight job checks the role is configured and skips cleanly if it isn't. Then the discipline that makes infra feel like code:

concurrency:
  group: terraform-${{ github.event.inputs.environment || 'dev' }}
  cancel-in-progress: false   # queue, never cancel — safe for infra

plan runs automatically on every pull request and posts the plan as a PR comment. apply is gated — manual workflow_dispatch with an environment choice, and on Addris, production apply sits behind a GitHub approval gate. So even working alone, I read a plan before prod ever changes. Secrets that do need to exist — API keys, the like — live in SSM Parameter Store and Secrets Manager and get referenced by path, never committed.

Why bother, as one person?

Because "one person" is exactly why. There's no teammate to remember why a security group is the way it is, no ops team to clean up after a 2am console fix. The Terraform is the documentation, the audit log, and the rollback plan. Every change gets a plan, a diff, and a PR — the same gate as application code — so the boring, legible version is the one that's still maintainable when I've completely forgotten the details. Clever infrastructure you can't safely change isn't an asset. It's a liability with a monthly bill.