saltstackdevopsautomation

Salt — Complete Visual Guide

Salt — Complete Visual Guide

Every concept explained with animated diagrams · Salt 3007.x (Chlorine) · Built for the kri Mac Mini fleet platform

Architecture · Data · kri

Salt Architecture — All Components

SaltStack is a remote execution and configuration management system. One salt-master controls many salt-minions over encrypted ZeroMQ channels. Everything is event-driven and asynchronous.

Complete salt architecture — animated

Tailscale overlay network (WireGuard encryption) salt-master (mm1) native launchd service · :4505 · :4506 · :8080 Event Bus fire/subscribe to all salt events 📡 Pillar /srv/salt/pillar/ — per-node secrets 🔒 File Server /srv/salt/states/ — SLS files served via salt:// 📁 Reactor event pattern → trigger action PKI / Key Store /etc/salt/pki/master/ — RSA key pairs 🔑 salt-api HTTP REST interface · port 8080 · PAM auth 🌐 mm2 salt-minion · Apple M4 Schedule: kri_heartbeat (5m) Status: ● online mm3 salt-minion · Apple M2 Schedule: kri_heartbeat (5m) Status: ● online mm4 salt-minion (pending) Key: pending acceptance :4505 PUB :4506 RETURN kri API/Worker (Docker) ↔ salt-api (:8080) ↔ salt-master event bus kri → salt-api → master

Core Components

🖥️

salt-master

Control plane. One per fleet. Manages keys, serves state files and pillar, listens on :4505/:4506. In kri: runs natively on mm1 (launchd service).

💻

salt-minion

Agent on each managed node. Subscribes to master events, executes commands locally, returns results. Runs via launchd on macOS.

🌐

salt-api

HTTP REST layer over the master. kri API/worker use it to dispatch ad-hoc commands (test.ping, state.apply). Runs alongside salt-master on mm1.

salt-call

Run salt functions locally on the minion without going to the master. Used in heartbeat scripts and grain collection (salt-call grains.items).

🔑

salt-key

Manage minion RSA keys: list pending/accepted/rejected, accept, reject, delete. kri exposes this via Settings → Minion Keys.

🏃

salt-run

Execute runner modules on the master itself (not on minions). Used for jobs, orchestration, managing jobs cache.

Communication Model

1

Minion connects

Minion subscribes to master’s ZeroMQ PUBLISH socket (:4505). Stays connected. This is a persistent long-lived TCP connection.

2

Master broadcasts job

When you run salt mm2 test.ping, master publishes a job to :4505. All minions receive it; only targeted ones execute.

3

Minion executes

Targeted minion runs the function locally (e.g. calls Python code). Completely async — minion doesn’t block master.

4

Minion returns

Result is pushed back to master on :4506 (PUSH/PULL socket), tagged with the job ID. Master stores in job cache and returns to caller.

5

Events fire

Every job, return, state run, and minion start/stop fires an event on the master’s event bus. Reactor subscriptions trigger on matching patterns.

Key insight: Salt never polls minions. All communication is push-based from the master, and minions maintain persistent TCP connections to :4505. This scales to thousands of minions on a single master.


kri Integration

kri uses Salt as its fleet control plane. Every Mac Mini runs a salt-minion; mm1 runs the salt-master as a native launchd service. Here is the complete integration map.

kri + Salt complete integration (v0.1.194)

Linux Docker Host (Tailscale: 198.51.100.27) Docker Compose services kri API FastAPI :8000 nginx proxy Celery Worker salt_tasks.py → salt-api Postgres TimescaleDB nodes, grains drift, health Redis Beat scheduler task queue rate limits React Frontend nginx :80 nginx reverse proxy · :80/:443 → api:8000 · Tailscale IP: 198.51.100.27 WebSocket support (ip_hash upstream) for SSH/VNC sessions Ingest endpoints (called by Mac Minis over Tailscale) POST /api/v1/ingest/grains → updates node status POST /api/v1/ingest/sbom → SBOM indexing POST /api/v1/builds/ingest → Jenkins builds mm1 (198.51.100.75) salt-master + salt-api · native launchd · Apple M2 salt-master Pillar: /srv/salt/pillar/ States: /srv/salt/states/ PKI: /etc/salt/pki/master/ ZeroMQ :4505/:4506 Reactor: grain on start salt-api HTTP :8080 PAM auth: krisalt POST /run kri worker calls this for ad-hoc cmds mm2 (minion) Heartbeat: 5 min → ingest ● online mm3 (minion) Heartbeat: 5 min → ingest ● online salt-api calls (ad-hoc commands) heartbeat POST /api/v1/ingest/grains

kri Salt Configuration Reference

SettingValueWhere
salt_master_address198.51.100.75group_vars/all.yml
salt_version3007.14group_vars/all.yml
SALT_API_URLhttp://198.51.100.75:8080.env.docker
SALT_API_USERkrisalt.env.docker
Heartbeat interval5 minutes/etc/salt/minion.d/kri-heartbeat.conf
Pillar dir/srv/salt/pillar/mm1 (salt-master)
States dir/srv/salt/states/mm1 (synced from git)
PKI dir/etc/salt/pki/master/mm1 (launchd)

