> For the complete documentation index, see [llms.txt](https://docs.i3d.net/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.i3d.net/compute/flexmetal/post-install-scripts.md).

# 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](/compute/flexmetal/api.md#creating-a-server-post) 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.

<table data-full-width="true"><thead><tr><th width="180">Property</th><th>What happens</th></tr></thead><tbody><tr><td><code>postInstallScript</code></td><td><strong>Is executed</strong> on the server on first boot.</td></tr><tr><td><code>userData</code></td><td>Is <strong>only</strong> published as metadata. It is never executed. You retrieve it yourself from the <a href="/compute/flexmetal/metadata-api.md#userdata">Metadata API userdata endpoint</a>.</td></tr></tbody></table>

{% hint style="warning" %}
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](/compute/flexmetal/metadata-api.md#userdata) and do whatever it likes with it — including handing it to cloud-init. See [Combining the two](#combining-the-two) below.
{% endhint %}

### 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](/compute/flexmetal/metadata-api.md) 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.

{% tabs %}
{% tab title="Linux" %}
A `postInstallScript` that fetches its own configuration out of `userData`:

```bash
#!/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
```

{% endtab %}

{% tab title="Windows" %}
The same idea in PowerShell. Here `userData` carries the firewall allowlist, so one generic script gives every server its own set of permitted sources:

```powershell
$ErrorActionPreference = 'Stop'
$log = 'C:\postinstall.log'
function Write-Log($m) {
    "$(Get-Date -Format o)  $m" | Out-File -FilePath $log -Append -Encoding utf8
}

# No authentication needed, and reachable from the server itself
$meta = Invoke-RestMethod -Uri 'https://metadata.i3d.net/v1/metadata'
Write-Log "Booting $($meta.hostname) in $($meta.location.name)"

# userData, exactly as set on the create-server request
$raw = (Invoke-WebRequest -Uri 'https://metadata.i3d.net/v1/userdata' -UseBasicParsing).Content

# No decoding needed -- see the note below. For a genuinely binary payload,
# write it straight to disk instead:
#   Invoke-WebRequest -Uri 'https://metadata.i3d.net/v1/userdata' `
#       -UseBasicParsing -OutFile 'C:\userdata.bin'

# Example: userData holds {"allowedSources":["203.0.113.10/32","198.51.100.0/24"]}
$cfg = $raw | ConvertFrom-Json

New-NetFirewallRule -Name 'Allowlist-RDP-In' -DisplayName 'RDP (allowlist)' `
    -Enabled True -Direction Inbound -Protocol TCP -Action Allow `
    -LocalPort 3389 -Profile Any -RemoteAddress $cfg.allowedSources
Write-Log "RDP restricted to $($cfg.allowedSources -join ', ')"
```

{% endtab %}
{% endtabs %}

{% hint style="info" %}
**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.
{% endhint %}

{% hint style="info" %}
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](/compute/flexmetal/reinstall-your-servers.md).
{% endhint %}

### Handling credentials

{% hint style="danger" %}
**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.
{% endhint %}

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](/compute/flexmetal/reinstall-your-servers.md) without editing the script, and keeps one generic script working across a fleet. It is not any safer. See [Combining the two](#combining-the-two) for how to read it.

## How the script is executed

<table data-full-width="true"><thead><tr><th width="150">OS</th><th width="200">Interpreter</th><th>Notes</th></tr></thead><tbody><tr><td>Linux</td><td>The shell named in the script's shebang</td><td>Start the script with a shebang, for example <code>#!/bin/bash</code>. Runs as <code>root</code>.</td></tr><tr><td>Windows</td><td><strong>PowerShell</strong>, always</td><td>Write plain PowerShell. The body is always executed as PowerShell regardless of what the first line says, so a marker such as <code>#ps1_sysnative</code> is harmless but unnecessary (<code>#</code> is a PowerShell comment), and there is no need to wrap the body in <code>powershell.exe -Command</code>. Runs with full administrative privileges.</td></tr></tbody></table>

Regardless of OS:

* It runs **once**, on the first boot after installation. It is not re-run on subsequent reboots.
* A [reinstall](/compute/flexmetal/reinstall-your-servers.md) is a fresh installation, so the `postInstallScript` you pass to the reinstall request runs again on the reinstalled server.

{% hint style="info" %}
**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](#knowing-when-the-script-has-finished).
{% endhint %}

### Where it does not apply

<table data-full-width="true"><thead><tr><th width="200">Installation type</th><th>Behaviour</th></tr></thead><tbody><tr><td>Talos</td><td><code>postInstallScript</code> is <strong>not applied</strong>. Talos has no shell. Configure the machine through its own machine configuration instead — see <a href="/compute/flexmetal/talos.md">Talos installation</a>.</td></tr><tr><td><code>custom-ipxe</code> (for example Flatcar)</td><td><code>postInstallScript</code> is <strong>not applied</strong>. Use <code>userData</code> and fetch it from the <a href="/compute/flexmetal/metadata-api.md#userdata">Metadata API</a> during first boot — see <a href="/compute/flexmetal/custom-ipxe-booting.md">Custom iPXE booting</a> and the <a href="/compute/flexmetal/flatcar-installation.md">Flatcar installation guide</a> for a worked example.</td></tr></tbody></table>

## 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):

```bash
jq -n --rawfile script ./post-install.ps1 \
  '{
     name: "win-01",
     location: "EU: Rotterdam",
     instanceType: "bm7.std.8",
     os: { slug: "windows-server-2022-standard" },
     postInstallScript: $script
   }' > body.json

curl -X POST https://api.i3d.net/v3/flexMetal/servers \
  -H "PRIVATE-TOKEN: $I3D_API_KEY" \
  -H 'Content-Type: application/json' \
  --data @body.json
```

Or in Python:

```python
import json, requests

with open("post-install.sh") as fh:
    script = fh.read()

body = {
    "name": "web-01",
    "location": "EU: Rotterdam",
    "instanceType": "bm7.std.8",
    "os": {"slug": "ubuntu-2404-lts"},
    "sshKey": ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... you@example.com"],
    "postInstallScript": script,
}

requests.post(
    "https://api.i3d.net/v3/flexMetal/servers",
    headers={"PRIVATE-TOKEN": "<your api key>"},
    json=body,
)
```

For a short script, inlining it is fine:

```json
{
  "name": "web-01",
  "location": "EU: Rotterdam",
  "instanceType": "bm7.std.8",
  "os": { "slug": "ubuntu-2404-lts" },
  "sshKey": ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... you@example.com"],
  "postInstallScript": "#!/bin/bash\nset -euo pipefail\necho \"Hello flex world\" > /root/hello.txt\n"
}
```

## 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

<table data-full-width="true"><thead><tr><th width="150">OS</th><th>What the platform logs</th></tr></thead><tbody><tr><td>Windows</td><td>A log under <code>C:\Temp</code> records that a post-install script was downloaded and executed, and whether that succeeded or failed. It does <strong>not</strong> contain your script's own output.</td></tr><tr><td>Linux</td><td>No platform-side log of your script's output.</td></tr></tbody></table>

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

```bash
exec > >(tee -a /var/log/postinstall.log) 2>&1
```

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

```powershell
$log = 'C:\postinstall.log'
function Write-Log($m) {
    "$(Get-Date -Format o)  $m" | Out-File -FilePath $log -Append -Encoding utf8
}
```

Both examples below use these. Since a failed bootstrap often means you cannot reach the box at all, also see [Troubleshooting](/compute/flexmetal/troubleshooting.md).

## Linux examples

{% tabs %}
{% tab title="Bootstrap a server" %}
Installs packages, creates a non-root deploy user with its own SSH key and sudo rights, and drops a readiness marker.

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

echo "post-install started $(date -Is)"

export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y curl jq htop fail2ban
systemctl enable --now fail2ban

# Non-root user for deployments
useradd --create-home --shell /bin/bash deploy
install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
cat > /home/deploy/.ssh/authorized_keys <<'KEY'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... deploy@example.com
KEY
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 600 /home/deploy/.ssh/authorized_keys

echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy
chmod 440 /etc/sudoers.d/deploy

# Readiness marker — poll for this instead of trusting the `delivered` status
echo "post-install finished $(date -Is)"
touch /var/lib/postinstall.done
```

