Infrastructure Layout
Pattern for organizing infrastructure-as-code using the .infra/ convention in repositories.
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:
| Layer | A resource belongs here if… | Examples | State 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 secrets | services/<svc>/... (own key) |
shared/ | it is transversal to environments/versions, changes rarely, but is managed in the normal flow with the deploy role | custom domain, ACM cert, Route53 record | services/<svc>/shared/... |
service/ | it is the service, re-applied on every deploy, parameterized per environment | Lambdas, Step Functions, DynamoDB, S3, API Gateway, alarms | services/<svc>/{ENV}-{API_VERSION}/... |
modules/ | it is reusable code with no state, consumed by the layers above | the 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
infrarepository) - 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
.tfstatefiles 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:planto 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
masterbranch
Related Patterns
- Taskfile as Contract — Exposing infrastructure operations
- AI Agent Entrypoint — Agent rules for infrastructure
- Repository Structure — Where
.infra/lives - Lambda Deploy — OIDC role tiers provisioned by the
bootstrap/layer
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-roles → bootstrap rename.
GitHub Actions Workflows
Pattern for CI/CD workflow composition, authentication, and multi-environment deploy architecture.
Local AWS Sandbox
Pattern for local development environments that emulate AWS services using LocalStack, including sub-patterns for resource provisioning, Lambda networking, and workflow orchestration.