Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Automate Server Provisioning with cloud-init

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.

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.

cloud-init First-Boot PipelineCreate VMuser-data attachedMetadata169.254.169.254cloud-initparse modulesReady NodeSSH + stack liveModule execution order (simplified)userspackageswrite_filesruncmdRuns once per instance ID unless cloud-init is resetLogs: /var/log/cloud-init.log and cloud-init-output.log
Automate Server Provisioning with cloud-init: hypervisor attaches user-data, cloud-init executes modules, and the node reaches a known baseline before manual SSH work.

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.

cloud-init Data SourcesMetadatainstance-id, regionUser-datayour #cloud-configVendor-dataprovider extrasCommon #cloud-config modulesuserspackageswrite_filesruncmdpower_statentp / timezone
Metadata, user-data, and vendor-data feed cloud-init modules that install packages, create users, and run bootstrap commands on first boot.

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 schema before launch.
  • Keep user-data under provider size limits—often 16 KB on smaller VPS plans.
  • Check logs at /var/log/cloud-init.log and /var/log/cloud-init-output.log.
  • Run cloud-init status --long to 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.

cloud-init GotchasNo secretsin user-data16 KB limiton many VPS plansRuns once per instance IDBad YAMLindent errorsDebug logscloud-init-outputFix driftuse Ansible later
Automate Server Provisioning with cloud-init safely: avoid secrets in user-data, respect size limits, and use Ansible for ongoing drift correction.

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.

ToolPrimary roleRuns whenBest for
cloud-initInstance bootstrapFirst boot (mostly)Users, packages, base firewall, initial files
TerraformInfrastructure as codeterraform applyNetworks, disks, DNS, passing user-data
AnsibleConfiguration managementOn demand / scheduleApp deploy, updates, drift repair

My typical pipeline for a web development client looks like this:

  1. Terraform creates the VPS and supplies cloud-init user-data.
  2. Cloud-init installs PHP, web server, Redis, and hardening baseline.
  3. Ansible or Deployer deploys the Laravel application and shared .env.
  4. Cron and backup jobs land via automated rsync backups after the node is verified.
  5. 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.

Production Bootstrap PipelineTerraformcreate VPScloud-initOS baselineDeployerapp releaseLiveLaravel production stack after cloud-initPHP 8.3ApacheMySQLRedisUFWSame pattern used on Adventure Third Pole Trek booking infrastructure
Terraform provisions the VM, cloud-init builds the Laravel-ready stack, and Deployer ships application code—repeatable across projects like Adventure Third Pole Trek.

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-config YAML 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 schema and test using cloud-localds before 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.log first 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

Cloud-init is the de facto first-boot agent on most Linux cloud images, including Ubuntu, Debian, and RHEL-family distros on AWS, GCP, Azure, DigitalOcean, Linode, and Vultr. It pulls metadata and user-data at launch, then runs modules in order to install packages, create users, write files, and start services before manual SSH work.

Reach for cloud-init when you need identical servers at scale: web nodes, queue workers, staging clones, or replacement VMs after failure. It excels at day-zero bootstrap. Ongoing drift correction belongs to Ansible or similar tools. Treat user-data as immutable per instance generation and update your launch template when the baseline evolves rather than patching live servers by hand.

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 predictably. A minimal config sets hostname, timezone, package updates, packages, users with SSH keys, write_files entries, and a runcmd list that executes last. Validate with cloud-init devel schema before launch.

YAML uses cloud-init modules for packages, users, files, and commands in a defined order. Bash scripts need #!/bin/bash as the first bytes and suit complex branching with set -euo pipefail and logging to /var/log/bootstrap.log. Mixing both formats requires MIME multipart encoding. Most engineers keep a single format unless a provider UI demands multipart. Shell scripts give more control; YAML stays readable for standard bootstrap tasks.

Pass a #cloud-config file that installs Apache, PHP-FPM 8.3, MySQL, Redis, UFW, fail2ban, git, and Composer 2.10 on Ubuntu 24.04. Create a deploy user, write an Apache vhost pointing at /var/www/current/public, enable modules and sites in runcmd, and open firewall ports. Laravel 13 needs PHP 8.3 or higher. Do not bake database passwords into user-data; application deploy stays a separate Git or Deployer 7 step after bootstrap.

Every major provider exposes a user-data field at instance creation. On AWS, pass --user-data file://user-data.yaml with aws ec2 run-instances or paste raw text in the console. DigitalOcean labels it User data under Advanced Options. Vultr exposes it in Server Deploy settings. Linode calls it Metadata or User Data. Combine with Terraform when you want version-controlled launch templates that attach the same bootstrap file every time.

Install cloud-image-utils on Ubuntu, then run cloud-init devel schema --config-file user-data.yaml to catch YAML errors. Build a seed ISO with cloud-localds seed.iso user-data.yaml and attach it to a KVM VM using the official Ubuntu cloud image. Watch /var/log/cloud-init-output.log inside the guest for runtime failures. This avoids burning cloud credits on a broken bootstrap script.

Most failures are YAML indentation errors, oversized user-data payloads, or assuming cloud-init re-runs on reboot. Blocking runcmd on interactive prompts is another frequent issue; always pass non-interactive flags like ufw --force enable. Check /var/log/cloud-init.log and /var/log/cloud-init-output.log, run cloud-init status --long, and validate YAML before launch. Keep user-data under provider size limits, often around 16 KB on smaller VPS plans.

These tools complement each other. Cloud-init handles first-boot bootstrap: users, packages, base firewall, initial files. Terraform declares infrastructure and attaches user-data at terraform apply. Ansible converges configuration on running hosts for app deploy, updates, and drift repair. A typical pipeline: Terraform creates the VPS, cloud-init builds the OS baseline, then Ansible or Deployer ships the Laravel application and shared .env.

Generally no. Cloud-init treats the instance ID as unique and skips most modules on subsequent boots. Rebooting does not re-execute standard runcmd steps unless you explicitly reset cloud-init on a disposable test instance.

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 stored in a private object store and use a short bootstrap stub in user-data to download the rest.

No. Provider APIs often store user-data in plaintext, so production secrets must never be embedded at launch. Inject credentials after boot via your vault, SSH, or configuration management. Use placeholder files like /var/www/shared/.env.placeholder during bootstrap and populate real values through Ansible or a secure post-boot step. The same rule applies to API keys and database passwords on Laravel stacks.

No. Proxmox, VMware, and KVM with Ubuntu cloud images support cloud-init through a virtual seed ISO or config drive labelled cidata. Bare-metal installs can use cloud-init when the image is cloud-ready, which is common for homelab and on-prem Ubuntu deployments. Public clouds are the most common use case, but any hypervisor that can attach metadata or a seed ISO can run it.

Yes. Add Docker's official apt repository in runcmd or use the packages module after writing the repository key via write_files. Keep Kubernetes manifests and cluster join tokens out of user-data and fetch them from a secure store after the node registers. The same size and secret-handling rules apply as for any other bootstrap configuration.

Cloud-init stops at the OS baseline: packages, deploy user, firewall, web server, and directory structure under /var/www. Code deployment remains a separate auditable step through Git, Deployer 7, or CI. Ansible handles anything that changes weekly, such as application releases, SSL renewals, or database migrations. On production legal-tech portals I maintain, this split keeps VM creation repeatable without coupling secrets to provider APIs.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: