For the complete documentation index, see llms.txt. This page is also available as Markdown.

Post-install scripts

Run your own script on the first boot of a freshly installed FlexMetal server, on both Linux and Windows.

The postInstallScript property on a server request lets you hand us a script that runs once, on the first boot of the newly installed operating system. It is the supported way to bootstrap a bare-metal server: install packages, create users, open a management port, apply a firewall policy, or pull in a configuration-management agent.

It works on both Linux and Windows.

postInstallScript is not userData

These two properties are easy to confuse and behave completely differently.

Property
What happens

postInstallScript

Is executed on the server on first boot.

userData

Is only published as metadata. It is never executed. You retrieve it yourself from the Metadata API userdata endpoint.

Combining the two

Because postInstallScript is a fixed string baked into the server request, while userData is data you can read at boot, pairing them lets one generic script drive many differently-configured servers.

The Metadata API is an ordinary unauthenticated HTTPS endpoint reachable from the server itself, so this works the same on Linux and Windows — anything that can make an HTTPS request can read it.

A postInstallScript that fetches its own configuration out of userData:

#!/bin/bash
set -euo pipefail
exec > >(tee -a /var/log/postinstall.log) 2>&1

# Whatever you set in userData.data on the create-server request
curl -fsS https://metadata.i3d.net/v1/userdata -o /root/userdata

# No decoding needed. `isBase64` on the request is only a transport trick for
# binary payloads; the Metadata API hands the data back already decoded.

# Example: userData holds a cloud-config document, hand it to the
# cloud-init that ships with the image
install -D -m 600 /root/userdata /var/lib/cloud/seed/nocloud/user-data
: > /var/lib/cloud/seed/nocloud/meta-data
cloud-init clean --logs
cloud-init init
cloud-init modules --mode config
cloud-init modules --mode final

touch /var/lib/postinstall.done

The same idea in PowerShell. Here userData carries the firewall allowlist, so one generic script gives every server its own set of permitted sources:

You never have to base64-decode what the Metadata API returns. The userData.isBase64 flag on the create-server request exists only because JSON cannot carry binary data — it tells us how to decode your payload on the way in. What you fetch from https://metadata.i3d.net/v1/userdata is always the original, decoded content.

Reading configuration from userData instead of hard-coding it means the same postInstallScript works across a fleet, and you can change what a server does at build time without editing the script. userData can also be updated on a reinstall.

Handling credentials

So a credential your bootstrap needs — a local account password, a registry token, a licence key, an agent enrolment secret — has to be handled with that in mind:

  • Prefer things that are not secret at all. A public SSH key or a certificate signing request gives you access without a shared secret to protect. This is why the OpenSSH example below installs a public key rather than setting a password.

  • If a credential must be embedded, make it short-lived and single-purpose. A one-time enrolment token that the script exchanges for real credentials against your own secret store, and that expires whether or not it is used, keeps the blast radius small. Prefer that over a long-lived password that never rotates.

  • Treat anything you do embed as already disclosed. Rotate it immediately after the server comes up, and scope it so that it cannot do anything beyond the bootstrap.

  • Put it in userData rather than the script — for convenience, not confidentiality. userData can be changed on a reinstall without editing the script, and keeps one generic script working across a fleet. It is not any safer. See Combining the two for how to read it.

How the script is executed

OS
Interpreter
Notes

Linux

The shell named in the script's shebang

Start the script with a shebang, for example #!/bin/bash. Runs as root.

Windows

PowerShell, always

Write plain PowerShell. The body is always executed as PowerShell regardless of what the first line says, so a marker such as #ps1_sysnative is harmless but unnecessary (# is a PowerShell comment), and there is no need to wrap the body in powershell.exe -Command. Runs with full administrative privileges.

Regardless of OS:

  • It runs once, on the first boot after installation. It is not re-run on subsequent reboots.

  • A reinstall is a fresh installation, so the postInstallScript you pass to the reinstall request runs again on the reinstalled server.

The delivered status does not wait for your script. A server can report delivered while your script is still running, and a long script (installing packages, pulling images) can easily run for several minutes after that. Do not treat delivered as "my bootstrap is done" — publish your own readiness signal, as shown in Knowing when the script has finished.

Where it does not apply

Installation type
Behaviour

Talos

postInstallScript is not applied. Talos has no shell. Configure the machine through its own machine configuration instead — see Talos installation.

custom-ipxe (for example Flatcar)

postInstallScript is not applied. Use userData and fetch it from the Metadata API during first boot — see Custom iPXE booting and the Flatcar installation guide for a worked example.

