Organizing Ansible: A Deep Dive into Standard Directory Structure

Everyone starts the same way. A playbook, a hosts file, maybe a config, all sitting together in one directory. It works, too. There’s genuinely nothing wrong with a flat layout when you’re automating three tasks against one server.

The trouble arrives later. Two environments become four. Somebody adds a playbook with an IP address baked into it. Somebody else commits a password, notices, and commits a fix that doesn’t actually remove it from the history. Six months on, nobody’s quite sure which file is authoritative for the production database settings, and the honest answer is that three of them are.

The layout below is roughly what most teams land on eventually, usually after living through the version above. It’s worth understanding why it looks the way it does rather than copying it and hoping.

Every directory in it exists to keep one kind of thing away from another: data apart from logic, secrets apart from both, environments apart from each other. That’s the whole idea. Everything else is detail.

It’s also the structure sitting behind the stack we built in deploying a full-stack Node.js application on Ubuntu server, so if you want to see it carrying actual weight rather than sitting in a diagram, that walkthrough is the companion to this one.

The Big Picture

Here is the high-level shape. What’s worth noticing is how little lives at the top level, and how deliberately the pieces are held apart: data in the inventories, logic in playbooks/roles, secrets in the vault, and the application itself off to one side.

PROJECT13-ANSIBLE/
├── ansible/
│   ├── ansible.cfg       # The brain of the operation
│   ├── inventory/        # Environment definitions (local, staging, prod)
│   ├── playbooks/        # The "orchestrators"
│   ├── roles/            # The reusable "building blocks"
│   └── vault/            # Encrypted secrets
├── app/                  # Application source code
└── scripts/              # Helper utilities

1. ansible.cfg: The Control Center

Everything Ansible does is shaped by ansible.cfg. It’s the difference between a command you can type from memory and one that drags a dozen flags along behind it. Set your defaults once and every run picks them up.

Three settings carry most of the weight in this project:

  • inventory: points at a default inventory, so -i becomes something you type when you mean it rather than every single time.
  • roles_path: tells Ansible where roles live, which is what keeps the project root from filling up with directories.
  • ssh_connection: the performance one. pipelining = True plus SSH multiplexing through ControlMaster reuses connections instead of rebuilding them per task, and on a long playbook the difference is not subtle.

2. Inventory: Environments as First-Class Citizens

Hardcoding IP addresses into playbooks is the fastest way to end up with automation that only works on the machine it was written on. The Inventory directory is the fix for that, split by environment so each one owns its own hosts and its own variables.

inventory/
├── local/
│   ├── hosts.yml
│   └── group_vars/
│       ├── all.yml       # Variables for ALL local hosts
│       └── db.yml        # Variables specific to local DBs
├── production/
│   └── hosts.yml

The payoff is that a single playbook serves every environment. Same logic, different data, and the only thing changing on the command line is which inventory you point at:

# Deploy to local
ansible-playbook -i inventory/local/hosts.yml playbooks/site.yml

# Deploy to production
ansible-playbook -i inventory/production/hosts.yml playbooks/site.yml

The group_vars directory is where most of the real work happens. Variables attach to hosts by group membership, so local/group_vars/all.yml can set node_env: development while the production copy sets it to production, and the playbook never needs to know which one it’s running against.

This is also where the discipline earns its keep. If a value differs between environments, it belongs in group_vars. If it is the same everywhere, it belongs in the role. Getting that one split right is most of what separates a project that scales from one that slowly rots.

3. Playbooks: The Orchestrators

Playbooks should be boring. A file in playbooks/ maps hosts to roles and then stops. If you catch yourself writing real task logic in one, it almost certainly wants to live in a role instead.

Here is site.yml in full:

- name: Setup database server
  hosts: db
  roles:
    - postgresql

- name: Setup application servers
  hosts: app
  roles:
    - nodejs
    - app

It reads like a table of contents, which is the point. Where something happens gets answered here. How it happens gets answered in the role. Keeping those two questions in separate files is what makes a project navigable to somebody who didn’t write it, including you in a year.

4. Roles: Reusable Building Blocks

Roles are where the actual work lives, and they are the part worth spending time on. Under roles/:

  • common: the things every server needs regardless of its job. User creation, curl and git, timezones.
  • postgresql: installs Postgres, creates the appdb database and sorts out pg_hba.conf.
  • nodejs: Node.js 20.x and PM2, nothing more.
  • nginx: the reverse proxy in front of it all.

Every role follows the same internal shape: tasks/main.yml for the steps, templates/ for config files, handlers/ for anything triggered by a change. That sameness is not bureaucracy for its own sake. It means you can open a role you’ve never seen before and already know where to look.

It also makes replacement cheap. Swapping Nginx for Apache is a new role and a one-line change in the playbook, not an afternoon of unpicking what depends on what.

5. Ansible Vault: Keeping Secrets Safe

Passwords do not go in Git. Not in a private repo, not just for now, not with a note promising you’ll move them later. The vault/ directory holds encrypted YAML instead, and that’s the only place credentials live.

Decrypted, vault/local.yml looks like any other variables file:

vault_db_password: supersecretpassword
vault_jwt_secret: anothersecrettoken

Roles pull these in exactly like any other variable, using Jinja2 syntax such as {{ vault_db_password }}. You supply the vault password when you run the playbook and Ansible decrypts the values in memory, so the plaintext never lands on disk.

Worth being clear about the boundary, though. The vault protects secrets at rest in your repository. It doesn’t protect them from somebody who can already run your playbooks. That’s a genuinely different problem and it needs a different answer.

Summary

Four concerns, four homes. Configuration in ansible.cfg, data in the inventory, logic in roles, orchestration in playbooks. Nearly every structural argument you’ll ever have about an Ansible project traces back to something sitting in the wrong one of those four.

None of it matters much on day one, which is precisely why people skip it. It matters enormously on the day somebody who isn’t you has to deploy to production while you’re away. Set it up properly at the start and that day turns out to be uneventful.

Asif Khan

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.