Enable Networking and SSH on Bare Metal (Debian 13 overlay)#
Overview#
Goal: get a Debian 13 overlay image that boots on bare metal to acquire a
DHCP address and start sshd, when the image is a cloud image with no cloud-init
datasource.
Why this is needed: the Debian 13 generic cloud image relies on cloud-init to
configure networking and SSH from a datasource at first boot. Flashed to real
hardware there is usually no datasource, so cloud-init stalls boot probing the
network and never brings up DHCP or sshd — the device gets no IP and SSH
never starts. (QEMU hides this: its instant built-in DHCP lets cloud-init
succeed, so the failure only shows on hardware.)
Approach: deliver a small, idempotent, mode-agnostic bring-up script plus a
systemd unit through systemConfig.additionalFiles, and pick who runs it with a
single-line MODE toggle in systemConfig.configurations.
Full working example:
image-templates/debian13/debian13-x86_64-bb-overlay-initrd-raw.yml
with image-templates/additionalfiles/debian13-bb-baremetal/.
What the bring-up script does#
bare-metal-bringup.sh runs on the device at boot and performs the two steps the
flashed cloud image is missing:
Networking — write a
systemd-networkd.networkdrop-in that requests DHCP on every wired NIC (matched by device type,Type=ether, not by name prefix), then enable and (re)startsystemd-networkd. Matching by type covers NICs whose predictable names do not start withen(e.g.eth0undernet.ifnames=0) while still excluding loopback and WiFi.systemd-networkdships with systemd, so this needs noifupdown/netplan/NetworkManager.SSH —
ssh-keygen -Agenerates any missing host keys, ensure/run/sshd, enable password auth via a00-sshd_config.ddrop-in, then enable and startssh(Debian’s unit isssh, notsshd). Keys are per-device only because MODE A removes any host keys baked by theopenssh-serverinstall at build time, sossh-keygen -Afills them in freshly on first boot.
The script never touches cloud-init — the cloud-init policy is the template’s job (see the MODE toggle below), so the same script is correct under both invokers.
The DHCP client identifier matters on bare metal#
The single most common bare-metal failure is getting no IPv4 lease or an unexpected address, even though the link is up. The cause is the DHCP client identifier:
By default
systemd-networkdsends an RFC-4361 DUID as the client id.Many bare-metal DHCP servers lease by MAC address (reservations or per-MAC pools) and ignore the DUID, so they hand out no address for that identity or a different one than you expect.
The drop-in therefore forces MAC-based identification and disables IPv6 RA so the NIC does not come “up” with only an RA-derived IPv6 address and no usable IPv4:
[Match]
Type=ether
[Network]
DHCP=yes
IPv6AcceptRA=no
[DHCPv4]
SendRelease=false
ClientIdentifier=mac
QEMU’s slirp DHCP leases regardless of client id, so this only matters on hardware. If networking works in QEMU but not on a device, check this first.
Template wiring#
Deliver the script and its systemd unit with additionalFiles. Default
(end-of-build) stage is correct — neither belongs in the initramfs. The script is
0755 in the repo and additionalFiles preserves mode (cp -p), so it lands
executable.
packages:
- openssh-server # so sshd exists (only openssh-client ships by default)
additionalFiles:
- local: additionalfiles/debian13-bb-baremetal/usr/local/sbin/bare-metal-bringup.sh
final: /usr/local/sbin/bare-metal-bringup.sh
- local: additionalfiles/debian13-bb-baremetal/etc/systemd/system/bare-metal-bringup.service
final: /etc/systemd/system/bare-metal-bringup.service
Run a script on first boot only#
bare-metal-bringup.service is an idempotent oneshot that runs on every boot
(re-applying the same config is harmless and self-heals a wiped /run). If instead
you want a unit to run its script only on the first boot, guard it with a
sentinel file and have systemd create that file after a successful run:
[Unit]
Description=First-boot only setup
# Skip the unit once the sentinel exists, so the script runs exactly once.
ConditionPathExists=!/var/lib/bare-metal-firstboot.done
Wants=network.target
Before=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/my-firstboot.sh
# Only mark "done" if the script succeeded, so a failed first boot retries next boot.
ExecStartPost=/usr/bin/touch /var/lib/bare-metal-firstboot.done
[Install]
WantedBy=multi-user.target
ConditionPathExists=!<file> makes systemd skip the unit once the sentinel exists,
so the script runs once and is a no-op on later boots. (systemd also offers
ConditionFirstBoot=yes, which fires only while /etc/machine-id is unpopulated —
handy for images that ship without a committed machine-id.)
Choose a MODE (the switch)#
Exactly one of the two configurations lines below is active. Switch by moving the
# — comment the active line and uncomment the other. Nothing else changes.
MODE |
Use when |
What it does |
|---|---|---|
A (bare metal, default) |
The device has no datasource |
Disable cloud-init at build so it cannot stall boot, and enable the systemd oneshot that runs the script. |
B (cloud) |
The image also runs where a real datasource exists |
Leave cloud-init enabled so it provisions networking/SSH from the cloud metadata. The bring-up script/unit are still shipped but stay unenabled. |
configurations:
# MODE A (default): disable cloud-init, drop baked SSH host keys, enable the oneshot.
- cmd: mkdir -p /etc/cloud && touch /etc/cloud/cloud-init.disabled && rm -f /etc/ssh/ssh_host_* && mkdir -p /etc/systemd/system/multi-user.target.wants && ln -sf /etc/systemd/system/bare-metal-bringup.service /etc/systemd/system/multi-user.target.wants/bare-metal-bringup.service
# MODE B: leave cloud-init enabled; it provisions from the cloud datasource.
# - cmd: rm -f /etc/cloud/cloud-init.disabled
Notes:
MODE A creates the
multi-user.target.wantssymlink directly becausesystemctl enablecannot run in theconfigurationsstage (it executes beforeadditionalFilesare copied in); the symlink resolves once the unit lands at end-of-build.MODE A chains its steps with
&&(not;) so the build fails fast if disabling cloud-init or enabling the oneshot did not actually happen — a;chain would mask an early failure behind the final command’s exit status.MODE A removes host keys baked by the
openssh-serverinstall (rm -f /etc/ssh/ssh_host_*) so first boot generates per-device keys; without this every flashed unit would share the same build-time keys.MODE A disables cloud-init, which forfeits its first-boot rootfs
growpart.Do not bake a local NoCloud seed for MODE B — a seed under
/var/lib/cloud/seed/nocloud/can outrank and shadow the real cloud datasource.
Provide a login user#
Overlay mode applies systemConfig.users, but this feature does not create an
account itself. Password SSH is useless without one, so add a login user — see
Configure Users. For example:
users:
- name: user
password: user1234
groups: ["sudo"]
Insecure bring-up. This flow enables password SSH auth with a known credential purely to get a first shell. For any real deployment switch to key-based auth (drop an
authorized_keysviaadditionalFiles) and remove/etc/ssh/sshd_config.d/00-baremetal.conf.
Build and verify#
Build and compose the image per the README, then flash the raw artifact to the device.
On the booted device:
ip -4 a— the wired NIC should show the expected DHCP address.networkctl status <iface>— confirms the DHCP client id is the link/MAC.systemctl is-active sshandssh user@<ip>— sshd is up with per-device keys.journalctl -u bare-metal-bringup.service— the script’sbare-metal-bringup:log lines (MODE A only).
Troubleshooting#
Problem |
Check |
|---|---|
Works in QEMU, no IPv4 on hardware |
|
Link up but only an IPv6/ |
|
No IP and no SSH at all (MODE A) |
The oneshot did not run — verify the |
Boot stalls / hangs probing network |
cloud-init is still enabled — MODE A must |
|
Missing host keys ( |
Can reach host but cannot log in |
No login user — add one via |
An offline diagnostic, scripts/inspect-image-login.sh, mounts a built image
read-only via qemu-nbd and reports the account + sshd state without booting.