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.
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.
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 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.
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.
%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."
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.
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
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.
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.
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.
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/.
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]}"
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]}"
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
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).
"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.
"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.
"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.
"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.
"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())"
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.
| 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 |