kri Salt Flows

1

Node goes online

Minion starts → connects to master → reactor fires → state.apply base.grain_report → minion POSTs grains to kri API → node status: online

2

Heartbeat (every 5 min)

kri_heartbeat schedule fires → salt-call --local grains.items → HTTP POST to ingest API → refreshes last_seen_at

3

Quick Actions (UI)

User clicks Test Ping/Reboot → kri API calls Celery → run_salt_cmd → POST to salt-api HTTP → ZeroMQ → minion

4

Bootstrap

Ansible playbook (bootstrap_mac_mini.yml) installs salt-minion pkg, writes minion config, applies base.heartbeat state, POSTs initial grains

5

Mark stale / offline

Celery beat runs mark_stale_nodes every 5 min. Nodes stale >15 min, offline >4 hours. Node remains stale/offline until next grain report.

Install salt-master on mm1: ansible-playbook playbooks/setup_salt_master.yml -i playbooks/inventory/hosts.ini -e "kri_salt_api_password=yourpassword"
Then update .env.docker with SALT_API_URL, SALT_API_USER, SALT_API_PASSWORD.


📊 Data Layer — Grains, Pillar & Mine

Data — Grains, Pillar & Mine

Three distinct data stores in Salt, each with a different owner, audience, and update mechanism.

Grains vs Pillar vs Mine — who owns it, who sees it

Grains Owner: minion (collected locally) Audience: master + all minions Update: on restart or refresh os, cpuarch, ip, mem_total hostname, saltversion, custom For targeting and state conditions Pillar Owner: master (stored on master) Audience: targeted minion ONLY Update: saltutil.refresh_pillar ingest_url, node_token passwords, API keys, per-node config For secrets — encrypted in transit Mine Owner: minion (pushed to master) Audience: ALL minions (shared) Update: mine.update (scheduled) IP addresses, SSL certs load averages, disk usage For peer-to-peer data sharing

🌱 Grains

Static/semi-static facts about the minion collected locally at startup. Used for targeting (-G 'os:MacOS') and state conditionals. In kri, grains are POSTed to the ingest API as the node’s heartbeat.

# Collect all grains
salt mm2 grains.items

# Get one grain
salt mm2 grains.get cpuarch

# Set a custom grain
salt mm2 grains.set role worker

# Force re-collection
salt mm2 saltutil.refresh_grains

# Target by grain
salt -G 'cpuarch:arm64' state.apply

In kri: kri_heartbeat.py runs salt-call --local grains.items then POSTs to /api/v1/ingest/grains every 5 minutes.

🔒 Pillar

Secure configuration stored on the master. Each minion only sees its own pillar slice. Never transmitted to other minions. Perfect for tokens, passwords, URLs. Rendered from Jinja2 SLS files.

# /srv/salt/pillar/mm2.sls
fleet_platform:
  ingest_url: http://198.51.100.27/api/v1/ingest
  node_token: pLmxKN...

# /srv/salt/pillar/top.sls
base:
  'mm2':
    - mm2
  'mm3':
    - mm3

# Refresh after editing
salt mm2 saltutil.refresh_pillar

# Read from state
{% set token = pillar['fleet_platform']['node_token'] %}

⛏️ Mine

Minions push selected data to the master, which shares it with all other minions. Useful for service discovery — a load balancer minion reads the IP addresses of all web server minions.

# /etc/salt/minion.d/mine.conf
mine_functions:
  network.ip_addrs: []
  disk.usage: []
  status.loadavg: []

mine_interval: 60  # minutes

# Manually update
salt mm2 mine.update

# Read another minion's mine data
salt mm2 mine.get 'mm3' network.ip_addrs

# Read from a state template
{% set ips = salt['mine.get']('web*','network.ip_addrs') %}

kri does not currently use Mine — nodes report grains directly via HTTP. Mine is useful if you need minions to discover each other.

States · Events · Reactor · Thorium

States & Execution Modules

States describe desired configuration (declarative, idempotent). Execution modules run ad-hoc commands (imperative, immediate).

State rendering and application pipeline

SLS File YAML + Jinja2 .sls template render Jinja2 Pass pillar, grains substituted parse YAML Parse state dict with requisites order Dependency require, watch onchanges sorted apply Result ok / changed failed / skipped Idempotent — running twice produces the same result. changed=0 on second run.

State File Anatomy (.sls)

# base.grain_report
# state ID — must be unique
report_grains_to_fleet_platform:
  # state module.function
  cmd.run:
    - name: |
        python3 - <<'EOF'
        import urllib.request, json
        ...
        EOF
    # Requisites
    - require:
      - file: kri_heartbeat_script
    - onchanges:
      - file: kri_config

# Jinja2 in states
{% set url = pillar.get('fleet_platform',{})
              .get('ingest_url','') %}
{% if url %}
report:
  cmd.run:
    - name: "curl -X POST {{ url }}"
{% endif %}

