SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

What cloud-init does on a new VPS

cloud-init runs once on a new VPS: datasource, user-data, default user, SSH host keys. Read the logs, and fix the fresh image that will not let you log in.

What cloud-init does on a new VPS

cloud-init is the program that turns a generic disk image into your server on the first boot of a new VPS. It reads a small block of configuration that your provider hands to the machine, called user-data, and then creates your login user, installs your SSH public key, sets the hostname, grows the root filesystem to fill the disk, generates fresh SSH host keys, and runs any commands you gave it. It does this once per instance. On every boot after that it starts, decides there is nothing new to do, and exits.

This is why one Ubuntu image works on twenty different hosting providers. The image knows nothing about you. cloud-init is the part that asks, and the provider's metadata service is the part that answers. Every mainstream cloud image ships it: Ubuntu, Debian, Fedora, Rocky Linux, AlmaLinux, openSUSE.

The datasource: where the configuration comes from

A datasource is cloud-init's name for the place it reads instance metadata from. On most VPS providers that is an HTTP endpoint at the link-local address 169.254.169.254, reachable only from inside the machine. Other datasources exist. NoCloud reads a small filesystem labelled cidata from an attached ISO, which is how local KVM and Proxmox setups pass user-data. ConfigDrive reads an attached OpenStack volume.

Detection happens before the general network is up, in a shell script called ds-identify. Ask the running system what it decided:

cloud-id
sudo cat /run/cloud-init/ds-identify.log

cloud-id prints one word, such as openstack, nocloud or hetzner. If it prints none, cloud-init found no datasource, so nothing in your user-data ever ran, and the reason is in that ds-identify log.

The order things happen in on first boot

cloud-init runs as four systemd units at four points in the boot, and each unit runs a different list of modules.

  • cloud-init-local.service runs before networking. It picks the datasource and can write the network configuration that the rest of the boot depends on.
  • cloud-init-network.service runs once the network is up. It fetches user-data, then runs the init modules: write_files, growpart, resizefs, set_hostname, users-groups and ssh.
  • cloud-config.service runs the config modules, including set-passwords, apt-configure, timezone and runcmd.
  • cloud-final.service runs last: package installation, then scripts-user, then keys-to-console and final-message.

On cloud-init older than 24.3 the second unit is called cloud-init.service. The rename kept an alias, so both names resolve on current images.

Two details in that list save real debugging time. Your user and your authorized_keys file are created in the network stage, not at the end, so a failure late in the boot rarely explains a missing login. And the runcmd module in the config stage does not run your commands: it writes them into a script, and scripts-user in the final stage executes it. Because package installation is ordered before scripts-user, anything you list under packages: is already installed by the time your runcmd lines run. You can rely on that ordering.

Read your own image's module list rather than trusting mine:

grep -n -A 40 cloud_init_modules /etc/cloud/cloud.cfg

What the default user actually gets

Every cloud image defines a default user, and the name differs per distribution. cloud-init creates it, puts it in the distribution's admin group, and writes a sudoers drop-in for it:

sudo cat /etc/sudoers.d/90-cloud-init-users

You will see something close to this:

# Created by cloud-init v. 24.4 on Sat, 22 Aug 2026 09:14:02 +0000
ubuntu ALL=(ALL) NOPASSWD:ALL

NOPASSWD:ALL is deliberate. The default user has no password hash at all, so the account is locked for password logins and sudo could never prompt for one successfully. Key authentication is the only way in, which is the intended design. It also means there is no fallback if your key does not land. Setting a root password from the provider console is the usual way back in when that happens.

The default username, per image, as of August 2026:

  • Ubuntu: ubuntu
  • Debian: debian
  • Fedora Cloud: fedora
  • Rocky Linux: rocky
  • AlmaLinux: almalinux
  • RHEL and CentOS Stream generic cloud images: cloud-user
  • Amazon Linux: ec2-user

If you already have a shell, the image states it directly:

grep -A 3 default_user /etc/cloud/cloud.cfg

SSH host keys and the fingerprints on the console

The ssh module deletes the host keys baked into the image and generates a new set for this instance. This matters. If every VPS booted from one image kept the same host key, anyone holding that image could impersonate your server. Regeneration is also why a rebuilt server greets you with WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!, because the key is new and your local known_hosts still holds the old one.

cloud-init prints the new fingerprints to the serial console, between marker lines:

-----BEGIN SSH HOST KEY FINGERPRINTS-----
256 SHA256:hK9k...redacted... root@web01 (ED25519)
-----END SSH HOST KEY FINGERPRINTS-----

Open your provider's console view on the first boot and compare that against the fingerprint your SSH client shows you. That is the only trust-on-first-use check that is actually a check. Host keys belong to the instance rather than to the disk, so an image you cloned yourself needs the same care as a cloned VPS whose machine ID was never regenerated.

