What SaltStack Actually Is
Salt is two things at once, and this trips people up at the start. It's a remote execution framework — you run commands across a fleet of servers in parallel — and it's a configuration management system that lets you declare what your servers should look like. Most tools pick one. Salt does both, and that's not just a marketing point; the two halves genuinely complement each other.
The architecture is master/minion. A central salt-master process talks to salt-minion agents running on every managed server. Minions connect outbound to the master on TCP ports 4505 (publish) and 4506 (return), which means your firewall rules go on the master, not the minions. The transport is ZeroMQ by default — fast, asynchronous, event-driven. When you run a command from the master, it publishes to a channel and all matched minions execute concurrently. Results come back to the master as they arrive, not in a batch at the end.
There's also a Salt SSH transport that runs agentless over SSH, which I'll mention but won't dwell on. It's useful for bootstrapping or one-off access to servers you don't want to fully manage, but it's slower and you lose the real-time event bus. For production management, run the minion daemon.
Installing Salt
The easiest path on Amazon Linux or CentOS 7 is the official bootstrap script:
curl -o bootstrap-salt.sh -L https://bootstrap.saltstack.com
sudo sh bootstrap-salt.sh
On the master server:
sudo yum install salt-master
sudo systemctl enable salt-master
sudo systemctl start salt-master
On each minion, you need to tell it where the master is. Edit /etc/salt/minion before starting the daemon:
master: salt-master.internal.example.com
Then start the minion:
sudo yum install salt-minion
sudo systemctl enable salt-minion
sudo systemctl start salt-minion
Key Exchange
When a minion starts for the first time, it generates an RSA keypair and sends its public key to the master for acceptance. Until you accept it, the minion can't receive commands. On the master:
salt-key -L # list pending, accepted, and rejected keys
salt-key -A # accept all pending keys
salt-key -a web01 # accept a specific minion by name
The minion ID defaults to the hostname. You can override it in /etc/salt/minion with id: myserver. Do this before the first start — changing the ID later means deleting the old key and re-accepting.
Your First Commands
Once keys are accepted:
salt '*' test.ping # verify all minions are reachable
salt 'web*' cmd.run 'uptime' # run a shell command on web servers
salt '*' grains.item os # get the OS grain from all minions
The first argument is the target expression. '*' matches everything. 'web*' is a glob. Results come back as a dictionary keyed by minion ID. If a minion doesn't respond within the timeout, it simply doesn't appear in the output — there's no failure noise by default, which is a gotcha when you're expecting a minion that's actually down.
cmd.run is an execution module function. Execution modules are for immediate, imperative actions: run this command, install this package, restart this service right now. They're not idempotent. They do the thing and return the result.
Grains
Grains are static facts about a minion — its OS, hostname, IP addresses, memory, CPU count. Salt discovers most of these automatically at startup. You can also set custom grains, which is where they become genuinely useful:
salt 'web01' grains.setval role webserver
salt 'db01' grains.setval role database
Now you can target by grain:
salt -G 'role:webserver' cmd.run 'nginx -t'
This is cleaner than maintaining a separate inventory file. The role lives on the minion itself.
States: Declarative Configuration
States are where Salt does configuration management. A state file is YAML, typically with a .sls extension, stored under /srv/salt/. Here's a state that installs nginx and ensures it's running:
nginx:
pkg.installed: []
service.running:
- enable: True
- require:
- pkg: nginx
The require declaration is important — it tells Salt to install the package before trying to start the service. State declarations are idempotent: if nginx is already installed and running, Salt does nothing.
The top file at /srv/salt/top.sls maps minions to states:
base:
'web*':
- nginx
'db*':
- postgresql
Apply everything defined for a minion in top.sls:
salt '*' state.highstate
Apply a specific state by name without going through top.sls:
salt 'web01' state.apply nginx
For testing on the minion itself without involving the master:
salt-call --local state.apply nginx
This is invaluable when you're writing a new state and don't want to loop through the master on every iteration.
A Real State: Jinja2 Templates and Pillar
A state that deploys an nginx config from a template and reloads nginx if the file changes:
/etc/nginx/conf.d/myapp.conf:
file.managed:
- source: salt://nginx/myapp.conf.j2
- template: jinja
- context:
server_name: {{ pillar['app_server_name'] }}
upstream_host: {{ pillar['upstream_host'] }}
- watch_in:
- service: nginx
The watch_in tells the nginx service to reload if this file changes. pillar['app_server_name'] pulls a value from pillar — Salt's secure configuration data store. The Jinja2 template itself looks like a normal nginx config with {{ server_name }} placeholders.
The salt:// prefix refers to the salt fileserver root, which defaults to /srv/salt/. You can use it in any state to reference files relative to that root.
How Salt Compares to Puppet and Chef
In 2015 the main alternatives were Puppet and Chef. Both have a steeper initial setup curve. Puppet's DSL is its own language — it's powerful but you're learning something new. Chef is Ruby, which is flexible but means your ops team needs Ruby comfort.
Salt's states are YAML with Jinja2 templating. Most people can read a state file on day one. The execution module side — ad-hoc commands — has no real equivalent in Puppet or Chef. With Salt, you get config management and a fast remote shell in the same tool. The event bus and reactor system (more on that later) enable automation patterns that aren't easy to replicate elsewhere.
The real gotcha early on is understanding that execution modules and state modules are different things. salt '*' pkg.install nginx installs nginx right now, unconditionally. salt '*' state.apply nginx runs a state that declares nginx should be installed — idempotent, with dependency resolution, logging, and change tracking. Both are useful. Reach for states when you want a permanent declaration of how a server should be configured. Reach for execution modules when you need to do something right now.
Start with a single master, a handful of minions, and a state that manages something real — a package, a config file, a service. Get comfortable with state.apply and salt-call --local before reaching for orchestration. Salt's complexity ceiling is high, but the floor is genuinely approachable.