Requisites — Execution Order

RequisiteMeaning
requireThis state only runs if the listed states succeeded
require_inThe listed state must run after this one
watchLike require, but also runs if the listed state made a change
watch_inNotify another state to run if this one changed
onchangesOnly run if one of the listed states reported a change
unlessSkip if this shell command returns 0 (exit success)
onlyifOnly run if this shell command returns 0
prereqRun before listed state only if listed state would change
useCopy arguments from another state ID
onfailRun only if the listed state FAILED

top.sls — Highstate Routing

The top file maps minions to state trees. Running state.highstate applies everything in top.sls that matches the minion.

# /srv/salt/states/top.sls
base:
  '*':           # all minions
    - base.grain_report
    - base.heartbeat

  'G@os:MacOS':  # grain match
    - macos.defaults

  'mm1':         # exact match
    - role.salt_master

  'web*':        # glob
    - nginx
    - ssl_cert

production:    # saltenv
  'prod*':
    - production.config

State vs Execution Modules

State moduleExecution module
file.managedfile.read
pkg.installedpkg.install
service.runningservice.start
cmd.runcmd.run
schedule.presentschedule.list

State modules are called in SLS files and enforce desired state. Execution modules are called ad-hoc via salt minion module.function. Some share the same name but have different semantics.

Rule: States = idempotent desired state. Execution = one-time imperative action.

Common State Modules

file.managed

Ensure file content, ownership, permissions

pkg.installed

Ensure package is installed at version

service.running

Ensure daemon is running and enabled

cmd.run

Run shell command, optionally conditional

user.present

Ensure OS user exists with correct settings

git.latest

Keep a git repo at a specific branch/tag

pip.installed

Ensure Python package installed in venv

schedule.present

Create a scheduled job on the minion


Orchestrate, Runners & Formulas

Orchestrate coordinates complex multi-stage workflows across multiple minions. Runners execute on the master itself. Formulas are reusable, community-maintained state collections.

🎼 Orchestrate — Multi-Stage Workflows

Orchestrate runs on the master (via salt-run) and coordinates state runs across multiple minions in a specific order. Unlike regular states (per-minion), orchestrate spans the whole fleet.

# /srv/salt/states/orch/deploy.sls
# Step 1: Deploy to staging first
deploy_staging:
  salt.state:
    - tgt: staging*
    - sls: app.deploy

# Step 2: Run integration tests
run_tests:
  salt.function:
    - tgt: test-runner
    - fun: cmd.run
    - arg:
      - pytest /tests/
    - require:
      - deploy_staging  # only after staging

# Step 3: Deploy to production
deploy_prod:
  salt.state:
    - tgt: prod*
    - sls: app.deploy
    - require:
      - run_tests  # only if tests pass

# Run it
salt-run state.orchestrate orch.deploy

🏃 Runners — Master-Side Modules

Runners execute on the master process itself, not on minions. Used for fleet-wide operations, job management, and orchestration.

# Common runners
# Manage online/offline minions
salt-run manage.present     # online
salt-run manage.absent      # offline
salt-run manage.status      # all with status

# Job history
salt-run jobs.list_jobs     # recent jobs
salt-run jobs.lookup_jid 2026053014000001

# Mine data
salt-run mine.get '*' network.ip_addrs

# Cache
salt-run cache.grains mm2   # cached grains

# Orchestrate
salt-run state.orchestrate orch.deploy
salt-run state.orchestrate orch.upgrade   pillar='{"version": "2.5.0"}'

📦 Formulas — Reusable State Collections

Formulas are pre-built, community-maintained Salt state collections. Like Ansible roles or Helm charts — install nginx, postgres, docker with a single include.

# Install a formula (clone to states dir)
cd /srv/salt/states
git clone https://github.com/saltstack-formulas/nginx-formula nginx

# Use in your state or top.sls
# /srv/salt/states/top.sls
base:
  'web*':
    - nginx

# Configure via pillar
# /srv/salt/pillar/nginx.sls
nginx:
  server:
    config:
      worker_processes: auto
  vhosts:
    mysite:
      enabled: True
      name: example.com

# Popular formulas
# nginx-formula, postgres-formula
# docker-formula, users-formula
# git-formula, openssh-formula

🌍 SaltEnv — Multiple Environments

SaltEnv lets you serve different state trees for different environments (base, dev, staging, prod) from the same master. Minions request a specific saltenv.

# /etc/salt/master.d/environments.conf
file_roots:
  base:
    - /srv/salt/states/base
  dev:
    - /srv/salt/states/dev
    - /srv/salt/states/base   # fallback
  prod:
    - /srv/salt/states/prod
    - /srv/salt/states/base

pillar_roots:
  base:
    - /srv/salt/pillar/base
  prod:
    - /srv/salt/pillar/prod
    - /srv/salt/pillar/base

# Apply a specific environment
salt mm2 state.apply saltenv=prod

# Pin minion to an env
# /etc/salt/minion
saltenv: prod

⚡ Events, Reactor, Schedule & Automation

Events, Reactor, Schedule & Beacons

