Getting Started

Your first Salt state.

Salt is installed. The masters are talking to the data layer. Now what? This is what you do next. A hands-on tutorial that picks up exactly where the install guide ends.

v1.2 · Updated 2026-05-09

What you'll do

  • Create the folder structure Salt expects under /srv/salt and /srv/salt/pillar.
  • Write the two top files that tell Salt which states apply to which minions.
  • Write your first state for Windows — make a folder, drop a file in it.
  • Write your first state for Linux — same idea, native paths.

One mental shift before you start. Stop thinking "install nginx on the minion." Start thinking "nginx should be installed on the minion." Salt is declarative — you describe the desired state, Salt makes it true and keeps it that way. From here on, manage minions from the master. Stop SSHing into them.

Five steps. Run them on master 1.

SSH to your Salt master from the install guide. Become root. Then walk through these five steps in order. Everything happens under /srv/salt and /srv/salt/pillar.

The full module reference lives at docs.saltproject.io. Every module we touch here has dozens more options. We're showing the patterns we actually run in production — when you need the full surface area of file.managed, pkg.installed, or anything else, that's the canonical reference. We've linked the modules we use at the bottom of this page ↓.

01

Set up the folders

Create the two roots Salt reads from. /srv/salt holds your states. /srv/salt/pillar holds your config data.

# on master 1, as root
sudo su
cd /srv/salt
mkdir -p windows linux pillar

# final tree
tree /srv/salt
#  /srv/salt
#  ├── linux/
#  ├── pillar/
#  └── windows/
02

Create /srv/salt/top.sls

The top file is Salt's routing table. It says "for each minion, which states apply?" Right now we'll send the Windows state to Windows boxes and the Linux state to everything else.

# /srv/salt/top.sls
base:
  'os_family:Windows':
    - match: grain
    - windows.hello

  'os_family:RedHat':
    - match: grain
    - linux.hello
03

Create /srv/salt/pillar/top.sls

Same idea, but for pillar data. Empty for now — we'll add data here in a later section.

# /srv/salt/pillar/top.sls
base:
  '*': []
04

First Windows state Windows

Create a folder at C:\saltdemo and a text file inside it. The simplest possible thing — but it proves Salt can write to a Windows box.

# create the file
vi /srv/salt/windows/hello.sls
# /srv/salt/windows/hello.sls
hello_folder:
  file.directory:
    - name: C:\saltdemo
    - makedirs: True

hello_file:
  file.managed:
    - name: C:\saltdemo\hello.txt
    - contents: |
        Hello from Salt — Windows edition.
        This file came from /srv/salt/windows/hello.sls
    - require:
      - file: hello_folder

Apply it

sudo salt '<your minion id>' state.apply windows.hello

→ Should create C:\saltdemo\hello.txt. Replace <your minion id> with your Windows minion's actual ID (whatever you set in the install guide). Re-run it — second run reports "no changes."

05

First Linux state Linux

# create the file
vi /srv/salt/linux/hello.sls
# /srv/salt/linux/hello.sls
hello_folder:
  file.directory:
    - name: /opt/saltdemo
    - makedirs: True
    - mode: 755

hello_file:
  file.managed:
    - name: /opt/saltdemo/hello.txt
    - mode: 644
    - contents: |
        Hello from Salt — Linux edition.
        This file came from /srv/salt/linux/hello.sls
    - require:
      - file: hello_folder

Apply it

sudo salt '<your minion id>' state.apply linux.hello

→ Creates /opt/saltdemo/hello.txt on the target minion. Idempotent — re-run safely.

Why two states? Same intent (folder + file), different operating system, different paths. Same Salt module (file.directory, file.managed) — Salt translates to whatever the OS needs underneath. That cross-platform parity is one of the reasons Salt exists.

Real-world states. Going further.

Five patterns you'll reach for in week one. Four for Windows, one for Linux. Each one is a self-contained .sls file you can drop in the right folder and run.

Windows
06