Putting the script in the request

postInstallScript is a single JSON string, so a multi-line script has to be escaped: newlines become \n, literal double quotes become \", and backslashes become \\. Windows paths are full of backslashes, so escaping by hand is error-prone.

Keep the script in its own file and let a tool do the escaping. With jq (1.6 or newer):

Or in Python:

For a short script, inlining it is fine:

Knowing when the script has finished

Because delivered does not wait for your script, give yourself something to poll. Two patterns work well:

  • Write a marker file as the very last statement, and check for it over SSH.

  • Open the port last. Do all the work — install, write keys, set permissions, add firewall rules — and only then start the service that listens. "The port answers" then genuinely means "the bootstrap completed", which turns your readiness check into a simple wait_for instead of a retry loop that guesses at ordering. The OpenSSH example below is built this way.

Logging and troubleshooting

OS
What the platform logs

Windows

A log under C:\Temp records that a post-install script was downloaded and executed, and whether that succeeded or failed. It does not contain your script's own output.

Linux

No platform-side log of your script's output.

So for anything beyond "did it run at all", log it yourself. On Linux, tee everything to a file:

On Windows, add a small logging helper and call it at each step:

Both examples below use these. Since a failed bootstrap often means you cannot reach the box at all, also see Troubleshooting.

Linux examples

Installs packages, creates a non-root deploy user with its own SSH key and sudo rights, and drops a readiness marker.

set -euo pipefail makes the script stop at the first failing command. Without it, a failed apt-get is silently ignored and the script reports success having done half its work.

Names the machine and its monitoring labels from the server's own metadata, so one script works for an entire fleet.

The Metadata API needs no authentication and is reachable from the server itself. It is not Linux-specific — see the Windows tab under Combining the two for the PowerShell equivalent, and Metadata API for the full response shape.

Windows examples

FlexMetal supports windows-server-2022-standard and windows-server-2019-standard.

Gives you key-based SSH access to a Windows box. Note the deliberate ordering: the key, its permissions, and the firewall rule all land before sshd starts, so "port 22 answers" is a trustworthy readiness signal.

Four details are load-bearing:

  • administrators_authorized_keys, not ~/.ssh/authorized_keys. Win32-OpenSSH ignores the per-user file for members of the Administrators group.

  • The icacls line. sshd refuses to read the key file if anyone besides SYSTEM and Administrators can write it.

  • -Encoding ascii. PowerShell writes UTF-16 by default, and sshd silently rejects a UTF-16 key file.

  • -Profile Any. A public IP classifies as the Public network profile. A rule scoped to Domain or Private looks correct in the GUI and still drops the traffic.

$ErrorActionPreference = 'Stop' only covers cmdlets. Native executables such as icacls need an explicit $LASTEXITCODE check, otherwise a failure there lets sshd start with an unreadable key file — which surfaces as a confusing authentication failure rather than a clear error.

Creates an additional administrator account, so you are not dependent on the built-in Administrator password.

Opens WinRM over HTTPS with a self-signed certificate, restricted to the addresses you list. Useful if you drive the server with Ansible, Terraform, or PowerShell remoting.

Locks the server down to an allowlist: everything inbound is denied by default, and only the addresses you name may reach your management ports.

An explicit Block rule, or a DefaultInboundAction Block applied by group policy, beats your Allow rules. If a port stays closed despite a correct-looking rule, look for a blocking rule with a higher precedence before suspecting the allowlist.

Reinstalling with a different script

postInstallScript is also accepted on the reinstall endpoint, PATCH /v3/flexMetal/servers/{uuid}. The script you pass there runs on the first boot of the reinstalled OS. Omitting it means no script runs — the one from the original request is not carried over.

Good practice

  • Make it idempotent and fail loudly. Use set -euo pipefail on Linux and $ErrorActionPreference = 'Stop' on Windows, so a half-finished bootstrap does not masquerade as a successful one.

  • Keep it short; delegate the rest. Use the script to install and start your configuration-management agent (Ansible pull, Salt, Puppet) rather than encoding your whole server build in it. Long scripts are hard to debug on a machine you cannot yet log in to.

  • No long-lived secrets, in either property. Neither postInstallScript nor userData is encrypted. Bootstrap a public key, or use a short-lived token the script exchanges for real credentials. See Handling credentials.

  • Log everything, and mark completion. You will want both the first time something goes wrong.

  • Test on one server first. Especially for anything touching the firewall or the SSH configuration.

Last updated

Was this helpful?