DigitalOcean Droplet User Data: Automate First-Boot Provisioning with cloud-init
DigitalOcean user data runs a cloud-config document on a Droplet's first boot, letting you create users, harden SSH, and install packages automatically. Here's a worked example, its one-shot limits, and how to debug silent failures.
20 Feb 2026, 15:49 UTC

Every DigitalOcean Droplet accepts a user data field at creation time. If you put a #cloud-config document there, the Droplet provisions itself on first boot — creating users, writing config files, installing packages, and running commands — before you ever open an SSH session. This is the fastest way to turn "new server" into "hardened, ready server" with zero manual steps, and it works identically from the web console, the API, doctl, and Terraform.
How the mechanism works
DigitalOcean images ship with cloud-init, the standard first-boot provisioning agent used across cloud Linux distributions. When a new Droplet boots for the first time, cloud-init contacts DigitalOcean's metadata service at the link-local address 169.254.169.254, downloads whatever you placed in the user data field, and executes it. Link-local means the address only works from inside the Droplet itself; it is not routable from the internet.
User data can be several formats, but the most practical is a #cloud-config YAML document. It declares what you want — users, files, packages, commands — and cloud-init translates that into actions during the boot sequence.
A worked cloud-config: hardened web server in one shot
The example below creates a non-root deploy user with your SSH key, drops an SSH hardening file, updates the system, installs nginx, and enables it. Paste it into the "User Data" box when creating a Droplet (Ubuntu 22.04 or 24.04 LTS images are a safe assumption), or pass it via doctl compute droplet create --user-data-file cloud.yaml ....
#cloud-config
users:
- name: deploy
groups: sudo
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ssh-ed25519 AAAA...your-public-key-here
write_files:
- path: /etc/ssh/sshd_config.d/60-hardening.conf
permissions: "0644"
content: |
PasswordAuthentication no
PermitRootLogin no
package_update: true
package_upgrade: true
packages:
- nginx
- ufw
runcmd:
- systemctl enable --now nginx
- ufw allow OpenSSH
- ufw allow 'Nginx Full'
- ufw --force enable
A few things to notice: write_files sets exact permissions, so the sshd drop-in lands correctly without a follow-up chmod. runcmd runs last, after packages are installed, so systemctl enable nginx cannot fail due to a missing binary. The firewall rules allow SSH before enabling ufw — getting that order wrong locks you out of a machine you cannot console into easily.
To verify the result, SSH in as deploy and run cloud-init status (expect status: done), then confirm nginx is active with systemctl is-active nginx and that /etc/ssh/sshd_config.d/60-hardening.conf exists.
Limits that bite people
It runs once, ever. User data executes only on the first boot of a newly created Droplet. Editing the field after creation does nothing. Rebuilding from a snapshot does not re-run it. If you need to change the provisioning, you destroy and recreate the Droplet — which is fine if you treat servers as disposable, and painful if you don't.
It is not a secret store. Any process on the Droplet can read the user data back from the metadata endpoint: curl http://169.254.169.254/metadata/v1/user-data. Never embed API tokens or passwords in it. Instead, use cloud-init to install just enough tooling to pull secrets from a vault or secrets manager at boot.
Failures are silent. cloud-init runs unattended, so a broken directive produces no visible error — your Droplet just comes up missing pieces. The authoritative debugging sources are /var/log/cloud-init.log and /var/log/cloud-init-output.log on the Droplet itself. Check them first, always.
YAML is unforgiving. One wrong indent in cloud-config and the whole document fails or, worse, partially applies. Validate locally before creating the Droplet: cloud-init schema --config-file cloud.yaml (requires the cloud-init package on your local machine) catches malformed structure.
Common mistakes and guardrails
- Non-idempotent runcmd scripts. If you ever re-run a runcmd script manually, unguarded
apt installor file appends can duplicate state. Prefer declarative directives (packages,write_files) over shell commands where possible. - Assuming every image supports it. Some Marketplace or custom images ship a stripped or missing cloud-init. Test user data on the exact image you plan to use before baking it into automation.
- Long-running commands. Heavy work in runcmd delays SSH availability and can hit cloud-init timeouts. Keep first-boot work minimal; defer big jobs to a systemd unit or a configuration management tool.
Using it from Terraform
The same mechanism maps directly onto infrastructure-as-code. The digitalocean_droplet resource exposes a user_data argument:
resource "digitalocean_droplet" "web" {
image = "ubuntu-24-04-x64"
name = "web-1"
region = "fra1"
size = "s-1vcpu-1gb"
user_data = file("${path.module}/cloud.yaml")
}Because user data only applies at creation, changing cloud.yaml and running terraform apply will force Droplet replacement — Terraform correctly models the destroy-and-recreate behavior described above. Plan for that before pointing it at anything stateful.
Quick verification checklist
- On the Droplet:
cloud-init statusshowsdonewith no errors. tail -50 /var/log/cloud-init-output.logshows your packages installed and runcmd steps completed.curl -s http://169.254.169.254/metadata/v1/user-datareturns exactly what you submitted — also a reminder of why secrets don't belong there.- Confirm each artifact by hand: user exists (
id deploy), file present, service active.
Used this way, user data replaces the "spin up a box, then spend 20 minutes hardening it" ritual with a declarative file you can version, review, and reuse — as long as you respect its one-shot nature and keep secrets out of it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.