Run PowerShell inline

For one-liners and short snippets. The unless: clause makes cmd.run idempotent — only runs if the condition is false.

# /srv/salt/windows/hostname.sls
record_hostname:
  cmd.run:
    - name: '$env:COMPUTERNAME | Out-File C:\saltdemo\hostname.txt'
    - shell: powershell
    - unless: 'Test-Path C:\saltdemo\hostname.txt'

Apply it

sudo salt '<your minion id>' state.apply windows.hostname

C:\saltdemo\hostname.txt appears with the box's hostname. Idempotent because of the unless: clause.

07

Run a .ps1 script

For real scripts. Drop the .ps1 on the master, Salt pulls it down and runs it on the minion. salt:// is Salt's file-server URL scheme.

# /srv/salt/windows/scripts/sysinfo.ps1
$out = "C:\saltdemo\sysinfo.txt"
@"
Hostname:   $env:COMPUTERNAME
OS:         $((Get-CimInstance Win32_OperatingSystem).Caption)
PS version: $($PSVersionTable.PSVersion)
Stamp:      $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
"@ | Out-File -FilePath $out -Encoding UTF8
# /srv/salt/windows/sysinfo.sls
write_sysinfo:
  cmd.script:
    - source: salt://windows/scripts/sysinfo.ps1
    - shell: powershell

Apply it

sudo salt '<your minion id>' state.apply windows.sysinfo

C:\saltdemo\sysinfo.txt with hostname, OS, PowerShell version, and a timestamp.

08

Install a Windows role or feature

win_servermanager wraps PowerShell's Install-WindowsFeature. Use it for IIS, RSAT, .NET, anything in the Server Manager catalog.

# /srv/salt/windows/iis.sls
install_iis:
  win_servermanager.installed:
    - name: Web-Server
    - recurse: True       # include sub-features
    - restart: False      # no auto-reboot during install

install_iis_console:
  win_servermanager.installed:
    - name: Web-Mgmt-Console
    - require:
      - win_servermanager: install_iis

Apply it

sudo salt '<your minion id>' state.apply windows.iis

→ Installs the IIS Web-Server feature plus the management console. Browse to http://<your minion id>/ — IIS welcome page should load.

09

Add a user to a group

Use group.present with addusers:. The user must already exist — local or AD-joined. For AD users, prefix with DOMAIN\\.

# /srv/salt/windows/group_membership.sls
add_to_remote_desktop_users:
  group.present:
    - name: Remote Desktop Users
    - addusers:
      - saltdemo
      - DOMAIN\\app-svc

add_to_backup_operators:
  group.present:
    - name: Backup Operators
    - addusers:
      - DOMAIN\\backup-svc

Apply it

sudo salt '<your minion id>' state.apply windows.group_membership

→ Adds the listed users to Remote Desktop Users and Backup Operators. Verify on the box: net localgroup "Remote Desktop Users"

Linux
10

Package + service + require chain

The everyday Linux pattern: install a package, make sure its service is running, chain them so Salt does it in the right order. The require: tells Salt "do nginx_package first." Re-run this all you want — Salt only changes what's not already correct.

# /srv/salt/linux/webserver.sls

# shared utilities — install both packages in one state
network_utilities:
  pkg.installed:
    - pkgs:
      - rsync
      - curl

# nginx package
nginx_package:
  pkg.installed:
    - name: nginx

# nginx service — runs on boot, depends on the package being there
nginx_service:
  service.running:
    - name: nginx
    - enable: True
    - require:
      - pkg: nginx_package

Apply it

sudo salt '<your minion id>' state.apply linux.webserver

→ Installs rsync, curl, and nginx; starts and enables nginx. From the box: curl http://localhost/ should return the nginx welcome page.

About require. Salt doesn't run states in file order — it runs them in dependency order. If you don't say require, Salt is free to run them in parallel. For most things that's fine. For "package must exist before service starts," it's not. require is how you tell Salt the order matters.