Salt’s event bus is the nervous system. Every action produces an event. Reactor listens and triggers responses. Schedule and Beacons add time-based and condition-based automation.

Event bus flow — everything produces events, reactor subscribes

Event Producers minion start/stop state run complete job published/returned beacon events custom fire_event() Event Bus ZeroMQ IPC socket salt/minion/{id}/start salt/job/{jid}/ret/{id} salt/state/ret salt/beacon/{minion}/{type} myapp/custom/event Subscribers Reactor pattern match → action Returner log to DB / Splunk / ES External App kri, monitoring, alerts

⚡ Reactor

The reactor listens to event patterns and triggers actions. kri uses it to fire grain_report the instant a minion connects — eliminating the heartbeat delay (from 5 min to <2 seconds).

# /etc/salt/master.d/kri.conf
reactor:
  - 'salt/minion/*/start':
    - salt://reactor/grain_report_on_start.sls
  - 'salt/beacon/*/disk_usage':
    - salt://reactor/alert_disk.sls

# /srv/salt/states/reactor/grain_report_on_start.sls
fire_grain_report:
  local.state.apply:
    - tgt: "{{ data['id'] }}"
    - arg:
      - base.grain_report

The reactor runs on the master and is event-driven. It can trigger local commands, state applies, runner modules, or wheel functions.

📅 Schedule

Run jobs on a timer — on the minion (without master involvement) or on the master. kri uses a minion-side schedule stored in /etc/salt/minion.d/ so it persists across restarts and cache clears.

# Minion-side — survives cache clears
# /etc/salt/minion.d/kri-heartbeat.conf
schedule:
  kri_heartbeat:
    function: cmd.run
    job_kwargs:
      cmd: /opt/salt/bin/python3.10 /usr/local/bin/kri_heartbeat.py
    minutes: 5
    enabled: True
    run_on_start: True  # fires on minion start

# Master-side (via salt-run)
# runs on master, NOT on minions
salt-run schedule.add nightly_backup \
  function=state.apply \
  job_kwargs="{'arg': ['backup']}" \
  hours=24

schedule.present (state module) writes to volatile /var/cache/salt/minion/schedule.p. kri writes to /etc/salt/minion.d/ for persistence.

🔦 Beacons

Beacons monitor the local system and fire events when conditions are met — without a master initiating anything. Useful for disk full alerts, process monitoring, log file watching.

# /etc/salt/minion.d/beacons.conf
beacons:
  disk_usage:         # monitor disk
    - /: 80%          # fire if / > 80%
    - /data: 90%
    - interval: 60

  ps:                 # process monitor
    - processes:
        salt-master: running
    - interval: 30

  log:                # log file watcher
    - file: /var/log/salt/minion
    - tags:
        salt.ERROR: {}
    - interval: 10

Beacon events: salt/beacon/{minion}/{type}/{data}

🔄 Returners

By default, job results go back to the master. Returners let you also send results to external systems — databases, log aggregators, monitoring platforms — without changing states.

# Send all results to Elasticsearch
salt mm2 state.apply base.grain_report \
  --return=elasticsearch

# Configure returner in minion
# /etc/salt/minion.d/returner.conf
elasticsearch.host: "logs.example.internal:9200"
elasticsearch.index: "salt"

# Built-in returners include:
# sqlite3, mysql, postgres, redis
# elasticsearch, splunk, carbon (Graphite)
# sentry, slack, hipchat

kri acts as a custom returner — the ingest API receives grain data pushed via HTTP POST from the minion’s heartbeat script.


Advanced — Syndic, Proxy Minions & More

Advanced Salt patterns for hierarchical deployments, managing non-standard devices, and scaling to thousands of nodes.

🔗 Syndic — Hierarchical Masters

The Syndic allows chaining salt-masters. A top-level master controls sub-masters (syndics), which each control their own minions. Used for geo-distributed fleets or organizational separation.

# Architecture:
# master-of-masters → syndic1 → mm1,mm2
#                  → syndic2 → mm3,mm4

# On syndic master:
# /etc/salt/master
syndic_master: top-master.example.com

# /etc/salt/minion
master: top-master.example.com

# Command flows down the chain:
# salt '*' test.ping (on top master)
# → syndic1 fans out to mm1,mm2
# → syndic2 fans out to mm3,mm4
# Results bubble back up

🔌 Proxy Minions — Non-Linux Devices

Proxy minions manage devices that can’t run a salt-minion: network switches, IoT devices, APIs, legacy systems. The proxy runs on a Linux host and translates salt commands.

# Proxy minion talks to a REST API device
# /etc/salt/proxy
master: salt-master.example.com

# /srv/salt/pillar/switch01.sls
proxy:
  proxytype: napalm        # network device
  driver: ios
  host: 192.0.2.1
  username: admin
  password: secret

# Start the proxy
salt-proxy --proxyid=switch01 -d

# Use normally
salt switch01 net.interfaces
salt switch01 net.arp

☁️ salt-cloud — Provision Cloud VMs