{% hint style="info" %}
`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.
{% endhint %}
{% endtab %}

{% tab title="Use the Metadata API" %}
Names the machine and its monitoring labels from the server's own metadata, so one script works for an entire fleet.

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

META="https://metadata.i3d.net/v1/metadata"

hostname=$(curl -fsS "$META" | jq -r '.hostname')
location=$(curl -fsS "$META" | jq -r '.location.name // "unknown"')

hostnamectl set-hostname "$hostname"

mkdir -p /etc/i3d
cat > /etc/i3d/server.env <<EOF
I3D_HOSTNAME=$hostname
I3D_LOCATION=$location
EOF

echo "configured $hostname in $location"
touch /var/lib/postinstall.done
```

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](#combining-the-two) for the PowerShell equivalent, and [Metadata API](/compute/flexmetal/metadata-api.md) for the full response shape.
{% endtab %}
{% endtabs %}

## Windows examples

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

{% hint style="warning" %}
**Windows servers get an Administrator password, but no SSH keys.** `sshKey` is optional, so simply leave it out. Keys you do pass are still validated, but none are installed 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](/compute/flexmetal/api.md#ssh-keys).

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.
{% endhint %}

{% tabs %}
{% tab title="Enable OpenSSH" %}
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.

```powershell
$ErrorActionPreference = 'Stop'
$log = 'C:\postinstall.log'
function Write-Log($m) {
    "$(Get-Date -Format o)  $m" | Out-File -FilePath $log -Append -Encoding utf8
}

Write-Log 'Enabling OpenSSH Server'

$cap = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' | Select-Object -First 1
if (-not $cap) { throw 'OpenSSH.Server capability not present in this image' }
if ($cap.State -ne 'Installed') { Add-WindowsCapability -Online -Name $cap.Name }

# sshd creates this directory on its first start, which has not happened yet
$sshDir = 'C:\ProgramData\ssh'
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null

$keyPath = Join-Path $sshDir 'administrators_authorized_keys'
Set-Content -Path $keyPath `
    -Value 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... you@example.com' `
    -Encoding ascii

icacls $keyPath /inheritance:r /grant 'SYSTEM:F' /grant 'BUILTIN\Administrators:F'
if ($LASTEXITCODE -ne 0) { throw "icacls failed on $keyPath (exit $LASTEXITCODE)" }
Write-Log 'Key written and ACLs applied'

if (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue) {
    Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -Enabled True -Profile Any
} else {
    New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' `
        -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound `
        -Protocol TCP -Action Allow -LocalPort 22 -Profile Any
}

Set-Service -Name sshd -StartupType Automatic
Start-Service sshd          # last, deliberately
Write-Log 'sshd started'
```

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.

{% hint style="info" %}
`$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.
{% endhint %}
{% endtab %}

{% tab title="Create a local admin" %}
Creates an additional administrator account, so you are not dependent on the built-in Administrator password.

```powershell
$ErrorActionPreference = 'Stop'
$log = 'C:\postinstall.log'
function Write-Log($m) {
    "$(Get-Date -Format o)  $m" | Out-File -FilePath $log -Append -Encoding utf8
}

$user = 'deploy'
$password = ConvertTo-SecureString 'REPLACE-WITH-A-STRONG-PASSWORD' -AsPlainText -Force

New-LocalUser -Name $user -Password $password `
    -FullName 'Deployment account' `
    -Description 'Created by FlexMetal post-install script' `
    -PasswordNeverExpires
Add-LocalGroupMember -Group 'Administrators' -Member $user
Write-Log "Created local administrator $user"

# Allow this account to sign in over RDP
Add-LocalGroupMember -Group 'Remote Desktop Users' -Member $user -ErrorAction SilentlyContinue
```

{% hint style="danger" %}
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](#handling-credentials).
{% endhint %}
{% endtab %}

{% tab title="Enable WinRM" %}
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.

```powershell
$ErrorActionPreference = 'Stop'
$log = 'C:\postinstall.log'
function Write-Log($m) {
    "$(Get-Date -Format o)  $m" | Out-File -FilePath $log -Append -Encoding utf8
}

# Only these sources may reach WinRM. Replace with your own addresses.
$allowed = @('203.0.113.10/32', '198.51.100.0/24')

Enable-PSRemoting -Force -SkipNetworkProfileCheck
Write-Log 'PSRemoting enabled'

# HTTPS listener on 5986 with a self-signed certificate
$hostName = [System.Net.Dns]::GetHostByName($env:COMPUTERNAME).HostName
$cert = New-SelfSignedCertificate -DnsName $hostName `
    -CertStoreLocation 'Cert:\LocalMachine\My'

New-Item -Path 'WSMan:\localhost\Listener' -Transport HTTPS -Address * `
    -CertificateThumbPrint $cert.Thumbprint -Force | Out-Null
Write-Log "HTTPS listener created for $hostName"

New-NetFirewallRule -Name 'WINRM-HTTPS-In-TCP-Restricted' `
    -DisplayName 'Windows Remote Management (HTTPS-In, restricted)' `
    -Enabled True -Direction Inbound -Protocol TCP -Action Allow `
    -LocalPort 5986 -Profile Any -RemoteAddress $allowed

# Close the plaintext HTTP listener that Enable-PSRemoting opens on 5985
Get-ChildItem -Path 'WSMan:\localhost\Listener' |
    Where-Object { $_.Keys -contains 'Transport=HTTP' } |
    Remove-Item -Recurse -Force
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP*' -ErrorAction SilentlyContinue |
    Set-NetFirewallRule -Enabled False
Write-Log 'HTTP listener on 5985 removed'
```

{% hint style="warning" %}
`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.
{% endhint %}
{% endtab %}

{% tab title="Restrict access by IP" %}
Locks the server down to an allowlist: everything inbound is denied by default, and only the addresses you name may reach your management ports.

```powershell
$ErrorActionPreference = 'Stop'
$log = 'C:\postinstall.log'
function Write-Log($m) {
    "$(Get-Date -Format o)  $m" | Out-File -FilePath $log -Append -Encoding utf8
}

# Your office ranges, VPN exit, jump host — replace these.
$allowed = @('203.0.113.10/32', '198.51.100.0/24')

# 1. Narrow every existing inbound allow rule for RDP down to the allowlist.
#    Matching on the port rather than the rule's display name keeps this working
#    regardless of the image's display language.
Get-NetFirewallPortFilter |
    Where-Object { $_.LocalPort -eq 3389 } |
    Get-NetFirewallRule |
    Where-Object { $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } |
    Set-NetFirewallRule -RemoteAddress $allowed -Profile Any
Write-Log 'Existing RDP rules scoped to the allowlist'

#    ...and add our own scoped RDP rule as a safety net. The step above only
#    narrows rules that already exist and are enabled; if the image ships none,
#    step 5 below would leave you with no way in at all.
New-NetFirewallRule -Name 'Allowlist-RDP-In' -DisplayName 'RDP (allowlist)' `
    -Enabled True -Direction Inbound -Protocol TCP -Action Allow `
    -LocalPort 3389 -Profile Any -RemoteAddress $allowed

# 2. Add scoped rules for the other management ports.
New-NetFirewallRule -Name 'Allowlist-SSH-In' -DisplayName 'SSH (allowlist)' `
    -Enabled True -Direction Inbound -Protocol TCP -Action Allow `
    -LocalPort 22 -Profile Any -RemoteAddress $allowed

New-NetFirewallRule -Name 'Allowlist-WinRM-HTTPS-In' -DisplayName 'WinRM HTTPS (allowlist)' `
    -Enabled True -Direction Inbound -Protocol TCP -Action Allow `
    -LocalPort 5986 -Profile Any -RemoteAddress $allowed

# 3. Allow ICMP echo from the allowlist so monitoring can ping the box.
New-NetFirewallRule -Name 'Allowlist-ICMPv4-In' -DisplayName 'ICMPv4 echo (allowlist)' `
    -Enabled True -Direction Inbound -Protocol ICMPv4 -IcmpType 8 `
    -Action Allow -Profile Any -RemoteAddress $allowed

# 4. Publicly reachable service ports, open to everyone.
New-NetFirewallRule -Name 'Public-HTTPS-In' -DisplayName 'HTTPS (public)' `
    -Enabled True -Direction Inbound -Protocol TCP -Action Allow `
    -LocalPort 443 -Profile Any

# 5. Default-deny everything else inbound, on every profile.
Set-NetFirewallProfile -Profile Domain,Private,Public `
    -DefaultInboundAction Block -DefaultOutboundAction Allow -Enabled True
Write-Log 'Default inbound action set to Block'
```

{% hint style="danger" %}
**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](/compute/flexmetal/reinstall-your-servers.md). 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).
{% endhint %}

{% hint style="info" %}
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.
{% endhint %}
{% endtab %}
{% endtabs %}

## Reinstalling with a different script

`postInstallScript` is also accepted on the [reinstall](/compute/flexmetal/reinstall-your-servers.md) endpoint, [`PATCH /v3/flexMetal/servers/{uuid}`](https://docs.i3d.net/api/api_flexmetal#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](#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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.i3d.net/compute/flexmetal/post-install-scripts.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
