
September 11, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
You spin up a fresh Ubuntu VPS, SSH in, and spend two hours installing PHP, tuning Apache, and copying the same firewall rules you wrote last month. That manual loop is exactly what you fix when you Automate Server Provisioning with cloud-init. Cloud-init reads metadata and user-data on first boot, then configures packages, users, files, and services before you ever log in. On production Laravel stacks I maintain, repeatable first-boot automation cuts setup drift and makes disaster recovery boring in the best way.
#cloud-config YAML file as instance user-data. Cloud-init runs modules on first boot to install packages, create users, write files, and start services—so every VPS matches your baseline automatically.What is cloud-init and when should you Automate Server Provisioning with cloud-init?
Cloud-init is the de facto first-boot agent on most Linux cloud images. It ships on Ubuntu, Debian, RHEL-family distros, and vendor images from AWS, GCP, Azure, DigitalOcean, Linode, and Vultr. The daemon pulls data from a metadata service or an ISO labelled cidata, then executes configured modules in a defined order.
You should reach for cloud-init when you need identical servers at scale: web nodes, queue workers, staging clones, or replacement VMs after a failure. It excels at day-zero bootstrap. Ongoing drift correction belongs to tools like Ansible, which pairs well with cloud-init—something I cover alongside configuration management versus provisioning patterns.
Cloud-init is not a full orchestrator. It runs chiefly on first boot, though some modules support cloud-init clean and re-run for testing. Treat user-data as immutable per instance generation; change the launch template or snapshot when the baseline evolves.
How do you write a cloud-init user-data file for Ubuntu?
User-data is plain text passed at instance creation. The first line must be #cloud-config for YAML directives, or #!/bin/bash for a shell script. YAML is easier to maintain because cloud-init validates structure and runs modules in a predictable order.
Minimal #cloud-config example
Save this as user-data.yaml and paste it into your provider's user-data field, or pass it with the CLI when launching an instance.
#cloud-config
hostname: web-01
manage_etc_hosts: true
timezone: Asia/Kathmandu
package_update: true
package_upgrade: true
packages:
- ufw
- fail2ban
- git
- curl
users:
- name: deploy
groups: sudo
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ssh-ed25519 AAAA...your-public-key...
write_files:
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
permissions: '0644'
runcmd:
- ufw default deny incoming
- ufw default allow outgoing
- ufw allow OpenSSH
- ufw allow 'Apache Full'
- ufw --force enable
- systemctl restart ssh
The runcmd list runs last. Commands execute sequentially but errors do not halt later steps unless you wrap them with explicit checks. I log every bootstrap to a file inside runcmd on production systems so post-mortems are painless.
Shell script alternative
Some teams prefer bash for complex branching. The shebang must be the first bytes of the file:
#!/bin/bash
set -euo pipefail
exec > /var/log/bootstrap.log 2>&1
apt-get update
apt-get install -y apache2
Mixing #cloud-config and bash in one file requires MIME multipart encoding. Most engineers keep a single format unless the provider UI demands multipart—documented in the official cloud-init examples reference.
How do you provision a PHP and Laravel web server with cloud-init?
For stacks I run in production—Apache or Nginx with PHP-FPM 8.3+, MySQL 8.4 or MariaDB 12.3, Redis 8.10, and Composer 2.10—cloud-init handles the repeatable base. Application deploy still flows through Git, Deployer 7, or CI, as described in my notes on Ubuntu server setup and Ansible playbooks for PHP provisioning.
Below is a practical cloud-config tuned for Laravel 13 on Ubuntu 24.04. Laravel 13 requires PHP 8.3 or higher; PHP 8.5 is the current anchor if your packages support it on the target image.
#cloud-config
hostname: laravel-prod-01
timezone: Asia/Kathmandu
package_update: true
package_upgrade: true
packages:
- software-properties-common
- apache2
- libapache2-mod-fcgid
- php8.3
- php8.3-fpm
- php8.3-cli
- php8.3-mysql
- php8.3-redis
- php8.3-xml
- php8.3-mbstring
- php8.3-curl
- php8.3-zip
- php8.3-gd
- mysql-server
- redis-server
- ufw
- fail2ban
- git
- unzip
users:
- name: deploy
groups: [www-data, sudo]
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- ssh-ed25519 AAAA...deploy-key...
write_files:
- path: /etc/apache2/sites-available/laravel.conf
content: |
<VirtualHost *:80>
ServerName example.com
DocumentRoot /var/www/current/public
<Directory /var/www/current/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
permissions: '0644'
- path: /var/www/shared/.env.placeholder
content: |
APP_ENV=production
APP_DEBUG=false
permissions: '0640'
owner: deploy:www-data
runcmd:
- a2enmod rewrite proxy_fcgi setenvif
- a2enconf php8.3-fpm
- a2ensite laravel
- a2dissite 000-default
- mkdir -p /var/www/shared /var/www/current
- chown -R deploy:www-data /var/www
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- ufw default deny incoming
- ufw allow OpenSSH
- ufw allow 'Apache Full'
- ufw --force enable
- systemctl enable --now apache2 php8.3-fpm redis-server mysql
- systemctl reload apache2
Do not bake database passwords or API keys into user-data. Provider APIs often store user-data in plaintext. Inject secrets after boot via your vault, SSH, or configuration management. Use the site password generator only for local testing—not for embedding in launch metadata.
On sister sites I maintain—legal-tech portals sharing a Deployer 7 pipeline—the cloud-init layer stops at OS packages, users, and firewall rules. Code deployment remains a separate, auditable step. That split mirrors how Notary Kathmandu and related properties stay consistent without coupling secrets to VM creation APIs.
How do you launch cloud-init instances on major cloud providers?
Every hyperscaler and VPS vendor exposes a user-data field during instance creation. The field name varies, but the content is the same UTF-8 text file.
AWS EC2
aws ec2 run-instances \
--image-id ami-0abcdef1234567890 \
--instance-type t3.small \
--key-name deploy-key \
--user-data file://user-data.yaml \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=laravel-web}]'
EC2 also supports base64-encoded user-data automatically when passed via CLI. Console uploads accept raw text. Combine with Terraform for VPS provisioning when you want version-controlled launch templates.
DigitalOcean, Vultr, and Linode
DigitalOcean calls the field "User data" under Advanced Options. Vultr exposes it in the Server Deploy settings. Linode/Akamai labels it "Metadata / User Data." Paste your YAML directly. For provider comparisons, see the Vultr cloud compute guide and Linode Akamai cloud basics articles.
Local testing with cloud-localds
Before burning cloud credits, validate syntax locally. Ubuntu ships helper tools in the cloud-image-utils package:
sudo apt install cloud-image-utils
cloud-init devel schema --config-file user-data.yaml
cloud-localds seed.iso user-data.yaml
Attach seed.iso to a KVM VM built from the official Ubuntu cloud image. Watch /var/log/cloud-init-output.log inside the guest for runtime errors.
What are common cloud-init mistakes and how do you debug them?
Most failures I see in the field are YAML indentation errors, oversized user-data payloads, or assuming cloud-init re-runs on reboot. Another frequent issue is blocking runcmd on interactive prompts—always pass non-interactive flags.
- Validate YAML with
cloud-init schemabefore launch. - Keep user-data under provider size limits—often 16 KB on smaller VPS plans.
- Check logs at
/var/log/cloud-init.logand/var/log/cloud-init-output.log. - Run
cloud-init status --longto see which modules succeeded. - Never store production secrets in user-data; fetch them post-boot.
Security baselines from CIS benchmarks for server hardening and Ubuntu web server hardening fit naturally into write_files and runcmd blocks. Pair that with ongoing patch cadence via support and maintenance contracts so bootstrap is not your only defence.
How does cloud-init compare to Ansible and Terraform for server setup?
These tools complement each other; they do not compete directly. Cloud-init handles first boot. Terraform declares infrastructure and attaches user-data. Ansible converges configuration on running hosts.
| Tool | Primary role | Runs when | Best for |
|---|---|---|---|
| cloud-init | Instance bootstrap | First boot (mostly) | Users, packages, base firewall, initial files |
| Terraform | Infrastructure as code | terraform apply | Networks, disks, DNS, passing user-data |
| Ansible | Configuration management | On demand / schedule | App deploy, updates, drift repair |
My typical pipeline for a web development client looks like this:
- Terraform creates the VPS and supplies cloud-init user-data.
- Cloud-init installs PHP, web server, Redis, and hardening baseline.
- Ansible or Deployer deploys the Laravel application and shared
.env. - Cron and backup jobs land via automated rsync backups after the node is verified.
- Monitoring hooks align with an Ubuntu server monitoring guide checklist.
If you already use Ansible playbooks for server setup, move static, slow-changing steps into cloud-init. Keep Ansible for anything that changes weekly—application releases, SSL renewals with custom hooks, or database migrations.
Idempotency and re-runs
Cloud-init tracks the instance ID. Rebooting does not re-execute most modules. To test changes, destroy and recreate the VM, or run sudo cloud-init clean --logs followed by sudo cloud-init init on a disposable test instance only. Never clean production instances casually.
Networking and Nepal-specific notes
Set timezone: Asia/Kathmandu for local log timestamps and cron alignment. If your provider assigns IPv6, add explicit ufw rules rather than assuming defaults. For DNS and traffic routing across regions, cross-check cross-cloud DNS patterns before pointing production domains at a freshly bootstrapped node.
Hosting cost decisions—local versus global VPS—affect latency for Nepali users. Cloud-init does not solve geography; it solves repeatability. Read AWS cloud hosting versus shared hosting in Nepal when choosing where to attach your user-data template. Domain and TLS setup still belong in your runbook under domain registration and hosting.
Observability after bootstrap
Cloud-init finishes before your application health checks exist. Add a final runcmd entry that writes a sentinel file—/var/lib/bootstrap-complete—and configure monitoring to alert if that file is missing after ten minutes. Align with Nagios monitoring for servers or your preferred agent. Security reviews should follow Ubuntu server security best practices and securing websites and servers in Nepal.
The Ubuntu project documents module keys in the Ubuntu cloud-init module reference. When behaviour differs between cloud images, check /etc/cloud/cloud.cfg and vendor overrides before assuming a module is broken.
Key Takeaways
- Pass
#cloud-configYAML as user-data to Automate Server Provisioning with cloud-init on first boot. - Keep secrets out of user-data; inject credentials after boot via vault or Ansible.
- Validate with
cloud-init schemaand test usingcloud-localdsbefore launching production VMs. - Use cloud-init for OS baseline; use Terraform to create infrastructure and Ansible or Deployer for app releases.
- Read
/var/log/cloud-init-output.logfirst when a node misses packages or firewall rules. - Version your user-data in Git alongside Terraform templates so every rebuild is auditable.
People Also Ask
Does cloud-init run on every reboot?
Generally no. Cloud-init treats the instance ID as a unique marker and skips most modules on subsequent boots. Modules under bootcmd run earlier and may behave differently, but standard runcmd steps are first-boot only unless you explicitly reset cloud-init on a test machine.
Can cloud-init install Docker or Kubernetes?
Yes. Add Docker's official apt repository in runcmd or use the packages module after adding the repository key via write_files. Keep manifests and cluster join tokens out of user-data—fetch them from a secure store after the node registers.
What is the maximum user-data size?
AWS EC2 allows roughly 16 KB for plain text user-data in many configurations. Several VPS providers enforce similar limits. Split heavy configuration into scripts pulled from a private object store, then execute a short bootstrap stub that downloads the rest.
Is cloud-init only for public clouds?
No. Proxmox, VMware, and KVM with Ubuntu cloud images support cloud-init through a virtual seed ISO or config drive. Bare-metal installs can use cloud-init when the image is cloud-ready—common for homelab and on-prem Ubuntu deployments.
Build repeatable servers instead of repeating yourself
Manual SSH setup does not scale past a handful of nodes. When you Automate Server Provisioning with cloud-init, every Laravel or PHP web server starts from the same hardened baseline, and recovery after hardware failure becomes a template launch instead of a weekend lost to package installs. Start with a validated #cloud-config in Git, test it on a throwaway VM, then wire it into Terraform or your provider API. Need help designing the full bootstrap-to-deploy pipeline for a production site? Contact us or explore Linux system administration services to get a baseline that matches how your applications actually run.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