Apply everything in one shot.

You've been applying states one at a time with state.apply <name>. Now we wire them all into the top file and apply the lot with state.highstate.

11

Update top.sls, then highstate everything

Add the new states to /srv/salt/top.sls. Salt reads this file, matches each minion against the grain rules, and applies whatever's listed. One command, every minion converges.

# /srv/salt/top.sls
base:
  'os_family:Windows':
    - match: grain
    - windows.hello
    - windows.hostname
    - windows.sysinfo
    - windows.iis
    - windows.group_membership

  'os_family:RedHat':
    - match: grain
    - linux.hello
    - linux.webserver

Apply it — to every minion at once

# the headline command
sudo salt '*' state.highstate

# or, equivalently — state.apply with no name
sudo salt '*' state.apply

→ Salt reads top.sls, decides which states each minion gets (Windows minions get the Windows states, Linux minions get the Linux states), and applies them. Idempotent — re-run any time. Re-run after every Git pull.

The split: state.apply <name> vs state.highstate. state.apply <name> applies one specific state regardless of top.sls — useful for testing a single change. state.highstate reads top.sls and applies everything that matches each minion. Both are idempotent. In day-to-day operation, you'll mostly run state.highstate.

Pillar — config in one place.

Pillar moves credentials, paths, and per-environment values out of state files. State files describe what should be done. Pillar describes what to do it with. Edit pillar, redeploy — no state file changes needed.

12

Tell the master where pillar lives

Drop a single config file in /etc/salt/master.d/. Restart the master so it picks up the change. Salt's default pillar root is /srv/pillar — we keep ours nested under /srv/salt/pillar so the whole config tree lives in one repo.

# /etc/salt/master.d/pillar.conf
pillar_roots:
  base:
    - /srv/salt/pillar
# restart so the master sees the change
sudo systemctl restart salt-master
13

Add pillar data

Two files: a top file that says which minions get which pillar, and a data file with the actual values.

# /srv/salt/pillar/top.sls — assign data to Windows minions
base:
  'os_family:Windows':
    - match: grain
    - windows.users
# /srv/salt/pillar/windows/users.sls — the data itself
windows_local_groups:
  Remote Desktop Users:
    - saltdemo
    - DOMAIN\app-svc

  Backup Operators:
    - DOMAIN\backup-svc

Refresh and verify

# tell every minion to re-pull pillar from the master
sudo salt '*' saltutil.refresh_pillar

# confirm the data is reachable from the minion
sudo salt '<your minion id>' pillar.items

→ The output should show windows_local_groups with the values you defined. If it doesn't, check /var/log/salt/master.

14

Use pillar from a state Windows

The same group-membership idea as step 09 — but the user list now lives in pillar. The state loops over pillar data with Jinja: {% for ... %} iterates, {{ ... }} interpolates. Edit pillar tomorrow, the state still works.

# /srv/salt/windows/group_membership_pillar.sls
{% for group, members in pillar.get('windows_local_groups', {}).items() %}
add_to_{{ group | replace(' ', '_') }}:
  group.present:
    - name: {{ group }}
    - addusers: {{ members }}
{% endfor %}

Apply it

sudo salt '<your minion id>' state.apply windows.group_membership_pillar

→ Same outcome as step 09, but the data is now in pillar. Add another group to users.sls, refresh pillar, re-apply — no state-file edit needed.

Why this matters. Per-environment values (dev vs staging vs prod) live in pillar. Secrets live in pillar (encrypted with GPG). The same state file ships everywhere — pillar makes it behave correctly in each environment. That's the separation of logic (states) from data (pillar).

Put it in Git.

Your states are useful on this master. They become an asset when they're in Git — versioned, reviewable, and ready to clone onto the next master in your blue/green pair (or any future stack you build).

15

Initialize and commit

Make /srv/salt a Git repo. Add a sensible .gitignore. Stage everything. Commit.

