Static NetworkManager Profiles in Kickstart

How to write .nmconnection keyfiles directly from a Kickstart %post block, with MAC-to-IP lookup tables for fleet provisioning. Because nmcli isn't going to help you here.

The Problem

You're writing a Kickstart .cfg file. You need machines to come up with static IPs based on their MAC address. And you've already discovered the fun part: nmcli con add and nmcli con modify don't stick during the Anaconda installer, because the network stack is in this weird half-alive liminal state where commands technically run but nothing persists into the installed system.

So forget nmcli. We're going to write the NetworkManager keyfile directly.

How NetworkManager Keyfiles Work

NetworkManager stores connection profiles as .nmconnection files in:

/etc/NetworkManager/system-connections/

These are INI-style keyfiles. NM reads them on startup (or when you run nmcli con reload). The critical rules:

The three rules you cannot break

The file must have a .nmconnection extension. NM ignores everything else in that directory.

Permissions must be 600, owned by root:root. If they're world-readable, NM will silently skip the file. No error, no warning, just vibes.

Every connection must have a unique uuid. Generate one with uuidgen.

Anatomy of a Keyfile

Here's what a static ethernet connection looks like, pinned to a specific NIC by MAC address:

ini[connection]
id=static-lan
uuid=<some-uuid-here>
type=ethernet

[ethernet]
mac-address=AA:BB:CC:DD:EE:FF

[ipv4]
method=manual
addresses=192.168.1.100/24
gateway=192.168.1.1
dns=8.8.8.8;1.1.1.1;

[ipv6]
method=disabled

Breaking that down:

[connection] — the identity block. id is the human-readable name (what shows up in nmcli con show). type=ethernet tells NM what kind of connection this is.

[ethernet] — hardware matching. The mac-address field is what pins this profile to a specific NIC. You don't need interface-name when you're matching on MAC — NM will find the right interface on its own.

[ipv4] — the good stuff. method=manual means static IP. addresses takes CIDR notation (IP/prefix-length). dns entries are semicolon-separated and need a trailing semicolon. That trailing semicolon will bite you if you forget it.

[ipv6] — set to disabled here because we don't need it, but you could set method=auto for SLAAC or method=manual with its own addresses if you do.

The Kickstart Side

Why %post --nochroot?

Kickstart's %post section runs after the OS is installed to disk. It comes in two flavors:

%post (default) — runs chrooted into the installed system at /mnt/sysimage. You see the installed system's filesystem as /. But the network interfaces you're trying to inspect belong to the live installer environment, not the chroot, so ip link might not see what you expect.

%post --nochroot — runs in the installer's own environment. You have full access to the live network interfaces, their MACs, everything. The installed system's filesystem lives at /mnt/sysimage/. This is the one you want for "read the MAC, then write a config file into the installed system."

The Basic Pattern

For a single machine with a known IP — the simplest version:

bash%post --nochroot

IFACE="eno1"
MAC=$(ip link show "$IFACE" | awk '/ether/ {print toupper($2)}')
UUID=$(uuidgen)

cat > /mnt/sysimage/etc/NetworkManager/system-connections/static-lan.nmconnection << EOF
[connection]
id=static-lan
uuid=${UUID}
type=ethernet

[ethernet]
mac-address=${MAC}

[ipv4]
method=manual
addresses=192.168.1.100/24
gateway=192.168.1.1
dns=8.8.8.8;1.1.1.1;

[ipv6]
method=disabled
EOF

chmod 600 /mnt/sysimage/etc/NetworkManager/system-connections/static-lan.nmconnection

%end

This works, but you need a lookup table. So let's do that properly.

The Full Pattern: MAC-to-IP Lookup Table

This is for when you have one Kickstart file serving a fleet of machines (PXE booting, typically), and each machine should get a different static IP based on its MAC address. Same .cfg, different machines, different IPs.

bash%post --nochroot

# ============================================================
#  MAC → IP MAPPING TABLE
# ============================================================
#  Format: NET_MAP["<MAC>"]="<IP>|<GATEWAY>|<HOSTNAME>"
#
#  MAC addresses MUST be uppercase with colon separators.
#  Add or remove entries as needed.
# ============================================================

declare -A NET_MAP
NET_MAP["AA:BB:CC:DD:EE:01"]="192.168.1.10|192.168.1.1|server-01"
NET_MAP["AA:BB:CC:DD:EE:02"]="192.168.1.11|192.168.1.1|server-02"
NET_MAP["AA:BB:CC:DD:EE:03"]="192.168.1.12|192.168.1.1|server-03"
NET_MAP["AA:BB:CC:DD:EE:04"]="192.168.1.13|192.168.1.1|server-04"

# Common settings (change these to match your environment)
DNS="8.8.8.8;1.1.1.1;"
PREFIX="24"

# ============================================================
#  INTERFACE DETECTION
# ============================================================
#  Option A: You know the interface name (e.g., eno1, eth0).
#  Option B: Loop all interfaces and match against the table.
#
#  Pick one. Option B is more resilient to hardware variance.
# ============================================================

# --- Option A: Known interface name ---
# IFACE="eno1"
# MAC=$(ip link show "$IFACE" | awk '/ether/ {print toupper($2)}')

