Cloud-init is the standard tool for configuring Linux servers on their first boot. It ships in the official cloud images of Ubuntu, Debian, Rocky Linux and most other distributions, reads configuration (user data) supplied when the server is created, and uses it to create users, install SSH keys and packages, write files and run commands before you ever log in. In this tutorial you will write a cloud-config file that turns a fresh Ubuntu 24.04 server into a hardened Nginx web server, validate it, and test it on your own machine with LXD containers before using it on real servers.
Prerequisites
To follow this guide you need:
- A server or workstation running Ubuntu 24.04 LTS, for example a CubePath VPS, to act as the test host. At least 2 GB of RAM and 10 GB of free disk space are recommended for the LXD test instances.
- A non-root user with
sudoprivileges. - An SSH key pair on that host. If
ls ~/.ssh/id_ed25519.pubshows nothing, create one withssh-keygen -t ed25519.
How cloud-init works
On every boot, cloud-init first detects its datasource, the place where the platform exposes instance metadata and user data (a metadata HTTP endpoint, a config drive, or in LXD's case files provided by the container manager). It then runs its modules in four stages:
| Stage | What happens |
|---|---|
| Local | Finds the datasource and writes the network configuration, before networking starts |
| Network | Reads user data, sets the hostname, creates users and writes files |
| Config | Runs configuration modules such as timezone and SSH settings |
| Final | Installs packages, runs runcmd commands and user scripts, prints the final message |
Most modules run once per instance: cloud-init stores the instance ID, and on later reboots it skips anything that already ran. That is why user data is a first-boot mechanism, not a way to reconfigure a running server.
User data comes in several formats. The two you will use are a shell script (a file starting with #!) and cloud-config, a YAML file whose first line is exactly #cloud-config. Cloud-config is preferred, because each module is declarative, cloud-init runs them in the correct order, and the file can be validated before use.
Step 1 - Setting up LXD as a test lab
Booting a real server for every change to your user data is slow. LXD system containers boot the same Ubuntu cloud images with cloud-init in a few seconds, which makes them a convenient test lab.
Install LXD from its snap and initialize it with default settings (a storage pool and a NAT bridge called lxdbr0):
sudo snap install lxd
sudo lxd init --minimal
If the snap is already installed, the first command says so and you can continue. Add your user to the lxd group so that you can run lxc without sudo, and start a shell with the new group:
sudo usermod -aG lxd "$USER"
newgrp lxd
Verify that LXD responds:
lxc list
+------+-------+------+------+------+-----------+
| NAME | STATE | IPV4 | IPV6 | TYPE | SNAPSHOTS |
+------+-------+------+------+------+-----------+
An empty table means LXD is working.
Step 2 - Writing the cloud-config file
Print your public key, since you will paste it into the file:
cat ~/.ssh/id_ed25519.pub
Create a working directory and the user data file:
mkdir -p ~/cloud-init-lab
cd ~/cloud-init-lab
nano user-data.yaml
Paste the following configuration, replacing the ssh-ed25519 AAAA... line with your own public key:
#cloud-config
hostname: web01
timezone: UTC
package_update: true
package_upgrade: true
packages:
- nginx
- ufw
users:
- default
- name: deploy
gecos: Deploy user
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAA... your_user@your_host
ssh_pwauth: false
write_files:
- path: /etc/ssh/sshd_config.d/10-hardening.conf
owner: root:root
permissions: "0644"
content: |
PermitRootLogin no
PasswordAuthentication no
- path: /var/www/html/index.html
owner: root:root
permissions: "0644"
defer: true
content: |
<h1>Provisioned by cloud-init</h1>
runcmd:
- [ufw, allow, OpenSSH]
- [ufw, allow, "Nginx Full"]
- [ufw, --force, enable]
- [systemctl, try-reload-or-restart, ssh]
final_message: "cloud-init finished after $UPTIME seconds"
Here is what each section does:
hostnameandtimezoneset basic system identity.package_update,package_upgradeandpackagesrunapt update, upgrade the installed packages and install Nginx and UFW. Upgrading makes the first boot slower but means the server starts with current security fixes.usersstarts withdefault, which keeps the distribution's default user (ubuntuon Ubuntu images). Omit it if you only want your own users. Thedeployuser gets your SSH key, passwordlesssudothrough a sudoers rule, and a locked password, so it can only log in with the key.ssh_pwauth: falsedisables SSH password logins.write_filescreates files. The SSH drop-in disables root login; files in/etc/ssh/sshd_config.d/are read in name order and the first value for each setting wins, so the10-prefix puts it ahead of the image's own drop-ins. The web page usesdefer: true, which postpones writing it until the final stage, after packages are installed.runcmdruns commands once, as root, in the final stage after packages are installed. The list form ([ufw, allow, OpenSSH]) avoids shell quoting problems.Nginx Fullis the UFW application profile that the Nginx package installs, covering ports 80 and 443.final_messageis written to the console and log when cloud-init completes.
WarningKeep the
#cloud-configline as the very first line of the file, with no leading spaces. Without it, cloud-init does not treat the file as cloud-config and silently ignores it.
Step 3 - Validating the file
A typo in a key name does not stop cloud-init; the unknown key is simply ignored, and you only notice when the server is missing something. Validate the file against cloud-init's schema first. Ubuntu 24.04 includes the cloud-init package, so you can run the check on the test host:
cloud-init schema --config-file user-data.yaml --annotate
Valid schema user-data.yaml
To see what an error looks like, change lock_passwd: true to lock_passwd: yes please and run the command again. The --annotate flag prints the file with the problem marked next to the offending line. Revert the change before continuing.
The schema check catches wrong types and unknown keys, but not logic errors such as a package name that does not exist. That is what the test instance in the next step is for.
Step 4 - Launching a test instance
Launch an Ubuntu 24.04 container and pass the file as its user data through the cloud-init.user-data configuration key:
lxc launch ubuntu:24.04 ci-test --config=cloud-init.user-data="$(cat user-data.yaml)"
Creating ci-test
Starting ci-test
The first launch downloads the image, which takes a minute. Wait for cloud-init to finish; --wait blocks until all stages are done:
lxc exec ci-test -- cloud-init status --wait --long
.......................
status: done
extended_status: done
boot_status_code: enabled-by-generator
detail: DataSourceLXD
errors: []
recoverable_errors: {}
status: done with an empty errors list means every module succeeded. If it shows status: error, go to Step 6.
Step 5 - Verifying the result
Check each part of the configuration from the host. The lxc exec commands run as root inside the container.
Hostname and user:
lxc exec ci-test -- hostname
lxc exec ci-test -- id deploy
web01
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)
The web page, served by Nginx:
lxc exec ci-test -- curl -s http://localhost
<h1>Provisioned by cloud-init</h1>
The firewall rules:
lxc exec ci-test -- ufw status
Status: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
Nginx Full ALLOW Anywhere
OpenSSH (v6) ALLOW Anywhere (v6)
Nginx Full (v6) ALLOW Anywhere (v6)
The effective SSH settings, as the SSH daemon resolves them from all its configuration files:
lxc exec ci-test -- sshd -T | grep -E '^(permitrootlogin|passwordauthentication) '
permitrootlogin no
passwordauthentication no
Finally, log in over SSH as deploy with your key. Get the container's IPv4 address:
lxc list ci-test -c 4
Then connect, replacing container_ip with the address from the table:
ssh deploy@container_ip
Inside the session, sudo -v should succeed without asking for a password. Type exit to return to the host.
Step 6 - Debugging cloud-init
When something does not work, cloud-init's own logs usually say why. Open a shell in the container:
lxc exec ci-test -- bash
The two log files to check are:
/var/log/cloud-init-output.log: the output of every command cloud-init ran, includingaptandruncmd. Start here for failed packages or commands./var/log/cloud-init.log: the detailed internal log of each module, useful when a module was skipped or a key was ignored.
Search the detailed log for warnings and failures:
grep -E 'WARNING|Traceback|Failed' /var/log/cloud-init.log
Confirm which user data the instance actually received:
cloud-init query userdata
See how long each stage and module took, which helps when the first boot is slow:
cloud-init analyze blame
Type exit to leave the container. To test a new version of your file, the most reliable way is to delete the container and launch a new one, so that cloud-init runs on a genuinely fresh instance:
lxc delete --force ci-test
lxc launch ubuntu:24.04 ci-test --config=cloud-init.user-data="$(cat user-data.yaml)"
On a disposable machine you can also make cloud-init run again from scratch with sudo cloud-init clean --logs --reboot, which removes its record of the previous run. Never do this on a production server: every once-per-instance module, including user creation and runcmd, runs again.
Using the file on real servers
Once the file passes validation and the LXD test, use the same user-data.yaml when creating servers. Most platforms that boot cloud images, including public clouds, Proxmox VE and OpenStack, accept user data in the server creation form, API or CLI, usually in a field called "User data" or "Cloud-init". Paste the file content unchanged, including the #cloud-config line.
Keep a few points in mind:
- User data is readable by processes on the server through the datasource, and it is often stored in plain text by the platform. Do not put passwords, API tokens or private keys in it. Fetch secrets at boot from a secrets manager, or configure them after provisioning.
- Changing user data on a server that already booted has no effect, because the modules already ran for that instance ID. Rebuild the server, or apply later changes with a configuration management tool such as Ansible.
- Keep the file in Git and run
cloud-init schema --config-filein CI, so every change is reviewed and validated.
Troubleshooting
The container has no IPv4 address and status never reaches done: package installation waits for network access. If UFW is enabled on the test host, it blocks DHCP and DNS on the LXD bridge. Allow the bridge with sudo ufw allow in on lxdbr0, sudo ufw route allow in on lxdbr0 and sudo ufw route allow out on lxdbr0, then recreate the container. Docker on the same host can also block forwarded traffic from LXD; the LXD documentation describes the fix for your setup.
ssh: Permission denied (publickey) when logging in as deploy: the key in ssh_authorized_keys does not match your private key, or it was split across lines when pasting. Compare it with lxc exec ci-test -- cat /home/deploy/.ssh/authorized_keys.
Nothing in the file was applied, and cloud-init query userdata shows it: the first line is not exactly #cloud-config, or the YAML is invalid. Run the schema check from Step 3.
status: error with a runcmd failure: find the failing command in /var/log/cloud-init-output.log. Commands in runcmd run once; fix the file and test on a new instance.
Conclusion
You wrote a cloud-config file that creates a key-only sudo user, installs and upgrades packages, hardens SSH, writes files and configures the firewall on first boot, and you validated and tested it with LXD before using it on real servers. With this workflow, every new server starts in a known, reviewed state within minutes of being created.
As next steps, split the file into a common base for all servers and role-specific additions, hand off ongoing configuration to Ansible after the first boot, and bake slow steps such as package installation into a custom image with Packer so that first boot is faster.
