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 sudo privileges.
  • An SSH key pair on that host. If ls ~/.ssh/id_ed25519.pub shows nothing, create one with ssh-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:

StageWhat happens
LocalFinds the datasource and writes the network configuration, before networking starts
NetworkReads user data, sets the hostname, creates users and writes files
ConfigRuns configuration modules such as timezone and SSH settings
FinalInstalls 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:

  • hostname and timezone set basic system identity.
  • package_update, package_upgrade and packages run apt 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.
  • users starts with default, which keeps the distribution's default user (ubuntu on Ubuntu images). Omit it if you only want your own users. The deploy user gets your SSH key, passwordless sudo through a sudoers rule, and a locked password, so it can only log in with the key.
  • ssh_pwauth: false disables SSH password logins.
  • write_files creates 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 the 10- prefix puts it ahead of the image's own drop-ins. The web page uses defer: true, which postpones writing it until the final stage, after packages are installed.
  • runcmd runs commands once, as root, in the final stage after packages are installed. The list form ([ufw, allow, OpenSSH]) avoids shell quoting problems. Nginx Full is the UFW application profile that the Nginx package installs, covering ports 80 and 443.
  • final_message is written to the console and log when cloud-init completes.

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, including apt and runcmd. 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-file in 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.