Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Infrastructure as Code By James Joyner IV · · 8 min read

Cloud-init Recipes for Bootstrapping Servers the Right Way

Cloud-init runs on first boot across every major cloud. Get it right and your instances are configured before you ever SSH in. Here are the patterns that hold up.

  • #iac
  • #cloud-init
  • #provisioning
  • #aws
  • #automation
  • #bootstrap

Cloud-init is the quiet workhorse of cloud infrastructure. Every Ubuntu, Amazon Linux, RHEL, and most other cloud images ship with it, and it runs on first boot to turn a generic image into your configured server — before you ever log in. It’s the layer that makes “user data” work on AWS, “custom data” on Azure, and “startup scripts” on GCP.

Most people use maybe 5% of it, pasting a bash script into user-data and calling it done. There’s a much better way. Here are the recipes I actually use.

Use cloud-config, not a bash blob

The single biggest upgrade is switching from a #!/bin/bash user-data script to a #cloud-config YAML document. The bash approach is imperative, order-sensitive, and silent on failure. Cloud-config is declarative and has modules for the common tasks:

#cloud-config
package_update: true
package_upgrade: true
packages:
  - nginx
  - postgresql-client
  - htop

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

write_files:
  - path: /etc/nginx/sites-available/app.conf
    content: |
      server {
        listen 80;
        root /var/www/app;
      }
    owner: root:root
    permissions: '0644'

runcmd:
  - systemctl enable --now nginx
  - ln -sf /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/

The first line #cloud-config is mandatory and load-bearing — cloud-init dispatches on it. With this one document you’ve installed packages, created a user with SSH access, written a config file with correct ownership, and enabled a service, all declaratively.

Order matters: write_files runs before runcmd

A trap worth knowing: cloud-config modules run in a fixed order, not the order you write them. write_files always runs before runcmd, which is exactly what you want — drop config into place, then run commands that depend on it. But packages are installed after write_files too, so if a write_files entry targets a directory a package creates, it may not exist yet. When you have a true ordering dependency, put the whole sequence in runcmd where order is guaranteed.

Combine multiple parts with MIME multipart

You can ship more than one cloud-init document by wrapping them in a MIME multipart archive — for example a cloud-config plus a shell script:

Content-Type: multipart/mixed; boundary="===="
MIME-Version: 1.0

--====
Content-Type: text/cloud-config

#cloud-config
packages:
  - docker.io

--====
Content-Type: text/x-shellscript

#!/bin/bash
docker run -d -p 80:80 myapp:latest
--====--

This lets you keep your declarative config separate from a genuinely-imperative bootstrap step. Most cloud SDKs and Terraform’s cloudinit_config data source build this archive for you so you don’t hand-assemble MIME boundaries.

Bootstrap into a config-management tool

The cleanest pattern at scale: keep cloud-init small. Its job is to install just enough to hand off to Ansible, Salt, or a container runtime — not to be your entire configuration system. Cloud-init has a built-in Ansible module for exactly this:

#cloud-config
ansible:
  install_method: pip
  pull:
    url: "https://github.com/myorg/infra-playbooks.git"
    playbook_name: site.yml

On boot, the instance installs Ansible, clones your playbook repo, and runs it against itself (ansible-pull). Now cloud-init owns bootstrapping and Ansible owns configuration — a clean separation that keeps your user-data from becoming a 400-line monster.

Debugging: where the logs live

When an instance comes up wrong, cloud-init’s logs are the first place to look:

  • /var/log/cloud-init.log — the detailed execution log
  • /var/log/cloud-init-output.log — stdout/stderr of runcmd and scripts, where your actual command failures show up
  • cloud-init status --long — current state and any errors
  • cloud-init schema --system — validates your config; run it to catch YAML mistakes early

The validation command is underused. Cloud-init silently skips modules it can’t parse, so a typo’d key just… does nothing, with no error at boot. Run cloud-init schema --config-file user-data.yaml before you launch and you’ll catch the typo instead of debugging a mysteriously-misconfigured server.

Idempotency and re-runs

By default cloud-init runs once per instance, keyed off the instance ID — it won’t re-run on reboot. That’s usually what you want. If you’re iterating on a config and want to force a re-run on the same instance for testing:

sudo cloud-init clean --logs
sudo cloud-init init

But don’t design around re-running cloud-init in production. It’s a first-boot tool. Ongoing configuration belongs in a config-management system or a fresh image, not in repeatedly poking cloud-init.

Where AI helps

Cloud-config’s module names and exact key syntax are easy to half-remember, which makes this a sweet spot for AI assistance.

  • Converting a bootstrap bash script to cloud-config. Paste the script and ask for the equivalent #cloud-config, splitting package installs, file writes, and users into their proper modules. It’s a clean mechanical translation.
  • Catching ordering bugs. Describe your dependencies and ask whether they’re safe given cloud-init’s module order — the model knows write_files precedes runcmd.
  • Generating the MIME multipart wrapper so you don’t hand-craft boundaries.

The model won’t know your SSH keys or repo URLs, and it can hallucinate module options that don’t exist — so always finish with cloud-init schema validation against the real thing. We keep a set of IaC prompts for provisioning and bootstrap tasks.

The bottom line

Cloud-init is already running on nearly every instance you launch; the only question is whether you’re using it deliberately. Switch from bash blobs to declarative cloud-config, keep it small and hand off to a real config tool, validate before launch, and read the logs when it breaks. Do that and your instances arrive configured, every time, before you ever open an SSH session.

AI-generated cloud-init configs are assistive, not authoritative. Always validate with cloud-init schema and test on a throwaway instance before baking into launch templates.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.