15 - Cloud-init
Working Code:
terraform/exercise-15-cloud-init/
The Problem: Bash scripts (Exercise 14) are brittle, don't handle errors well, and can't easily manage complex config.
The Solution: cloud-init is the industry standard for bootstrap configuration. Uses declarative YAML.
Objective
Use cloud-init.yaml to:
- Update OS packages
- Harden SSH (disable password auth, disable root)
- Install Nginx, UFW, Fail2ban
- Create custom index.html
How-to
1. Cloud Config
yaml
#cloud-config
hostname: web-server
package_update: true
package_upgrade: true
ssh_pwauth: false
disable_root: true
packages:
- nginx
- fail2ban
- ufw
users:
- name: devops
groups: [sudo]
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ${ssh_key}
write_files:
- path: /var/www/html/index.html
content: |
<h1>Configured via Cloud-Init!</h1>
runcmd:
- ufw allow 'Nginx Full'
- ufw allow OpenSSH
- ufw enable2. Pass to Terraform
hcl
resource "hcloud_server" "web" {
user_data = templatefile("cloud-init.yaml", {
ssh_key = var.my_public_key
})
}Verification
bash
terraform apply
# Wait ~2-3 mins (OS upgrade)
curl http://<ip> # See custom HTML
ssh root@<ip> # Permission denied (good!)
ssh devops@<ip> # SuccessProblems & Learnings
Common Issues
cloud-init status: donebut nothing installed — caused by the%{~ for ~}template syntax stripping the newline afterssh_authorized_keys:, producing invalid YAML. Cloud-init silently skips the entire config. Fix: use%{ for ~}(no leading~) so the newline is preserved. Diagnose withsudo cat /var/log/cloud-init-output.log.plocate-updatedb: command not found—plocate-updatedbis the systemd service name, not a binary. The correct command isupdatedb.- Terraform provisioning takes 5+ minutes — caused by
package_reboot_if_required: true. If a kernel upgrade is pulled, the server reboots mid-provisioning and the Hetzner provider waits through the reboot. Set tofalsefor initial provisioning.
Key Takeaways
- Always check
/var/log/cloud-init-output.logwhen cloud-init reportsdonebut the server isn't configured — the exit status can be misleading - Terraform template
~strip markers eat adjacent newlines; a leading~on aforloop removes the newline before it, which breaks YAML list syntax package_update: true+package_upgrade: truealready handle upgrades — a redundantapt-get dist-upgradeinruncmddoubles the work