Organizational

Infrastructure Layout

Pattern for organizing infrastructure-as-code using the .infra/ convention in repositories.

Production

Core Principle

Infrastructure-as-code lives in .infra/ within each repository.

Each repository that provisions cloud resources MUST place Terraform configurations in a .infra/ directory at the repository root.

The .infra/ Convention

Structure

.infra/ is not a single flat Terraform configuration. It is organized into layers, where each layer is an isolated Terraform stack with its own remote state key — not submodules of one shared state.

my-service/
├─ .infra/
│  ├─ bootstrap/        # enables the other layers; applied once; own state
│  ├─ shared/           # cross-environment/cross-version; own state
│  ├─ service/          # the service itself; state PER environment
│  └─ modules/          # reusable Terraform; no state of its own
├─ src/
├─ Taskfile.yaml
├─ AGENTS.md
└─ README.md

The Layer Model

Each layer is a separate stack because the resources inside them have different lifecycles and dependency directions. A resource belongs to a layer by two axes — how often it changes and what depends on what:

LayerA resource belongs here if…ExamplesState key
bootstrap/the other layers need it to function, it is applied once, and it cannot be created within the normal deploy flow (chicken-and-egg)CI OIDC roles (ci-plan, ci-deploy, ci-e2e); OIDC provider; root secretsservices/<svc>/... (own key)
shared/it is transversal to environments/versions, changes rarely, but is managed in the normal flow with the deploy rolecustom domain, ACM cert, Route53 recordservices/<svc>/shared/...
service/it is the service, re-applied on every deploy, parameterized per environmentLambdas, Step Functions, DynamoDB, S3, API Gateway, alarmsservices/<svc>/{ENV}-{API_VERSION}/...
modules/it is reusable code with no state, consumed by the layers abovethe lambda module

The decisive axis is dependency direction. The canonical bootstrap/ member is the ci-deploy role: CI uses it to apply service/, so it cannot be managed by service/ — it must exist before, created out-of-band (a human with admin credentials, once). bootstrap/ is an umbrella for any pre-deploy primitive, defined by this test — not by its current contents (CI roles today).

What Lives in .infra/

  • Terraform stacks for this repository's infrastructure, split into the layers above
  • Terraform modules specific to this repository (modules/)
  • Environment-specific variable files (service/pre.tfvars, service/prod.tfvars)
  • Infrastructure documentation specific to this repository

What Does NOT Live in .infra/

  • Shared Terraform modules (live in engineering-patterns or a dedicated modules repository)
  • Platform-wide infrastructure (lives in the central infra repository)
  • Application code or configuration
  • Deployment scripts (those belong in Taskfile or CI/CD)

The Central infra Repository Exception

The central infra repository is the ONLY exception to the .infra/ rule.

The infra repository:

  • Provisions foundational platform infrastructure
  • Does NOT follow the .infra/ convention
  • Organizes Terraform at the repository root or in top-level directories

Why This Exception Exists

The infra repository IS infrastructure. It doesn't contain application code that needs infrastructure; it defines the platform that other repositories build upon.

Example central infra repository structure:

infra/
├─ bootstrap/           # Backend initialization
│  ├─ init.sh          # Creates S3 bucket and DynamoDB table
│  └─ README.md
├─ global/             # Global Terraform configuration
│  ├─ main.tf
│  ├─ backend.tf
│  ├─ variables.tf
│  ├─ outputs.tf
│  ├─ route53/         # DNS management
│  ├─ iam/             # Identity and access management
│  ├─ cost-alerts/     # Budget monitoring
│  └─ amplify/         # Amplify applications (optional)
├─ utils/              # Reusable resources
│  ├─ policies/        # IAM policy documents
│  ├─ scripts/         # Utility scripts
│  └─ templates/       # Terraform templates
├─ Taskfile.yaml
├─ AGENTS.md
└─ README.md

Repository Categories

Application Repositories

Repositories that contain application code and need infrastructure:

  • Structure: Code in src/, infrastructure in .infra/
  • Purpose: Provision resources specific to this application
  • Examples: API services, web frontends, background workers

Infrastructure Repository

The central repository that provisions foundational platform infrastructure:

  • Structure: Terraform at root or top-level directories (bootstrap, global, utils)
  • Purpose: Bootstrap and manage the platform
  • Examples: DNS zones, IAM roles, cost alerts, Terraform backend

Library Repositories

Repositories that contain shared code but no infrastructure:

  • Structure: No .infra/ directory
  • Purpose: Provide reusable code to other repositories
  • Examples: Shared libraries, SDKs, utilities

Operating Infrastructure via Taskfile

Infrastructure operations MUST be exposed through Taskfile tasks:

For application repositories:

# Taskfile.yaml
infra:plan:
  desc: Show Terraform plan for infrastructure changes
  dir: .infra
  cmds:
    - terraform init
    - terraform plan

infra:apply:
  desc: Apply infrastructure changes
  dir: .infra
  preconditions:
    - sh: "[ -f .infra/terraform.tfvars ]"
      msg: "Missing terraform.tfvars. Copy from terraform.tfvars.example"
  cmds:
    - terraform init
    - terraform apply

infra:destroy:
  desc: Destroy infrastructure (use with caution)
  dir: .infra
  preconditions:
    - sh: "[ '{{.CONFIRM}}' = 'yes' ]"
      msg: "Must set CONFIRM=yes to destroy infrastructure"
  cmds:
    - terraform destroy

Usage:

task infra:plan
task infra:apply
CONFIRM=yes task infra:destroy

State Management

Terraform state MUST be stored remotely:

  • Use S3 + DynamoDB for state storage and locking
  • Never commit .tfstate files to version control
  • Document state backend configuration in .infra/backend.tf

Application repository backend configuration:

Each layer has its own state key under services/<svc>/. Stacks with a fixed key (bootstrap/, shared/) declare it in a backend.tf; the per-environment service/ stack uses a partial backend whose key the Taskfile injects at init.

# .infra/shared/backend.tf — fixed key
terraform {
  backend "s3" {
    bucket         = "ontopix-tfstate"
    key            = "services/my-service/shared/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    dynamodb_table = "ontopix-tflocks"
  }
}
# .infra/service/main.tf — partial backend, key injected per ENV
terraform {
  backend "s3" {}
}
# Taskfile infra:init supplies:
#   -backend-config="key=services/my-service/${ENV}-${API_VERSION}/terraform.tfstate"

Central infra repository backend configuration:

# global/backend.tf
terraform {
  backend "s3" {
    bucket         = "ontopix-tfstate"
    key            = "global/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    dynamodb_table = "ontopix-tflocks"
  }
}

Environment Separation

Environment separation is a property of the service/ layer, not of .infra/ as a whole. bootstrap/ and shared/ are environment-agnostic (one stack each); only service/ is applied once per environment.

service/ uses a partial backend (backend "s3" {}) whose state key is injected by the Taskfile per environment, combined with explicit .tfvars files:

.infra/service/
├─ main.tf        # backend "s3" {} — key supplied at init
├─ variables.tf
├─ pre.tfvars
└─ prod.tfvars
ENV=pre  task infra:init && ENV=pre  task infra:apply   # -> services/<svc>/pre-v1/terraform.tfstate
ENV=prod task infra:init && ENV=prod task infra:apply   # -> services/<svc>/prod-v1/terraform.tfstate

This gives complete state isolation between environments (a pre teardown cannot reach prod) while keeping one shared configuration. Terraform workspaces are NOT used for environment separation — separate state keys are the standard.

AI Agent Rules

Agents MUST request human approval before:

  • Running task infra:apply
  • Running task infra:destroy
  • Modifying Terraform configurations
  • Changing infrastructure state

Agents MAY:

  • Run task infra:plan to show proposed changes
  • Read Terraform configurations
  • Suggest infrastructure improvements

Agents MUST NOT:

  • Apply infrastructure changes without explicit human approval
  • Destroy infrastructure without explicit human approval
  • Modify production infrastructure without human oversight
  • Apply production infrastructure from non-master branches

Documentation in AGENTS.md

Repositories with infrastructure MUST document in AGENTS.md:

  • What infrastructure this repository provisions
  • How to view infrastructure plans (task infra:plan)
  • When infrastructure changes require human approval (always)
  • What infrastructure dependencies exist

Example for application repository:

## Infrastructure

This repository provisions:
- RDS PostgreSQL database
- ElastiCache Redis cluster
- S3 bucket for file storage
- IAM roles for service access

**View infrastructure plan:**

task infra:plan


**Apply infrastructure changes (requires human approval):**

task infra:apply


**⚠️ Agents: ALWAYS request human approval before running `infra:apply` or `infra:destroy`.**

Example for central infra repository:

## Infrastructure

This is the **central infrastructure repository** for Ontopix. It provisions foundational platform infrastructure:

- Route53 DNS management
- IAM roles and policies
- Cost alerts and budget monitoring
- Terraform state backend (S3 + DynamoDB)

**View infrastructure plan:**

task infra:plan


**Apply infrastructure changes (requires human approval):**

task infra:apply


**Initialize backend:**

task bootstrap:init


**⚠️ Agents: ALWAYS request human approval before running `infra:apply` or `infra:destroy`.**

This repository does NOT use the `.infra/` convention because it IS the infrastructure repository.

Module Reusability

Shared Terraform modules SHOULD live in:

  • The engineering-patterns repository (for Ontopix-standard patterns)
  • A dedicated Terraform modules repository (for complex shared modules)
  • The Terraform Registry (for open-source public modules)

Shared modules SHOULD NOT live in .infra/ of individual repositories.

Example module reference:

# .infra/main.tf
module "database" {
  source = "github.com/ontopix/engineering-patterns//terraform/modules/postgres?ref=v1.2.0"

  name        = "my-service-db"
  environment = var.environment
}

Security Considerations

Secrets Management

  • NEVER commit secrets to .infra/ configurations
  • Use AWS Secrets Manager, Parameter Store, or similar
  • Reference secrets in Terraform, don't define them

Least Privilege

  • Infrastructure MUST use least-privilege IAM policies
  • Service accounts MUST have minimal required permissions
  • Production infrastructure MUST require additional approval gates

Audit Trail

  • All infrastructure changes MUST be tracked in version control
  • Infrastructure changes SHOULD go through pull requests
  • Production changes MUST be reviewed by multiple engineers
  • Production changes MUST only be applied from the master branch

Rationale

The .infra/ convention exists because:

  • Infrastructure should live close to the code it supports
  • Each repository should own its infrastructure
  • Consistent location reduces cognitive load
  • Agents and humans know where to find infrastructure code

The central infra repository exception exists because:

  • The platform itself needs a home
  • Foundational infrastructure shouldn't be scattered
  • Bootstrapping requires a central coordination point

See decisions/adr-0004-infra-layout.md for the .infra/ convention decision, and decisions/adr-0015-infra-layer-model.md for the bootstrap/shared/service layer model and the deployment-rolesbootstrap rename.