A user-data file you can read

user-data in #cloud-config form is YAML. The first line must be exactly #cloud-config, with no blank line above it and no leading spaces. cloud-init decides how to handle the payload by reading that first line, so a stray blank line means your YAML is treated as an unknown content type and quietly ignored.

#cloud-config
hostname: web01
users:
  - default
  - name: deploy
    shell: /bin/bash
    groups: [sudo]
    sudo: "ALL=(ALL) NOPASSWD:ALL"
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleReplaceThisKey you@laptop

package_update: true
packages:
  - nftables
  - unattended-upgrades

write_files:
  - path: /etc/ssh/sshd_config.d/60-local.conf
    permissions: "0644"
    content: |
      PasswordAuthentication no
      PermitRootLogin no

runcmd:
  - [systemctl, enable, --now, nftables]
  - [systemctl, restart, ssh]

The - default entry in the users: list is the one people delete. If you write a users: block and leave default out, cloud-init creates only the users you named and does not create the image's default user. That is correct behaviour, and it is also the fastest way to lock yourself out of a machine you have not built yet. Keep default in the list until your own user is proven to work.

Two more notes on that file. write_files runs before users-groups, so an entry with owner: deploy:deploy fails because the user does not exist yet; add defer: true to that entry and it is written in the final stage instead. And YAML rejects tab characters for indentation, so an editor that inserts tabs produces a parse error rather than a partial run.

Validate the user-data this instance actually received:

sudo cloud-init schema --system --annotate

That prints the offending line with an arrow under it. On cloud-init older than 22.2 the command is cloud-init devel schema --system.

Why the second boot behaves differently from the first

cloud-init tracks work per instance, not per boot. The datasource reports an instance ID, and cloud-init compares it against the cached copy on disk:

cat /var/lib/cloud/data/instance-id
ls /var/lib/cloud/instance/sem/

Each file in that sem/ directory is a semaphore, a marker saying that a module already ran for this instance. Modules declared once-per-instance check for their semaphore and skip. runcmd, users-groups, write_files and package installation are all in that group. That is why editing user-data in the provider panel and rebooting changes nothing at all.

When the instance ID changes, and it does when a provider rebuilds the server or when you boot a clone, cloud-init sees a new instance, creates a fresh directory under /var/lib/cloud/instances/, and runs everything again including host key regeneration.

To force a re-run on purpose while testing, clear the cached state:

sudo cloud-init clean --logs --reboot

That deletes cloud-init's state and its logs, then reboots. Run it only on a machine you are willing to lose. If your current user was created by user-data and that user-data is broken, you will not get back in.

To stop cloud-init touching a machine you now manage by hand:

sudo touch /etc/cloud/cloud-init.disabled

Which log to read when cloud-init did not do what you asked

Start with the status. It is the only command that separates "still working" from "finished" from "failed":

cloud-init status --long

status: done means every stage completed. status: running on a machine that has been up for ten minutes usually means a stage is blocked waiting for the network or for the metadata service. status: error names the failing module and prints the exception under an errors: key. There is also cloud-init status --wait, which blocks until the run finishes. Put that at the top of any script that runs early in a machine's life.

Then two logs, and they are not the same log.

  • /var/log/cloud-init.log is cloud-init's own record: the datasource it chose, which modules ran in which stage, and the Python traceback when one raised an exception.
  • /var/log/cloud-init-output.log is the stdout and stderr of everything cloud-init executed on your behalf. Your runcmd lines, apt output and any script failure text land here. When a package install failed, this is the file that says why.
sudo grep -nE 'WARNING|ERROR|Traceback' /var/log/cloud-init.log | tail -n 40
sudo tail -n 60 /var/log/cloud-init-output.log
sudo cat /run/cloud-init/result.json

result.json is a short summary holding an errors list, which is convenient for a monitoring check. To see the user-data the machine really received, which is not always the text you pasted into the panel, read it back and time the run:

sudo cloud-init query userdata
sudo cloud-init analyze blame | head -n 20

analyze blame sorts modules by how long each took. That is how you find the boot that took four minutes because package_update: true reached a slow mirror.

No login on a fresh image: the four usual causes

The symptom is always the same string, and it is deliberately uninformative:

Permission denied (publickey).

sshd prints that when the username does not exist, when the key is missing, when the file permissions are wrong, and when SELinux blocks the read. Naming the cause would tell an attacker which one to work on. So work through the four in order. Open your provider's web console first, because every fix below needs a shell on the box.

1. The username is wrong for that distribution

