Cloud-Init: The 42-Line Config That Replaces Your Cloud Setup
Server provisioning is embarrassingly simple. Here’s the proof.
In the previous post, I argued that managing your own server is easier than the cloud industry wants you to believe. Today, I’m going to prove it.
We’re going to provision a production-ready, hardened server. Not with Terraform. Not with Ansible. Not with a 47-step runbook. With a single YAML file that fits on your screen.
Meet Cloud-Init
Cloud-init has been around for over a decade. It’s the engine behind that “user data” field you’ve probably seen (and ignored) in AWS EC2. But here’s the thing: it’s not an AWS feature. It’s an open-source tool that works everywhere.
Hetzner supports it. DigitalOcean supports it. Vultr supports it. Even your local VM running Ubuntu supports it.
One config. Every provider. No vendor lock-in.
The Entire Setup
Here’s a complete cloud-config that provisions a hardened server ready for Docker deployments. Take a look — I’ll break it down afterward:
#cloud-config
packages:
- apache-utils
- fail2ban
- ufw
- docker.io
- curl
- git
package_update: true
package_upgrade: true
users:
- name: app
groups: users, admin, docker
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZe78S7iGyDQJW8Dx3gYl5Iloa/CKFeFIyzu3p9uGH6 zde@hasek
write_files:
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 2
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
AllowUsers app
- path: /etc/fail2ban/jail.local
content: |
[sshd]
enabled = true
banaction = iptables-multiport
runcmd:
- systemctl enable fail2ban
- ufw allow OpenSSH
- ufw allow 80
- ufw allow 443
- ufw --force enable
- systemctl restart sshd
- reboot
That’s it.42 lines of readable YAML. You can find the full config as a Gist here.
Now compare that to what you’d need in AWS:
- VPC with public and private subnets
- Internet Gateway
- Route tables
- Security Groups (and figuring out the difference between inbound and outbound rules)
- IAM role for the instance
- IAM instance profile
- Launch template or launch configuration
- The actual EC2 instance
- Systems Manager setup (because apparently
ssh user@serveris too 2010)
Which one would you rather debug at 3 AM?
Breaking It Down
Let’s walk through each section:
The Magic Comment
#cloud-config
This line is crucial. Without it, the whole thing silently fails. No errors, no warnings — just a naked, unhardened server. Don’t forget it.
Packages
packages:
- apache-utils
- fail2ban
- ufw
- docker.io
- curl
- git
package_update: true
package_upgrade: true
We install what we need: Docker for containers, fail2ban to block brute-force attempts, ufw for the firewall, and a few utilities. The package_update and package_upgrade ensure we start with the latest security patches.
User Setup
users:
- name: app
groups: users, admin, docker
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAA... your-key-here
We create a non-root user called app. It’s a common practice to use a non-root user for production servers.
The user gets sudo access and Docker permissions. The SSH key means no password needed — ever.
Important: Replace the SSH key with your own. The one in the example is mine, and I promise I don’t want access to your servers.
The Hardening Commands
runcmd:
- systemctl enable fail2ban
- ufw allow OpenSSH
- ufw allow 80
- ufw allow 443
- ufw --force enable
- systemctl restart sshd
- reboot
The runcmd section is beautifully minimal. We enable fail2ban, configure the firewall (SSH, HTTP, HTTPS — everything else blocked), restart sshd to apply our hardening config, and finally reboot the server.
Why the reboot at the end? It ensures everything starts fresh with all configs applied — kernel updates from package_upgrade, all services properly initialized, firewall rules persisted. Yes, you could skip it and just restart individual services, but a clean reboot takes 30 seconds and guarantees you’re seeing exactly what the server will look like after a power cycle. No surprises.
The fail2ban configuration itself lives in write_files alongside the SSH hardening:
- path: /etc/fail2ban/jail.local
content: |
[sshd]
enabled = true
banaction = iptables-multiport
This tells fail2ban to watch SSH and ban IPs after failed login attempts. Simple, readable, no printf escaping nonsense.
Remember DHH’s advice from Rails World? “Just close the main door.” That’s exactly what we’re doing here.
SSH Hardening
Here’s where modern Linux makes our lives easier. Instead of using ugly sed commands to edit /etc/ssh/sshd_config, we use the write_files directive to drop a config file into /etc/ssh/sshd_config.d/:
write_files:
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 2
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
AllowUsers app
Modern Ubuntu and Debian include the line Include /etc/ssh/sshd_config.d/*.conf at the top of sshd_config. Any file you drop in that directory automatically gets loaded. No regex hell. No wondering if your sed pattern matched. Just plain, readable configuration.
What each setting does:
- PermitRootLogin no — even if someone gets root credentials, they can’t SSH in
- PasswordAuthentication no — keys only, brute-forcing is impossible
- MaxAuthTries 2 — two attempts, then you’re disconnected
- AllowTcpForwarding no / X11Forwarding no / AllowAgentForwarding no — prevents tunneling attacks
- AllowUsers app — only our user can SSH in, everyone else is rejected
Then we just restart sshd to apply the changes. Clean, declarative, and actually readable.
What About Providers That Don’t Support Cloud-Config?
No problem. Cloud-init is open-source and works on most Linux distributions. Here’s the manual setup:
# Install cloud-init
apt install cloud-init
# Copy your config
cp your-config.yaml /etc/cloud/cloud.cfg.d/99-custom-config.cfg
# Validate syntax (catches errors before they bite you)
cloud-init schema --config-file /etc/cloud/cloud.cfg.d/99-custom-config.cfg
# Apply it
sudo cloud-init clean
sudo cloud-init init
sudo cloud-init modules --mode=config
sudo cloud-init modules --mode=final
Four commands to validate and apply. That’s still simpler than reading through AWS documentation.
The Real Comparison
Let’s be honest about what we’re comparing here.
Traditional cloud approach:
- Learn CloudFormation/Terraform syntax
- Understand VPCs, subnets, security groups, NACLs
- Configure IAM roles and policies
- Set up the instance
- Figure out how to actually SSH in (spoiler: it’s not straightforward anymore)
- Run your hardening scripts manually anyway
- Hope you didn’t miss a step
- Pay $50-200/month for the privilege
Cloud-init approach:
- Write a YAML file (or copy mine)
- Paste it in the “user data” field
- Click “Create”
- Done
The server boots up hardened, with Docker ready, firewall configured, and your user waiting. You SSH in with ssh app@your-server-ip and start deploying.
This Is Infrastructure as Code
Here’s the funny part: cloud-init is Infrastructure as Code. It’s been IaC since before we called it that.
Your config is a file. You can version it in Git. You can review changes in pull requests. You can spin up identical servers by pasting the same config. You get reproducibility, auditability, and automation.
You just don’t get the $500/month bill for a managed Kubernetes cluster to run a blog.
What’s Next
You now have everything you need to provision a hardened server in minutes. But provisioning is just the beginning.
In the next posts, we’ll cover:
- Zero-downtime deployments — shipping code without taking down your app
- SSL certificates — because Let’s Encrypt makes this free and automatic
- Backups and disaster recovery — without paying for managed database services
- Monitoring — knowing when things break before your users tell you
The cloud made us forget that servers aren’t scary. They’re just computers. And with 42 lines of YAML, you can have one ready for production in the time it takes to read AWS documentation’s table of contents.
This is the second post in the FreeFrom.Cloud series. Read the first post: The Cloud is Scarier Than You Think. Subscribe to get notified when new posts drop.
Share this post
Don't Miss New Articles
Get practical guides delivered straight to your inbox
Don't Want to Do It Yourself?
I can help you migrate your infrastructure with 15+ years of experience