Salt-cloud provisions VMs on AWS, GCP, Azure, DigitalOcean etc., installs the salt-minion, and adds them to the fleet automatically. Infrastructure as code.

# /etc/salt/cloud.providers.d/aws.conf
my-aws:
  driver: ec2
  id: AKIAIOSFODNN7EXAMPLE
  key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
  keyname: my-keypair
  securitygroup: default
  location: us-east-1

# /etc/salt/cloud.profiles.d/web.conf
web-server:
  provider: my-aws
  image: ami-0c55b159cbfafe1f0
  size: t3.micro
  minion:
    grains:
      role: web

# Spin up and auto-register
salt-cloud -p web-server prod-web-01

# Destroy
salt-cloud -d prod-web-01

⚡ Thorium — Reactive System

Thorium is Salt’s advanced reactive system — a layer above the Reactor. Where Reactor matches single events, Thorium aggregates multiple events over time and fires when conditions are met (e.g., “3 disk alerts in 5 minutes”).

# /srv/salt/thorium/detect_disk_crisis.sls
# Accumulate beacon events from minions
check_disk:
  thresh.count:
    - count: 3          # 3 events needed
    - seconds: 300      # within 5 minutes
    - event_data:
        tag: salt/beacon/*/disk_usage

# When threshold met: fire action
alert_disk_crisis:
  local.state.apply:
    - tgt: "{{ data['id'] }}"
    - arg:
      - disk.emergency_cleanup
    - require:
      - check_disk

# Enable Thorium in master config
# /etc/salt/master.d/thorium.conf
thorium_roots:
  base:
    - /srv/salt/thorium

kri note: kri uses the simpler Reactor for immediate on-connect events. Thorium would be used for complex fleet-wide condition detection (e.g., cascade failures).

📊 Salt Versions — Release Codenames

⚗️

3005

Phosphorus

🟡

3006

Sulfur (LTS)

🟢

3007

Chlorine ← kri uses

🔵

3008

Argon (master)

Versions named after chemical elements. LTS releases (Sulfur/3006) get extended support. kri minions run 3007.14 (Chlorine); salt-master on mm1 runs 3008.0 (Argon) — backward compatible.


⚗️ Custom Module Development

Every Salt module is a Python file. The Salt loader injects special dunder (double-underscore) variables that give your module access to grains, pillar, other modules, and configuration.

Dunder Variables

VariableWhat it gives you
saltCall other execution modules: salt’cmd.run’
grainsRead minion grains: grains[‘os’]
pillarRead pillar data: pillar.get(‘key’, ‘default’)
optsMaster/minion config options
utilsUtility functions shared across modules
statesCall state functions from an execution module
contextPer-minion persistent cache (survives between calls)
virtualnameOverride the module name exposed to users

Writing a Custom Execution Module

# /srv/salt/states/_modules/kri_info.py
# salt '*' kri_info.node_summary

def node_summary():
    """Return a kri-specific node summary."""
    os = __grains__['os']
    mem = __grains__['mem_total']
    uptime = __salt__['cmd.run']('uptime -p')
    token = __pillar__.get('fleet_platform', {}).get('node_token', 'none')
    return {'os': os, 'mem_gb': mem//1024, 'uptime': uptime, 'token_set': token != 'none'}

# Custom state module
# /srv/salt/states/_states/kri_node.py
def registered(name, token):
    """Ensure node is registered with kri."""
    result = {'name': name, 'changes': {}, 'result': True, 'comment': ''}
    current = __salt__['kri_info.node_summary']()
    if current['token_set']:
        result['comment'] = 'Already registered'
        return result
    # ... register logic
    result['changes']['registered'] = True
    return result

# Deploy custom modules
salt mm2 saltutil.sync_modules

✅ Testing with salt-check

salt-check lets you write and run unit tests for your salt states directly on the minion, without mocking.

# /srv/salt/states/base/test_grain_report.sls
# Tests for the grain_report state
check_heartbeat_script_exists:
  salt_check.run:
    - assertions:
      - assertion: assertTrue
        expr_form: True
        result: "{{ salt['file.file_exists']('/usr/local/bin/kri_heartbeat.py') }}"

check_heartbeat_is_executable:
  salt_check.run:
    - assertions:
      - assertion: assertEqual
        result: "{{ salt['cmd.run']('test -x /usr/local/bin/kri_heartbeat.py && echo ok') }}"
        expected: "ok"

# Run the tests
salt mm2 salt_check.run_state_tests base.grain_report
salt mm2 salt_check.run_highstate_tests

📋 Job Cache & Event Tags

Every salt command creates a job with a unique JID. Results are cached on the master. Event bus tags follow a structured pattern you can use in Reactor subscriptions.

# Job management
salt-run jobs.list_jobs              # recent jobs
salt-run jobs.lookup_jid 2026053014  # get result
salt-run jobs.active                  # running jobs
salt-run jobs.exit_success <jid>     # did it pass?

# /etc/salt/master.d/jobs.conf
keep_jobs: 72  # hours to keep job cache
job_cache: True

# Event tag patterns (use in Reactor)
salt/job/{jid}/new           # job published
salt/job/{jid}/ret/{minion}  # result returned
salt/minion/{id}/start       # minion connected
salt/minion/{id}/refresh_pillar
salt/state/ret               # state run complete
salt/beacon/{id}/{type}      # beacon fired
salt/presence/present        # presence heartbeat
salt/presence/change         # minion gone/appeared

🔐 Advanced Data — Vault & GPG Encrypted Pillar

HashiCorp Vault Integration

Use Vault as a pillar backend so secrets never touch the salt-master filesystem. Minions request their secrets from Vault via the master as a proxy.

# /etc/salt/master.d/vault.conf
ext_pillar:
  - vault:
      path: secret/data/nodes/{minion_id}

vault:
  url: https://vault.example.internal:8200
  auth:
    method: token
    token: s.xxxxxxxxxxx

# Minion accesses pillar normally
{% set secret = pillar['my_secret'] %}

# Vault path is per-minion by ID
# vault write secret/data/nodes/mm2 token=xxx

GPG-Encrypted Pillar

Encrypt secrets directly in pillar files using GPG. Salt decrypts on the master before sending to the minion. Secrets are safe to commit to git.

# Encrypt a value
echo -n "my-secret-password" | gpg   --armor --batch --encrypt   -r salt-master@example.com

# /srv/salt/pillar/mm2.sls
fleet_platform:
  node_token: |
    -----BEGIN PGP MESSAGE-----
    hQEMA7...encrypted...data
    -----END PGP MESSAGE-----

# /etc/salt/master.d/gpg.conf
decrypt_pillar: True
decrypt_pillar_default: gpg
gpg_keydir: /etc/salt/gpgkeys

Security · Targeting · CLI · Reference

Security, Targeting & Keys

Salt uses RSA public-key cryptography for all master-minion authentication. Every minion has a unique key pair. The master is the trust anchor.

Full key lifecycle — from first boot to trusted minion

1. First Boot Minion generates RSA 4096 key pair /etc/salt/pki/minion/ 🔐 2. Send Pub Key Minion sends public key to master → minions_pre/ 📤 3. Admin Reviews salt-key -L kri UI → Minion Keys Accept or Reject 👤 4. Accepted Key moved to minions/ Mutual auth complete 5. Full Trust Master sends its pub key to minion AES session established 🔒 All payload encrypted with AES after handshake. RSA used only for key exchange.

PKI Directory Structure

# On salt-master (mm1)
/etc/salt/pki/master/
  master.pem      # RSA private key (NEVER share)
  master.pub      # RSA public key (sent to minions)
  minions/        # accepted minion pub keys
    mm2           # one file per accepted minion
    mm3
  minions_pre/    # pending (not yet accepted)
    mm4
  minions_rejected/ # explicitly rejected
  minions_denied/   # denied (accepted then revoked)

# On salt-minion (each Mac Mini)
/etc/salt/pki/minion/
  minion.pem        # minion private key
  minion.pub        # minion public key (sent to master)
  minion_master.pub # master's public key (for auth)

Key States & Commands

CommandAction
salt-key -LList all keys by state (accepted/pending/rejected)
salt-key -a mm3Accept a specific pending key
salt-key -A -yAccept ALL pending keys (auto-trust, risky)
salt-key -r mm4Reject a key (moved to minions_rejected)
salt-key -d mm4Delete a key entirely (decommission)
salt-key -f mm2Show fingerprint of a specific key
salt-key -FShow all fingerprints
salt-key —gen-keys=testGenerate a key pair (for testing)

In kri: use Settings → Minion Keys to accept/reject. Never use -A -y in production — always inspect fingerprints first.

External Authentication (salt-api)

When using salt-api, clients authenticate via eauth (external authentication) before they can call salt functions. kri uses PAM auth with a dedicated system user.

# /etc/salt/master.d/kri.conf
external_auth:
  pam:
    krisalt:       # OS user on mm1
      - '.*'      # allow all functions
      - '@runner' # allow runner modules
      - '@wheel'  # allow key management

# HTTP call to salt-api
POST http://198.51.100.75:8080/run
{
  "client": "local",
  "tgt": "mm2",
  "fun": "test.ping",
  "username": "krisalt",
  "password": "...",
  "eauth": "pam"
}

Encryption Layers

1

Transport encryption

All ZeroMQ traffic is encrypted with a rotating AES session key. The AES key is exchanged using RSA during the authentication handshake.

2

Pillar encryption

Pillar data is encrypted per-minion using the minion’s public key. Other minions cannot decrypt pillar data sent to a different minion.

3

Tailscale (kri)

In kri, all traffic also travels through Tailscale’s WireGuard tunnel, adding a second encryption layer and network isolation.

4

salt-api TLS

salt-api should use TLS in production. In kri we rely on Tailscale’s encryption, so TLS is disabled locally (disable_ssl: true).


Targeting

Every salt command needs a target. Salt supports 8 different targeting methods. Mix them with compound expressions for surgical precision.

Targeting methods — how each selects minions

Glob mm* default -G Grain os:MacOS grain match -P Regex mm[23] PCRE regex -L List mm2,mm3 explicit list -E Grain-P cpuarch:arm.* grain regex -N NodeGroup webservers named group -I Pillar role:web pillar data -C Compound G@ and not L@ mix methods Compound targeting examples: salt -C 'G@os:MacOS and not L@mm1,mm3' state.apply → All macOS nodes EXCEPT mm1 and mm3 salt -C 'mm* or G@role:web' test.ping → All mm* minions OR those with grain role=web

Targeting Syntax Reference

FlagTypeExample
(none)Glob (default)mm* web[12]
-GGrainos:MacOS cpuarch:arm64
-PGrain PCREos:Mac.*
-EPCRE minion IDmm[0-9]+
-LComma listmm1,mm2,mm3
-NNodegroupproduction
-IPillarrole:primary
-JPillar PCRErole:web.*
-SSubnet/IP192.0.2.0/24
-CCompoundG@os:MacOS and not mm1

Nodegroups

Named groups of minions defined in the master config. Can include any targeting expression. Reusable across commands and states.

# /etc/salt/master.d/kri.conf
nodegroups:
  fleet: 'mm1 or mm2 or mm3'
  arm_minis: 'G@cpuarch:arm64'
  prod: 'I@env:production'
  all_except_master: '* and not mm1'

# Use them
salt -N fleet test.ping
salt -N arm_minis state.apply base.grain_report
salt -N prod state.highstate

# In top.sls
base:
  'N@arm_minis':
    - macos.arm_optimizations

Batch Execution — Limiting Blast Radius

For large fleets, apply changes in batches to avoid taking down everything at once.

# Apply to 10% at a time
salt --batch=10% \
  '*' state.apply

# Apply to 3 at a time
salt --batch=3 \
  'mm*' pkg.upgrade
# Async — don't wait for results
salt --async mm2 \
  state.apply base.grain_report

# Get result of async job
salt-run jobs.lookup_jid \
  20260530140000123456
# Timeout — don't wait forever
salt -t 30 mm2 state.apply

# Show progress
salt --progress \
  '*' test.ping

📋 CLI, Config Files, Ports & Logs

CLI Reference — All Salt Commands

Complete reference for salt, salt-call, salt-key, salt-run, and salt-api.

salt — Run commands on minions

CommandDescription
salt ’*’ test.pingPing all minions
salt mm2 grains.itemsGet all grains from mm2
salt mm2 state.apply base.grain_reportApply a specific state
salt mm2 state.highstateApply all states in top.sls
salt mm2 state.apply test=TrueDry run — show what would change
salt mm2 cmd.run ‘uptime’Run a shell command
salt mm2 saltutil.refresh_grainsForce grains re-collection
salt mm2 saltutil.refresh_pillarReload pillar from master
salt mm2 saltutil.sync_allSync all custom modules/states
salt -G ‘os:MacOS’ test.pingTarget by grain
salt -L ‘mm1,mm2,mm3’ test.pingTarget by list
salt —batch=2 ’*’ state.applyApply 2 at a time
salt —async mm2 state.applyFire and forget
salt -t 10 mm2 cmd.run ‘uptime’10 second timeout
salt mm2 system.rebootReboot the minion OS
salt mm2 schedule.listList scheduled jobs on minion

salt-call — Run locally on minion

Run salt functions locally without needing the master. Used in heartbeat scripts and bootstrapping.

# Run on the minion itself (no master)
salt-call --local grains.items

# Apply a state locally
salt-call --local state.apply base.grain_report

# Get pillar (needs master)
salt-call pillar.items

# JSON output for scripting
salt-call --local --out=json grains.items

# kri heartbeat uses this:
raw = subprocess.check_output(
  ["/opt/salt/salt-call", "--local",
   "grains.items", "--out=json"]
)

salt-run — Runner modules (master-side)

Execute runner modules on the master itself. Not dispatched to minions.

# List all recent jobs
salt-run jobs.list_jobs

# Look up a specific job result
salt-run jobs.lookup_jid 2026053014000012

# Check which minions are active
salt-run manage.present
salt-run manage.absent

# Cache status
salt-run manage.status

# Orchestrate (multi-state workflow)
salt-run state.orchestrate orch.deploy

# Mine stats
salt-run mine.get '*' network.ip_addrs

salt-key — Key management

# List all keys by state
salt-key -L

# Accept specific        # Accept all pending
salt-key -a mm3 -y            salt-key -A -y

# Reject                  # Delete (decommission)
salt-key -r mm4 -y            salt-key -d mm4 -y

# Show fingerprints       # Show specific
salt-key -F                   salt-key -f mm2

# Generate standalone key pair (testing)
salt-key --gen-keys=test --gen-keys-dir=/tmp/

🔌 Ports Reference

PortProtocolDirectionPurposeWho listens
4505TCP (ZeroMQ PUB)master → minionsCommand publish channel. Minions subscribe and stay connected.salt-master on mm1
4506TCP (ZeroMQ PULL)minions → masterReturn channel. Minions push job results back here.salt-master on mm1
8080HTTP (REST)kri → mastersalt-api endpoint. kri worker POSTs commands here.salt-api on mm1
80 / 443HTTP / HTTPSbrowser → krikri web frontend + API. nginx reverse proxy.Docker host
8000HTTPnginx → apiFastAPI internal port (not exposed externally).Docker container
5432PostgreSQLinternalTimescaleDB. Only accessible inside Docker network.Docker container
6379RedisinternalCelery broker + rate limiting. Internal only.Docker container

Firewall: Only 4505, 4506, and 8080 need to be reachable on mm1 from other Mac Minis. In kri these are protected by Tailscale — no public exposure.

📄 Configuration Files

File / DirectoryPurpose
/etc/salt/masterMain salt-master config
/etc/salt/master.d/*.confDrop-in master config files (kri.conf, salt-api.conf)
/etc/salt/minionMinion config (master address, id, log level)
/etc/salt/minion.d/*.confDrop-in minion config — kri writes kri-heartbeat.conf here
/etc/salt/pki/master/Master RSA keys + all minion public keys
/etc/salt/pki/minion/Minion private key + cached master.pub
/srv/salt/states/State files (.sls) served via salt://
/srv/salt/pillar/Pillar files — per-node secrets and config
/Library/LaunchDaemons/com.saltstack.salt.master.plistmacOS launchd plist for salt-master
/Library/LaunchDaemons/com.saltstack.salt.api.plistmacOS launchd plist for salt-api
/Library/LaunchDaemons/com.saltstack.salt.minion.plistmacOS launchd plist for salt-minion
/usr/local/bin/kri_heartbeat.pykri heartbeat script (managed by base.heartbeat state)

📋 Log Files & Debugging

Log FileWhat it contains
/var/log/salt/mastersalt-master activity, auth events, job dispatch
/var/log/salt/master-errorsalt-master errors and exceptions
/var/log/salt/apisalt-api HTTP requests and responses
/var/log/salt/minionMinion connection, job execution, state results
/var/log/salt/minion-errorMinion errors (look here first when things break)
# Tail master log
tail -f /var/log/salt/master

# Tail minion log on mm2 (via SSH)
ssh 198.51.100.62 "sudo tail -f /var/log/salt/minion"

# Increase verbosity (troubleshooting)
# In /etc/salt/minion:
log_level: debug

# Watch events in real time (on master)
salt-run state.event pretty=True

# Check job history
salt-run jobs.list_jobs

🔗 salt-ssh — Agentless Mode

salt-ssh runs salt commands over SSH without installing a minion. No ZeroMQ, no PKI keys, no persistent connection. Uses a thin client library injected over SSH. Perfect for bootstrapping new nodes or managing legacy systems.

salt-ssh vs salt-minion

Featuresalt-sshsalt-minion
Agent neededNo — SSH onlyYes
TransportSSH (port 22)ZeroMQ (4505/4506)
AuthSSH keys/passwordRSA key pairs
SpeedSlower (per-connection)Fast (persistent)
ScaleHundredsTens of thousands
State supportFullFull
GrainsLimited (no cache)Full + cached
Best forBootstrap, one-offsProduction fleet

Roster — ssh Inventory

The roster file defines SSH targets. Like an Ansible inventory.

# /etc/salt/roster
mm1:
  host: 198.51.100.75
  user: dk
  sudo: True
  priv: /root/.ssh/id_ed25519

mm2:
  host: 198.51.100.62
  user: dk
  sudo: True

# Use it
salt-ssh mm1 test.ping
salt-ssh '*' state.apply base.grain_report
salt-ssh mm1 grains.items

# kri uses Ansible (not salt-ssh) for
# bootstrap because Ansible has better
# macOS support and the bootstrap playbook
# installs the full salt-minion afterwards

🏗️ High Availability — Multi-Master

Multi-Master Setup

Minions can connect to multiple masters simultaneously. If one master fails, minions failover to the next. Both masters must have identical PKI keys (same master.pem/pub).

# /etc/salt/minion (on each minion)
master:
  - mm1.tailscale           # primary
  - mm1-backup.tailscale    # failover

master_alive_interval: 30   # check every 30s
master_tries: -1            # retry forever

# Both masters MUST share PKI
# Copy master.pem + master.pub to backup
# Both masters share accepted minion keys
# Use shared file system or DB for job cache

# kri note: single master on mm1 is sufficient
# for a home lab. Multi-master for production
# fleets with SLA requirements.

Peer Runner — Minion-to-Minion

Allow minions to request data from other minions or trigger runners, via the master as a proxy. Useful for service discovery and coordination.

# Allow mm2 to call runners on master
# /etc/salt/master.d/peer.conf
peer:
  mm2:                  # this minion ID
    - network.ip_addrs  # can call these
    - grains.items
    - mine.get

peer_run:
  mm2:
    - manage.present    # can call these runners

# From mm2 (via salt-call)
salt-call publish.publish 'mm3' network.ip_addrs
salt-call publish.runner manage.present

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.