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-ibecomes 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 = Trueplus SSH multiplexing throughControlMasterreuses 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,
curlandgit, timezones. - postgresql: installs Postgres, creates the
appdbdatabase and sorts outpg_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.