I've used all three in the past.

Chef was the first I used, many years ago. It's...complex. Despite being Just Ruby, the DSL takes quite a lot to learn, and I don't like that Chef is imperative rather than declarative. It also just seemed crazy verbose. It felt like it took ages to get my configurations right. For example, here's a community cookbook to install and setup NTP: https://github.com/chef-cookbooks/ntp/blob/master/recipes/de...

I tried Puppet after Chef. It was better, but I always seemed to be spending more time fighting it than actually getting stuff done with it. It suffered from the inability to push states to boxes at will. It's probably more pure to do it periodically via cron, but when you have stuff to get done, sometimes you just want your state applied to a machine. Configuration was less complex than Chef, but still quite verbose. Because box state is "all or nothing" you end up with a pretty slow management system that felt really subject to bitrot. On the subject of complexity, here's the community NTP module: https://github.com/puppetlabs/puppetlabs-ntp

I like Ansible a lot. No real complaints about it, except that Salt is "Ansible++" with the command channel, reactor system, etc.

Salt's just YAML, like Ansible. You have minimal logic available in YAML via Jinja, but it's discouraged by design; your state declarations are meant to _describe_ the state of the system, rather than to execute a series of explicit steps. Salt figures out how to bring them into compliance. You aren't meant to program in it, you're meant to describe in it. This makes states very straightforward and easy to interpret.

By comparison to the first two, here's a similar formula for Salt: https://github.com/saltstack-formulas/ntp-formula/tree/maste.... Terse, simple, straightfoward. ntp/init.sls is the whole state that gets run - it just installs the package, marks the service as enabled, starts the service, and if there's a config defined, installs it. Templates can be colocated with the state definitions. Nothing too fancy, just yaml and plaintext configs. I can apply just that single state to a box (salt mybox state.apply ntp) or set of boxes (salt -G "os:Ubuntu" state.apply ntp) or I can do a pull from the box (on mybox: # salt-call state.apply ntp). I don't have to run the 479 states that apply to the box in sum; this makes incremental management of boxes very quick.

You can use salt like Ansible via salt-ssh (shells into target box, executes states), like Puppet or Chef (operating in master mode, with agents that connect and execute states), you have the active command channel to your fleet, and you can do event-driven stuff with it, too. For example. we provision and renew LetsEncrypt wildcard certs on our Salt master. We have a Reactor set up that that watches for when a cert (on the master) is updated, which finds all machines subscribed to that cert and executes our `ssl.certs` state, which pushes the new cert out and executes any service reloads necessary to get the renewed cert into play. This lets us keep a small set of certs/renewals in a centralized location rather than having to worry about a bunch of scattered LE renewals all over the fleet. When we have to provision a new machine or a new service, we can just subscribe to the desired cert and we're off to the races - no need to do the LE provisioning each time we salt a new box.

There's a lot to love about it. It's not without its bugs, but the Saltstack project and team are very active, and the project itself is Python, which is easy to read and write. I've had to debug things a number of times, but it's never too difficult.

Been using Salt for years now, and I still think it's the best in class out there.