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.
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.
Many Linux images (Ubuntu among them) do ship and run cloud-init — but we do not wire userData into it as a datasource. Only your postInstallScript is injected. So a cloud-config document placed in userData is not picked up and applied automatically; nothing consumes it unless you fetch and act on it yourself.
That is a supported pattern, though: your postInstallScript can retrieve userData from the Metadata API and do whatever it likes with it — including handing it to cloud-init. See Combining the two below.
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.doneThe same idea in PowerShell. Here userData carries the firewall allowlist, so one generic script gives every server its own set of permitted sources:
Handling credentials
Neither property is encrypted. postInstallScript and userData are both stored in the clear with your server request, and userData is additionally served by an unauthenticated endpoint that any process or user on the server can read. Neither is a secret store.
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
userDatarather than the script — for convenience, not confidentiality.userDatacan 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
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
postInstallScriptyou pass to the reinstall request runs again on the reinstalled server.
Where it does not apply
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_forinstead of a retry loop that guesses at ordering. The OpenSSH example below is built this way.
Logging and troubleshooting
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.
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.
Windows servers get an Administrator password, but no SSH keys. Any sshKey you pass is ignored on Windows.
We do inject an Administrator password, which you retrieve from the API — and you must fetch it within the first 24 hours after installation. See Creating a Server.
Anything beyond that account is yours to set up in a postInstallScript: an additional user needs a password you supply, and SSH or WinRM access needs a key or certificate you supply. The examples below do exactly that.
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
icaclsline.sshdrefuses to read the key file if anyone besides SYSTEM and Administrators can write it.-Encoding ascii. PowerShell writes UTF-16 by default, andsshdsilently 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.
Creates an additional administrator account, so you are not dependent on the built-in Administrator password.
The password is hard-coded above only to keep the example readable. The postInstallScript is stored in the clear with your server request, so treat any credential you put there as already disclosed: make it a bootstrap-only password, rotate it as soon as the server is up, or skip it entirely and grant access with an SSH key as in the OpenSSH example. See Handling credentials.
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.
Enable-PSRemoting opens an unencrypted HTTP listener on port 5985. On a server with a public IP that is not acceptable — hence the last block, which removes it and leaves only HTTPS on 5986. Because the certificate is self-signed, your client has to skip certificate validation (-SkipCACheck for New-PSSessionOption, or ansible_winrm_server_cert_validation: ignore). Install a real certificate if you need full verification.
Locks the server down to an allowlist: everything inbound is denied by default, and only the addresses you name may reach your management ports.
Order matters, and a mistake here locks you out permanently. The allow rules are created before the default action flips to Block. If you get $allowed wrong, there is no SSH or RDP left to fix it with — recovery means a reinstall. Test the script on one disposable server before rolling it out to a fleet, and make sure the allowlist covers the address you will actually connect from (check your egress IP, not your LAN IP).
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 pipefailon 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
postInstallScriptnoruserDatais 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?