Trying root@ on an Ubuntu image, or ubuntu@ on a Rocky Linux image, gives Permission denied (publickey) with no hint that the account does not exist. Check the list above, or check your provider's image documentation. Run your SSH command with -v, as in ssh -v deploy@203.0.113.10. The verbose output shows which key your client offered, which at least confirms the client side is doing its part.

2. The key never reached authorized_keys

From the console, look at the file cloud-init should have written:

sudo cat /home/ubuntu/.ssh/authorized_keys

An empty or missing file means the key never arrived. Either the datasource had no key for this instance, which happens when the key was not selected in the create form, or a users: block replaced the default user, or the key was pasted with a line break in the middle of it. A public key is one line. ssh-ed25519 AAAA... split across two lines is two broken lines. Compare against what the instance was actually given with sudo cloud-init query userdata. The shape of a valid key file is covered in our guide to generating and managing SSH keys.

3. The image disabled password authentication

Ubuntu and Debian cloud images ship with password logins off, so the fallback you were reaching for is not there. cloud-init writes its decision into a drop-in file on images whose sshd_config starts with an Include /etc/ssh/sshd_config.d/*.conf line:

sudo sshd -T | grep -iE 'passwordauthentication|permitrootlogin'
sudo ls /etc/ssh/sshd_config.d/

sshd -T prints the effective configuration after every include is resolved, and that is the value that decides your login. The main sshd_config saying PasswordAuthentication yes counts for nothing if a drop-in sets no. To turn it on for the next build, set ssh_pwauth: true in user-data. To turn it on right now from the console, edit the drop-in and run sudo systemctl restart ssh.

4. Ownership, modes or SELinux on ~/.ssh

sshd refuses to read a key file that other users could write to. It says so, but only in its own log:

Authentication refused: bad ownership or modes for directory /home/deploy/.ssh
sudo journalctl -u ssh -n 50 --no-pager
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys

The unit is ssh on Debian and Ubuntu, and sshd on the Red Hat family. On Fedora, Rocky Linux, AlmaLinux and RHEL there is a second layer: SELinux labels. A file carrying the wrong context is unreadable to sshd even with perfect permissions, and the log line is a generic refusal rather than an SELinux message, which is what makes this one slow to find. Restore the labels and check the audit log:

getenforce
sudo restorecon -R -v /home/deploy/.ssh
sudo ausearch -m avc -ts recent

The client-side causes, which are the other half of this problem, are worked through in our walkthrough of Permission denied (publickey).

Once you are in

A successful cloud-init run leaves you with a user, a key and a fresh set of host keys. The rest of the server is yours to build. Confirm the run really finished before you start:

cloud-init status --long
sudo systemctl --failed

Keep your user-data small and boring. A file that creates one user and installs two packages is a file you can read at a glance when a boot goes wrong. Anything more complicated belongs in a configuration tool you can run repeatedly and watch, because cloud-init runs once and reports its failures quietly. For the hardening and housekeeping that comes next, work through the first ten minutes on a new VPS.

FAQ

Where are the cloud-init logs on a VPS?

Two files, and they hold different things. /var/log/cloud-init.log is cloud-init's own record: the datasource it selected, every module it ran, and the Python traceback if one failed. /var/log/cloud-init-output.log is the stdout and stderr of the commands cloud-init ran for you, so runcmd and apt output land there. Start with cloud-init status --long, which names the failing module, then grep the first file for WARNING and Traceback and read the tail of the second.

Why did my edited user-data do nothing on reboot?

cloud-init tracks work per instance, not per boot. It compares the instance ID reported by the datasource against the cached copy in /var/lib/cloud/data/instance-id. When they match, every module marked once-per-instance finds its semaphore file in /var/lib/cloud/instance/sem/ and skips, and that group includes runcmd, users-groups, write_files and package installation. To test a change, run sudo cloud-init clean --logs --reboot on a machine you can afford to lose, or destroy the server and create a new one.

What is the default username on a cloud image?

It depends on the distribution: ubuntu on Ubuntu, debian on Debian, fedora on Fedora Cloud, rocky on Rocky Linux, almalinux on AlmaLinux, and cloud-user on RHEL and CentOS Stream generic images. Direct root login is refused on most of them. If you have console access, grep -A 3 default_user /etc/cloud/cloud.cfg gives the answer for that exact image instead of a guess.

Can I use user-data to set a password instead of an SSH key?

Yes. chpasswd sets the password and ssh_pwauth: true tells cloud-init to allow password logins, which it does by writing a drop-in under /etc/ssh/sshd_config.d/. Verify the result with sudo sshd -T | grep -i passwordauthentication, because that prints the effective value after all includes are resolved. A password on port 22 is attacked continuously from the moment the server has a public IP, so treat this as a temporary fallback and remove it once your key works.