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 ofruncmdand scripts, where your actual command failures show upcloud-init status --long— current state and any errorscloud-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_filesprecedesruncmd. - 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.
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.