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 ]; theneverywhere 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:
packagesruns beforeruncmd. No need forapt-get installinruncmdif you’ve already listed it inpackages. - YAML indentation: Use 2 spaces, not tabs. Once I used a tab inside
write_filesand 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.