# on master 1, as root
cd /srv/salt
git init
# /srv/salt/.gitignore — keep noise out
*.swp
*~
.DS_Store
__pycache__/
*.pyc
# stage and commit everything
git add .
git commit -m "Initial: states from getting-started tutorial"
16

Push to a remote

Create an empty repo on GitHub, GitLab, Azure DevOps, or your internal Git host. Point /srv/salt at it. Push.

# add the remote and push
git remote add origin <your git repo url>
git branch -M main
git push -u origin main

Internal Git host with self-signed cert? git config --global http.sslVerify false works for testing — for production, install your CA chain in /etc/pki/ca-trust/source/anchors/ instead.

17

Sync master 2 from Git

Master 2 was installed from the same bundle, so the SSE files are already there. Replace /srv/salt with the same Git clone and both masters now serve identical states.

# on prod-master2, as root
sudo rm -rf /srv/salt
sudo git clone <your git repo url> /srv/salt
cd /srv/salt && git checkout main

Verify both masters are in sync

# run on each master, should return identical hashes
git -C /srv/salt rev-parse HEAD

→ Same SHA on both masters means both are serving the same code. Different SHA means one didn't pull the latest.

Optional: add a 10-minute cron pull on both masters so a git push from your laptop reaches both stacks automatically. The install guide's GIT card has the cron line.

You've just closed the loop with the install guide. When you build a green stack later, the master doesn't need to be configured by hand — the install guide's GIT card (section 6) does this exact pattern, plus a 10-minute cron pull. Future masters get every state you wrote, automatically.

About /srv/salt/pillar. Pillar belongs in Git too — it's just data. Encrypt secrets first with GPG (we covered the setup in the install guide's GPG deep-dive). Many teams keep pillar in a separate repo from /srv/salt for tighter access control — same git init / push pattern, different repo.

Modules used in this guide.

Every Salt state module we used, with a direct link to its full reference. Bookmark this — the official docs are the source of truth. We've shown one or two options per module; each module typically has 20+.

FILE

file.directory · file.managed

Folders, files, ownership, permissions, content, source from the master, render with Jinja. Used in steps 04 and 05.

CMD

cmd.run · cmd.script

Run shell or PowerShell. unless: and onlyif: for idempotency. cmd.script pulls from salt://. Used in steps 06 and 07.

PKG

pkg.installed

Install a package or list of packages. Pin versions, hold packages, source from custom repos. Used in step 10.

SERVICE

service.running

Start a service, enable on boot, restart on config change with watch:. Used in step 10.

WINDOWS

win_servermanager.installed

Install Windows roles and features. Wraps Install-WindowsFeature. Used in step 08 (IIS).

GROUP

group.present

Local group membership on Windows and Linux. Add users with addusers:. Used in steps 09 and 14.

The complete index of every Salt state module is at docs.saltproject.io/en/3007/ref/states/all/. There are hundreds. file, pkg, service, and cmd cover most of what you'll do day-to-day.

Coming next

You now have the skeleton: folders, top files, and one state per platform. We'll continue from here.

Talk to minions directly — remote execution

When you don't need a state, you don't need a state. salt '*' test.ping, salt '*' cmd.run 'uptime', salt 'web*' pkg.install nginx. Useful for quick fleet-wide queries and one-off tasks.

Multi-environment — dev, staging, prod

Add more roots to file_roots.conf and pillar.conf. Same states, different pillar per environment. The same blue/green pattern from the install guide, applied at the data level.

Jinja templating — go deeper

Conditionals ({% if grains['os_family'] == 'RedHat' %}…{% endif %}), filters, file templating with file.managed + template: jinja. The same template, behaving correctly on different OSs.

Targeting — by name, glob, grain

salt 'web1' …, salt 'web*' …, salt -G 'os_family:RedHat' …. How to talk to one box, a group, or every box that matches a grain. The same matchers work in top.sls too.

Want this in your environment, faster?

We've done this build dozens of times. We'll do it with your team and hand it back working.

[email protected]