The previous lesson decided where repository boundaries go. This one decides what happens inside one.
The tool-specific layouts — Terraform, Kubernetes, Ansible — each solve their own problem. A repository holding several of them needs a layer above that, and the decisions are about ownership and discoverability rather than about any tool.
A starting shape
Section titled “A starting shape”platform/├── infrastructure/ # Cloud resources — Terraform or OpenTofu│ ├── modules/│ └── environments/├── clusters/ # Per-cluster GitOps configuration│ ├── dev/│ ├── staging/│ └── production/├── applications/ # Deployment configuration per application│ ├── api/│ └── web/├── platform/ # Shared components — ingress, monitoring, certs├── policies/ # Policy as code├── configuration/ # Ansible, if the estate has hosts├── scripts/ # Operational tooling├── docs/│ ├── runbooks/│ └── decisions/└── .github/ ├── CODEOWNERS ├── workflows/ └── ISSUE_TEMPLATE/This is a starting point, not a prescription. What matters is the reasoning behind each top-level directory.
The organising questions
Section titled “The organising questions”Four questions produce the tree.
What changes together? Things that change in the same pull request belong near each other. An application’s deployment configuration and its environment overlays change together; its cloud infrastructure usually does not.
What has the same owner? Ownership is what CODEOWNERS expresses, and it expresses it by path. A directory with two owners is a directory that will produce ambiguous reviews.
What has the same blast radius? Cluster-scoped Kubernetes resources, IAM policy and networking affect everything. They belong together and apart from things that do not.
What has the same lifecycle? Infrastructure changes monthly; application deployments change daily. Mixing them means every application deploy touches a directory with infrastructure’s review requirements.
Where those four agree, you have a directory. Where they disagree, you have a decision — and the usual tiebreak is ownership, because that is the one that produces friction every single day. A boundary that gets blast radius right and ownership wrong means every change needs the wrong reviewer, which is a cost paid per pull request forever. A boundary that gets ownership right and blast radius slightly wrong means an occasional over-broad change, which is a cost paid rarely.
Top-level directories, and why
Section titled “Top-level directories, and why”infrastructure/ — cloud resources. Terraform or OpenTofu, structured as that lesson describes. Changes here are the highest-blast-radius in the repository.
clusters/ — what runs in each cluster, as thin references. A cluster directory says which applications and platform components belong there; it does not contain their configuration.
applications/ — per-application deployment configuration, with environment overlays. Owned by the teams that own the applications.
platform/ — shared cluster components: ingress controllers, certificate management, monitoring, service mesh. Different owner and different change cadence from applications, and a broken one affects everything behind it rather than one service. These also have ordering dependencies that applications generally do not — certificate management must exist before anything requests a certificate — which is a second reason to keep them separate and reconcile them first.
policies/ — the rules changes must satisfy. Version-controlled and reviewed like everything else, and covered in policy as code.
configuration/ — Ansible, where the estate has hosts. Separate because its execution model is entirely different from everything else here: imperative, push-based, and run deliberately by a person rather than reconciled by a controller. Mixing it into directories organised around declarative tooling confuses both.
scripts/ — operational tooling. Worth keeping small; a scripts/ directory that has grown to forty files is a tool that has not been packaged.
docs/ — runbooks and decision records. The two kinds of documentation that survive contact with reality.
.github/ — workflows, CODEOWNERS, issue and pull request templates. This is the highest-consequence directory in the repository: it decides what automation runs, with what permissions, and who must approve anything. It is also the one most often left without a code owner, which means anybody who can open a pull request can propose a change to what your automation does.
The clusters/ indirection
Section titled “The clusters/ indirection”Worth explaining because it is not obvious and it earns its place.
clusters/production/├── flux-system/ # Or Argo CD Applications├── platform.yaml # Reconciles platform/└── applications.yaml # Reconciles applications/, depends on platformThe cluster directory references; it does not contain. Adding an application to production is a one-file change here. Changing how the application is configured is a change under applications/.
Two actions, two reviewers, two levels of risk. “Should this run in production at all” is a different question from “what should its memory limit be”, and separating them means the first gets asked explicitly rather than being bundled into the second.
It also scopes the reconciler. A controller pointed at clusters/production/ reconciles what that directory references and nothing else — which is what stops one cluster applying another’s configuration.
Ownership through paths
Section titled “Ownership through paths”CODEOWNERS is where the structure becomes operational.
# Default — the platform team sees everything not otherwise owned* @example-org/platform
# Highest consequence/.github/ @example-org/platform-leads/.github/workflows/ @example-org/platform-leads/policies/ @example-org/platform-leads @example-org/security
# Infrastructure/infrastructure/modules/ @example-org/platform/infrastructure/environments/production/ @example-org/platform-leads
# Networking and identity need their specialists/infrastructure/**/network* @example-org/network/infrastructure/**/iam* @example-org/security
# Applications belong to their teams/applications/api/ @example-org/api-team/applications/web/ @example-org/web-team
# Clusters — deciding what runs where/clusters/production/ @example-org/platform-leadsOrder matters: later patterns win. A general rule first, specific overrides after.
CODEOWNERS routes; it does not enforce. “Require review from Code Owners” in a ruleset is what makes it a control, and teams routinely add the file and assume they got the control. The dedicated lesson covers the pairing.
Every path should have an owner. The default * rule ensures that, and a path whose only owner is the catch-all is one worth looking at.
The structure should make this file short. If CODEOWNERS needs thirty rules with complex globs, the directory tree does not match the ownership map — and the tree is easier to change than the organisation.
CI scope
Section titled “CI scope”The layout determines what a pipeline has to do.
Path filters per workflow. A change under applications/api/ should not run the Terraform pipeline.
Change detection for the expensive checks. Planning every root module on every pull request trains reviewers to skim.
Shared paths trigger broadly. A change to infrastructure/modules/ affects every consumer; a change to policies/ affects every check. Path detection alone misses this, and the crude fallback — run everything when shared paths change — is much harder to get wrong than a dependency map you must maintain.
Reusable workflows for the repeated parts. The Terraform pipeline for dev and production is one definition with different inputs.
The general principle: pipeline complexity is inversely proportional to how much it runs. Running everything is simple and slow. Running precisely what changed is fast and is a system you now own. Start simple; add detection when duration becomes a genuine complaint.
Documentation that survives
Section titled “Documentation that survives”Two kinds, and both are worth the effort.
Runbooks — how to do the operational things. How to add an environment, how to rotate a credential, how to recover a cluster, what to do when the reconciler is stuck. Written for somebody at 3am who did not build it.
Decision records — why the structure is what it is. Why three availability zones. Why production is a separate account. Why this module is duplicated rather than shared. Dated, short, and the thing that ends an annual re-litigation in thirty seconds.
A root README answering four questions: what this repository manages, how to make a change safely, what CI does automatically, and who to ask.
Not architecture diagrams that must be hand-maintained. They are wrong within a quarter and nobody trusts them afterwards.
The test: could somebody who has never seen this repository make a small, safe change to development without asking a question? Whatever they hit first is what to document next.
Rulesets scoped to paths
Section titled “Rulesets scoped to paths”CODEOWNERS decides who reviews. A ruleset decides what is required, and it can differ by path.
Production paths get more. Two approving reviews, a code owner among them, required status checks, no bypass.
Development paths get less. One approval, or none for a repository where everybody is trusted with development.
.github/ gets the most. A change to a workflow changes what runs with what permissions across the whole repository, and it deserves the strictest rule you have.
Why this matters structurally: it is one of the strongest arguments for a monorepo, because it recovers much of what a separate production repository would give you. What it does not recover is read access — a ruleset controls who must approve, not who can see.
Check the bypass list. A ruleset with a long bypass list describes an intention rather than a control, and administrators are frequently on it by default.
Verify skipped-check behaviour. A path filter meaning a required check does not run can, depending on configuration, leave a merge unblocked. Test it on your own repository rather than assuming, and prefer running the workflow always with an early exit inside the job.
Scripts and tooling
Section titled “Scripts and tooling”Every infrastructure repository accumulates operational tooling, and how it is treated determines whether it stays useful.
Small scripts belong in scripts/, with a comment at the top saying what they do and who to ask.
A scripts/ directory of forty files is a tool that has not been packaged. At that point it wants a README at minimum, and probably its own repository with a release process.
Scripts that CI depends on are code, with the same review requirements as anything else the pipeline runs. A workflow calling scripts/deploy.sh means that file has the workflow’s permissions.
Make them executable and give them a shebang. A script somebody has to know to run with bash is a script somebody will run with the wrong shell.
Fail loudly. set -euo pipefail at the top of every shell script in an infrastructure repository. A script that silently continues after a failed step is one that reports success having done half its job.
Test the ones that matter. A script that computes which environments to deploy to is logic, and logic deserves a test.
Delete the ones nobody runs. A scripts/ directory is where abandoned tooling goes to be mistaken for current tooling, and a script that has not been run in two years will not work when somebody tries.
Anti-patterns
Section titled “Anti-patterns”A directory per tool. terraform/, kubernetes/, ansible/ at the top level looks tidy and cuts across ownership: the API team’s Terraform and the API team’s manifests end up in different branches of the tree, so a change to one service touches two distant directories and CODEOWNERS cannot express that one team owns both. It is the single most common structural mistake, because it is the organisation that occurs to people first.
A directory per environment at the top level. production/ containing everything — infrastructure, applications, platform components — means every production change touches one directory regardless of what it is. Ownership cannot be expressed, because the network team and the API team both change things under it, and a ruleset scoped to that path applies the same requirements to a firewall rule and a memory limit.
Numeric ordering prefixes. 01-network/, 02-compute/ encodes a dependency order in the filesystem, which becomes wrong when the order changes and misleading when somebody inserts 015-dns/. Dependencies belong in the configuration.
A deep top level. More than five or six directories a reader cannot hold in their head. Depth below is fine; breadth at the root is not.
common/, shared/, misc/. Directories named after their lack of a defining property accumulate everything and are owned by nobody.
Generated content committed without saying so. A directory that a script produces, indistinguishable from one people write.
Growing into it
Section titled “Growing into it”Nobody starts here, and starting here is a mistake.
Start with what you have. One tool, one environment, a flat directory. That is a correct structure for a small estate.
Add a directory when something genuinely does not fit. A second tool, a second environment, a shared module.
Add CODEOWNERS when the second team appears. Before that it is ceremony.
Add change detection when the pipeline is slow enough to complain about. Not before — it is a system you maintain, and maintaining one nobody needed is pure cost.
Add docs/decisions/ the first time somebody re-asks a settled question. That is the signal.
Refactor when the four organising questions stop agreeing. A directory with two owners, or one whose contents change on wildly different cadences, is a directory to split. Both are visible in the pull request history: look for directories that appear in pull requests from two different teams, or that appear both daily and quarterly.
The failure to avoid is building this tree on day one for a two-person team. Empty directories with elaborate conventions are overhead, and the conventions will be wrong because they were designed before anybody knew what the estate would look like.
Making it navigable
Section titled “Making it navigable”Discovery is the cost a large repository imposes on everybody, every day, and it is addressable with an hour of writing.
A root README with four sections: what this manages, how to make a change, what CI does, who to ask. Four paragraphs, and it is the highest-return documentation in the repository.
A README per top-level directory, one paragraph each, saying what belongs there. This is what stops the next person putting something in the wrong place.
Consistent naming across the tree. One word per environment — production, not prod here and production there. It appears in directory names, cluster names, namespace names, GitHub environment names, ruleset names and CODEOWNERS patterns, and an inconsistency between any two of them is a place somebody targets the wrong thing.
Name things after what they are, not what tool made them. applications/ survives a change of deployment tool; helm/ does not.
Avoid abbreviations that only the original authors expand correctly.
Keep the depth reasonable. Four levels to reach a configuration file is navigable; seven is not, and the deep ones are usually a sign that a directory is doing two jobs.
The test worth running on somebody else: ask a person unfamiliar with the repository to find where a specific thing is configured, and time it. Anything over a minute is a structure problem or a documentation problem, and both are cheap to fix compared with what they cost daily.
Common mistakes
Section titled “Common mistakes”Organising by tool rather than by ownership. Cuts across the boundary that produces daily friction.
A top level nobody can hold in their head. Discovery fails.
CODEOWNERS without the ruleset. Routing, not enforcement.
A shared/ directory. Accumulates everything, owned by nobody.
Numeric prefixes encoding dependencies. Wrong the moment the order changes.
Committing generated content without marking it. Nobody can tell what to edit.
Building the full tree before the estate needs it. Conventions designed in ignorance.
No decision records. The same questions re-litigated annually.
Path filters that let a required check be skipped. Verify the behaviour rather than assuming.
Where the boundaries between tools go
Section titled “Where the boundaries between tools go”A repository holding Terraform, Kubernetes manifests and Ansible has three tools with different execution models, and the seams between them are where the interesting decisions are.
Terraform provisions; Kubernetes reconciles; Ansible configures hosts. They overlap at the edges and the overlap is where duplication creeps in.
Who creates the cluster? Terraform, almost always. That means the cluster’s existence is in infrastructure/ and everything running in it is in clusters/ — two directories, two tools, one dependency.
Who creates the namespaces? Either, and picking one matters. Terraform creating namespaces means the GitOps controller does not manage them, which is a boundary somebody must know about. The GitOps controller creating them is usually cleaner.
Who manages cloud resources an application needs? A database, a bucket, a queue. Terraform is the conventional answer; a Kubernetes operator representing cloud resources as custom resources is the other, and it brings them under the reconciliation loop. Both work; running both for the same resource does not.
How does one tool learn what another created? A VPC ID, a database endpoint, a cluster certificate. The options are the same as within Terraform: remote state, provider data sources, an explicit value, or a published contract. The last scales best.
The rule that avoids the worst outcomes: each resource has exactly one tool that manages it, and it is written down. Two tools managing one thing produces the same fighting as two controllers on one Kubernetes field, with a longer feedback loop and no status to report it.
Templates and consistency
Section titled “Templates and consistency”Once several repositories share a shape, keeping them consistent is its own problem.
Repository templates give a new repository the right structure, workflows, CODEOWNERS skeleton and README on creation. That is the cheapest possible enforcement, and it works only for new repositories.
Existing repositories drift. A template updated six months after a repository was created does not reach it, and nothing reports the gap.
The options for keeping them aligned:
A scheduled check that reports which repositories are missing an expected workflow, a CODEOWNERS file, or a required ruleset. Reporting only; it does not change anything.
Automated pull requests from a central definition, which teams review and merge. More work to build, and it produces a queue that teams can act on rather than a report nobody reads.
Organisation-level rulesets, where the platform supports them, applying rules across repositories without touching each one. The strongest option for anything that must hold, and it applies to rules rather than to files.
Reusable workflows so the pipeline is one definition rather than N copies. The copies are what drift; a three-line caller does not.
What to standardise and what to leave alone:
Standardise the security-relevant parts — required checks, .github/ ownership, secret handling. Leave the directory structure inside a team’s own repository to that team, within a stated convention. A platform team that mandates every detail becomes a bottleneck, and the details it mandates will be wrong for somebody.
Mental model
Section titled “Mental model”The directory tree is where ownership, blast radius, change cadence and CI scope become legible. When those four agree about a boundary, put a directory there. When they disagree, ownership wins — because it is the one that produces friction every day.
Everything else follows: CODEOWNERS maps onto the tree, rulesets map onto CODEOWNERS, pipelines scope to paths, and a reader can navigate it because the structure reflects how the work is actually divided.
What you learned
Section titled “What you learned”- Four questions produce the tree: what changes together, same owner, same blast radius, same lifecycle
- Where they disagree, ownership wins
clusters/references andapplications/configures — two actions, two reviewersCODEOWNERSroutes; a ruleset enforces; teams routinely add the first and assume the second- A
CODEOWNERSfile needing thirty complex rules means the tree does not match the ownership map - Organise by ownership, not by tool
- Runbooks and decision records are the documentation that survives
- Grow into the structure; building it in advance encodes conventions designed in ignorance
Exercise
Section titled “Exercise”Use your own infrastructure repository, or design one for a hypothetical estate.
-
List every top-level directory and name its owner. Predict: how many have more than one?
-
For each, note the typical change cadence. Predict: are any mixing daily and monthly changes?
-
Write the
CODEOWNERSfile the structure implies. How many rules does it take? -
Take the last twenty pull requests. How many touched more than one top-level directory?
-
For each workflow, note which paths trigger it and which paths should. Predict: any gaps?
-
Find a directory whose contents a script generates. Is that obvious from its name or a README?
-
Ask somebody unfamiliar with it to find where a specific thing is configured. Time it.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.