To do the same thing using pure shell scripting, you have to write a large number of helper functions to avoid boilerplate. Things like asserting — in an idempotent way — the existence of files (with the right content, the right ownership and mode flags, etc.), services, packages and so on.
Tools like Ansible and Puppet already provide that set of functionality. If you write it yourself, you pretty much end up with something like Ansible, except it's specific only to your use case. Better to focus on commonality.
I'm a Puppet guy myself (although I appreciate the simplicity Ansible can bring to the table), and make extensive use not just of the primitives, but of the ability to bundle primitives as reusable modules. For example, rather than explicitly putting files in /etc/logrotate.d, I define a "logrotate class" and do:
logrotate::rotation {
postgresql:
pattern => "/var/log/postgresql/*.log",
keep_count => 10,
require => Package['postgresql'];
}
However, this is not merely a macro that is expanded in-place. Rather, it's an object which can be referenced (for example, I can have something else which requires that the object Logrotate::Rotation[postgresql] runs first) as well as "inventoried" (I can ask the system about all the log rotations that have been declared, and use that to drive a UI, for example).
The expressiveness and flexibility of tools like Ansible (which must have something similar) and Puppet is best seen in a multi-node server environment, however.