Using cloud-init on Ubuntu Server: Automate System Configuration on First Boot

Ubuntu tutorial - IT technology blog
Ubuntu tutorial - IT technology blog

The first month I started deploying infrastructure on the cloud, every time I spun up a new VPS I’d open my checklist and work through it step by step: update packages, create a user, configure SSH, install basic tools… It took 15–20 minutes each time, and I’d still miss a step here and there. After 6 months using cloud-init in production, I have no desire to go back to the old way.

Three Ways to Configure a New Server: A Real-World Comparison

Three approaches I’ve tried — and each one has its place:

Option 1: Manual Step-by-Step Configuration

SSH into the new server and run commands one by one from a checklist. Everyone’s done this. Totally flexible, but not scalable — add 10 servers and there goes your entire morning. And checklists have a way of going stale.

Option 2: Bash Script After Login

Write a setup.sh file, SSH in, and run it. Much better than doing it manually — reproducible, version-controllable. But it still needs someone to trigger it. If the script fails halfway through without idempotency checks, re-running it can cause problems.

Option 3: cloud-init user-data

Pass configuration in at VM/VPS creation time. The server configures itself on first boot — no one needs to SSH in. Most major cloud providers — AWS, GCP, Azure, DigitalOcean, Vultr, Hetzner — support this out of the box.

Pros and Cons Breakdown

Bash Scripts: Good, But Not Quite There

I still use bash scripts alongside cloud-init for complex tasks. But pure bash scripts have a few weaknesses:

  • Runs after the system is fully booted: Requires network and SSH — adds manual steps or a more complex pipeline.
  • No module system: Creating users, configuring SSH keys, writing files — you handle everything yourself.
  • Idempotency must be implemented manually: Scattering checks like if [ ! -f /etc/myapp/configured ]; then everywhere clutters the script.

cloud-init: Runs Earlier, More Consistent

cloud-init runs during the early boot stage, before you can even SSH in. By the time you connect for the first time, the server is already fully configured — typically 3–8 minutes depending on how many packages need to be installed and upgraded. Built-in modules handle users, SSH keys, packages, files, and commands — covering 90% of initial provisioning needs.

The real downside: debugging is harder than with bash scripts. When something goes wrong, you need to know where to look. A slightly off YAML indent and the entire config silently fails — no clear error message.

When to Choose cloud-init Over Bash Scripts

I reach for cloud-init when:

  • Spinning up a new VPS from a cloud provider — there’s almost always a user-data field when creating an instance.
  • I need to rebuild a server multiple times (test environments, staging).
  • I want consistent configuration across multiple servers at the same time.
  • Infrastructure as Code — storing user-data in git alongside Terraform/Ansible.

Pure bash scripts are a better fit when you need complex multi-layered conditional logic, or when your provider doesn’t support user-data.

Practical cloud-init Deployment Guide

Basic user-data Syntax

The user-data file is YAML and must start with #cloud-config. Without this line, cloud-init ignores everything — no error, no action.

#cloud-config

# Update packages on boot
package_update: true
package_upgrade: true

# Install packages
packages:
  - curl
  - git
  - htop
  - ufw
  - fail2ban
  - unattended-upgrades

Creating a User and Configuring SSH Keys

This is where you save the most time: no more SSH-ing in as root, manually creating a user, and copying SSH keys step by step.

#cloud-config

users:
  - name: deploy
    groups: sudo
    shell: /bin/bash
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... your-public-key

# Disable password-based SSH login, use keys only
ssh_pwauth: false

# Disable root login
disable_root: true

Writing Files and Running Commands

Combining write_files and runcmd, you can replace most of what a setup bash script would do — and much more cleanly:

#cloud-config

write_files:
  - path: /etc/sysctl.d/99-custom.conf
    content: |
      net.ipv4.tcp_syncookies = 1
      net.ipv4.conf.all.rp_filter = 1
      net.ipv6.conf.all.disable_ipv6 = 0
    permissions: '0644'

  - path: /etc/motd
    content: |
      ==========================================
       Production Server — Authorized access only
      ==========================================
    permissions: '0644'

runcmd:
  # Configure firewall
  - ufw default deny incoming
  - ufw default allow outgoing
  - ufw allow 22/tcp
  - ufw allow 80/tcp
  - ufw allow 443/tcp
  - ufw --force enable

  # Apply sysctl settings
  - sysctl --system

  # Set timezone
  - timedatectl set-timezone Asia/Tokyo

Complete user-data Example for a Web Server

This is the config I use for new Nginx VPSes — combines everything above, paste it in and you’re good to go:

#cloud-config

package_update: true
package_upgrade: true

packages:
  - curl
  - git
  - nginx
  - certbot
  - python3-certbot-nginx
  - ufw
  - fail2ban
  - htop
  - unattended-upgrades

users:
  - name: deploy
    groups: sudo, www-data
    shell: /bin/bash
    sudo: ['ALL=(ALL) NOPASSWD:ALL']
    ssh_authorized_keys:
      - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... your-key-here

ssh_pwauth: false
disable_root: true

write_files:
  - path: /etc/fail2ban/jail.local
    content: |
      [DEFAULT]
      bantime = 3600
      findtime = 600
      maxretry = 3

      [sshd]
      enabled = true
    permissions: '0644'

runcmd:
  - timedatectl set-timezone Asia/Tokyo
  - ufw default deny incoming
  - ufw default allow outgoing
  - ufw allow 22/tcp
  - ufw allow 80/tcp
  - ufw allow 443/tcp
  - ufw --force enable
  - systemctl enable nginx
  - systemctl start nginx
  - systemctl enable fail2ban
  - systemctl start fail2ban

# Reboot after upgrade completes to apply new kernel (if any)
power_state:
  mode: reboot
  condition: true

How to Pass user-data When Creating a VPS

Depending on your provider, the approach varies slightly:

Hetzner Cloud (hcloud CLI):

hcloud server create \
  --name my-webserver \
  --type cx21 \
  --image ubuntu-24.04 \
  --user-data-from-file cloud-init.yaml

DigitalOcean (doctl CLI):

doctl compute droplet create my-webserver \
  --image ubuntu-24-04-x64 \
  --size s-1vcpu-1gb \
  --region sgp1 \
  --user-data-file cloud-init.yaml

No CLI? Just paste it directly into the “User Data” field in the web UI — Hetzner, DigitalOcean, and Vultr all have this field when creating a new instance.

Checking Logs When Something Goes Wrong

cloud-init not running correctly? Three places to check, in order:

# General log
sudo cat /var/log/cloud-init.log

# Output from runcmd commands
sudo cat /var/log/cloud-init-output.log

# View overall status
sudo cloud-init status --long

# Verify config parses correctly
sudo cloud-init schema --config-file /path/to/cloud-init.yaml

I run the schema command before creating the actual server. Made a YAML mistake once and had to delete and recreate the VPS — after that, validating beforehand became a habit.

Lessons from Real-World Experience

When I first switched from CentOS to Ubuntu, it took about a week to get comfortable. CentOS also has cloud-init, but module names and behavior differ in a few small ways. On Ubuntu, the apt module is the default and works very smoothly.

Things that tend to trip people up:

  • Execution order: packages runs before runcmd. No need for apt-get install in runcmd if you’ve already listed it in packages.
  • YAML indentation: Use 2 spaces, not tabs. Once I used a tab inside write_files and the entire block silently failed with no clear error — took nearly an hour to debug.
  • runcmd runs only once: cloud-init doesn’t re-run on reboot (unless you delete the state files). This is the correct behavior for provisioning.
  • Test locally first: Multipass (free, available for macOS and Linux) lets you create Ubuntu VMs with user-data — spin up a test environment in minutes without spending a cent on a VPS.
# Quick test with Multipass
multipass launch --name test-vm --cloud-init cloud-init.yaml ubuntu:24.04

# SSH in to check
multipass shell test-vm

# Clean up when done
multipass delete test-vm && multipass purge

cloud-init isn’t a silver bullet — for complex setups with many conditional steps, Ansible is still the better choice. But for initial provisioning of a few dozen servers, user-data YAML is clean enough and powerful enough. I store my cloud-init files in git alongside Terraform configs — rebuilding an environment is a single command.

Share: