All Linux templates support cloud-init. Supply user_data at creation and the server configures itself before you first log in — which also means the window between "server exists" and "server is hardened" never happens.
A hardened baseline
#cloud-config
hostname: node-01
fqdn: node-01.example.com
users:
- name: deploy
groups: [sudo]
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...
package_update: true
package_upgrade: true
packages:
- ufw
- fail2ban
- unattended-upgrades
- nftables
write_files:
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
AuthenticationMethods publickey
MaxAuthTries 3
runcmd:
- ufw default deny incoming
- ufw default allow outgoing
- ufw allow 22/tcp
- ufw --force enable
- systemctl enable --now fail2ban
- systemctl restart sshd
power_state:
mode: reboot
message: cloud-init complete
timeout: 30
Docker host
#cloud-config
packages: [ca-certificates, curl, gnupg]
runcmd:
- install -m 0755 -d /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
- echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list
- apt-get update
- apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
- usermod -aG docker deploy
WireGuard endpoint
#cloud-config
packages: [wireguard, nftables]
write_files:
- path: /etc/wireguard/wg0.conf
permissions: '0600'
content: |
[Interface]
Address = 10.66.66.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY
PostUp = nft add rule inet filter forward iifname wg0 accept
PostUp = nft add rule inet nat postrouting oifname eth0 masquerade
[Peer]
PublicKey = CLIENT_PUBLIC_KEY
AllowedIPs = 10.66.66.2/32
runcmd:
- sysctl -w net.ipv4.ip_forward=1
- echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf
- systemctl enable --now wg-quick@wg0
Verifying
cloud-init status --wait
cloud-init analyze show
journalctl -u cloud-final
If something did not apply, /var/log/cloud-init-output.log has the answer. YAML indentation is the cause about nine times out of ten.