Infrastructure as Code for Small Teams: Where Ansible and Docker Compose Actually Fit
Every infrastructure article ends with a Kubernetes diagram. Most small products never get there, and the teams that try anyway spend a quarter building a platform nobody asked for. The useful question is narrower: how do you make a server reproducible so the next one takes minutes instead of an afternoon?
That is a much smaller problem than it sounds, and the tooling for it is unglamorous.
The problem is real even with three servers
We run projects on a handful of VPS boxes. The moment you have a second environment — staging, a client box, a rebuild after a provider change — the tribal knowledge problem arrives. Someone knows that the queue worker needs a specific environment variable, that the cron entry is registered in two places, that the deploy user needs a specific key. None of that is in the repository, and all of it matters.
Infrastructure as code is not about scale. It is about the third machine. Before that, a shell script and a checklist genuinely are enough.
What each tool is actually for
Docker Compose: the application, not the server. Compose describes the containers of one application — web, worker, database, cache — and their wiring. It is the right tool for making a deploy a single docker compose up -d. It is not a server provisioner: it does not install a system user, configure fail2ban, set up TLS certificates or rotate logs.
Ansible: the server. A playbook describes the state of a machine — packages installed, service enabled, file with specific content, cron job present. Run it twice and the second run changes nothing. That idempotency is the whole point, and it is what makes the playbook worth writing instead of a list of commands to paste.
Plain shell scripts: the glue. Sometimes a playbook is overkill for one task, and a script is clearer. Keep those in the repository too.
The mistake is reaching for the biggest tool available. Pick the smallest one that covers the job.
Start with the part that hurts most
Do not begin by containerising everything. Begin with the setup step that takes the longest and breaks most often. Usually it is one of these:
- Initial server setup — creating the user, installing the base packages, opening exactly two ports.
- Application deployment — fetching the release, migrating, restarting services, verifying.
- Certificate renewal — a cron job that should never fail silently.
A playbook that covers just one of these is worth more than a half-finished platform.
A deploy playbook that reads like the runbook
Ansible playbooks are YAML, which makes them approachable, but the value is in the structure. A real deploy playbook looks like this:
- name: Deploy application
hosts: app
become: true
vars:
release_dir: "/srv/apps/{{ app_name }}/current"
tasks:
- name: Create release directory
ansible.builtin.file:
path: "{{ release_dir }}"
state: directory
mode: "0755"
- name: Extract release archive
ansible.builtin.unarchive:
src: "{{ release_file }}"
dest: "{{ release_dir }}"
remote_src: true
- name: Run database migrations
ansible.builtin.command:
cmd: "php artisan migrate --force"
chdir: "{{ release_dir }}"
environment:
APP_ENV: production
notify: Restart application
- name: Restart application
ansible.builtin.service:
name: "{{ app_name }}"
state: restarted
when: not ansible_check_mode
Three properties make this worth the afternoon it took:
- It is declarative. You state the desired end state, not the sequence of commands. Running it again on an already-deployed server is a no-op.
- It is reviewable. A change to the playbook shows up in a pull request. A change to a shell script someone runs by hand shows up nowhere.
- It is testable in check mode.
ansible-playbook --checkreports what would change without changing it. That one flag catches the majority of "it worked on my machine" deploy failures.
Idempotency is the property to design for
The rule that keeps a playbook honest: it must be safe to run twice. Most of the time that means "does the thing only if the thing is not already true".
- A file task with
state: presentand explicitmode:handles the file case. - A service task with
state: restartedis not idempotent by itself — pair it withnotify:and a handler so the restart only happens when something actually changed. - A command task is never idempotent. Use it only for things that genuinely must run (migrations), and understand the cost.
The temptation to reach for command: ... && echo or shell: is strong because it is familiar. Every such line is a step that will surprise you on the second run.
The secrets question has a boring answer
Ansible needs the deployment key. There are three ways to handle it, in increasing order of effort:
- An SSH key on your workstation, with the private key never leaving it. Works for a team of two, where someone is always around.
- A vault server (Ansible Vault) with the password in the keyring. One encrypted file in the repository, one command to decrypt it.
- A secrets manager (Vault, SOPS, cloud secret manager). The right answer once you have more than one environment and more than one person.
What not to do: put the key in the playbook, in a .env committed by accident, or in a shell script on the server. The playbook itself is in git — treat it as public.
Keep one honest state file
The most useful thing we added was a single inventory.ini per environment:
[web]
app1.example.com
app2.example.com
[worker]
app1.example.com
[app:vars]
app_name=myapp
release_dir=/srv/apps/myapp
The playbooks reference {{ app_name }} and never hardcode a hostname. When a new machine is needed, adding one line to the inventory is the whole change. It also makes the infrastructure visible in a pull request, which matters more than it sounds when the person who built it leaves.
Where a full platform is wasted effort
Kubernetes earns its cost when you have dozens of services, multiple teams, and a need for automatic horizontal scaling and self-healing. If you have one application with a web process, a worker and a database, a Kubernetes cluster gives you more ways to break the deployment than the app itself has components.
The honest middle ground for a small team: one VPS per environment, Docker Compose for the application, Ansible for the server, and a deploy script that runs the playbook, runs the health check, and rolls back when the check fails. That covers reproducibility, reviewability and a one-command rollback — which is most of what a platform team gives you, at a fraction of the maintenance.
Start with the third machine. Automate the thing that wastes the most afternoon. Everything else can wait.