Blog
April 8, 2015 Marie H.

Getting Started with SaltStack

Getting Started with SaltStack

Photo by <a href="https://www.pexels.com/@brett-sayles" target="_blank" rel="noopener">Brett Sayles</a> on <a href="https://www.pexels.com" target="_blank" rel="noopener">Pexels</a>

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.