# --- Option B: Loop all interfaces ---
MAC=""
IFACE=""
for IFACE_PATH in /sys/class/net/*/address; do
    CANDIDATE=$(tr '[:lower:]' '[:upper:]' < "$IFACE_PATH")
    if [[ -n "${NET_MAP[$CANDIDATE]+x}" ]]; then
        MAC="$CANDIDATE"
        IFACE=$(echo "$IFACE_PATH" | cut -d'/' -f5)
        break
    fi
done

# ============================================================
#  WRITE THE PROFILE
# ============================================================

if [[ -n "$MAC" && -n "${NET_MAP[$MAC]+x}" ]]; then
    IFS='|' read -r IP GW HNAME <<< "${NET_MAP[$MAC]}"
    UUID=$(uuidgen)

    CONN_DIR="/mnt/sysimage/etc/NetworkManager/system-connections"
    CONN_FILE="${CONN_DIR}/static-lan.nmconnection"

    cat > "$CONN_FILE" << EOF
[connection]
id=static-lan
uuid=${UUID}
type=ethernet
autoconnect=true

[ethernet]
mac-address=${MAC}

[ipv4]
method=manual
addresses=${IP}/${PREFIX}
gateway=${GW}
dns=${DNS}

[ipv6]
method=disabled
EOF

    chmod 600 "$CONN_FILE"

    # Set the hostname on the installed system
    echo "${HNAME}" > /mnt/sysimage/etc/hostname

    echo "SUCCESS: ${HNAME} (${MAC}) → ${IP}/${PREFIX} via ${GW}"
else
    echo "WARNING: No matching MAC found in NET_MAP. Skipping static network config."
    echo "Detected interfaces:"
    for P in /sys/class/net/*/address; do
        echo "  $(echo $P | cut -d'/' -f5): $(cat $P)"
    done
fi

%end

What's Happening Here

01

The lookup table is a bash associative array (declare -A). Each key is a MAC address (uppercase, colon-separated — this matters). Each value packs the IP, gateway, and hostname together with | as a delimiter, which we split apart with IFS='|' read.

02

Interface detection loops through every network interface the live installer can see, reads its MAC from /sys/class/net/<name>/address, uppercases it, and checks if it exists in the table. When it finds a match, it grabs the interface name too and breaks out of the loop.

03

Profile writing templates out the .nmconnection keyfile into the installed system's filesystem (under /mnt/sysimage/ because we're in --nochroot mode). Sets permissions to 600. Also writes the hostname.

04

The fallback dumps all detected interfaces and their MACs to the Anaconda log, so if something doesn't match, you can figure out why. Check /tmp/ks-post.log or the install log on the installed system at /var/log/anaconda/.

Extending the Table

Variable subnet masks

If the prefix length varies per host, add it to the table:

bashNET_MAP["AA:BB:CC:DD:EE:01"]="192.168.1.10|24|192.168.1.1|server-01"

And adjust the read:

bashIFS='|' read -r IP PREFIX GW HNAME <<< "${NET_MAP[$MAC]}"

Variable DNS per site

Same idea — add it to the table value:

bashNET_MAP["AA:BB:CC:DD:EE:01"]="192.168.1.10|24|192.168.1.1|10.0.0.53;10.0.0.54;|server-01"
bashIFS='|' read -r IP PREFIX GW DNS HNAME <<< "${NET_MAP[$MAC]}"

VLANs

You can create a VLAN connection profile as a second file. The parent field references the id of the base ethernet connection:

ini[connection]
id=vlan100
uuid=<another-uuid>
type=vlan

[vlan]
parent=static-lan
id=100

[ipv4]
method=manual
addresses=10.100.0.10/24

Multiple NICs per machine

If a machine has multiple NICs that each need a profile, change the loop to not break on first match — instead, iterate all interfaces and write a separate .nmconnection for each one that appears in the table. You'll want unique id and filename per connection (e.g., static-lan-0, static-lan-1).

Debugging Tips

The #1 gotcha

"The profile exists but NM isn't using it" — check permissions. ls -la /etc/NetworkManager/system-connections/. It must be 600 root:root. This is always the first thing to check.

Case sensitivity trap

"The MAC doesn't match" — NM keyfiles expect uppercase MACs with colons (AA:BB:CC:DD:EE:FF). The ip link command outputs lowercase. That's why we pipe through toupper() or tr '[:lower:]' '[:upper:]'. If you forget this, nothing matches and you spend an hour staring at a correct-looking config wondering what's wrong.

Checking the logs

"I want to see what happened during %post" — Anaconda logs %post output. Check /var/log/anaconda/ks-post.log on the installed system, or /tmp/ during the install. The echo statements in the script above will show up there.

Timing

"NM starts before the profile is written" — shouldn't happen with the %post approach since the profile is written before first boot. But if it does, you can add a chrooted %post section that runs nmcli con reload via a first-boot systemd unit. Usually overkill.

Missing tools

"uuidgen not found" — unlikely in the Anaconda environment, but if it happens, you can use Python as a fallback: python3 -c "import uuid; print(uuid.uuid4())"

Verification

After install — on the installed system, run nmcli con show to see if the profile loaded, and nmcli con show static-lan to inspect its details. ip addr will confirm the IP is actually assigned.

Quick Reference: Keyfile Fields

Section Key Example Notes
[connection] id static-lan Human-readable name
uuid 550e8400-... Must be unique, use uuidgen
type ethernet Or vlan, wifi, bridge, etc.
autoconnect true Connect automatically on boot
[ethernet] mac-address AA:BB:CC:DD:EE:FF Uppercase, colon-separated
[ipv4] method manual manual = static, auto = DHCP
addresses 192.168.1.10/24 CIDR notation; semicolon-separated for multiple
gateway 192.168.1.1 Default gateway
dns 8.8.8.8;1.1.1.1; Semicolon-separated, trailing semicolon required
[ipv6] method disabled Or auto, manual, ignore