🚀 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:

  1. When you create the machine, you give the cloud a small text file: user data (or "custom data", or an "initialization script").
  2. The cloud makes the file available to the machine through its metadata service (and sometimes a virtual disk).
  3. 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):

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'

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

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

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

← Cloud Cost Control: Stop Surprises Before They Start · ↑ All tutorials · Cloud Backups and Disaster Recovery: Protect, Copy, Restore →