Cloud-init, Ansible and shell scripts are usually presented as three ways to do the same job. They aren’t. Each one has a different failure mode, a different cost at boot, and a different debugging experience, and picking the wrong one for a single VPS costs more in maintenance than it saves in setup time. This is a decision matrix for the specific case of one or two servers where you control the whole lifecycle — not a fleet, not a managed platform. For hardware, start with the VPS plans that support custom cloud-init user-data before you design the workflow.
The Four Axes That Actually Decide It
Option descriptions are useless because all three options can “install nginx.” What separates them is behaviour under conditions that are not the happy path: running twice, running after a partial failure, running with a secret in scope, and running when something breaks at 2 a.m. Score each candidate on those four and the choice usually makes itself.
| Axis | cloud-init | Ansible | Shell script |
|---|---|---|---|
| Idempotency | None (runs once) | Strong, per-task | Manual, per-line |
| Secrets handling | Weak — user-data is readable at metadata endpoint | Good — vault, vars_prompt, no_log | Bad — args in process list, files in repo |
| Failure recovery | Silent partial state | Per-task failure, rerun from failed task | Depends on set -e discipline |
| Boot time cost | Seconds to minutes, blocks boot | Zero at boot, N seconds per run | Zero at boot, seconds per run |
| Debuggability | Poor — one blob, retried blindly | Good — task names, -v, check mode |
Good locally, poor remotely |
| Requires agent | No | No (SSH + Python) | No (SSH + sh) |
Idempotency: The Real Cost of Running Twice
An idempotent operation produces the same state whether it runs once or five times. Cloud-init does not do this — user-data runs on first boot and, by design, is not replayed. That is fine when the payload is “create user, install packages.” It is not fine when it’s “append to /etc/sysctl.conf,” because on a reimage you get a duplicate line and on a repair run you get nothing at all.
Shell scripts are idempotent only if you write them that way, line by line, and people rarely do:
# Not idempotent — second run duplicates the line
echo 'net.core.somaxconn = 4096' >> /etc/sysctl.conf
# Idempotent
grep -qxF 'net.core.somaxconn = 4096' /etc/sysctl.conf \
|| echo 'net.core.somaxconn = 4096' >> /etc/sysctl.conf
sysctl -p
With four or five such lines, the shell version is fine and you can read it in full. With forty, the guard clauses outnumber the intent and someone eventually forgets one. Ansible expresses the same thing declaratively and checks state for you, which is why it survives contact with a second run.
Secrets: Where Shell and Cloud-Init Both Leak
Cloud-init user-data is not private. On AWS, GCP and most VPS panels, the blob is retrievable over the metadata service and often visible in the provider’s API and console. Anything you put there — a database password, a deploy key, an API token — should be assumed readable by anyone with instance metadata access. The correct pattern is to pass a short-lived token or nothing at all, and fetch real secrets from Vault, SSM Parameter Store, or a file you place over SSH after boot.
Shell scripts leak differently: a password passed as an argument appears in ps aux and in shell history. Ansible is the only one of the three with a first-class answer — no_log: true on the task, ansible-vault on the variable file, and diff output suppressed for templated secrets. If your provisioning touches real credentials, that alone narrows the choice.
- name: Write app env
template:
src: env.j2
dest: /opt/app/.env
mode: "0600"
owner: app
no_log: true
Failure Recovery: Partial State Is the Enemy
Ask what happens when the network drops thirty seconds into provisioning. A cloud-init run that failed halfway leaves a booted machine with half the packages installed and no clear signal that anything went wrong — the log exists at /var/log/cloud-init-output.log, but nothing retries and nothing alerts. A shell script with set -euo pipefail stops at the first error, which is better, but rerunning it re-executes everything from the top, including the steps that already succeeded. Ansible stops at the failed task and a rerun picks up from there, because every preceding task re-checks state and reports ok.
For a single VPS, “reimage and start over” is often cheaper than a repair. That is a legitimate argument for a dumb script: if the box is disposable, the recovery procedure is destroy && rebuild, and partial state doesn’t matter. It stops being legitimate the moment the box holds data you can’t rebuild.
Boot Time Cost
Cloud-init blocks until user-data finishes. Installing a LEMP stack with a package upgrade takes 90–300 seconds, during which the instance is up but not serving. On a VPS you rent by the month that’s irrelevant; on an autoscaling group or a spot instance it directly extends the time your load balancer has capacity gaps. Measure it rather than guessing:
cloud-init analyze show --long
systemd-analyze blame | head -15
journalctl -u cloud-init --no-pager | tail -30
A common split: cloud-init does the minimum that must exist before SSH is usable (users, keys, hostname, firewall rules) and finishes in under fifteen seconds. Ansible, invoked from the control machine immediately after the instance reports ready, does everything else. You get fast boot and idempotent configuration, at the cost of a two-stage pipeline you have to sequence correctly.
Debugging: What You See When It Breaks
Ansible’s advantage is not speed, it’s the failure message. Each task has a name, each failure prints the target, the module and the changed state, and --check --diff lets you preview changes without applying them. On a single VPS you will debug provisioning maybe three times in the server’s life — but each of those times is painful under a shell script that died silently on line 27 of 90 with exit code 1 and no output.
ansible-playbook -i inventory.ini site.yml --check --diff
ansible-playbook -i inventory.ini site.yml -vvv --start-at-task="Install nginx"
The counterargument for shell is real: a shell script is the only one of the three that runs on any Linux host with no Python, no cloud-init, no SSH control machine. A rescue container, a stripped Alpine image, a box where cloud-init was removed — shell is what’s left.
When Shell Is Genuinely Correct
- Single-use bootstrap that must run before anything else exists — partitioning, filesystem creation, moving
/varto a volume. Ansible needs Python; shell needs/bin/sh. - Inside a container image build — Dockerfile
RUNlines are shell. Ansible in a build stage is a layer-size mistake. - Disposable instances under 60 seconds of setup — four package installs and a systemd unit. Writing a playbook with roles and handlers for this is overhead, not engineering.
- Rescue and recovery — when the box is broken and Python may be broken too.
- Vendor installer glue — piping a signed install script that already handles its own idempotency.
The Matrix Applied to One Server
For a single long-lived VPS holding real data: cloud-init sets the hostname, adds your SSH key, disables root login and installs Python — 20 lines, 10 seconds. Ansible owns everything after that, in one playbook with three roles, run from your laptop and re-run after every change. No shell script at all, unless a specific step genuinely cannot be expressed in a module.
For a box you rebuild weekly for testing: a shell script with set -euo pipefail, stored in the repo next to the app. Idempotency is irrelevant if you always start clean, and a 60-line script you can read in one screen beats a playbook you have to remember how to run.
The mistake in both directions is the same: reaching for the heavier tool because it looks more professional, or the lighter one because YAML is annoying. Decide from the four axes above, write down the decision, and revisit it when the server stops being disposable. If you’re still choosing hardware, the VPS options with snapshot and rebuild support determine how cheap it is to get the recovery path wrong.




Leave a Reply
You must be logged in to post a comment.