🚀 First Boot on Every Cloud: cloud-init and User Data
How a new server configures itself on Azure, AWS, Google Cloud and Oracle Cloud: where each cloud takes the first-boot file, how to write and check it before launch, how to combine scripts, how to template it per cloud, and how to debug it
💡 How this page was tested. The cloud-init parts were run for real on 6 October 2026, with the cloud-init 26.1 package that Ubuntu 24.04 ships (it is installed but switched off on this machine, so nothing was configured): the schema validator (including its --annotate mode and the network-config checker), make-mime for combining files, and render for jinja templates. Nothing here ran on a cloud: how each cloud hands the file to the machine is from the providers' documentation, read on 6 October 2026. The EC2 page already uses a user-data file for a web server and shows the AWS side in detail, and the FreeBSD Corner's cloud page does the same job on FreeBSD with nuageinit, which is a lookalike of the cloud-init format.
The idea
You do not want to log in to a new server and type commands. You want the server to configure itself the first time it starts, from a file that you keep in version control, so that a new one is the same as the last. Every cloud supports that, in a similar way:
- When you create the machine, you give the cloud a small text file: user data (or "custom data", or an "initialization script").
- The cloud makes the file available to the machine through its metadata service (and sometimes a virtual disk).
- A program inside the image, cloud-init on most Linux cloud images, reads it on first boot and does what it says: set the host name, create users, install packages, write files, run commands.
The file can be a shell script (starting with #!) or a cloud-config (a YAML file starting with the line #cloud-config). A cloud-config is declarative and works the same on Debian, Ubuntu, Red Hat and others: you list packages to install, and cloud-init uses the right package manager. That is why the rest of this page is about cloud-config.
How each cloud takes the file
From the documentation (read on 6 October 2026):
- AWS: user data, given at launch (--user-data file://FILE with the CLI). Limit 16 KB raw. Runs on the first boot only by default, as root. Output goes to /var/log/cloud-init-output.log. The details are on the EC2 page.
- Azure: custom data, given with az vm create --custom-data cloud-init.txt. It needs an image that is "cloud-init ready" (Microsoft's list includes Ubuntu 20.04, 22.04 and 24.04, recent Red Hat, Oracle Linux, SUSE and some Debian images). cloud-init runs on first boot, and, unlike the older Azure Linux Agent, it has no deployment time limit and cannot make the deployment fail by timing out. Microsoft's warning: a module failing does not always fail the whole run (a failing runcmd script still reports provisioning as succeeded, because the module ran). The log to read is /var/log/cloud-init.log.
- Google Cloud: different on purpose. Compute Engine's mechanism is the startup-script metadata key (up to 256 KB; larger scripts go to Cloud Storage with startup-script-url). It runs as root on every boot, not just the first: gcloud compute instances create ... --metadata-from-file startup-script=FILE. Logs: journalctl -u google-startup-scripts.service, and you can rerun with sudo google_metadata_script_runner startup. Shutdown scripts exist too. (The documentation page I read does not say whether cloud-init user data is supported on a given image, so check your image's documentation before assuming a #cloud-config file works there.)
- Oracle Cloud: the initialization script (in the console, under advanced options and management) or oci compute instance launch --user-data-file FILE. The user data plus other metadata may be 32,000 bytes in total, it runs on the first boot through cloud-init (Linux), and Oracle's warning matters: do not put anything in it that reboots the machine, because that can make the launch fail. Do reboot-requiring work after the instance is running.
The pattern: AWS, Azure and Oracle run cloud-init once at first boot; Google's startup script runs every boot. A script that is safe to run once may not be safe to run every time, so on Google Cloud write startup scripts to be repeatable (check whether a thing exists before creating it).
The file: a cloud-config
This is the web server file from the EC2 page, extended with a user that has an SSH key (a made-up example key) and no password logins:
#cloud-config
hostname: web1
users:
- name: deploy
groups: [sudo]
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYEXAMPLEKEYEXAMPLEKEYEXAMPLEKEY lab-key
package_update: true
packages:
- nginx
ssh_pwauth: false
runcmd:
- systemctl enable --now nginx
The modules you will use most: hostname, users (with ssh_authorized_keys: public keys only), packages and package_update, write_files (a file with an owner, a mode and content), runcmd (commands, in order, once), and ssh_pwauth. Two warnings about this example: NOPASSWD:ALL is convenient and generous: use a narrower sudo rule for anything that matters (the IAM page is about least privilege, and it applies on the server too). And the file is not a place for secrets (below).
Check it before you launch
A mistake in this file is found after the machine has booted, in a log, minutes later. cloud-init can check the file first, offline. A good file, and a bad one with the --annotate option, which prints the file with the errors marked at their lines (as run):
user@lab:~$ cloud-init schema --config-file good.yaml
Valid schema good.yaml
user@lab:~$ cloud-init schema --config-file bad.yaml --annotate
Error: Invalid schema: user-data
#cloud-config
users:
- name: deploy
sudo: yes please
ssh_authorized_keys: ssh-ed25519 AAAA lab-key # E1
packages:
- nginx
runcmd: systemctl restart nginx # E2
write_files:
- path: /etc/motd
content: hi
owner: root
# Errors: -------------
# E1: 'ssh-ed25519 AAAA lab-key' is not of type 'array'
# E2: 'systemctl restart nginx' is not of type 'array'
- The good file: "Valid schema good.yaml".
- The bad file has two type mistakes, and --annotate shows them in place: ssh_authorized_keys was given a single string where a list is required (E1), and runcmd was given a string where a list is required (E2). The legend at the bottom spells out each error.
- What the validator does not catch is just as important. The same bad file also has sudo: yes please (nonsense, but a valid string) and owner: root with no group, and neither was reported. The validator checks the shape (keys, types), not the meaning. "Valid schema" means cloud-init will accept the file, not that it does what you want.
Run cloud-init schema --config-file FILE --annotate on every cloud-config before launch, and put it in your pipeline so a broken file never reaches a cloud.
Mixing a script and a cloud-config
Sometimes you want both: declarative config, and a script for the odd step. User data holds one content type, so you pack them into a multi-part MIME message (AWS's documentation shows the format; cloud-init itself accepts it, but check in your cloud's documentation that it passes the message through unchanged). cloud-init has a tool to build it so that you do not write the boundaries by hand (as run):
user@lab:~$ cloud-init devel make-mime -a good.yaml:cloud-config -a script.sh:x-shellscript | cut -c1-76 Content-Type: multipart/mixed; boundary="===============NNNN==" MIME-Version: 1.0 --===============NNNN== Content-Type: text/cloud-config; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: base64 Content-Disposition: attachment; filename="good.yaml" I2Nsb3VkLWNvbmZpZwpob3N0bmFtZTogd2ViMQp1c2VyczoKICAtIG5hbWU6IGRlcGxveQogICAg Z3JvdXBzOiBbc3Vkb10KICAgIHNoZWxsOiAvYmluL2Jhc2gKICAgIHN1ZG86IEFMTD0oQUxMKSBO T1BBU1NXRDpBTEwKICAgIHNzaF9hdXRob3JpemVkX2tleXM6CiAgICAgIC0gc3NoLWVkMjU1MTkg QUFBQUMzTnphQzFsWkRJMU5URTVBQUFBSUVYQU1QTEVLRVlFWEFNUExFS0VZRVhBTVBMRUtFWUVY QU1QTEVLRVkgbGFiLWtleQpwYWNrYWdlX3VwZGF0ZTogdHJ1ZQpwYWNrYWdlczoKICAtIG5naW54 CnNzaF9wd2F1dGg6IGZhbHNlCnJ1bmNtZDoKICAtIHN5c3RlbWN0bCBlbmFibGUgLS1ub3cgbmdp bngK --===============NNNN== Content-Type: text/x-shellscript; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: base64 Content-Disposition: attachment; filename="script.sh" IyEvYmluL2Jhc2gKZWNobyAiYm9vdGVkIGF0ICQoZGF0ZSAtdSArJUZUJVRaKSIgPiAvdmFyL2xv Zy9maXJzdC1ib290LnR4dAo= --===============NNNN==--
make-mime wrapped good.yaml as text/cloud-config and script.sh as text/x-shellscript, each base64-encoded, in one message (the middle of the encoded parts is left out here). Validate the parts before you combine them: the schema checker does not understand the combined message:
user@lab:~$ cloud-init schema --config-file mime.txt Error: Cloud config schema errors: format-l1.c1: Unrecognized user-data header in mime.txt: "Content-Type: multipart/mixed; boundary="===============5471724308722539521=="". Expected first line to be one of: #!, ## template: jinja, #cloud-boothook, #cloud-config, #cloud-config-archive, #cloud-config-jsonp, #include, #include-once, #part-handler Error: Invalid schema: user-data Invalid user-data mime.txt
It stops at the first line, saying the header is not recognized and listing what it expects: #!, ## template: jinja, #cloud-boothook, #cloud-config, #cloud-config-archive, #include and others. So: validate each part, then make-mime, then launch.
One file for every cloud: templating
You can write one cloud-config that adapts to where it runs, with jinja templating. Put ## template: jinja as the first line, and refer to the instance data: the standard keys include v1.cloud_name, v1.region and v1.instance_id. On a real instance the data comes from the cloud's metadata (the cloud-init query command prints it). cloud-init devel render shows the result offline, given a small file of instance data. As run, with the data of an imaginary AWS machine (inst.json: cloud aws, region eu-west-1):
user@lab:~$ cloud-init devel render -i inst.json tpl.yaml
#cloud-config
hostname: web-eu-west-1-1
write_files:
- path: /etc/motd
content: |
Cloud: aws Region: eu-west-1
The template line hostname: web-{{ v1.region }}-1 became web-eu-west-1-1, and the motd text picked up the cloud name and region. Use it for host names, region-specific settings and to fork a few lines by cloud ({% if v1.cloud_name == 'aws' %}...{% endif %}) while keeping one file in version control. Always render with sample data for each cloud before you launch.
Network configuration
Normally the cloud provides the network configuration itself (DHCP), and you do nothing. When you must override it (a fixed address, a second interface), cloud-init takes a network config in the version 2 (netplan-style) format, and the schema tool checks it too (--schema-type network-config). Real output on a file that looked fine:
user@lab:~$ cloud-init schema --schema-type network-config --config-file net.yaml
Error: Cloud config schema errors: format-l5.c24: Invalid netplan schema. Error in network definition: expected sequence
addresses: 10.0.1.10/24
^
Error: Invalid schema: network-config
Invalid network-config net.yaml
The file said addresses: 10.0.1.10/24, and the checker answered "Invalid netplan schema ... expected sequence" and points at the column: addresses must be a list. Another file with dhcp4: maybe was rejected too:
user@lab:~$ cloud-init schema --schema-type network-config --config-file net-bad.yaml
Error: Cloud config schema errors: format-l5.c20: Invalid netplan schema. Error in network definition: invalid boolean value 'maybe'
dhcp4: maybe
^
Error: Invalid schema: network-config
Invalid network-config net-bad.yaml
(invalid boolean value 'maybe'.) And the corrected version:
user@lab:~$ cloud-init schema --schema-type network-config --config-file net-ok.yaml Valid schema net-ok.yaml
(addresses: [10.0.1.10/24] is valid.) A broken network config can leave a machine you cannot log in to, so validating it before launch matters even more here. (The check is only for the shape: the addresses must also fit your cloud's network: see the networking page.)
Debugging on the machine
When the server did not turn out as written, log in (if you can) and use cloud-init's own tools. The real subcommand list, and the status command on a machine where cloud-init is installed but turned off:
user@lab:~$ cloud-init --help | sed -n '1,28p'
usage: /usr/bin/cloud-init [-h] [--version] [--debug] [--force] [--all-stages]
{init,modules,single,query,features,analyze,devel,collect-logs,clean,status,schema}
...
options:
-h, --help show this help message and exit
--version, -v Show program's version number and exit.
--debug, -d Show additional pre-action logging (default: False).
--force Force running even if no datasource is found (use at
your own risk).
--all-stages Run cloud-init's stages under a single process using a
synchronization protocol. This is not intended for CLI
usage.
Subcommands:
{init,modules,single,query,features,analyze,devel,collect-logs,clean,status,schema}
init Initialize cloud-init and perform initial modules.
modules Activate modules using a given configuration key.
single Run a single module.
query Query standardized instance metadata from the command
line.
features List defined features.
analyze Devel tool: Analyze cloud-init logs and data.
devel Run development tools.
collect-logs Collect and tar all cloud-init debug info.
clean Remove logs and artifacts so cloud-init can re-run.
status Report cloud-init status or wait on completion.
schema Validate cloud-config files using jsonschema.
- cloud-init status says whether it is running, done, in error or disabled (status: disabled on this desktop machine, as run: below). cloud-init status --wait blocks until it finishes, which is useful in scripts that must not start before first boot is done.
- cloud-init collect-logs bundles the logs and debug information into an archive, which is what you attach when asking for help. cloud-init analyze is a development tool that analyses the logs. cloud-init query shows the instance data that the cloud supplied.
- cloud-init clean "removes logs and artifacts so cloud-init can re-run": used when preparing an image, so that machines launched from it run first boot again. Do not run it on a server you care about.
- The log files: /var/log/cloud-init.log (what cloud-init did: the one Azure points to), /var/log/cloud-init-output.log (the output of your commands: the one AWS points to). Search the module name (runcmd, write_files, users).
user@lab:~$ cloud-init status status: disabled
Also remember what each cloud told us: on Azure a failing runcmd does not fail provisioning (look at the log, not at the "success" message), on Google Cloud the script runs every boot (the log is in the journal), and on Oracle a reboot inside the script can break the launch.
Security
- User data is not a safe place for secrets. It is stored with the instance, readable by anyone with access to the instance or the cloud console settings, and available to programs on the machine through the metadata service (the reason for requiring IMDSv2 on AWS: EC2 page). Put public SSH keys in it, never passwords or tokens. Fetch secrets later from a secrets service, using the machine's identity (the IAM page).
- Do not enable password logins (ssh_pwauth: false above), and prefer a named user with a key to the image's default user.
- Scripts stay on disk. AWS notes the script is kept in /var/lib/cloud/instances/INSTANCE-ID/ and not deleted: remove it before you make an image from the machine.
- Limits differ: AWS 16 KB raw, Oracle 32,000 bytes of user data plus metadata, Google 256 KB for a startup script (larger ones from Cloud Storage). Keep the file small and let it fetch bigger things.
Where first boot stops being enough
User data runs once (except on Google Cloud), so it is a bootstrap, not a way to manage a server over time. The usual grown-up pattern: a small user-data file that installs the basics and then hands over to configuration management (the site's Ansible tutorial in the Sysadmin tab) or to a pre-built image, and the infrastructure itself is described in Terraform, which can pass the file to the machine (user_data on AWS, custom_data on Azure). A server that drifts from its file is a server you cannot rebuild: so change the file and replace the machine rather than patching by hand.
Common mistakes
- Missing the first line. Without #cloud-config (exactly), the file is not treated as cloud-config.
- Wrong types (a string where a list is needed): the validator finds them.
- Trusting "valid schema" to mean "correct".
- Secrets in user data.
- A reboot in the script (Oracle), or a script that is not repeatable (Google).
- Expecting changes to apply on the next boot: user data runs at first boot only. Changing it on a running machine does nothing on AWS, Azure and Oracle.
- Choosing an image without cloud-init (on Azure, the list of ready images) and wondering why nothing happened.
- Never reading the log. /var/log/cloud-init-output.log has the answer.
- Testing only by launching. Validate, render and make-mime locally first: it costs nothing.
What this page did not cover
Cloud-init on non-Linux systems (Windows uses its own launch agents), boothooks and part handlers, disk and filesystem setup modules, cloud-init with Ansible (ansible-pull), image building with Packer, vendor data and the datasource order, the Azure provisioning agent in detail. The cloud-init documentation covers them.
The cheat sheet
- Idea: a small file at launch, read by cloud-init on first boot. Cloud-config (#cloud-config, YAML, declarative) or a script (#!).
- Delivery: AWS --user-data file://F (16 KB, once) | Azure --custom-data F (cloud-init-ready image, once, log /var/log/cloud-init.log) | Google startup-script metadata (256 KB, every boot, journal) | Oracle initialization script or --user-data-file F (32,000 bytes with metadata, once, no reboot inside).
- Check: cloud-init schema --config-file F --annotate (shape, not meaning). Network: --schema-type network-config. Validate parts before make-mime.
- Combine: cloud-init devel make-mime -a good.yaml:cloud-config -a script.sh:x-shellscript.
- Template: first line ## template: jinja, keys v1.cloud_name, v1.region, v1.instance_id. Test with cloud-init devel render -i inst.json FILE.
- Debug: cloud-init status [--wait], collect-logs, analyze, query; logs /var/log/cloud-init.log and cloud-init-output.log. cloud-init clean is for images.
- Safety: public keys only, no secrets, no password logins, remove scripts before imaging, replace servers instead of patching them.
← Cloud Cost Control: Stop Surprises Before They Start · ↑ All tutorials · Cloud Backups and Disaster Recovery: Protect, Copy, Restore →