ARTICLE

Cloud-Init: The 42-Line Config That Replaces Your Cloud Setup

Me

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@server is 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:

  1. Learn CloudFormation/Terraform syntax
  2. Understand VPCs, subnets, security groups, NACLs
  3. Configure IAM roles and policies
  4. Set up the instance
  5. Figure out how to actually SSH in (spoiler: it’s not straightforward anymore)
  6. Run your hardening scripts manually anyway
  7. Hope you didn’t miss a step
  8. Pay $50-200/month for the privilege

Cloud-init approach:

  1. Write a YAML file (or copy mine)
  2. Paste it in the “user data” field
  3. Click “Create”
  4. 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.

STAY UPDATED

Don't Miss New Articles

Get practical guides delivered straight to your inbox

NEED HELP?

Don't Want to Do It Yourself?

I can help you migrate your infrastructure with 15+ years of experience